Merge remote-tracking branch 'upstream/main'
All checks were successful
Build and Push Images / build-and-push (push) Successful in 14m50s

This commit is contained in:
kjs
2026-01-06 10:29:09 +09:00
22 changed files with 454 additions and 146 deletions

View File

@@ -55,3 +55,4 @@ export default router;

View File

@@ -51,3 +51,4 @@ export default router;

View File

@@ -67,3 +67,4 @@ export default router;

View File

@@ -55,3 +55,4 @@ export default router;

View File

@@ -65,6 +65,13 @@ export class AdminService {
}
);
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
// TODO: 권한 체크 다시 활성화 필요
logger.info(
`⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
);
/* [원본 코드 - 권한 그룹 체크]
if (userType === "COMPANY_ADMIN") {
// 회사 관리자: 권한 그룹 기반 필터링 적용
if (userRoleGroups.length > 0) {
@@ -141,6 +148,7 @@ export class AdminService {
return [];
}
}
*/
} else if (
menuType !== undefined &&
userType === "SUPER_ADMIN" &&
@@ -412,6 +420,15 @@ export class AdminService {
let queryParams: any[] = [userLang];
let paramIndex = 2;
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
// TODO: 권한 체크 다시 활성화 필요
logger.info(
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
);
authFilter = "";
unionFilter = "";
/* [원본 코드 - getUserMenuList 권한 그룹 체크]
if (userType === "SUPER_ADMIN") {
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
@@ -471,6 +488,7 @@ export class AdminService {
return [];
}
}
*/
// 2. 회사별 필터링 조건 생성
let companyFilter = "";

View File

@@ -2409,11 +2409,19 @@ export class TableManagementService {
}
// SET 절 생성 (수정할 데이터) - 먼저 생성
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
const setConditions: string[] = [];
const setValues: any[] = [];
let paramIndex = 1;
const skippedColumns: string[] = [];
Object.keys(updatedData).forEach((column) => {
// 테이블에 존재하지 않는 컬럼은 스킵
if (!columnTypeMap.has(column)) {
skippedColumns.push(column);
return;
}
const dataType = columnTypeMap.get(column) || "text";
setConditions.push(
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
@@ -2424,6 +2432,10 @@ export class TableManagementService {
paramIndex++;
});
if (skippedColumns.length > 0) {
logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`);
}
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
let whereConditions: string[] = [];
let whereValues: any[] = [];
@@ -3930,9 +3942,10 @@ export class TableManagementService {
`컬럼 입력타입 정보 조회: ${tableName}, company: ${companyCode}`
);
// table_type_columns에서 입력타입 정보 조회 (company_code 필터링)
// table_type_columns에서 입력타입 정보 조회
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
const rawInputTypes = await query<any>(
`SELECT
`SELECT DISTINCT ON (ttc.column_name)
ttc.column_name as "columnName",
COALESCE(cl.column_label, ttc.column_name) as "displayName",
ttc.input_type as "inputType",
@@ -3946,8 +3959,10 @@ export class TableManagementService {
LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
WHERE ttc.table_name = $1
AND ttc.company_code = $2
ORDER BY ttc.display_order, ttc.column_name`,
AND ttc.company_code IN ($2, '*')
ORDER BY ttc.column_name,
CASE WHEN ttc.company_code = $2 THEN 0 ELSE 1 END,
ttc.display_order`,
[tableName, companyCode]
);
@@ -3961,17 +3976,20 @@ export class TableManagementService {
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
// 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists) {
logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
const mappings = await query<any>(
`SELECT
`SELECT DISTINCT ON (logical_column_name, menu_objid)
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
AND company_code IN ($2, '*')
ORDER BY logical_column_name, menu_objid,
CASE WHEN company_code = $2 THEN 0 ELSE 1 END`,
[tableName, companyCode]
);

View File

@@ -587,3 +587,4 @@ const result = await executeNodeFlow(flowId, {

View File

@@ -360,3 +360,4 @@

View File

@@ -346,3 +346,4 @@ const getComponentValue = (componentId: string) => {

View File

@@ -127,3 +127,4 @@ export default function ScreenManagementPage() {
</div>
);
}

View File

@@ -370,33 +370,14 @@ function AppLayoutInner({ children }: AppLayoutProps) {
};
// 모드 전환 핸들러
const handleModeSwitch = async () => {
const handleModeSwitch = () => {
if (isAdminMode) {
// 관리자 → 사용자 모드: 선택한 회사 유지
router.push("/main");
} else {
// 사용자 → 관리자 모드: WACE로 복귀 필요 (SUPER_ADMIN만)
if ((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN") {
const currentCompanyCode = (user as ExtendedUserInfo)?.companyCode;
// 이미 WACE("*")가 아니면 WACE로 전환 후 관리자 페이지로 이동
if (currentCompanyCode !== "*") {
const result = await switchCompany("*");
if (result.success) {
// 페이지 새로고침 (관리자 페이지로 이동)
window.location.href = "/admin";
} else {
toast.error("WACE로 전환 실패");
}
} else {
// 이미 WACE면 바로 관리자 페이지로 이동
// 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음)
router.push("/admin");
}
} else {
// 일반 관리자는 바로 관리자 페이지로 이동
router.push("/admin");
}
}
};
// 로그아웃 핸들러

View File

@@ -415,8 +415,10 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
maxWidth: `calc(100% - ${compPosition.x || 0}px)`,
};
// 🆕 formDataVersion을 key에서 제거하여 불필요한 remount 방지
// universal-form-modal 같은 컴포넌트가 채번 후 unmount되는 문제 해결
return (
<div key={`${component.id}-${formDataVersion}`} className="absolute" style={componentStyle}>
<div key={component.id} className="absolute" style={componentStyle}>
<DynamicComponentRenderer
component={component}
isInteractive={true}

View File

@@ -140,3 +140,4 @@ export const useActiveTabOptional = () => {

View File

@@ -197,3 +197,4 @@ export function applyAutoFillToFormData(

View File

@@ -44,7 +44,42 @@ export function AutocompleteSearchInputComponent({
const displayField = config?.displayField || propDisplayField || "";
const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드
const displaySeparator = config?.displaySeparator || " → "; // 구분자
const valueField = config?.valueField || propValueField || "";
// valueField 결정: fieldMappings 기반으로 추론 (config.valueField가 fieldMappings에 없으면 무시)
const getValueField = () => {
// fieldMappings가 있으면 그 안에서 추론 (가장 신뢰할 수 있는 소스)
if (config?.fieldMappings && config.fieldMappings.length > 0) {
// config.valueField가 fieldMappings의 sourceField에 있으면 사용
if (config?.valueField) {
const hasValueFieldInMappings = config.fieldMappings.some(
(m: any) => m.sourceField === config.valueField
);
if (hasValueFieldInMappings) {
return config.valueField;
}
// fieldMappings에 없으면 무시하고 추론
}
// _code 또는 _id로 끝나는 필드 우선 (보통 PK나 코드 필드)
const codeMapping = config.fieldMappings.find(
(m: any) => m.sourceField?.endsWith("_code") || m.sourceField?.endsWith("_id")
);
if (codeMapping) {
return codeMapping.sourceField;
}
// 없으면 첫 번째 매핑 사용
return config.fieldMappings[0].sourceField || "";
}
// fieldMappings가 없으면 기존 방식
if (config?.valueField) return config.valueField;
if (propValueField) return propValueField;
return "";
};
const valueField = getValueField();
const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용
const placeholder = config?.placeholder || propPlaceholder || "검색...";
@@ -76,11 +111,39 @@ export function AutocompleteSearchInputComponent({
// 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지)
const selectedDataRef = useRef<EntitySearchResult | null>(null);
const inputValueRef = useRef<string>("");
const initialValueLoadedRef = useRef<string | null>(null); // 초기값 로드 추적
// formData에서 현재 값 가져오기 (isInteractive 모드)
const currentValue = isInteractive && formData && component?.columnName
? formData[component.columnName]
: value;
// 우선순위: 1) component.columnName, 2) fieldMappings에서 valueField에 매핑된 targetField
const getCurrentValue = () => {
if (!isInteractive || !formData) {
return value;
}
// 1. component.columnName으로 직접 바인딩된 경우
if (component?.columnName && formData[component.columnName] !== undefined) {
return formData[component.columnName];
}
// 2. fieldMappings에서 valueField와 매핑된 targetField에서 값 가져오기
if (config?.fieldMappings && Array.isArray(config.fieldMappings)) {
const valueFieldMapping = config.fieldMappings.find(
(mapping: any) => mapping.sourceField === valueField
);
if (valueFieldMapping) {
const targetField = valueFieldMapping.targetField || valueFieldMapping.targetColumn;
if (targetField && formData[targetField] !== undefined) {
return formData[targetField];
}
}
}
return value;
};
const currentValue = getCurrentValue();
// selectedData 변경 시 ref도 업데이트
useEffect(() => {
@@ -98,6 +161,79 @@ export function AutocompleteSearchInputComponent({
}
}, []);
// 초기값이 있을 때 해당 값의 표시 텍스트를 조회하여 설정
useEffect(() => {
const loadInitialDisplayValue = async () => {
// 이미 로드된 값이거나, 값이 없거나, 이미 선택된 데이터가 있으면 스킵
if (!currentValue || selectedData || selectedDataRef.current) {
return;
}
// 이미 같은 값을 로드한 적이 있으면 스킵
if (initialValueLoadedRef.current === currentValue) {
return;
}
// 테이블명과 필드 정보가 없으면 스킵
if (!tableName || !valueField) {
return;
}
console.log("🔄 AutocompleteSearchInput 초기값 로드:", {
currentValue,
tableName,
valueField,
displayFields,
});
try {
// API를 통해 해당 값의 표시 텍스트 조회
const { apiClient } = await import("@/lib/api/client");
const filterConditionWithValue = {
...filterCondition,
[valueField]: currentValue,
};
const params = new URLSearchParams({
searchText: "",
searchFields: searchFields.join(","),
filterCondition: JSON.stringify(filterConditionWithValue),
page: "1",
limit: "10",
});
const response = await apiClient.get<{ success: boolean; data: EntitySearchResult[] }>(
`/entity-search/${tableName}?${params.toString()}`
);
if (response.data.success && response.data.data && response.data.data.length > 0) {
const matchedItem = response.data.data.find((item: EntitySearchResult) =>
String(item[valueField]) === String(currentValue)
);
if (matchedItem) {
const displayText = getDisplayValue(matchedItem);
console.log("✅ 초기값 표시 텍스트 로드 성공:", {
currentValue,
displayText,
matchedItem,
});
setSelectedData(matchedItem);
setInputValue(displayText);
selectedDataRef.current = matchedItem;
inputValueRef.current = displayText;
initialValueLoadedRef.current = currentValue;
}
}
} catch (error) {
console.error("❌ 초기값 표시 텍스트 로드 실패:", error);
}
};
loadInitialDisplayValue();
}, [currentValue, tableName, valueField, displayFields, filterCondition, searchFields, selectedData]);
// value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지
useEffect(() => {
// selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우)
@@ -107,6 +243,7 @@ export function AutocompleteSearchInputComponent({
if (!currentValue) {
setInputValue("");
initialValueLoadedRef.current = null; // 값이 없어지면 초기화
}
}, [currentValue, selectedData]);

View File

@@ -299,6 +299,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({});
// 🆕 splitPanelContext?.selectedLeftData를 로컬 상태로 추적 (리렌더링 보장)
const [trackedSelectedLeftData, setTrackedSelectedLeftData] = useState<Record<string, any> | null>(null);
// splitPanelContext?.selectedLeftData 변경 감지 및 로컬 상태 동기화
useEffect(() => {
const newData = splitPanelContext?.selectedLeftData ?? null;
setTrackedSelectedLeftData(newData);
console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
label: component.label,
hasData: !!newData,
dataKeys: newData ? Object.keys(newData) : [],
});
}, [splitPanelContext?.selectedLeftData, component.label]);
// modalDataStore 상태 구독 (실시간 업데이트)
useEffect(() => {
const actionConfig = component.componentConfig?.action;
@@ -357,8 +371,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 2. 분할 패널 좌측 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
// SplitPanelContext에서 확인
if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) {
// SplitPanelContext에서 확인 (trackedSelectedLeftData 사용으로 리렌더링 보장)
if (trackedSelectedLeftData && Object.keys(trackedSelectedLeftData).length > 0) {
if (!hasSelection) {
hasSelection = true;
selectionCount = 1;
@@ -397,7 +411,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
selectionCount,
selectionSource,
hasSplitPanelContext: !!splitPanelContext,
selectedLeftData: splitPanelContext?.selectedLeftData,
trackedSelectedLeftData: trackedSelectedLeftData,
selectedRowsData: selectedRowsData?.length,
selectedRows: selectedRows?.length,
flowSelectedData: flowSelectedData?.length,
@@ -429,7 +443,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
component.label,
selectedRows,
selectedRowsData,
splitPanelContext?.selectedLeftData,
trackedSelectedLeftData,
flowSelectedData,
splitPanelContext,
modalStoreData,

View File

@@ -2043,7 +2043,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return row.id || row.uuid || `row-${index}`;
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
const handleRowSelection = (rowKey: string, checked: boolean, rowData?: any) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
@@ -2086,6 +2086,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장/해제 (체크박스 선택 시에도 작동)
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (checked && selectedRowsData.length > 0) {
// 선택된 경우: 첫 번째 선택된 데이터 저장 (또는 전달된 rowData)
const dataToStore = rowData || selectedRowsData[selectedRowsData.length - 1];
splitPanelContext.setSelectedLeftData(dataToStore);
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 저장:", {
rowKey,
dataToStore,
});
} else if (!checked && selectedRowsData.length === 0) {
// 모든 선택이 해제된 경우: 데이터 초기화
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 초기화");
} else if (selectedRowsData.length > 0) {
// 일부 선택 해제된 경우: 남은 첫 번째 데이터로 업데이트
splitPanelContext.setSelectedLeftData(selectedRowsData[0]);
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 업데이트:", {
remainingCount: selectedRowsData.length,
firstData: selectedRowsData[0],
});
}
}
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && filteredData.length > 0);
};
@@ -2155,35 +2180,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const rowKey = getRowKey(row, index);
const isCurrentlySelected = selectedRows.has(rowKey);
handleRowSelection(rowKey, !isCurrentlySelected);
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
// currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음)
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", {
splitPanelPosition,
currentSplitPosition,
effectiveSplitPosition,
hasSplitPanelContext: !!splitPanelContext,
disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer,
});
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (!isCurrentlySelected) {
// 선택된 경우: 데이터 저장
splitPanelContext.setSelectedLeftData(row);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", {
row,
parentDataMapping: splitPanelContext.parentDataMapping,
});
} else {
// 선택 해제된 경우: 데이터 초기화
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화");
}
}
// handleRowSelection에서 분할 패널 데이터 처리도 함께 수행됨
handleRowSelection(rowKey, !isCurrentlySelected, row);
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
};
@@ -3918,7 +3916,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (enterRow) {
const rowKey = getRowKey(enterRow, rowIndex);
const isCurrentlySelected = selectedRows.has(rowKey);
handleRowSelection(rowKey, !isCurrentlySelected);
handleRowSelection(rowKey, !isCurrentlySelected, enterRow);
}
break;
case " ": // Space
@@ -3928,7 +3926,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (spaceRow) {
const currentRowKey = getRowKey(spaceRow, rowIndex);
const isChecked = selectedRows.has(currentRowKey);
handleRowSelection(currentRowKey, !isChecked);
handleRowSelection(currentRowKey, !isChecked, spaceRow);
}
break;
case "F2":
@@ -4142,7 +4140,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return (
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean, row)}
aria-label={`${index + 1} 선택`}
/>
);

View File

@@ -216,6 +216,12 @@ export function UniversalFormModalComponent({
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
useEffect(() => {
console.log("[UniversalFormModal] useEffect 시작", {
initialData,
hasInitialized: hasInitialized.current,
lastInitializedId: lastInitializedId.current,
});
// initialData에서 ID 값 추출 (id, ID, objid 등)
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
@@ -229,10 +235,21 @@ export function UniversalFormModalComponent({
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
if (!createModeDataHash || capturedInitialData.current) {
console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨");
// 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
// (컴포넌트 remount로 인해 state가 초기화된 경우)
return;
}
}
// 🆕 컴포넌트 remount 감지: hasInitialized가 true인데 formData가 비어있으면 재초기화
// (React의 Strict Mode나 EmbeddedScreen 리렌더링으로 인한 remount)
if (hasInitialized.current && !currentIdString) {
console.log("[UniversalFormModal] 컴포넌트 remount 감지 - 채번 플래그 초기화");
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
}
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) {
console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
@@ -252,6 +269,7 @@ export function UniversalFormModalComponent({
console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
}
console.log("[UniversalFormModal] initializeForm 호출 예정");
hasInitialized.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -389,6 +407,94 @@ export function UniversalFormModalComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.sections]);
// 채번규칙 자동 생성 (중복 호출 방지)
// 중요: initializeForm에서 호출되므로 반드시 initializeForm보다 먼저 선언해야 함
const numberingGeneratedRef = useRef(false);
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
const generateNumberingValues = useCallback(
async (currentFormData: FormDataState) => {
// 이미 생성되었거나 진행 중이면 스킵
if (numberingGeneratedRef.current) {
console.log("[채번] 이미 생성됨 - 스킵");
return;
}
if (isGeneratingRef.current) {
console.log("[채번] 생성 진행 중 - 스킵");
return;
}
isGeneratingRef.current = true; // 진행 중 표시
console.log("[채번] 생성 시작", { sectionsCount: config.sections.length });
const updatedData = { ...currentFormData };
let hasChanges = false;
for (const section of config.sections) {
console.log("[채번] 섹션 검사:", section.title, { type: section.type, repeatable: section.repeatable, fieldsCount: section.fields?.length });
if (section.repeatable || section.type === "table") continue;
for (const field of (section.fields || [])) {
// generateOnOpen은 기본값 true (undefined일 경우 true로 처리)
const shouldGenerateOnOpen = field.numberingRule?.generateOnOpen !== false;
console.log("[채번] 필드 검사:", field.columnName, {
hasNumberingRule: !!field.numberingRule,
enabled: field.numberingRule?.enabled,
generateOnOpen: field.numberingRule?.generateOnOpen,
shouldGenerateOnOpen,
ruleId: field.numberingRule?.ruleId,
currentValue: updatedData[field.columnName],
});
if (
field.numberingRule?.enabled &&
shouldGenerateOnOpen &&
field.numberingRule?.ruleId &&
!updatedData[field.columnName]
) {
try {
console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
// generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함)
const response = await previewNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
updatedData[field.columnName] = response.data.generatedCode;
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
const ruleIdKey = `${field.columnName}_numberingRuleId`;
updatedData[ruleIdKey] = field.numberingRule.ruleId;
hasChanges = true;
numberingGeneratedRef.current = true; // 생성 완료 표시
console.log(
`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
);
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
if (onChange) {
onChange({
...updatedData,
[ruleIdKey]: field.numberingRule.ruleId,
});
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
}
}
} catch (error) {
console.error(`채번규칙 미리보기 실패 (${field.columnName}):`, error);
}
}
}
}
isGeneratingRef.current = false; // 진행 완료
if (hasChanges) {
setFormData(updatedData);
}
},
[config, onChange],
);
// 폼 초기화
const initializeForm = useCallback(async () => {
console.log("[initializeForm] 시작");
@@ -585,82 +691,6 @@ export function UniversalFormModalComponent({
return item;
};
// 채번규칙 자동 생성 (중복 호출 방지)
const numberingGeneratedRef = useRef(false);
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
const generateNumberingValues = useCallback(
async (currentFormData: FormDataState) => {
// 이미 생성되었거나 진행 중이면 스킵
if (numberingGeneratedRef.current) {
console.log("[채번] 이미 생성됨 - 스킵");
return;
}
if (isGeneratingRef.current) {
console.log("[채번] 생성 진행 중 - 스킵");
return;
}
isGeneratingRef.current = true; // 진행 중 표시
console.log("[채번] 생성 시작");
const updatedData = { ...currentFormData };
let hasChanges = false;
for (const section of config.sections) {
if (section.repeatable || section.type === "table") continue;
for (const field of (section.fields || [])) {
if (
field.numberingRule?.enabled &&
field.numberingRule?.generateOnOpen &&
field.numberingRule?.ruleId &&
!updatedData[field.columnName]
) {
try {
console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
// generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함)
const response = await previewNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
updatedData[field.columnName] = response.data.generatedCode;
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
const ruleIdKey = `${field.columnName}_numberingRuleId`;
updatedData[ruleIdKey] = field.numberingRule.ruleId;
hasChanges = true;
numberingGeneratedRef.current = true; // 생성 완료 표시
console.log(
`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
);
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
if (onChange) {
onChange({
...updatedData,
[ruleIdKey]: field.numberingRule.ruleId,
});
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
}
}
} catch (error) {
console.error(`채번규칙 미리보기 실패 (${field.columnName}):`, error);
}
}
}
}
isGeneratingRef.current = false; // 진행 완료
if (hasChanges) {
setFormData(updatedData);
}
},
[config, onChange],
);
// 필드 값 변경 핸들러
const handleFieldChange = useCallback(
(columnName: string, value: any) => {

View File

@@ -995,6 +995,40 @@ export class ButtonActionExecutor {
console.log("📋 [handleSave] 범용 폼 모달 공통 필드:", commonFields);
}
// 🆕 루트 레벨 formData에서 RepeaterFieldGroup에 전달할 공통 필드 추출
// 주문번호, 발주번호 등 마스터-디테일 관계에서 필요한 필드만 명시적으로 지정
const masterDetailFields = [
// 번호 필드
"order_no", // 발주번호
"sales_order_no", // 수주번호
"shipment_no", // 출하번호
"receipt_no", // 입고번호
"work_order_no", // 작업지시번호
// 거래처 필드
"supplier_code", // 공급처 코드
"supplier_name", // 공급처 이름
"customer_code", // 고객 코드
"customer_name", // 고객 이름
// 날짜 필드
"order_date", // 발주일
"sales_date", // 수주일
"shipment_date", // 출하일
"receipt_date", // 입고일
"due_date", // 납기일
// 담당자/메모 필드
"manager", // 담당자
"memo", // 메모
"remark", // 비고
];
for (const fieldName of masterDetailFields) {
const value = context.formData[fieldName];
if (value !== undefined && value !== "" && value !== null && !(fieldName in commonFields)) {
commonFields[fieldName] = value;
}
}
console.log("📋 [handleSave] 최종 공통 필드 (마스터-디테일 필드 포함):", commonFields);
for (const item of parsedData) {
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
@@ -5931,6 +5965,69 @@ export class ButtonActionExecutor {
return false;
}
// ✅ allComponents가 있으면 기존 필수 항목 검증 수행
if (context.allComponents && context.allComponents.length > 0) {
console.log("🔍 [handleQuickInsert] 필수 항목 검증 시작:", {
hasAllComponents: !!context.allComponents,
allComponentsLength: context.allComponents?.length || 0,
});
const requiredValidation = this.validateRequiredFields(context);
if (!requiredValidation.isValid) {
console.log("❌ [handleQuickInsert] 필수 항목 누락:", requiredValidation.missingFields);
toast.error(`필수 항목을 입력해주세요: ${requiredValidation.missingFields.join(", ")}`);
return false;
}
console.log("✅ [handleQuickInsert] 필수 항목 검증 통과");
}
// ✅ quickInsert 전용 검증: component 타입 매핑에서 값이 비어있는지 확인
const mappingsForValidation = quickInsertConfig.columnMappings || [];
const missingMappingFields: string[] = [];
for (const mapping of mappingsForValidation) {
// component 타입 매핑은 필수 입력으로 간주
if (mapping.sourceType === "component" && mapping.sourceComponentId) {
let value: any = undefined;
// 값 가져오기 (formData에서)
if (mapping.sourceColumnName) {
value = context.formData?.[mapping.sourceColumnName];
}
if (value === undefined || value === null) {
value = context.formData?.[mapping.sourceComponentId];
}
// allComponents에서 컴포넌트 찾아서 columnName으로 시도
if ((value === undefined || value === null) && context.allComponents) {
const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId);
if (comp?.columnName) {
value = context.formData?.[comp.columnName];
}
}
// targetColumn으로 폴백
if ((value === undefined || value === null) && mapping.targetColumn) {
value = context.formData?.[mapping.targetColumn];
}
// 값이 비어있으면 필수 누락으로 처리
if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) {
console.log("❌ [handleQuickInsert] component 매핑 값 누락:", {
targetColumn: mapping.targetColumn,
sourceComponentId: mapping.sourceComponentId,
sourceColumnName: mapping.sourceColumnName,
value,
});
missingMappingFields.push(mapping.targetColumn);
}
}
}
if (missingMappingFields.length > 0) {
console.log("❌ [handleQuickInsert] 필수 입력 항목 누락:", missingMappingFields);
toast.error(`다음 항목을 입력해주세요: ${missingMappingFields.join(", ")}`);
return false;
}
console.log("✅ [handleQuickInsert] quickInsert 매핑 검증 통과");
const { formData, splitPanelContext, userId, userName, companyCode } = context;
console.log("⚡ Quick Insert 상세 정보:", {

View File

@@ -1689,3 +1689,4 @@ const 출고등록_설정: ScreenSplitPanel = {

View File

@@ -536,3 +536,4 @@ const { data: config } = await getScreenSplitPanel(screenId);

View File

@@ -523,3 +523,4 @@ function ScreenViewPage() {