Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
b3e6613d66
|
|
@ -164,8 +164,8 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await this.pool.execute(sql, params);
|
const [rows] = await this.pool.execute(sql, params);
|
||||||
return rows;
|
return rows;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// 연결 닫힘 오류 감지
|
// 연결 닫힘 오류 감지
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -40,32 +40,37 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [screenInfo, setScreenInfo] = useState<any>(null);
|
const [screenInfo, setScreenInfo] = useState<any>(null);
|
||||||
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
|
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
|
||||||
|
const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용)
|
||||||
|
|
||||||
// 컴포넌트 참조 맵
|
// 컴포넌트 참조 맵
|
||||||
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
||||||
|
|
||||||
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
||||||
const splitPanelContext = useSplitPanelContext();
|
const splitPanelContext = useSplitPanelContext();
|
||||||
|
|
||||||
|
// 🆕 selectedLeftData 참조 안정화 (실제 값이 바뀔 때만 업데이트)
|
||||||
|
const selectedLeftData = splitPanelContext?.selectedLeftData;
|
||||||
|
const prevSelectedLeftDataRef = useRef<string>("");
|
||||||
|
|
||||||
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
|
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
|
||||||
const { userId, userName, companyCode } = useAuth();
|
const { userId, userName, companyCode } = useAuth();
|
||||||
|
|
||||||
// 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해)
|
// 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해)
|
||||||
const contentBounds = React.useMemo(() => {
|
const contentBounds = React.useMemo(() => {
|
||||||
if (layout.length === 0) return { width: 0, height: 0 };
|
if (layout.length === 0) return { width: 0, height: 0 };
|
||||||
|
|
||||||
let maxRight = 0;
|
let maxRight = 0;
|
||||||
let maxBottom = 0;
|
let maxBottom = 0;
|
||||||
|
|
||||||
layout.forEach((component) => {
|
layout.forEach((component) => {
|
||||||
const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component;
|
const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component;
|
||||||
const right = (compPosition.x || 0) + (size.width || 200);
|
const right = (compPosition.x || 0) + (size.width || 200);
|
||||||
const bottom = (compPosition.y || 0) + (size.height || 40);
|
const bottom = (compPosition.y || 0) + (size.height || 40);
|
||||||
|
|
||||||
if (right > maxRight) maxRight = right;
|
if (right > maxRight) maxRight = right;
|
||||||
if (bottom > maxBottom) maxBottom = bottom;
|
if (bottom > maxBottom) maxBottom = bottom;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { width: maxRight, height: maxBottom };
|
return { width: maxRight, height: maxBottom };
|
||||||
}, [layout]);
|
}, [layout]);
|
||||||
|
|
||||||
|
|
@ -92,26 +97,53 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
}, [initialFormData]);
|
}, [initialFormData]);
|
||||||
|
|
||||||
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
|
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
|
||||||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
// 🆕 좌측 선택 데이터가 변경되면 우측 formData를 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 우측 화면인 경우에만 적용
|
// 우측 화면인 경우에만 적용
|
||||||
if (position !== "right" || !splitPanelContext) return;
|
if (position !== "right" || !splitPanelContext) {
|
||||||
|
|
||||||
// 자동 데이터 전달이 비활성화된 경우 스킵
|
|
||||||
if (splitPanelContext.disableAutoDataTransfer) {
|
|
||||||
console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mappedData = splitPanelContext.getMappedParentData();
|
// 자동 데이터 전달이 비활성화된 경우 스킵
|
||||||
if (Object.keys(mappedData).length > 0) {
|
if (splitPanelContext.disableAutoDataTransfer) {
|
||||||
console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData);
|
return;
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
...mappedData,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}, [position, splitPanelContext, splitPanelContext?.selectedLeftData]);
|
|
||||||
|
// 🆕 값 비교로 실제 변경 여부 확인 (불필요한 리렌더링 방지)
|
||||||
|
const currentDataStr = JSON.stringify(selectedLeftData || {});
|
||||||
|
if (prevSelectedLeftDataRef.current === currentDataStr) {
|
||||||
|
return; // 실제 값이 같으면 스킵
|
||||||
|
}
|
||||||
|
prevSelectedLeftDataRef.current = currentDataStr;
|
||||||
|
|
||||||
|
// 🆕 현재 화면의 모든 컴포넌트에서 columnName 수집
|
||||||
|
const allColumnNames = layout.filter((comp) => comp.columnName).map((comp) => comp.columnName as string);
|
||||||
|
|
||||||
|
// 🆕 모든 필드를 빈 값으로 초기화한 후, selectedLeftData로 덮어쓰기
|
||||||
|
const initializedFormData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 먼저 모든 컬럼을 빈 문자열로 초기화
|
||||||
|
allColumnNames.forEach((colName) => {
|
||||||
|
initializedFormData[colName] = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// selectedLeftData가 있으면 해당 값으로 덮어쓰기
|
||||||
|
if (selectedLeftData && Object.keys(selectedLeftData).length > 0) {
|
||||||
|
Object.keys(selectedLeftData).forEach((key) => {
|
||||||
|
// null/undefined는 빈 문자열로, 나머지는 그대로
|
||||||
|
initializedFormData[key] = selectedLeftData[key] ?? "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔗 [EmbeddedScreen] 우측 폼 데이터 교체:", {
|
||||||
|
allColumnNames,
|
||||||
|
selectedLeftDataKeys: selectedLeftData ? Object.keys(selectedLeftData) : [],
|
||||||
|
initializedFormDataKeys: Object.keys(initializedFormData),
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormData(initializedFormData);
|
||||||
|
setFormDataVersion((v) => v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링
|
||||||
|
}, [position, splitPanelContext, selectedLeftData, layout]);
|
||||||
|
|
||||||
// 선택 변경 이벤트 전파
|
// 선택 변경 이벤트 전파
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -377,15 +409,15 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
<p className="text-muted-foreground text-sm">화면에 컴포넌트가 없습니다.</p>
|
<p className="text-muted-foreground text-sm">화면에 컴포넌트가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{
|
style={{
|
||||||
minHeight: contentBounds.height + 20, // 여유 공간 추가
|
minHeight: contentBounds.height + 20, // 여유 공간 추가
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{layout.map((component) => {
|
{layout.map((component) => {
|
||||||
const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
|
const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
|
||||||
|
|
||||||
// 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정
|
// 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정
|
||||||
// 부모 컨테이너의 100%를 기준으로 계산
|
// 부모 컨테이너의 100%를 기준으로 계산
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = {
|
||||||
|
|
@ -397,13 +429,9 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
// 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정
|
// 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정
|
||||||
maxWidth: `calc(100% - ${compPosition.x || 0}px)`,
|
maxWidth: `calc(100% - ${compPosition.x || 0}px)`,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={`${component.id}-${formDataVersion}`} className="absolute" style={componentStyle}>
|
||||||
key={component.id}
|
|
||||||
className="absolute"
|
|
||||||
style={componentStyle}
|
|
||||||
>
|
|
||||||
<DynamicComponentRenderer
|
<DynamicComponentRenderer
|
||||||
component={component}
|
component={component}
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,56 @@ interface EditModalState {
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
|
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
|
||||||
tableName?: string; // 🆕 테이블명 (그룹 조회용)
|
tableName?: string; // 🆕 테이블명 (그룹 조회용)
|
||||||
|
buttonConfig?: any; // 🆕 버튼 설정 (제어로직 실행용)
|
||||||
|
buttonContext?: any; // 🆕 버튼 컨텍스트 (screenId, userId 등)
|
||||||
|
saveButtonConfig?: {
|
||||||
|
enableDataflowControl?: boolean;
|
||||||
|
dataflowConfig?: any;
|
||||||
|
dataflowTiming?: string;
|
||||||
|
}; // 🆕 모달 내부 저장 버튼의 제어로직 설정
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditModalProps {
|
interface EditModalProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 내부에서 저장 버튼 찾기 (재귀적으로 탐색)
|
||||||
|
* action.type이 "save"인 button-primary 컴포넌트를 찾음
|
||||||
|
*/
|
||||||
|
const findSaveButtonInComponents = (components: any[]): any | null => {
|
||||||
|
if (!components || !Array.isArray(components)) return null;
|
||||||
|
|
||||||
|
for (const comp of components) {
|
||||||
|
// button-primary이고 action.type이 save인 경우
|
||||||
|
if (
|
||||||
|
comp.componentType === "button-primary" &&
|
||||||
|
comp.componentConfig?.action?.type === "save"
|
||||||
|
) {
|
||||||
|
return comp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// conditional-container의 sections 내부 탐색
|
||||||
|
if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) {
|
||||||
|
for (const section of comp.componentConfig.sections) {
|
||||||
|
if (section.screenId) {
|
||||||
|
// 조건부 컨테이너의 내부 화면은 별도로 로드해야 함
|
||||||
|
// 여기서는 null 반환하고, loadSaveButtonConfig에서 처리
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자식 컴포넌트가 있으면 재귀 탐색
|
||||||
|
if (comp.children && Array.isArray(comp.children)) {
|
||||||
|
const found = findSaveButtonInComponents(comp.children);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [modalState, setModalState] = useState<EditModalState>({
|
const [modalState, setModalState] = useState<EditModalState>({
|
||||||
|
|
@ -44,6 +88,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
onSave: undefined,
|
onSave: undefined,
|
||||||
groupByColumns: undefined,
|
groupByColumns: undefined,
|
||||||
tableName: undefined,
|
tableName: undefined,
|
||||||
|
buttonConfig: undefined,
|
||||||
|
buttonContext: undefined,
|
||||||
|
saveButtonConfig: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [screenData, setScreenData] = useState<{
|
const [screenData, setScreenData] = useState<{
|
||||||
|
|
@ -115,11 +162,88 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
|
||||||
|
const loadSaveButtonConfig = async (targetScreenId: number): Promise<{
|
||||||
|
enableDataflowControl?: boolean;
|
||||||
|
dataflowConfig?: any;
|
||||||
|
dataflowTiming?: string;
|
||||||
|
} | null> => {
|
||||||
|
try {
|
||||||
|
// 1. 대상 화면의 레이아웃 조회
|
||||||
|
const layoutData = await screenApi.getLayout(targetScreenId);
|
||||||
|
|
||||||
|
if (!layoutData?.components) {
|
||||||
|
console.log("[EditModal] 레이아웃 컴포넌트 없음:", targetScreenId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 저장 버튼 찾기
|
||||||
|
let saveButton = findSaveButtonInComponents(layoutData.components);
|
||||||
|
|
||||||
|
// 3. conditional-container가 있는 경우 내부 화면도 탐색
|
||||||
|
if (!saveButton) {
|
||||||
|
for (const comp of layoutData.components) {
|
||||||
|
if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) {
|
||||||
|
for (const section of comp.componentConfig.sections) {
|
||||||
|
if (section.screenId) {
|
||||||
|
try {
|
||||||
|
const innerLayoutData = await screenApi.getLayout(section.screenId);
|
||||||
|
saveButton = findSaveButtonInComponents(innerLayoutData?.components || []);
|
||||||
|
if (saveButton) {
|
||||||
|
console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", {
|
||||||
|
sectionScreenId: section.screenId,
|
||||||
|
sectionLabel: section.label,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (innerError) {
|
||||||
|
console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (saveButton) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saveButton) {
|
||||||
|
console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. webTypeConfig에서 제어로직 설정 추출
|
||||||
|
const webTypeConfig = saveButton.webTypeConfig;
|
||||||
|
if (webTypeConfig?.enableDataflowControl) {
|
||||||
|
const config = {
|
||||||
|
enableDataflowControl: webTypeConfig.enableDataflowControl,
|
||||||
|
dataflowConfig: webTypeConfig.dataflowConfig,
|
||||||
|
dataflowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming || "after",
|
||||||
|
};
|
||||||
|
console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 전역 모달 이벤트 리스너
|
// 전역 모달 이벤트 리스너
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenEditModal = (event: CustomEvent) => {
|
const handleOpenEditModal = async (event: CustomEvent) => {
|
||||||
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } =
|
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext } = event.detail;
|
||||||
event.detail;
|
|
||||||
|
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
|
||||||
|
let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined;
|
||||||
|
if (screenId) {
|
||||||
|
const config = await loadSaveButtonConfig(screenId);
|
||||||
|
if (config) {
|
||||||
|
saveButtonConfig = config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setModalState({
|
setModalState({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
|
|
@ -131,6 +255,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
onSave,
|
onSave,
|
||||||
groupByColumns, // 🆕 그룹핑 컬럼
|
groupByColumns, // 🆕 그룹핑 컬럼
|
||||||
tableName, // 🆕 테이블명
|
tableName, // 🆕 테이블명
|
||||||
|
buttonConfig, // 🆕 버튼 설정
|
||||||
|
buttonContext, // 🆕 버튼 컨텍스트
|
||||||
|
saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정
|
||||||
});
|
});
|
||||||
|
|
||||||
// 편집 데이터로 폼 데이터 초기화
|
// 편집 데이터로 폼 데이터 초기화
|
||||||
|
|
@ -578,6 +705,46 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
||||||
|
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||||
|
try {
|
||||||
|
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||||
|
|
||||||
|
console.log("[EditModal] 그룹 저장 완료 후 제어로직 실행 시도", {
|
||||||
|
hasSaveButtonConfig: !!modalState.saveButtonConfig,
|
||||||
|
hasButtonConfig: !!modalState.buttonConfig,
|
||||||
|
controlConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
|
||||||
|
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||||
|
|
||||||
|
// buttonActions의 executeAfterSaveControl 동적 import
|
||||||
|
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||||
|
|
||||||
|
// 제어로직 실행
|
||||||
|
await ButtonActionExecutor.executeAfterSaveControl(
|
||||||
|
controlConfig,
|
||||||
|
{
|
||||||
|
formData: modalState.editData,
|
||||||
|
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||||
|
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||||
|
userId: user?.userId,
|
||||||
|
companyCode: user?.companyCode,
|
||||||
|
onRefresh: modalState.onSave,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||||
|
} else {
|
||||||
|
console.log("ℹ️ [EditModal] 저장 후 실행할 제어로직 없음");
|
||||||
|
}
|
||||||
|
} catch (controlError) {
|
||||||
|
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
|
||||||
|
// 제어로직 오류는 저장 성공을 방해하지 않음 (경고만 표시)
|
||||||
|
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
} else {
|
} else {
|
||||||
toast.info("변경된 내용이 없습니다.");
|
toast.info("변경된 내용이 없습니다.");
|
||||||
|
|
@ -612,6 +779,37 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
||||||
|
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||||
|
try {
|
||||||
|
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||||
|
|
||||||
|
console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig });
|
||||||
|
|
||||||
|
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
|
||||||
|
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||||
|
|
||||||
|
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||||
|
|
||||||
|
await ButtonActionExecutor.executeAfterSaveControl(
|
||||||
|
controlConfig,
|
||||||
|
{
|
||||||
|
formData,
|
||||||
|
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||||
|
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||||
|
userId: user?.userId,
|
||||||
|
companyCode: user?.companyCode,
|
||||||
|
onRefresh: modalState.onSave,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||||
|
}
|
||||||
|
} catch (controlError) {
|
||||||
|
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
|
||||||
|
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || "생성에 실패했습니다.");
|
throw new Error(response.message || "생성에 실패했습니다.");
|
||||||
|
|
@ -654,6 +852,37 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
||||||
|
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||||
|
try {
|
||||||
|
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||||
|
|
||||||
|
console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig });
|
||||||
|
|
||||||
|
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
|
||||||
|
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||||
|
|
||||||
|
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||||
|
|
||||||
|
await ButtonActionExecutor.executeAfterSaveControl(
|
||||||
|
controlConfig,
|
||||||
|
{
|
||||||
|
formData,
|
||||||
|
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||||
|
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||||
|
userId: user?.userId,
|
||||||
|
companyCode: user?.companyCode,
|
||||||
|
onRefresh: modalState.onSave,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||||
|
}
|
||||||
|
} catch (controlError) {
|
||||||
|
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
|
||||||
|
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
handleClose();
|
handleClose();
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || "수정에 실패했습니다.");
|
throw new Error(response.message || "수정에 실패했습니다.");
|
||||||
|
|
|
||||||
|
|
@ -333,22 +333,72 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
|
|
||||||
const loadModalMappingColumns = async () => {
|
const loadModalMappingColumns = async () => {
|
||||||
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
|
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
|
||||||
// allComponents에서 split-panel-layout 또는 table-list 찾기
|
|
||||||
let sourceTableName: string | null = null;
|
let sourceTableName: string | null = null;
|
||||||
|
|
||||||
|
console.log("[openModalWithData] 컬럼 로드 시작:", {
|
||||||
|
allComponentsCount: allComponents.length,
|
||||||
|
currentTableName,
|
||||||
|
targetScreenId: config.action?.targetScreenId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 컴포넌트 타입 로그
|
||||||
|
allComponents.forEach((comp, idx) => {
|
||||||
|
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
||||||
|
console.log(` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || 'N/A'}`);
|
||||||
|
});
|
||||||
|
|
||||||
for (const comp of allComponents) {
|
for (const comp of allComponents) {
|
||||||
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
||||||
|
const compConfig = (comp as any).componentConfig || {};
|
||||||
|
|
||||||
|
// 분할 패널 타입들 (다양한 경로에서 테이블명 추출)
|
||||||
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
|
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
|
||||||
// 분할 패널의 좌측 테이블명
|
sourceTableName = compConfig?.leftPanel?.tableName ||
|
||||||
sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName ||
|
compConfig?.leftTableName ||
|
||||||
(comp as any).componentConfig?.leftTableName;
|
compConfig?.tableName;
|
||||||
break;
|
if (sourceTableName) {
|
||||||
|
console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// split-panel-layout2 타입 (새로운 분할 패널)
|
||||||
|
if (compType === "split-panel-layout2") {
|
||||||
|
sourceTableName = compConfig?.leftPanel?.tableName ||
|
||||||
|
compConfig?.tableName ||
|
||||||
|
compConfig?.leftTableName;
|
||||||
|
if (sourceTableName) {
|
||||||
|
console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 리스트 타입
|
||||||
if (compType === "table-list") {
|
if (compType === "table-list") {
|
||||||
sourceTableName = (comp as any).componentConfig?.tableName;
|
sourceTableName = compConfig?.tableName;
|
||||||
|
if (sourceTableName) {
|
||||||
|
console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 모든 컴포넌트에서 tableName 찾기 (폴백)
|
||||||
|
if (!sourceTableName && compConfig?.tableName) {
|
||||||
|
sourceTableName = compConfig.tableName;
|
||||||
|
console.log(`✅ [openModalWithData] ${compType}에서 소스 테이블 감지 (폴백): ${sourceTableName}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명)
|
||||||
|
if (!sourceTableName && currentTableName) {
|
||||||
|
sourceTableName = currentTableName;
|
||||||
|
console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceTableName) {
|
||||||
|
console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
// 소스 테이블 컬럼 로드
|
// 소스 테이블 컬럼 로드
|
||||||
if (sourceTableName) {
|
if (sourceTableName) {
|
||||||
|
|
@ -361,11 +411,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
|
|
||||||
if (Array.isArray(columnData)) {
|
if (Array.isArray(columnData)) {
|
||||||
const columns = columnData.map((col: any) => ({
|
const columns = columnData.map((col: any) => ({
|
||||||
name: col.name || col.columnName,
|
name: col.name || col.columnName || col.column_name,
|
||||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
|
||||||
}));
|
}));
|
||||||
setModalSourceColumns(columns);
|
setModalSourceColumns(columns);
|
||||||
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length);
|
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -379,8 +429,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
try {
|
try {
|
||||||
// 타겟 화면 정보 가져오기
|
// 타겟 화면 정보 가져오기
|
||||||
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
|
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
|
||||||
|
console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data);
|
||||||
|
|
||||||
if (screenResponse.data.success && screenResponse.data.data) {
|
if (screenResponse.data.success && screenResponse.data.data) {
|
||||||
const targetTableName = screenResponse.data.data.tableName;
|
const targetTableName = screenResponse.data.data.tableName;
|
||||||
|
console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName);
|
||||||
|
|
||||||
if (targetTableName) {
|
if (targetTableName) {
|
||||||
const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
|
const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
|
||||||
if (columnResponse.data.success) {
|
if (columnResponse.data.success) {
|
||||||
|
|
@ -390,23 +444,27 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
|
|
||||||
if (Array.isArray(columnData)) {
|
if (Array.isArray(columnData)) {
|
||||||
const columns = columnData.map((col: any) => ({
|
const columns = columnData.map((col: any) => ({
|
||||||
name: col.name || col.columnName,
|
name: col.name || col.columnName || col.column_name,
|
||||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
|
||||||
}));
|
}));
|
||||||
setModalTargetColumns(columns);
|
setModalTargetColumns(columns);
|
||||||
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length);
|
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
|
console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("[openModalWithData] 타겟 화면 ID가 없습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadModalMappingColumns();
|
loadModalMappingColumns();
|
||||||
}, [config.action?.type, config.action?.targetScreenId, allComponents]);
|
}, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]);
|
||||||
|
|
||||||
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1158,11 +1216,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
||||||
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
|
<div key={index} className="rounded-md border bg-background p-3 space-y-2">
|
||||||
{/* 소스 필드 선택 (Combobox) */}
|
{/* 소스 필드 선택 (Combobox) - 세로 배치 */}
|
||||||
<div className="flex-1">
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">소스 컬럼</Label>
|
||||||
<Popover
|
<Popover
|
||||||
open={modalSourcePopoverOpen[index] || false}
|
open={modalSourcePopoverOpen[index] || false}
|
||||||
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||||
|
|
@ -1171,15 +1230,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="h-7 w-full justify-between text-xs"
|
className="h-8 w-full justify-between text-xs"
|
||||||
>
|
>
|
||||||
{mapping.sourceField
|
<span className="truncate">
|
||||||
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
|
{mapping.sourceField
|
||||||
: "소스 컬럼 선택"}
|
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
|
||||||
|
: "소스 컬럼 선택"}
|
||||||
|
</span>
|
||||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[200px] p-0" align="start">
|
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="컬럼 검색..."
|
placeholder="컬럼 검색..."
|
||||||
|
|
@ -1187,7 +1248,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
value={modalSourceSearch[index] || ""}
|
value={modalSourceSearch[index] || ""}
|
||||||
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
|
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList className="max-h-[200px]">
|
||||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{modalSourceColumns.map((col) => (
|
{modalSourceColumns.map((col) => (
|
||||||
|
|
@ -1208,9 +1269,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
mapping.sourceField === col.name ? "opacity-100" : "opacity-0"
|
mapping.sourceField === col.name ? "opacity-100" : "opacity-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span>{col.label}</span>
|
<span className="truncate">{col.label}</span>
|
||||||
{col.label !== col.name && (
|
{col.label !== col.name && (
|
||||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
<span className="ml-1 text-muted-foreground truncate">({col.name})</span>
|
||||||
)}
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1221,10 +1282,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground">→</span>
|
{/* 화살표 표시 */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<span className="text-xs text-muted-foreground">↓</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 타겟 필드 선택 (Combobox) */}
|
{/* 타겟 필드 선택 (Combobox) - 세로 배치 */}
|
||||||
<div className="flex-1">
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">타겟 컬럼</Label>
|
||||||
<Popover
|
<Popover
|
||||||
open={modalTargetPopoverOpen[index] || false}
|
open={modalTargetPopoverOpen[index] || false}
|
||||||
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||||
|
|
@ -1233,15 +1298,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="h-7 w-full justify-between text-xs"
|
className="h-8 w-full justify-between text-xs"
|
||||||
>
|
>
|
||||||
{mapping.targetField
|
<span className="truncate">
|
||||||
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
|
{mapping.targetField
|
||||||
: "타겟 컬럼 선택"}
|
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
|
||||||
|
: "타겟 컬럼 선택"}
|
||||||
|
</span>
|
||||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[200px] p-0" align="start">
|
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="컬럼 검색..."
|
placeholder="컬럼 검색..."
|
||||||
|
|
@ -1249,7 +1316,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
value={modalTargetSearch[index] || ""}
|
value={modalTargetSearch[index] || ""}
|
||||||
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
|
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList className="max-h-[200px]">
|
||||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{modalTargetColumns.map((col) => (
|
{modalTargetColumns.map((col) => (
|
||||||
|
|
@ -1270,9 +1337,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
mapping.targetField === col.name ? "opacity-100" : "opacity-0"
|
mapping.targetField === col.name ? "opacity-100" : "opacity-0"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span>{col.label}</span>
|
<span className="truncate">{col.label}</span>
|
||||||
{col.label !== col.name && (
|
{col.label !== col.name && (
|
||||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
<span className="ml-1 text-muted-foreground truncate">({col.name})</span>
|
||||||
)}
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1284,19 +1351,22 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
<Button
|
<div className="flex justify-end pt-1">
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
size="sm"
|
||||||
onClick={() => {
|
className="h-6 text-[10px] text-destructive hover:bg-destructive/10"
|
||||||
const mappings = [...(config.action?.fieldMappings || [])];
|
onClick={() => {
|
||||||
mappings.splice(index, 1);
|
const mappings = [...(config.action?.fieldMappings || [])];
|
||||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
mappings.splice(index, 1);
|
||||||
}}
|
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||||
>
|
}}
|
||||||
<X className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater";
|
import {
|
||||||
|
RepeaterFieldGroupConfig,
|
||||||
|
RepeaterData,
|
||||||
|
RepeaterItemData,
|
||||||
|
RepeaterFieldDefinition,
|
||||||
|
CalculationFormula,
|
||||||
|
} from "@/types/repeater";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||||
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
||||||
|
|
@ -46,7 +52,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
const breakpoint = previewBreakpoint || globalBreakpoint;
|
const breakpoint = previewBreakpoint || globalBreakpoint;
|
||||||
|
|
||||||
// 카테고리 매핑 데이터 (값 -> {label, color})
|
// 카테고리 매핑 데이터 (값 -> {label, color})
|
||||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
|
const [categoryMappings, setCategoryMappings] = useState<
|
||||||
|
Record<string, Record<string, { label: string; color: string }>>
|
||||||
|
>({});
|
||||||
|
|
||||||
// 설정 기본값
|
// 설정 기본값
|
||||||
const {
|
const {
|
||||||
|
|
@ -78,10 +86,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
// 접힌 상태 관리 (각 항목별)
|
// 접힌 상태 관리 (각 항목별)
|
||||||
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
|
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
|
||||||
const initialCalcDoneRef = useRef(false);
|
const initialCalcDoneRef = useRef(false);
|
||||||
|
|
||||||
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
|
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
|
||||||
const deletedItemIdsRef = useRef<string[]>([]);
|
const deletedItemIdsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
|
@ -98,47 +106,60 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value.length > 0) {
|
// 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음)
|
||||||
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
if (value.length === 0) {
|
||||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
// minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화
|
||||||
|
if (minItems > 0) {
|
||||||
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
const emptyItems = Array(minItems)
|
||||||
const updatedValue = value.map(item => {
|
.fill(null)
|
||||||
const updatedItem = { ...item };
|
.map(() => createEmptyItem());
|
||||||
let hasChange = false;
|
setItems(emptyItems);
|
||||||
|
|
||||||
calculatedFields.forEach(calcField => {
|
|
||||||
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
|
||||||
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
|
||||||
updatedItem[calcField.name] = calculatedValue;
|
|
||||||
hasChange = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
|
||||||
if (updatedItem.id) {
|
|
||||||
updatedItem._existingRecord = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasChange ? updatedItem : item;
|
|
||||||
});
|
|
||||||
|
|
||||||
setItems(updatedValue);
|
|
||||||
initialCalcDoneRef.current = true;
|
|
||||||
|
|
||||||
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
|
|
||||||
const dataWithMeta = config.targetTable
|
|
||||||
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
|
||||||
: updatedValue;
|
|
||||||
onChange?.(dataWithMeta);
|
|
||||||
} else {
|
} else {
|
||||||
// 🆕 기존 레코드 플래그 추가
|
setItems([]);
|
||||||
const valueWithFlag = value.map(item => ({
|
|
||||||
...item,
|
|
||||||
_existingRecord: !!item.id,
|
|
||||||
}));
|
|
||||||
setItems(valueWithFlag);
|
|
||||||
}
|
}
|
||||||
|
initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
||||||
|
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||||
|
|
||||||
|
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
||||||
|
const updatedValue = value.map((item) => {
|
||||||
|
const updatedItem = { ...item };
|
||||||
|
let hasChange = false;
|
||||||
|
|
||||||
|
calculatedFields.forEach((calcField) => {
|
||||||
|
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
||||||
|
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
||||||
|
updatedItem[calcField.name] = calculatedValue;
|
||||||
|
hasChange = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
||||||
|
if (updatedItem.id) {
|
||||||
|
updatedItem._existingRecord = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasChange ? updatedItem : item;
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems(updatedValue);
|
||||||
|
initialCalcDoneRef.current = true;
|
||||||
|
|
||||||
|
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
|
||||||
|
const dataWithMeta = config.targetTable
|
||||||
|
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||||
|
: updatedValue;
|
||||||
|
onChange?.(dataWithMeta);
|
||||||
|
} else {
|
||||||
|
// 🆕 기존 레코드 플래그 추가
|
||||||
|
const valueWithFlag = value.map((item) => ({
|
||||||
|
...item,
|
||||||
|
_existingRecord: !!item.id,
|
||||||
|
}));
|
||||||
|
setItems(valueWithFlag);
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
|
@ -161,17 +182,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
// 항목 제거
|
// 항목 제거
|
||||||
const handleRemoveItem = (index: number) => {
|
const handleRemoveItem = (index: number) => {
|
||||||
if (items.length <= minItems) {
|
// 🆕 항목이 1개 이하일 때도 삭제 가능 (빈 상태 허용)
|
||||||
return;
|
// minItems 체크 제거 - 모든 항목 삭제 허용
|
||||||
}
|
|
||||||
|
|
||||||
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
||||||
const removedItem = items[index];
|
const removedItem = items[index];
|
||||||
if (removedItem?.id) {
|
if (removedItem?.id) {
|
||||||
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
|
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
|
||||||
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
|
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItems = items.filter((_, i) => i !== index);
|
const newItems = items.filter((_, i) => i !== index);
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
|
|
||||||
|
|
@ -179,10 +199,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
|
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
|
||||||
const currentDeletedIds = deletedItemIdsRef.current;
|
const currentDeletedIds = deletedItemIdsRef.current;
|
||||||
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
|
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
|
||||||
|
|
||||||
const dataWithMeta = config.targetTable
|
const dataWithMeta = config.targetTable
|
||||||
? newItems.map((item, idx) => ({
|
? newItems.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
_targetTable: config.targetTable,
|
_targetTable: config.targetTable,
|
||||||
// 첫 번째 항목에만 삭제 ID 목록 포함
|
// 첫 번째 항목에만 삭제 ID 목록 포함
|
||||||
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||||
|
|
@ -205,16 +225,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
...newItems[itemIndex],
|
...newItems[itemIndex],
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
||||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||||
calculatedFields.forEach(calcField => {
|
calculatedFields.forEach((calcField) => {
|
||||||
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
||||||
if (calculatedValue !== null) {
|
if (calculatedValue !== null) {
|
||||||
newItems[itemIndex][calcField.name] = calculatedValue;
|
newItems[itemIndex][calcField.name] = calculatedValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
||||||
itemIndex,
|
itemIndex,
|
||||||
|
|
@ -227,8 +247,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
// 🆕 삭제된 항목 ID 목록도 유지
|
// 🆕 삭제된 항목 ID 목록도 유지
|
||||||
const currentDeletedIds = deletedItemIdsRef.current;
|
const currentDeletedIds = deletedItemIdsRef.current;
|
||||||
const dataWithMeta = config.targetTable
|
const dataWithMeta = config.targetTable
|
||||||
? newItems.map((item, idx) => ({
|
? newItems.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
_targetTable: config.targetTable,
|
_targetTable: config.targetTable,
|
||||||
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
|
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
|
||||||
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||||
|
|
@ -288,14 +308,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
*/
|
*/
|
||||||
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
|
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
|
||||||
if (!formula || !formula.field1) return null;
|
if (!formula || !formula.field1) return null;
|
||||||
|
|
||||||
const value1 = parseFloat(item[formula.field1]) || 0;
|
const value1 = parseFloat(item[formula.field1]) || 0;
|
||||||
const value2 = formula.field2
|
const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0);
|
||||||
? (parseFloat(item[formula.field2]) || 0)
|
|
||||||
: (formula.constantValue ?? 0);
|
|
||||||
|
|
||||||
let result: number;
|
let result: number;
|
||||||
|
|
||||||
switch (formula.operator) {
|
switch (formula.operator) {
|
||||||
case "+":
|
case "+":
|
||||||
result = value1 + value2;
|
result = value1 + value2;
|
||||||
|
|
@ -331,7 +349,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
default:
|
default:
|
||||||
result = value1;
|
result = value1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -341,42 +359,44 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
* @param format 포맷 설정
|
* @param format 포맷 설정
|
||||||
* @returns 포맷된 문자열
|
* @returns 포맷된 문자열
|
||||||
*/
|
*/
|
||||||
const formatNumber = (
|
const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => {
|
||||||
value: number | null,
|
|
||||||
format?: RepeaterFieldDefinition["numberFormat"]
|
|
||||||
): string => {
|
|
||||||
if (value === null || isNaN(value)) return "-";
|
if (value === null || isNaN(value)) return "-";
|
||||||
|
|
||||||
let formattedValue = value;
|
let formattedValue = value;
|
||||||
|
|
||||||
// 소수점 자릿수 적용
|
// 소수점 자릿수 적용
|
||||||
if (format?.decimalPlaces !== undefined) {
|
if (format?.decimalPlaces !== undefined) {
|
||||||
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
|
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 천 단위 구분자
|
// 천 단위 구분자
|
||||||
let result = format?.useThousandSeparator !== false
|
let result =
|
||||||
? formattedValue.toLocaleString("ko-KR", {
|
format?.useThousandSeparator !== false
|
||||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
? formattedValue.toLocaleString("ko-KR", {
|
||||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||||
})
|
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||||
: formattedValue.toString();
|
})
|
||||||
|
: formattedValue.toString();
|
||||||
|
|
||||||
// 접두사/접미사 추가
|
// 접두사/접미사 추가
|
||||||
if (format?.prefix) result = format.prefix + result;
|
if (format?.prefix) result = format.prefix + result;
|
||||||
if (format?.suffix) result = result + format.suffix;
|
if (format?.suffix) result = result + format.suffix;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 개별 필드 렌더링
|
// 개별 필드 렌더링
|
||||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||||
const isReadonly = disabled || readonly || field.readonly;
|
const isReadonly = disabled || readonly || field.readonly;
|
||||||
|
|
||||||
|
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
|
||||||
|
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
|
||||||
|
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
value: value || "",
|
value: value || "",
|
||||||
disabled: isReadonly,
|
disabled: isReadonly,
|
||||||
placeholder: field.placeholder,
|
placeholder: defaultPlaceholder,
|
||||||
required: field.required,
|
required: field.required,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -385,25 +405,21 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
const item = items[itemIndex];
|
const item = items[itemIndex];
|
||||||
const calculatedValue = calculateValue(field.formula, item);
|
const calculatedValue = calculateValue(field.formula, item);
|
||||||
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
||||||
|
|
||||||
return (
|
return <span className="inline-block min-w-[80px] text-sm font-medium text-blue-700">{formattedValue}</span>;
|
||||||
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
|
|
||||||
{formattedValue}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
||||||
if (field.type === "category") {
|
if (field.type === "category") {
|
||||||
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
||||||
|
|
||||||
// field.name을 키로 사용 (테이블 리스트와 동일)
|
// field.name을 키로 사용 (테이블 리스트와 동일)
|
||||||
const mapping = categoryMappings[field.name];
|
const mapping = categoryMappings[field.name];
|
||||||
const valueStr = String(value); // 값을 문자열로 변환
|
const valueStr = String(value); // 값을 문자열로 변환
|
||||||
const categoryData = mapping?.[valueStr];
|
const categoryData = mapping?.[valueStr];
|
||||||
const displayLabel = categoryData?.label || valueStr;
|
const displayLabel = categoryData?.label || valueStr;
|
||||||
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
||||||
|
|
||||||
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
||||||
fieldName: field.name,
|
fieldName: field.name,
|
||||||
value: valueStr,
|
value: valueStr,
|
||||||
|
|
@ -412,12 +428,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
displayLabel,
|
displayLabel,
|
||||||
displayColor,
|
displayColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 색상이 "none"이면 일반 텍스트로 표시
|
// 색상이 "none"이면 일반 텍스트로 표시
|
||||||
if (displayColor === "none") {
|
if (displayColor === "none") {
|
||||||
return <span className="text-sm">{displayLabel}</span>;
|
return <span className="text-sm">{displayLabel}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -436,10 +452,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (field.displayMode === "readonly") {
|
if (field.displayMode === "readonly") {
|
||||||
// select 타입인 경우 옵션에서 라벨 찾기
|
// select 타입인 경우 옵션에서 라벨 찾기
|
||||||
if (field.type === "select" && value && field.options) {
|
if (field.type === "select" && value && field.options) {
|
||||||
const option = field.options.find(opt => opt.value === value);
|
const option = field.options.find((opt) => opt.value === value);
|
||||||
return <span className="text-sm">{option?.label || value}</span>;
|
return <span className="text-sm">{option?.label || value}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
|
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
|
||||||
const mapping = categoryMappings[field.name];
|
const mapping = categoryMappings[field.name];
|
||||||
if (mapping && value) {
|
if (mapping && value) {
|
||||||
|
|
@ -461,16 +477,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 색상이 없으면 텍스트로 표시
|
// 색상이 없으면 텍스트로 표시
|
||||||
return <span className="text-sm text-foreground">{categoryData.label}</span>;
|
return <span className="text-foreground text-sm">{categoryData.label}</span>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 텍스트
|
// 일반 텍스트
|
||||||
return (
|
return <span className="text-foreground text-sm">{value || "-"}</span>;
|
||||||
<span className="text-sm text-foreground">
|
|
||||||
{value || "-"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
|
|
@ -500,35 +512,55 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="resize-none min-w-[100px]"
|
className="min-w-[100px] resize-none"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "date":
|
case "date": {
|
||||||
|
// 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 (타임존 이슈 해결)
|
||||||
|
let dateValue = value || "";
|
||||||
|
if (dateValue && typeof dateValue === "string") {
|
||||||
|
// ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 로컬 시간으로 변환하여 날짜 추출
|
||||||
|
if (dateValue.includes("T")) {
|
||||||
|
const date = new Date(dateValue);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
dateValue = `${year}-${month}-${day}`;
|
||||||
|
} else {
|
||||||
|
dateValue = "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 유효한 날짜인지 확인
|
||||||
|
const parsedDate = new Date(dateValue);
|
||||||
|
if (isNaN(parsedDate.getTime())) {
|
||||||
|
dateValue = ""; // 유효하지 않은 날짜면 빈 값
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
|
value={dateValue}
|
||||||
type="date"
|
type="date"
|
||||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value || null)}
|
||||||
className="min-w-[120px]"
|
className="min-w-[120px]"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
||||||
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
|
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
|
||||||
const numValue = parseFloat(value) || 0;
|
const numValue = parseFloat(value) || 0;
|
||||||
const formattedDisplay = formatNumber(numValue, field.numberFormat);
|
const formattedDisplay = formatNumber(numValue, field.numberFormat);
|
||||||
|
|
||||||
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
||||||
if (isReadonly) {
|
if (isReadonly) {
|
||||||
return (
|
return <span className="inline-block min-w-[80px] text-sm">{formattedDisplay}</span>;
|
||||||
<span className="text-sm min-w-[80px] inline-block">
|
|
||||||
{formattedDisplay}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
||||||
return (
|
return (
|
||||||
<div className="relative min-w-[80px]">
|
<div className="relative min-w-[80px]">
|
||||||
|
|
@ -540,15 +572,11 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
max={field.validation?.max}
|
max={field.validation?.max}
|
||||||
className="pr-1"
|
className="pr-1"
|
||||||
/>
|
/>
|
||||||
{value && (
|
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
|
||||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
|
||||||
{formattedDisplay}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
|
|
@ -597,31 +625,31 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
||||||
const categoryFields = fields.filter(f => f.type === "category");
|
const categoryFields = fields.filter((f) => f.type === "category");
|
||||||
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text");
|
const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text");
|
||||||
|
|
||||||
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
||||||
|
|
||||||
const loadCategoryMappings = async () => {
|
const loadCategoryMappings = async () => {
|
||||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||||
|
|
||||||
// 1. 카테고리 타입 필드 매핑 로드
|
// 1. 카테고리 타입 필드 매핑 로드
|
||||||
for (const field of categoryFields) {
|
for (const field of categoryFields) {
|
||||||
const columnName = field.name;
|
const columnName = field.name;
|
||||||
|
|
||||||
if (categoryMappings[columnName]) continue;
|
if (categoryMappings[columnName]) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tableName = config.targetTable;
|
const tableName = config.targetTable;
|
||||||
if (!tableName) continue;
|
if (!tableName) continue;
|
||||||
|
|
||||||
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
||||||
|
|
||||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||||
|
|
||||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||||
const mapping: Record<string, { label: string; color: string }> = {};
|
const mapping: Record<string, { label: string; color: string }> = {};
|
||||||
|
|
||||||
response.data.data.forEach((item: any) => {
|
response.data.data.forEach((item: any) => {
|
||||||
const key = String(item.valueCode);
|
const key = String(item.valueCode);
|
||||||
mapping[key] = {
|
mapping[key] = {
|
||||||
|
|
@ -629,10 +657,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
color: item.color || "#64748b",
|
color: item.color || "#64748b",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||||
|
|
||||||
setCategoryMappings(prev => ({
|
setCategoryMappings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[columnName]: mapping,
|
[columnName]: mapping,
|
||||||
}));
|
}));
|
||||||
|
|
@ -641,29 +669,29 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
||||||
// material, division 등 조인된 테이블의 카테고리 필드
|
// material, division 등 조인된 테이블의 카테고리 필드
|
||||||
const joinedTableFields = ['material', 'division', 'status', 'currency_code'];
|
const joinedTableFields = ["material", "division", "status", "currency_code"];
|
||||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name));
|
const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name));
|
||||||
|
|
||||||
if (fieldsToLoadFromJoinedTable.length > 0) {
|
if (fieldsToLoadFromJoinedTable.length > 0) {
|
||||||
// item_info 테이블에서 카테고리 매핑 로드
|
// item_info 테이블에서 카테고리 매핑 로드
|
||||||
const joinedTableName = 'item_info';
|
const joinedTableName = "item_info";
|
||||||
|
|
||||||
for (const field of fieldsToLoadFromJoinedTable) {
|
for (const field of fieldsToLoadFromJoinedTable) {
|
||||||
const columnName = field.name;
|
const columnName = field.name;
|
||||||
|
|
||||||
if (categoryMappings[columnName]) continue;
|
if (categoryMappings[columnName]) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
||||||
|
|
||||||
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
||||||
|
|
||||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||||
const mapping: Record<string, { label: string; color: string }> = {};
|
const mapping: Record<string, { label: string; color: string }> = {};
|
||||||
|
|
||||||
response.data.data.forEach((item: any) => {
|
response.data.data.forEach((item: any) => {
|
||||||
const key = String(item.valueCode);
|
const key = String(item.valueCode);
|
||||||
mapping[key] = {
|
mapping[key] = {
|
||||||
|
|
@ -671,10 +699,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
color: item.color || "#64748b",
|
color: item.color || "#64748b",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||||
|
|
||||||
setCategoryMappings(prev => ({
|
setCategoryMappings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[columnName]: mapping,
|
[columnName]: mapping,
|
||||||
}));
|
}));
|
||||||
|
|
@ -694,9 +722,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-destructive/30 bg-destructive/5 p-8 text-center">
|
<div className="border-destructive/30 bg-destructive/5 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
|
||||||
<p className="text-sm font-medium text-destructive">필드가 정의되지 않았습니다</p>
|
<p className="text-destructive text-sm font-medium">필드가 정의되지 않았습니다</p>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">속성 패널에서 필드를 추가하세요.</p>
|
<p className="text-muted-foreground mt-2 text-xs">속성 패널에서 필드를 추가하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -706,8 +734,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 p-8 text-center">
|
<div className="border-border bg-muted/30 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
|
||||||
<p className="mb-4 text-sm text-muted-foreground">{emptyMessage}</p>
|
<p className="text-muted-foreground mb-4 text-sm">{emptyMessage}</p>
|
||||||
{!readonly && !disabled && items.length < maxItems && (
|
{!readonly && !disabled && items.length < maxItems && (
|
||||||
<Button type="button" onClick={handleAddItem} size="sm">
|
<Button type="button" onClick={handleAddItem} size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -740,7 +768,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
{fields.map((field) => (
|
{fields.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="ml-1 text-destructive">*</span>}
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold">작업</TableHead>
|
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold">작업</TableHead>
|
||||||
|
|
@ -751,7 +779,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
<TableRow
|
<TableRow
|
||||||
key={itemIndex}
|
key={itemIndex}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background transition-colors hover:bg-muted/50",
|
"bg-background hover:bg-muted/50 transition-colors",
|
||||||
draggedIndex === itemIndex && "opacity-50",
|
draggedIndex === itemIndex && "opacity-50",
|
||||||
)}
|
)}
|
||||||
draggable={allowReorder && !readonly && !disabled}
|
draggable={allowReorder && !readonly && !disabled}
|
||||||
|
|
@ -762,15 +790,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
>
|
>
|
||||||
{/* 인덱스 번호 */}
|
{/* 인덱스 번호 */}
|
||||||
{showIndex && (
|
{showIndex && (
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
|
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
|
||||||
{itemIndex + 1}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 */}
|
||||||
{allowReorder && !readonly && !disabled && (
|
{allowReorder && !readonly && !disabled && (
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||||
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
|
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -783,13 +809,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||||
{!readonly && !disabled && items.length > minItems && (
|
{!readonly && !disabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveItem(itemIndex)}
|
onClick={() => handleRemoveItem(itemIndex)}
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||||
title="항목 제거"
|
title="항목 제거"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
|
|
@ -829,12 +855,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 */}
|
||||||
{allowReorder && !readonly && !disabled && (
|
{allowReorder && !readonly && !disabled && (
|
||||||
<GripVertical className="h-4 w-4 flex-shrink-0 cursor-move text-muted-foreground" />
|
<GripVertical className="text-muted-foreground h-4 w-4 flex-shrink-0 cursor-move" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 인덱스 번호 */}
|
{/* 인덱스 번호 */}
|
||||||
{showIndex && (
|
{showIndex && (
|
||||||
<CardTitle className="text-sm font-semibold text-foreground">항목 {itemIndex + 1}</CardTitle>
|
<CardTitle className="text-foreground text-sm font-semibold">항목 {itemIndex + 1}</CardTitle>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -853,13 +879,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
{!readonly && !disabled && items.length > minItems && (
|
{!readonly && !disabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveItem(itemIndex)}
|
onClick={() => handleRemoveItem(itemIndex)}
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||||
title="항목 제거"
|
title="항목 제거"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
|
|
@ -873,9 +899,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
<div className={getFieldsLayoutClass()}>
|
<div className={getFieldsLayoutClass()}>
|
||||||
{fields.map((field) => (
|
{fields.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-sm font-medium text-foreground">
|
<label className="text-foreground text-sm font-medium">
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{renderField(field, itemIndex, item[field.name])}
|
{renderField(field, itemIndex, item[field.name])}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -906,7 +932,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 제한 안내 */}
|
{/* 제한 안내 */}
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="text-muted-foreground flex justify-between text-xs">
|
||||||
<span>현재: {items.length}개 항목</span>
|
<span>현재: {items.length}개 항목</span>
|
||||||
<span>
|
<span>
|
||||||
(최소: {minItems}, 최대: {maxItems})
|
(최소: {minItems}, 최대: {maxItems})
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
|
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
|
||||||
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
|
import {
|
||||||
|
RepeaterFieldGroupConfig,
|
||||||
|
RepeaterFieldDefinition,
|
||||||
|
RepeaterFieldType,
|
||||||
|
CalculationOperator,
|
||||||
|
CalculationFormula,
|
||||||
|
} from "@/types/repeater";
|
||||||
import { ColumnInfo } from "@/types/screen";
|
import { ColumnInfo } from "@/types/screen";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -34,10 +40,10 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
||||||
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
|
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
|
||||||
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
|
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
|
||||||
|
|
||||||
// 설정 입력 필드의 로컬 상태
|
// 설정 입력 필드의 로컬 상태
|
||||||
const [localConfigInputs, setLocalConfigInputs] = useState({
|
const [localConfigInputs, setLocalConfigInputs] = useState({
|
||||||
addButtonText: config.addButtonText || "",
|
addButtonText: config.addButtonText || "",
|
||||||
|
|
@ -88,13 +94,13 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 수정 (입력 중 - 로컬 상태만)
|
// 필드 수정 (입력 중 - 로컬 상태만)
|
||||||
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
|
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
|
||||||
setLocalInputs(prev => ({
|
setLocalInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[index]: {
|
[index]: {
|
||||||
...prev[index],
|
...prev[index],
|
||||||
[field]: value
|
[field]: value,
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -106,7 +112,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
newFields[index] = {
|
newFields[index] = {
|
||||||
...newFields[index],
|
...newFields[index],
|
||||||
label: localInput.label,
|
label: localInput.label,
|
||||||
placeholder: localInput.placeholder
|
placeholder: localInput.placeholder,
|
||||||
};
|
};
|
||||||
handleFieldsChange(newFields);
|
handleFieldsChange(newFields);
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +224,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 FK 컬럼 설정 (분할 패널용) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">FK 연결 컬럼 (분할 패널용)</Label>
|
||||||
|
<Select
|
||||||
|
value={(config as any).fkColumn || "__none__"}
|
||||||
|
onValueChange={(value) => handleChange("fkColumn" as any, value === "__none__" ? undefined : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="FK 컬럼 선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">사용 안함 (그룹화 컬럼 사용)</SelectItem>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
분할 패널에서 좌측 테이블 선택 시 이 컬럼을 기준으로 데이터를 필터링합니다.
|
||||||
|
<br />
|
||||||
|
예: serial_no를 선택하면 좌측에서 선택한 장비의 serial_no에 해당하는 데이터만 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 필드 정의 */}
|
{/* 필드 정의 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||||
|
|
@ -263,8 +295,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
||||||
const col = column as any;
|
const col = column as any;
|
||||||
const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
const fieldType =
|
||||||
|
col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
||||||
|
|
||||||
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
|
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
input_type: col.input_type,
|
input_type: col.input_type,
|
||||||
|
|
@ -273,19 +306,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
widgetType: col.widgetType,
|
widgetType: col.widgetType,
|
||||||
finalType: fieldType,
|
finalType: fieldType,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
name: column.columnName,
|
name: column.columnName,
|
||||||
label: column.columnLabel || column.columnName,
|
label: column.columnLabel || column.columnName,
|
||||||
type: fieldType as RepeaterFieldType,
|
type: fieldType as RepeaterFieldType,
|
||||||
});
|
});
|
||||||
// 로컬 입력 상태도 업데이트
|
// 로컬 입력 상태도 업데이트
|
||||||
setLocalInputs(prev => ({
|
setLocalInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[index]: {
|
[index]: {
|
||||||
label: column.columnLabel || column.columnName,
|
label: column.columnLabel || column.columnName,
|
||||||
placeholder: prev[index]?.placeholder || ""
|
placeholder: prev[index]?.placeholder || "",
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
||||||
}}
|
}}
|
||||||
|
|
@ -313,7 +346,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Label className="text-xs">라벨</Label>
|
<Label className="text-xs">라벨</Label>
|
||||||
<Input
|
<Input
|
||||||
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
|
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
|
||||||
onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
|
onChange={(e) => updateFieldLocal(index, "label", e.target.value)}
|
||||||
onBlur={() => handleFieldBlur(index)}
|
onBlur={() => handleFieldBlur(index)}
|
||||||
placeholder="필드 라벨"
|
placeholder="필드 라벨"
|
||||||
className="h-8 w-full text-xs"
|
className="h-8 w-full text-xs"
|
||||||
|
|
@ -358,8 +391,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Placeholder</Label>
|
<Label className="text-xs">Placeholder</Label>
|
||||||
<Input
|
<Input
|
||||||
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")}
|
value={
|
||||||
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
|
localInputs[index]?.placeholder !== undefined
|
||||||
|
? localInputs[index].placeholder
|
||||||
|
: field.placeholder || ""
|
||||||
|
}
|
||||||
|
onChange={(e) => updateFieldLocal(index, "placeholder", e.target.value)}
|
||||||
onBlur={() => handleFieldBlur(index)}
|
onBlur={() => handleFieldBlur(index)}
|
||||||
placeholder="입력 안내"
|
placeholder="입력 안내"
|
||||||
className="h-8 w-full text-xs"
|
className="h-8 w-full text-xs"
|
||||||
|
|
@ -374,15 +411,17 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Calculator className="h-4 w-4 text-blue-600" />
|
<Calculator className="h-4 w-4 text-blue-600" />
|
||||||
<Label className="text-xs font-semibold text-blue-800">계산식 설정</Label>
|
<Label className="text-xs font-semibold text-blue-800">계산식 설정</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드 1 선택 */}
|
{/* 필드 1 선택 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
||||||
<Select
|
<Select
|
||||||
value={field.formula?.field1 || ""}
|
value={field.formula?.field1 || ""}
|
||||||
onValueChange={(value) => updateField(index, {
|
onValueChange={(value) =>
|
||||||
formula: { ...field.formula, field1: value } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: { ...field.formula, field1: value } as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="필드 선택" />
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
|
@ -398,54 +437,75 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 연산자 선택 */}
|
{/* 연산자 선택 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] text-blue-700">연산자</Label>
|
<Label className="text-[10px] text-blue-700">연산자</Label>
|
||||||
<Select
|
<Select
|
||||||
value={field.formula?.operator || "+"}
|
value={field.formula?.operator || "+"}
|
||||||
onValueChange={(value) => updateField(index, {
|
onValueChange={(value) =>
|
||||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[9999]">
|
<SelectContent className="z-[9999]">
|
||||||
<SelectItem value="+" className="text-xs">+ 더하기</SelectItem>
|
<SelectItem value="+" className="text-xs">
|
||||||
<SelectItem value="-" className="text-xs">- 빼기</SelectItem>
|
+ 더하기
|
||||||
<SelectItem value="*" className="text-xs">× 곱하기</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="/" className="text-xs">÷ 나누기</SelectItem>
|
<SelectItem value="-" className="text-xs">
|
||||||
<SelectItem value="%" className="text-xs">% 나머지</SelectItem>
|
- 빼기
|
||||||
<SelectItem value="round" className="text-xs">반올림</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="floor" className="text-xs">내림</SelectItem>
|
<SelectItem value="*" className="text-xs">
|
||||||
<SelectItem value="ceil" className="text-xs">올림</SelectItem>
|
× 곱하기
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="/" className="text-xs">
|
||||||
|
÷ 나누기
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="%" className="text-xs">
|
||||||
|
% 나머지
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="round" className="text-xs">
|
||||||
|
반올림
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="floor" className="text-xs">
|
||||||
|
내림
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ceil" className="text-xs">
|
||||||
|
올림
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 두 번째 필드 또는 상수값 */}
|
{/* 두 번째 필드 또는 상수값 */}
|
||||||
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
|
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
||||||
<Select
|
<Select
|
||||||
value={field.formula?.field2 || (field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")}
|
value={
|
||||||
|
field.formula?.field2 ||
|
||||||
|
(field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")
|
||||||
|
}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value.startsWith("__const__")) {
|
if (value.startsWith("__const__")) {
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
formula: {
|
formula: {
|
||||||
...field.formula,
|
...field.formula,
|
||||||
field2: undefined,
|
field2: undefined,
|
||||||
constantValue: 0
|
constantValue: 0,
|
||||||
} as CalculationFormula
|
} as CalculationFormula,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
formula: {
|
formula: {
|
||||||
...field.formula,
|
...field.formula,
|
||||||
field2: value,
|
field2: value,
|
||||||
constantValue: undefined
|
constantValue: undefined,
|
||||||
} as CalculationFormula
|
} as CalculationFormula,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -475,14 +535,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
min={0}
|
min={0}
|
||||||
max={10}
|
max={10}
|
||||||
value={field.formula?.decimalPlaces ?? 0}
|
value={field.formula?.decimalPlaces ?? 0}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: {
|
||||||
|
...field.formula,
|
||||||
|
decimalPlaces: parseInt(e.target.value) || 0,
|
||||||
|
} as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 상수값 입력 필드 */}
|
{/* 상수값 입력 필드 */}
|
||||||
{field.formula?.constantValue !== undefined && (
|
{field.formula?.constantValue !== undefined && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -490,15 +555,20 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={field.formula.constantValue}
|
value={field.formula.constantValue}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: {
|
||||||
|
...field.formula,
|
||||||
|
constantValue: parseFloat(e.target.value) || 0,
|
||||||
|
} as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="숫자 입력"
|
placeholder="숫자 입력"
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 숫자 포맷 설정 */}
|
{/* 숫자 포맷 설정 */}
|
||||||
<div className="space-y-2 border-t border-blue-200 pt-2">
|
<div className="space-y-2 border-t border-blue-200 pt-2">
|
||||||
<Label className="text-[10px] text-blue-700">숫자 표시 형식</Label>
|
<Label className="text-[10px] text-blue-700">숫자 표시 형식</Label>
|
||||||
|
|
@ -507,9 +577,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`thousand-sep-${index}`}
|
id={`thousand-sep-${index}`}
|
||||||
checked={field.numberFormat?.useThousandSeparator ?? true}
|
checked={field.numberFormat?.useThousandSeparator ?? true}
|
||||||
onCheckedChange={(checked) => updateField(index, {
|
onCheckedChange={(checked) =>
|
||||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||||
천 단위 구분자
|
천 단위 구분자
|
||||||
|
|
@ -519,9 +591,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Label className="text-[10px]">소수점:</Label>
|
<Label className="text-[10px]">소수점:</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={10}
|
max={10}
|
||||||
|
|
@ -532,31 +606,34 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.prefix || ""}
|
value={field.numberFormat?.prefix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접두사 (₩)"
|
placeholder="접두사 (₩)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.suffix || ""}
|
value={field.numberFormat?.suffix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접미사 (원)"
|
placeholder="접미사 (원)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 계산식 미리보기 */}
|
{/* 계산식 미리보기 */}
|
||||||
<div className="rounded bg-white p-2 text-xs">
|
<div className="rounded bg-white p-2 text-xs">
|
||||||
<span className="text-gray-500">계산식: </span>
|
<span className="text-gray-500">계산식: </span>
|
||||||
<code className="font-mono text-blue-700">
|
<code className="font-mono text-blue-700">
|
||||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
|
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"}{" "}
|
||||||
field.formula?.field2 ||
|
{field.formula?.field2 ||
|
||||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
|
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")}
|
||||||
}
|
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -571,9 +648,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`number-thousand-sep-${index}`}
|
id={`number-thousand-sep-${index}`}
|
||||||
checked={field.numberFormat?.useThousandSeparator ?? false}
|
checked={field.numberFormat?.useThousandSeparator ?? false}
|
||||||
onCheckedChange={(checked) => updateField(index, {
|
onCheckedChange={(checked) =>
|
||||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||||
천 단위 구분자
|
천 단위 구분자
|
||||||
|
|
@ -583,9 +662,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Label className="text-[10px]">소수점:</Label>
|
<Label className="text-[10px]">소수점:</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={10}
|
max={10}
|
||||||
|
|
@ -596,17 +677,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.prefix || ""}
|
value={field.numberFormat?.prefix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접두사 (₩)"
|
placeholder="접두사 (₩)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.suffix || ""}
|
value={field.numberFormat?.suffix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접미사 (원)"
|
placeholder="접미사 (원)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -624,7 +709,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
||||||
className="h-8 w-full text-xs"
|
className="h-8 w-full text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-muted-foreground text-[10px]">
|
||||||
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useCallback, useRef } from "react";
|
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||||
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
||||||
import { logger } from "@/lib/utils/logger";
|
import { logger } from "@/lib/utils/logger";
|
||||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||||
|
|
@ -14,17 +14,21 @@ interface ScreenContextValue {
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||||
|
|
||||||
|
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||||
|
formData: Record<string, any>;
|
||||||
|
updateFormData: (fieldName: string, value: any) => void;
|
||||||
|
|
||||||
// 컴포넌트 등록
|
// 컴포넌트 등록
|
||||||
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
||||||
unregisterDataProvider: (componentId: string) => void;
|
unregisterDataProvider: (componentId: string) => void;
|
||||||
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
|
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
|
||||||
unregisterDataReceiver: (componentId: string) => void;
|
unregisterDataReceiver: (componentId: string) => void;
|
||||||
|
|
||||||
// 컴포넌트 조회
|
// 컴포넌트 조회
|
||||||
getDataProvider: (componentId: string) => DataProvidable | undefined;
|
getDataProvider: (componentId: string) => DataProvidable | undefined;
|
||||||
getDataReceiver: (componentId: string) => DataReceivable | undefined;
|
getDataReceiver: (componentId: string) => DataReceivable | undefined;
|
||||||
|
|
||||||
// 모든 컴포넌트 조회
|
// 모든 컴포넌트 조회
|
||||||
getAllDataProviders: () => Map<string, DataProvidable>;
|
getAllDataProviders: () => Map<string, DataProvidable>;
|
||||||
getAllDataReceivers: () => Map<string, DataReceivable>;
|
getAllDataReceivers: () => Map<string, DataReceivable>;
|
||||||
|
|
@ -42,10 +46,31 @@ interface ScreenContextProviderProps {
|
||||||
/**
|
/**
|
||||||
* 화면 컨텍스트 프로바이더
|
* 화면 컨텍스트 프로바이더
|
||||||
*/
|
*/
|
||||||
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
|
export function ScreenContextProvider({
|
||||||
|
screenId,
|
||||||
|
tableName,
|
||||||
|
splitPanelPosition,
|
||||||
|
children,
|
||||||
|
}: ScreenContextProviderProps) {
|
||||||
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
||||||
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
||||||
|
|
||||||
|
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 🆕 폼 데이터 업데이트 함수
|
||||||
|
const updateFormData = useCallback((fieldName: string, value: any) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const updated = { ...prev, [fieldName]: value };
|
||||||
|
logger.debug("ScreenContext formData 업데이트", {
|
||||||
|
fieldName,
|
||||||
|
valueType: typeof value,
|
||||||
|
isArray: Array.isArray(value),
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
|
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
|
||||||
dataProvidersRef.current.set(componentId, provider);
|
dataProvidersRef.current.set(componentId, provider);
|
||||||
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
||||||
|
|
@ -83,31 +108,38 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||||
const value = React.useMemo<ScreenContextValue>(() => ({
|
const value = React.useMemo<ScreenContextValue>(
|
||||||
screenId,
|
() => ({
|
||||||
tableName,
|
screenId,
|
||||||
splitPanelPosition,
|
tableName,
|
||||||
registerDataProvider,
|
splitPanelPosition,
|
||||||
unregisterDataProvider,
|
formData,
|
||||||
registerDataReceiver,
|
updateFormData,
|
||||||
unregisterDataReceiver,
|
registerDataProvider,
|
||||||
getDataProvider,
|
unregisterDataProvider,
|
||||||
getDataReceiver,
|
registerDataReceiver,
|
||||||
getAllDataProviders,
|
unregisterDataReceiver,
|
||||||
getAllDataReceivers,
|
getDataProvider,
|
||||||
}), [
|
getDataReceiver,
|
||||||
screenId,
|
getAllDataProviders,
|
||||||
tableName,
|
getAllDataReceivers,
|
||||||
splitPanelPosition,
|
}),
|
||||||
registerDataProvider,
|
[
|
||||||
unregisterDataProvider,
|
screenId,
|
||||||
registerDataReceiver,
|
tableName,
|
||||||
unregisterDataReceiver,
|
splitPanelPosition,
|
||||||
getDataProvider,
|
formData,
|
||||||
getDataReceiver,
|
updateFormData,
|
||||||
getAllDataProviders,
|
registerDataProvider,
|
||||||
getAllDataReceivers,
|
unregisterDataProvider,
|
||||||
]);
|
registerDataReceiver,
|
||||||
|
unregisterDataReceiver,
|
||||||
|
getDataProvider,
|
||||||
|
getDataReceiver,
|
||||||
|
getAllDataProviders,
|
||||||
|
getAllDataReceivers,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
@ -130,4 +162,3 @@ export function useScreenContext() {
|
||||||
export function useScreenContextOptional() {
|
export function useScreenContextOptional() {
|
||||||
return useContext(ScreenContext);
|
return useContext(ScreenContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 🔥 제어관리 설정 추가 (webTypeConfig에서 가져옴)
|
// 🔥 제어관리 설정 추가 (webTypeConfig에서 가져옴)
|
||||||
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
||||||
dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
||||||
|
dataflowTiming: component.webTypeConfig?.dataflowTiming,
|
||||||
};
|
};
|
||||||
} else if (componentConfig.action && typeof componentConfig.action === "object") {
|
} else if (componentConfig.action && typeof componentConfig.action === "object") {
|
||||||
// 🔥 이미 객체인 경우에도 제어관리 설정 추가
|
// 🔥 이미 객체인 경우에도 제어관리 설정 추가
|
||||||
|
|
@ -383,8 +384,19 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
...componentConfig.action,
|
...componentConfig.action,
|
||||||
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
||||||
dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
||||||
|
dataflowTiming: component.webTypeConfig?.dataflowTiming,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔍 디버깅: processedConfig.action 확인
|
||||||
|
console.log("[ButtonPrimaryComponent] processedConfig.action 생성 완료", {
|
||||||
|
actionType: processedConfig.action?.type,
|
||||||
|
enableDataflowControl: processedConfig.action?.enableDataflowControl,
|
||||||
|
dataflowTiming: processedConfig.action?.dataflowTiming,
|
||||||
|
dataflowConfig: processedConfig.action?.dataflowConfig,
|
||||||
|
webTypeConfigRaw: component.webTypeConfig,
|
||||||
|
componentText: component.text,
|
||||||
|
});
|
||||||
|
|
||||||
// 스타일 계산
|
// 스타일 계산
|
||||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
||||||
|
|
@ -868,6 +880,44 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모달 액션인데 선택된 데이터가 있으면 경고 메시지 표시하고 중단
|
||||||
|
// (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지)
|
||||||
|
if (processedConfig.action.type === "modal" && effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) {
|
||||||
|
toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수정(edit) 액션 검증
|
||||||
|
if (processedConfig.action.type === "edit") {
|
||||||
|
// 선택된 데이터가 없으면 경고
|
||||||
|
if (!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) {
|
||||||
|
toast.warning("수정할 항목을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupByColumns 설정이 있으면 해당 컬럼 값이 유일한지 확인
|
||||||
|
const groupByColumns = processedConfig.action.groupByColumns;
|
||||||
|
if (groupByColumns && groupByColumns.length > 0 && effectiveSelectedRowsData.length > 1) {
|
||||||
|
// 첫 번째 그룹핑 컬럼 기준으로 중복 체크 (예: order_no)
|
||||||
|
const groupByColumn = groupByColumns[0];
|
||||||
|
const uniqueValues = new Set(
|
||||||
|
effectiveSelectedRowsData.map((row: any) => row[groupByColumn]).filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueValues.size > 1) {
|
||||||
|
// 컬럼명을 한글로 변환 (order_no -> 수주번호)
|
||||||
|
const columnLabels: Record<string, string> = {
|
||||||
|
order_no: "수주번호",
|
||||||
|
shipment_no: "출하번호",
|
||||||
|
purchase_no: "구매번호",
|
||||||
|
};
|
||||||
|
const columnLabel = columnLabels[groupByColumn] || groupByColumn;
|
||||||
|
toast.warning(`${columnLabel} 하나만 선택해주세요. (현재 ${uniqueValues.size}개 선택됨)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등)
|
// 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등)
|
||||||
const componentConfigs: Record<string, any> = {};
|
const componentConfigs: Record<string, any> = {};
|
||||||
if (allComponents && Array.isArray(allComponents)) {
|
if (allComponents && Array.isArray(allComponents)) {
|
||||||
|
|
@ -907,8 +957,33 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 분할 패널 우측이면 여러 소스에서 formData를 병합
|
||||||
|
// 우선순위: props.formData > screenContext.formData > splitPanelParentData
|
||||||
|
const screenContextFormData = screenContext?.formData || {};
|
||||||
|
const propsFormData = formData || {};
|
||||||
|
|
||||||
|
// 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드
|
||||||
|
// (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음)
|
||||||
|
let effectiveFormData = { ...propsFormData, ...screenContextFormData };
|
||||||
|
|
||||||
|
// 🆕 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용
|
||||||
|
if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) {
|
||||||
|
effectiveFormData = { ...splitPanelParentData };
|
||||||
|
console.log("🔍 [ButtonPrimary] 분할 패널 우측 - splitPanelParentData 사용:", Object.keys(effectiveFormData));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔍 [ButtonPrimary] formData 선택:", {
|
||||||
|
hasScreenContextFormData: Object.keys(screenContextFormData).length > 0,
|
||||||
|
screenContextKeys: Object.keys(screenContextFormData),
|
||||||
|
hasPropsFormData: Object.keys(propsFormData).length > 0,
|
||||||
|
propsFormDataKeys: Object.keys(propsFormData),
|
||||||
|
hasSplitPanelParentData: !!splitPanelParentData && Object.keys(splitPanelParentData).length > 0,
|
||||||
|
splitPanelPosition,
|
||||||
|
effectiveFormDataKeys: Object.keys(effectiveFormData),
|
||||||
|
});
|
||||||
|
|
||||||
const context: ButtonActionContext = {
|
const context: ButtonActionContext = {
|
||||||
formData: formData || {},
|
formData: effectiveFormData,
|
||||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||||
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
||||||
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISO 8601 날짜 (2023-12-31T00:00:00.000Z 등)
|
// ISO 8601 날짜 (2023-12-31T00:00:00.000Z 등)
|
||||||
|
// 🆕 UTC 시간을 로컬 시간으로 변환하여 날짜 추출 (타임존 이슈 해결)
|
||||||
if (/^\d{4}-\d{2}-\d{2}T/.test(dateStr)) {
|
if (/^\d{4}-\d{2}-\d{2}T/.test(dateStr)) {
|
||||||
return dateStr.split("T")[0];
|
const date = new Date(dateStr);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다른 형식의 날짜 문자열이나 Date 객체 처리
|
// 다른 형식의 날짜 문자열이나 Date 객체 처리
|
||||||
|
|
@ -276,7 +281,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && <span className="text-destructive">*</span>}
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -299,16 +304,18 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 구분자 */}
|
{/* 구분자 */}
|
||||||
<span className="text-base font-medium text-muted-foreground">~</span>
|
<span className="text-muted-foreground text-base font-medium">~</span>
|
||||||
|
|
||||||
{/* 종료일 */}
|
{/* 종료일 */}
|
||||||
<input
|
<input
|
||||||
|
|
@ -326,11 +333,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -344,7 +353,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && <span className="text-destructive">*</span>}
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -368,11 +377,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -400,14 +411,16 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
disabled={componentConfig.disabled || false}
|
disabled={componentConfig.disabled || false}
|
||||||
required={componentConfig.required || false}
|
required={componentConfig.required || false}
|
||||||
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
)}
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ export interface LocationSwapSelectorProps {
|
||||||
formData?: Record<string, any>;
|
formData?: Record<string, any>;
|
||||||
onFormDataChange?: (field: string, value: any) => void;
|
onFormDataChange?: (field: string, value: any) => void;
|
||||||
|
|
||||||
|
// 🆕 사용자 정보 (DB에서 초기값 로드용)
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
// componentConfig (화면 디자이너에서 전달)
|
// componentConfig (화면 디자이너에서 전달)
|
||||||
componentConfig?: {
|
componentConfig?: {
|
||||||
dataSource?: DataSourceConfig;
|
dataSource?: DataSourceConfig;
|
||||||
|
|
@ -65,6 +68,10 @@ export interface LocationSwapSelectorProps {
|
||||||
showSwapButton?: boolean;
|
showSwapButton?: boolean;
|
||||||
swapButtonPosition?: "center" | "right";
|
swapButtonPosition?: "center" | "right";
|
||||||
variant?: "card" | "inline" | "minimal";
|
variant?: "card" | "inline" | "minimal";
|
||||||
|
// 🆕 DB 초기값 로드 설정
|
||||||
|
loadFromDb?: boolean; // DB에서 초기값 로드 여부
|
||||||
|
dbTableName?: string; // 조회할 테이블명 (기본: vehicles)
|
||||||
|
dbKeyField?: string; // 키 필드 (기본: user_id)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +87,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
formData = {},
|
formData = {},
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
componentConfig,
|
componentConfig,
|
||||||
|
userId,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
||||||
|
|
@ -93,6 +101,11 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
|
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
|
||||||
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
||||||
const variant = config.variant || props.variant || "card";
|
const variant = config.variant || props.variant || "card";
|
||||||
|
|
||||||
|
// 🆕 DB 초기값 로드 설정
|
||||||
|
const loadFromDb = config.loadFromDb !== false; // 기본값 true
|
||||||
|
const dbTableName = config.dbTableName || "vehicles";
|
||||||
|
const dbKeyField = config.dbKeyField || "user_id";
|
||||||
|
|
||||||
// 기본 옵션 (포항/광양)
|
// 기본 옵션 (포항/광양)
|
||||||
const DEFAULT_OPTIONS: LocationOption[] = [
|
const DEFAULT_OPTIONS: LocationOption[] = [
|
||||||
|
|
@ -104,6 +117,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
|
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isSwapping, setIsSwapping] = useState(false);
|
const [isSwapping, setIsSwapping] = useState(false);
|
||||||
|
const [dbLoaded, setDbLoaded] = useState(false); // DB 로드 완료 여부
|
||||||
|
|
||||||
// 로컬 선택 상태 (Select 컴포넌트용)
|
// 로컬 선택 상태 (Select 컴포넌트용)
|
||||||
const [localDeparture, setLocalDeparture] = useState<string>("");
|
const [localDeparture, setLocalDeparture] = useState<string>("");
|
||||||
|
|
@ -193,8 +207,89 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
loadOptions();
|
loadOptions();
|
||||||
}, [dataSource, isDesignMode]);
|
}, [dataSource, isDesignMode]);
|
||||||
|
|
||||||
// formData에서 초기값 동기화
|
// 🆕 DB에서 초기값 로드 (새로고침 시에도 출발지/목적지 유지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const loadFromDatabase = async () => {
|
||||||
|
// 디자인 모드이거나, DB 로드 비활성화이거나, userId가 없으면 스킵
|
||||||
|
if (isDesignMode || !loadFromDb || !userId) {
|
||||||
|
console.log("[LocationSwapSelector] DB 로드 스킵:", { isDesignMode, loadFromDb, userId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 로드했으면 스킵
|
||||||
|
if (dbLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("[LocationSwapSelector] DB에서 출발지/목적지 로드 시작:", { dbTableName, dbKeyField, userId });
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${dbTableName}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
search: { [dbKeyField]: userId },
|
||||||
|
autoFilter: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const vehicleData = response.data?.data?.data?.[0] || response.data?.data?.rows?.[0];
|
||||||
|
|
||||||
|
if (vehicleData) {
|
||||||
|
const dbDeparture = vehicleData[departureField] || vehicleData.departure;
|
||||||
|
const dbDestination = vehicleData[destinationField] || vehicleData.arrival || vehicleData.destination;
|
||||||
|
|
||||||
|
console.log("[LocationSwapSelector] DB에서 로드된 값:", { dbDeparture, dbDestination });
|
||||||
|
|
||||||
|
// DB에 값이 있으면 로컬 상태 및 formData 업데이트
|
||||||
|
if (dbDeparture && options.some(o => o.value === dbDeparture)) {
|
||||||
|
setLocalDeparture(dbDeparture);
|
||||||
|
onFormDataChange?.(departureField, dbDeparture);
|
||||||
|
|
||||||
|
// 라벨도 업데이트
|
||||||
|
if (departureLabelField) {
|
||||||
|
const opt = options.find(o => o.value === dbDeparture);
|
||||||
|
if (opt) {
|
||||||
|
onFormDataChange?.(departureLabelField, opt.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbDestination && options.some(o => o.value === dbDestination)) {
|
||||||
|
setLocalDestination(dbDestination);
|
||||||
|
onFormDataChange?.(destinationField, dbDestination);
|
||||||
|
|
||||||
|
// 라벨도 업데이트
|
||||||
|
if (destinationLabelField) {
|
||||||
|
const opt = options.find(o => o.value === dbDestination);
|
||||||
|
if (opt) {
|
||||||
|
onFormDataChange?.(destinationLabelField, opt.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDbLoaded(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[LocationSwapSelector] DB 로드 실패:", error);
|
||||||
|
setDbLoaded(true); // 실패해도 다시 시도하지 않음
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 옵션이 로드된 후에 DB 로드 실행
|
||||||
|
if (options.length > 0) {
|
||||||
|
loadFromDatabase();
|
||||||
|
}
|
||||||
|
}, [userId, loadFromDb, dbTableName, dbKeyField, departureField, destinationField, options, isDesignMode, dbLoaded, onFormDataChange, departureLabelField, destinationLabelField]);
|
||||||
|
|
||||||
|
// formData에서 초기값 동기화 (DB 로드 후에도 formData 변경 시 반영)
|
||||||
|
useEffect(() => {
|
||||||
|
// DB 로드가 완료되지 않았으면 스킵 (DB 값 우선)
|
||||||
|
if (loadFromDb && userId && !dbLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const depVal = formData[departureField];
|
const depVal = formData[departureField];
|
||||||
const destVal = formData[destinationField];
|
const destVal = formData[destinationField];
|
||||||
|
|
||||||
|
|
@ -204,7 +299,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
if (destVal && options.some(o => o.value === destVal)) {
|
if (destVal && options.some(o => o.value === destVal)) {
|
||||||
setLocalDestination(destVal);
|
setLocalDestination(destVal);
|
||||||
}
|
}
|
||||||
}, [formData, departureField, destinationField, options]);
|
}, [formData, departureField, destinationField, options, loadFromDb, userId, dbLoaded]);
|
||||||
|
|
||||||
// 출발지 변경
|
// 출발지 변경
|
||||||
const handleDepartureChange = (selectedValue: string) => {
|
const handleDepartureChange = (selectedValue: string) => {
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,58 @@ export function LocationSwapSelectorConfigPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* DB 초기값 로드 설정 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<h4 className="text-sm font-medium">DB 초기값 로드</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
새로고침 시에도 DB에 저장된 출발지/목적지를 자동으로 불러옵니다
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>DB에서 초기값 로드</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config?.loadFromDb !== false}
|
||||||
|
onCheckedChange={(checked) => handleChange("loadFromDb", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config?.loadFromDb !== false && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>조회 테이블</Label>
|
||||||
|
<Select
|
||||||
|
value={config?.dbTableName || "vehicles"}
|
||||||
|
onValueChange={(value) => handleChange("dbTableName", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="vehicles">vehicles (기본)</SelectItem>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<SelectItem key={table.name} value={table.name}>
|
||||||
|
{table.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>키 필드</Label>
|
||||||
|
<Input
|
||||||
|
value={config?.dbKeyField || "user_id"}
|
||||||
|
onChange={(e) => handleChange("dbKeyField", e.target.value)}
|
||||||
|
placeholder="user_id"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
현재 사용자 ID로 조회할 필드 (기본: user_id)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 안내 */}
|
{/* 안내 */}
|
||||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||||
|
|
@ -480,6 +532,8 @@ export function LocationSwapSelectorConfigPanel({
|
||||||
2. 출발지/도착지 값이 저장될 필드를 지정합니다
|
2. 출발지/도착지 값이 저장될 필드를 지정합니다
|
||||||
<br />
|
<br />
|
||||||
3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다
|
3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다
|
||||||
|
<br />
|
||||||
|
4. DB 초기값 로드를 활성화하면 새로고침 후에도 값이 유지됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,24 +20,56 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
const screenContext = useScreenContextOptional();
|
const screenContext = useScreenContextOptional();
|
||||||
const splitPanelContext = useSplitPanelContext();
|
const splitPanelContext = useSplitPanelContext();
|
||||||
const receiverRef = useRef<DataReceivable | null>(null);
|
const receiverRef = useRef<DataReceivable | null>(null);
|
||||||
|
|
||||||
// 🆕 그룹화된 데이터를 저장하는 상태
|
// 🆕 그룹화된 데이터를 저장하는 상태
|
||||||
const [groupedData, setGroupedData] = useState<any[] | null>(null);
|
const [groupedData, setGroupedData] = useState<any[] | null>(null);
|
||||||
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
|
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
|
||||||
const groupDataLoadedRef = useRef(false);
|
const groupDataLoadedRef = useRef(false);
|
||||||
|
|
||||||
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
|
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
|
||||||
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
|
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 🆕 DB에서 로드한 컬럼 정보 (webType 등)
|
||||||
|
const [columnInfo, setColumnInfo] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 컴포넌트의 필드명 (formData 키)
|
// 컴포넌트의 필드명 (formData 키)
|
||||||
const fieldName = (component as any).columnName || component.id;
|
const fieldName = (component as any).columnName || component.id;
|
||||||
|
|
||||||
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
||||||
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
const rawConfig = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||||
|
|
||||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||||
const groupByColumn = config.groupByColumn;
|
const groupByColumn = rawConfig.groupByColumn;
|
||||||
const targetTable = config.targetTable;
|
const targetTable = rawConfig.targetTable;
|
||||||
|
|
||||||
|
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
|
||||||
|
const config = useMemo(() => {
|
||||||
|
const rawFields = rawConfig.fields || [];
|
||||||
|
console.log("📋 [RepeaterFieldGroup] config 생성:", {
|
||||||
|
rawFieldsCount: rawFields.length,
|
||||||
|
rawFieldNames: rawFields.map((f: any) => f.name),
|
||||||
|
columnInfoKeys: Object.keys(columnInfo),
|
||||||
|
hasColumnInfo: Object.keys(columnInfo).length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = rawFields.map((field: any) => {
|
||||||
|
const colInfo = columnInfo[field.name];
|
||||||
|
// DB의 webType 또는 web_type을 field.type으로 적용
|
||||||
|
const dbWebType = colInfo?.webType || colInfo?.web_type;
|
||||||
|
|
||||||
|
// 타입 오버라이드 조건:
|
||||||
|
// 1. field.type이 없거나
|
||||||
|
// 2. field.type이 'direct'(기본값)이고 DB에 더 구체적인 타입이 있는 경우
|
||||||
|
const shouldOverride = !field.type || (field.type === "direct" && dbWebType && dbWebType !== "text");
|
||||||
|
|
||||||
|
if (colInfo && dbWebType && shouldOverride) {
|
||||||
|
console.log(`✅ [RepeaterFieldGroup] 필드 타입 매핑: ${field.name} → ${dbWebType}`);
|
||||||
|
return { ...field, type: dbWebType };
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
return { ...rawConfig, fields };
|
||||||
|
}, [rawConfig, columnInfo]);
|
||||||
|
|
||||||
// formData에서 값 가져오기 (value prop보다 우선)
|
// formData에서 값 가져오기 (value prop보다 우선)
|
||||||
const rawValue = formData?.[fieldName] ?? value;
|
const rawValue = formData?.[fieldName] ?? value;
|
||||||
|
|
@ -45,21 +77,127 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
|
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
|
||||||
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
|
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
|
||||||
const isEditMode = formData?.id && !rawValue && !value;
|
const isEditMode = formData?.id && !rawValue && !value;
|
||||||
|
|
||||||
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
|
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
|
||||||
const configFields = config.fields || [];
|
const configFields = config.fields || [];
|
||||||
const hasRepeaterFieldsInFormData = configFields.length > 0 &&
|
const hasRepeaterFieldsInFormData =
|
||||||
configFields.some((field: any) => formData?.[field.name] !== undefined);
|
configFields.length > 0 && configFields.some((field: any) => formData?.[field.name] !== undefined);
|
||||||
|
|
||||||
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
||||||
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
||||||
|
|
||||||
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
||||||
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
||||||
|
|
||||||
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
|
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
|
||||||
fieldName,
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
hasFormData: !!formData,
|
const isRightPanel = splitPanelPosition === "right";
|
||||||
|
const selectedLeftData = splitPanelContext?.selectedLeftData;
|
||||||
|
|
||||||
|
// 🆕 연결 필터 설정에서 FK 컬럼 정보 가져오기
|
||||||
|
// screen-split-panel에서 설정한 linkedFilters 사용
|
||||||
|
const linkedFilters = splitPanelContext?.linkedFilters || [];
|
||||||
|
const getLinkedFilterValues = splitPanelContext?.getLinkedFilterValues;
|
||||||
|
|
||||||
|
// 🆕 FK 컬럼 설정 우선순위:
|
||||||
|
// 1. linkedFilters에서 targetTable에 해당하는 설정 찾기
|
||||||
|
// 2. config.fkColumn (컴포넌트 설정)
|
||||||
|
// 3. config.groupByColumn (그룹화 컬럼)
|
||||||
|
let fkSourceColumn: string | null = null;
|
||||||
|
let fkTargetColumn: string | null = null;
|
||||||
|
let linkedFilterTargetTable: string | null = null;
|
||||||
|
|
||||||
|
// linkedFilters에서 FK 컬럼 찾기
|
||||||
|
if (linkedFilters.length > 0 && selectedLeftData) {
|
||||||
|
// 첫 번째 linkedFilter 사용 (일반적으로 하나만 설정됨)
|
||||||
|
const linkedFilter = linkedFilters[0];
|
||||||
|
fkSourceColumn = linkedFilter.sourceColumn;
|
||||||
|
|
||||||
|
// targetColumn이 "테이블명.컬럼명" 형식일 수 있음 → 분리
|
||||||
|
// 예: "dtg_maintenance_history.serial_no" → table: "dtg_maintenance_history", column: "serial_no"
|
||||||
|
const targetColumnParts = linkedFilter.targetColumn.split(".");
|
||||||
|
if (targetColumnParts.length === 2) {
|
||||||
|
linkedFilterTargetTable = targetColumnParts[0];
|
||||||
|
fkTargetColumn = targetColumnParts[1];
|
||||||
|
} else {
|
||||||
|
fkTargetColumn = linkedFilter.targetColumn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 targetTable 우선순위: config.targetTable > linkedFilters에서 추출한 테이블
|
||||||
|
const effectiveTargetTable = targetTable || linkedFilterTargetTable;
|
||||||
|
|
||||||
|
// 🆕 DB에서 컬럼 정보 로드 (webType 등)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumnInfo = async () => {
|
||||||
|
if (!effectiveTargetTable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${effectiveTargetTable}/columns`);
|
||||||
|
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 응답:", response.data);
|
||||||
|
|
||||||
|
// 응답 구조에 따라 데이터 추출
|
||||||
|
// 실제 응답: { success: true, data: { columns: [...], page, size, total, totalPages } }
|
||||||
|
let columns: any[] = [];
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
// data.columns가 배열인 경우 (실제 응답 구조)
|
||||||
|
if (Array.isArray(response.data.data.columns)) {
|
||||||
|
columns = response.data.data.columns;
|
||||||
|
}
|
||||||
|
// data가 배열인 경우
|
||||||
|
else if (Array.isArray(response.data.data)) {
|
||||||
|
columns = response.data.data;
|
||||||
|
}
|
||||||
|
// data 자체가 객체이고 배열이 아닌 경우 (키-값 형태)
|
||||||
|
else if (typeof response.data.data === "object") {
|
||||||
|
columns = Object.values(response.data.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// success 없이 바로 배열인 경우
|
||||||
|
else if (Array.isArray(response.data)) {
|
||||||
|
columns = response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📋 [RepeaterFieldGroup] 파싱된 컬럼 배열:", columns.length, "개");
|
||||||
|
|
||||||
|
if (columns.length > 0) {
|
||||||
|
const colMap: Record<string, any> = {};
|
||||||
|
columns.forEach((col: any) => {
|
||||||
|
// columnName 또는 column_name 또는 name 키 사용
|
||||||
|
const colName = col.columnName || col.column_name || col.name;
|
||||||
|
if (colName) {
|
||||||
|
colMap[colName] = col;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setColumnInfo(colMap);
|
||||||
|
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 로드 완료:", {
|
||||||
|
table: effectiveTargetTable,
|
||||||
|
columns: Object.keys(colMap),
|
||||||
|
webTypes: Object.entries(colMap).map(
|
||||||
|
([name, info]: [string, any]) => `${name}: ${info.webType || info.web_type || "unknown"}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [RepeaterFieldGroup] 컬럼 정보 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadColumnInfo();
|
||||||
|
}, [effectiveTargetTable]);
|
||||||
|
|
||||||
|
// linkedFilters가 없으면 config에서 가져오기
|
||||||
|
const fkColumn = fkTargetColumn || config.fkColumn || config.groupByColumn;
|
||||||
|
const fkValue =
|
||||||
|
fkSourceColumn && selectedLeftData
|
||||||
|
? selectedLeftData[fkSourceColumn]
|
||||||
|
: fkColumn && selectedLeftData
|
||||||
|
? selectedLeftData[fkColumn]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
|
||||||
|
fieldName,
|
||||||
|
hasFormData: !!formData,
|
||||||
formDataId: formData?.id,
|
formDataId: formData?.id,
|
||||||
formDataValue: formData?.[fieldName],
|
formDataValue: formData?.[fieldName],
|
||||||
propsValue: value,
|
propsValue: value,
|
||||||
|
|
@ -72,8 +210,24 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
groupByColumn,
|
groupByColumn,
|
||||||
groupKeyValue,
|
groupKeyValue,
|
||||||
targetTable,
|
targetTable,
|
||||||
|
linkedFilterTargetTable,
|
||||||
|
effectiveTargetTable,
|
||||||
hasGroupedData: groupedData !== null,
|
hasGroupedData: groupedData !== null,
|
||||||
groupedDataLength: groupedData?.length,
|
groupedDataLength: groupedData?.length,
|
||||||
|
// 🆕 분할 패널 관련 정보
|
||||||
|
linkedFiltersCount: linkedFilters.length,
|
||||||
|
linkedFilters: linkedFilters.map((f) => `${f.sourceColumn} → ${f.targetColumn}`),
|
||||||
|
fkSourceColumn,
|
||||||
|
fkTargetColumn,
|
||||||
|
splitPanelPosition,
|
||||||
|
isRightPanel,
|
||||||
|
hasSelectedLeftData: !!selectedLeftData,
|
||||||
|
// 🆕 selectedLeftData 상세 정보 (디버깅용)
|
||||||
|
selectedLeftDataId: selectedLeftData?.id,
|
||||||
|
selectedLeftDataFkValue: fkSourceColumn ? selectedLeftData?.[fkSourceColumn] : "N/A",
|
||||||
|
selectedLeftData: selectedLeftData ? JSON.stringify(selectedLeftData).slice(0, 200) : null,
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 수정 모드에서 그룹화된 데이터 로드
|
// 🆕 수정 모드에서 그룹화된 데이터 로드
|
||||||
|
|
@ -82,16 +236,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 이미 로드했거나 조건이 맞지 않으면 스킵
|
// 이미 로드했거나 조건이 맞지 않으면 스킵
|
||||||
if (groupDataLoadedRef.current) return;
|
if (groupDataLoadedRef.current) return;
|
||||||
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
|
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
|
||||||
|
|
||||||
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
|
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
|
||||||
groupByColumn,
|
groupByColumn,
|
||||||
groupKeyValue,
|
groupKeyValue,
|
||||||
targetTable,
|
targetTable,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsLoadingGroupData(true);
|
setIsLoadingGroupData(true);
|
||||||
groupDataLoadedRef.current = true;
|
groupDataLoadedRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
|
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
|
||||||
// search 파라미터 사용 (filters가 아닌 search)
|
// search 파라미터 사용 (filters가 아닌 search)
|
||||||
|
|
@ -100,14 +254,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
size: 100, // 충분히 큰 값
|
size: 100, // 충분히 큰 값
|
||||||
search: { [groupByColumn]: groupKeyValue },
|
search: { [groupByColumn]: groupKeyValue },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
|
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
|
||||||
success: response.data?.success,
|
success: response.data?.success,
|
||||||
hasData: !!response.data?.data,
|
hasData: !!response.data?.data,
|
||||||
dataType: typeof response.data?.data,
|
dataType: typeof response.data?.data,
|
||||||
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
|
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
|
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
|
||||||
if (response.data?.success && response.data?.data?.data) {
|
if (response.data?.success && response.data?.data?.data) {
|
||||||
const items = response.data.data.data; // 실제 데이터 배열
|
const items = response.data.data.data; // 실제 데이터 배열
|
||||||
|
|
@ -118,17 +272,17 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
firstItem: items[0],
|
firstItem: items[0],
|
||||||
});
|
});
|
||||||
setGroupedData(items);
|
setGroupedData(items);
|
||||||
|
|
||||||
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
||||||
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
|
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
|
||||||
setOriginalItemIds(itemIds);
|
setOriginalItemIds(itemIds);
|
||||||
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
||||||
|
|
||||||
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
|
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
|
||||||
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
|
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
|
||||||
splitPanelContext.addItemIds(itemIds);
|
splitPanelContext.addItemIds(itemIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// onChange 호출하여 부모에게 알림
|
// onChange 호출하여 부모에게 알림
|
||||||
if (onChange && items.length > 0) {
|
if (onChange && items.length > 0) {
|
||||||
const dataWithMeta = items.map((item: any) => ({
|
const dataWithMeta = items.map((item: any) => ({
|
||||||
|
|
@ -150,15 +304,126 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
setIsLoadingGroupData(false);
|
setIsLoadingGroupData(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadGroupedData();
|
loadGroupedData();
|
||||||
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
|
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
|
||||||
|
|
||||||
|
// 🆕 분할 패널에서 좌측 데이터 선택 시 FK 기반으로 데이터 로드
|
||||||
|
// 좌측 테이블의 serial_no 등을 기준으로 우측 repeater 데이터 필터링
|
||||||
|
const prevFkValueRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDataByFK = async () => {
|
||||||
|
// 우측 패널이 아니면 스킵
|
||||||
|
if (!isRightPanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 fkValue가 없거나 빈 값이면 빈 상태로 초기화
|
||||||
|
if (!fkValue || fkValue === "" || fkValue === null || fkValue === undefined) {
|
||||||
|
console.log("🔄 [RepeaterFieldGroup] FK 값 없음 - 빈 상태로 초기화:", {
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
prevFkValue: prevFkValueRef.current,
|
||||||
|
});
|
||||||
|
// 이전에 데이터가 있었다면 초기화
|
||||||
|
if (prevFkValueRef.current !== null) {
|
||||||
|
setGroupedData([]);
|
||||||
|
setOriginalItemIds([]);
|
||||||
|
onChange?.([]);
|
||||||
|
prevFkValueRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FK 컬럼이나 타겟 테이블이 없으면 스킵
|
||||||
|
if (!fkColumn || !effectiveTargetTable) {
|
||||||
|
console.log("⏭️ [RepeaterFieldGroup] FK 기반 로드 스킵 (설정 부족):", {
|
||||||
|
fkColumn,
|
||||||
|
effectiveTargetTable,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 FK 값으로 이미 로드했으면 스킵
|
||||||
|
const currentFkValueStr = String(fkValue);
|
||||||
|
if (prevFkValueRef.current === currentFkValueStr) {
|
||||||
|
console.log("⏭️ [RepeaterFieldGroup] 같은 FK 값 - 스킵:", currentFkValueStr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevFkValueRef.current = currentFkValueStr;
|
||||||
|
|
||||||
|
console.log("📥 [RepeaterFieldGroup] 분할 패널 FK 기반 데이터 로드:", {
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
effectiveTargetTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoadingGroupData(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// API 호출: FK 값을 기준으로 데이터 조회
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${effectiveTargetTable}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 100,
|
||||||
|
search: { [fkColumn]: fkValue },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
const items = response.data?.data?.data || [];
|
||||||
|
console.log("✅ [RepeaterFieldGroup] FK 기반 데이터 로드 완료:", {
|
||||||
|
count: items.length,
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
effectiveTargetTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 데이터가 있든 없든 항상 상태 업데이트 (빈 배열도 명확히 설정)
|
||||||
|
setGroupedData(items);
|
||||||
|
|
||||||
|
// 원본 데이터 ID 목록 저장
|
||||||
|
const itemIds = items.map((item: any) => String(item.id)).filter(Boolean);
|
||||||
|
setOriginalItemIds(itemIds);
|
||||||
|
|
||||||
|
// onChange 호출 (effectiveTargetTable 사용)
|
||||||
|
if (onChange) {
|
||||||
|
if (items.length > 0) {
|
||||||
|
const dataWithMeta = items.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
_targetTable: effectiveTargetTable,
|
||||||
|
_existingRecord: !!item.id,
|
||||||
|
}));
|
||||||
|
onChange(dataWithMeta);
|
||||||
|
} else {
|
||||||
|
// 🆕 데이터가 없으면 빈 배열 전달 (이전 데이터 클리어)
|
||||||
|
console.log("ℹ️ [RepeaterFieldGroup] FK 기반 데이터 없음 - 빈 상태로 초기화");
|
||||||
|
onChange([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// API 실패 시 빈 배열로 설정
|
||||||
|
console.log("⚠️ [RepeaterFieldGroup] FK 기반 데이터 로드 실패 - 빈 상태로 초기화");
|
||||||
|
setGroupedData([]);
|
||||||
|
setOriginalItemIds([]);
|
||||||
|
onChange?.([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [RepeaterFieldGroup] FK 기반 데이터 로드 오류:", error);
|
||||||
|
setGroupedData([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingGroupData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDataByFK();
|
||||||
|
}, [isRightPanel, fkColumn, fkValue, effectiveTargetTable, onChange]);
|
||||||
|
|
||||||
// 값이 JSON 문자열인 경우 파싱
|
// 값이 JSON 문자열인 경우 파싱
|
||||||
let parsedValue: any[] = [];
|
let parsedValue: any[] = [];
|
||||||
|
|
||||||
// 🆕 그룹화된 데이터가 있으면 우선 사용
|
// 🆕 그룹화된 데이터가 설정되어 있으면 우선 사용 (빈 배열 포함!)
|
||||||
if (groupedData !== null && groupedData.length > 0) {
|
// groupedData가 null이 아니면 (빈 배열이라도) 해당 값을 사용
|
||||||
|
if (groupedData !== null) {
|
||||||
parsedValue = groupedData;
|
parsedValue = groupedData;
|
||||||
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
||||||
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
||||||
|
|
@ -201,7 +466,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 데이터 수신 핸들러
|
// 데이터 수신 핸들러
|
||||||
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
||||||
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
toast.warning("전달할 데이터가 없습니다");
|
toast.warning("전달할 데이터가 없습니다");
|
||||||
return;
|
return;
|
||||||
|
|
@ -230,13 +495,20 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
const definedFields = configRef.current.fields || [];
|
const definedFields = configRef.current.fields || [];
|
||||||
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
|
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
|
||||||
// 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해)
|
// 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해)
|
||||||
const systemFields = new Set(['_targetTable', '_isNewItem', 'created_date', 'updated_date', 'writer', 'company_code']);
|
const systemFields = new Set([
|
||||||
|
"_targetTable",
|
||||||
|
"_isNewItem",
|
||||||
|
"created_date",
|
||||||
|
"updated_date",
|
||||||
|
"writer",
|
||||||
|
"company_code",
|
||||||
|
]);
|
||||||
|
|
||||||
const filteredData = normalizedData.map((item: any) => {
|
const filteredData = normalizedData.map((item: any) => {
|
||||||
const filteredItem: Record<string, any> = {};
|
const filteredItem: Record<string, any> = {};
|
||||||
Object.keys(item).forEach(key => {
|
Object.keys(item).forEach((key) => {
|
||||||
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
|
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
|
||||||
if (key === 'id') {
|
if (key === "id") {
|
||||||
return; // id 필드 제외
|
return; // id 필드 제외
|
||||||
}
|
}
|
||||||
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
||||||
|
|
@ -254,25 +526,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
|
|
||||||
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
|
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
|
||||||
const currentValue = parsedValueRef.current;
|
const currentValue = parsedValueRef.current;
|
||||||
|
|
||||||
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
||||||
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
||||||
|
|
||||||
let newItems: any[];
|
let newItems: any[];
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
let duplicateCount = 0;
|
let duplicateCount = 0;
|
||||||
|
|
||||||
if (mode === "replace") {
|
if (mode === "replace") {
|
||||||
newItems = filteredData;
|
newItems = filteredData;
|
||||||
addedCount = filteredData.length;
|
addedCount = filteredData.length;
|
||||||
} else {
|
} else {
|
||||||
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
|
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
|
||||||
const existingItemCodes = new Set(
|
const existingItemCodes = new Set(currentValue.map((item: any) => item.item_code).filter(Boolean));
|
||||||
currentValue
|
|
||||||
.map((item: any) => item.item_code)
|
|
||||||
.filter(Boolean)
|
|
||||||
);
|
|
||||||
|
|
||||||
const uniqueNewItems = filteredData.filter((item: any) => {
|
const uniqueNewItems = filteredData.filter((item: any) => {
|
||||||
const itemCode = item.item_code;
|
const itemCode = item.item_code;
|
||||||
if (itemCode && existingItemCodes.has(itemCode)) {
|
if (itemCode && existingItemCodes.has(itemCode)) {
|
||||||
|
|
@ -281,14 +549,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
newItems = [...currentValue, ...uniqueNewItems];
|
newItems = [...currentValue, ...uniqueNewItems];
|
||||||
addedCount = uniqueNewItems.length;
|
addedCount = uniqueNewItems.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
||||||
currentValue,
|
currentValue,
|
||||||
newItems,
|
newItems,
|
||||||
mode,
|
mode,
|
||||||
addedCount,
|
addedCount,
|
||||||
duplicateCount,
|
duplicateCount,
|
||||||
|
|
@ -300,21 +568,19 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
|
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
|
||||||
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
|
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
|
||||||
if (splitPanelContext?.addItemIds && addedCount > 0) {
|
if (splitPanelContext?.addItemIds && addedCount > 0) {
|
||||||
const newItemCodes = newItems
|
const newItemCodes = newItems.map((item: any) => String(item.item_code)).filter(Boolean);
|
||||||
.map((item: any) => String(item.item_code))
|
|
||||||
.filter(Boolean);
|
|
||||||
splitPanelContext.addItemIds(newItemCodes);
|
splitPanelContext.addItemIds(newItemCodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON 문자열로 변환하여 저장
|
// JSON 문자열로 변환하여 저장
|
||||||
const jsonValue = JSON.stringify(newItems);
|
const jsonValue = JSON.stringify(newItems);
|
||||||
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
||||||
jsonValue,
|
jsonValue,
|
||||||
hasOnChange: !!onChangeRef.current,
|
hasOnChange: !!onChangeRef.current,
|
||||||
hasOnFormDataChange: !!onFormDataChangeRef.current,
|
hasOnFormDataChange: !!onFormDataChangeRef.current,
|
||||||
fieldName: fieldNameRef.current,
|
fieldName: fieldNameRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
|
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
|
||||||
if (onFormDataChangeRef.current) {
|
if (onFormDataChangeRef.current) {
|
||||||
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
|
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
|
||||||
|
|
@ -337,18 +603,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// DataReceivable 인터페이스 구현
|
// DataReceivable 인터페이스 구현
|
||||||
const dataReceiver = useMemo<DataReceivable>(() => ({
|
const dataReceiver = useMemo<DataReceivable>(
|
||||||
componentId: component.id,
|
() => ({
|
||||||
componentType: "repeater-field-group",
|
componentId: component.id,
|
||||||
receiveData: handleReceiveData,
|
componentType: "repeater-field-group",
|
||||||
}), [component.id, handleReceiveData]);
|
receiveData: handleReceiveData,
|
||||||
|
}),
|
||||||
|
[component.id, handleReceiveData],
|
||||||
|
);
|
||||||
|
|
||||||
// ScreenContext에 데이터 수신자로 등록
|
// ScreenContext에 데이터 수신자로 등록
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (screenContext && component.id) {
|
if (screenContext && component.id) {
|
||||||
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
|
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
|
||||||
screenContext.registerDataReceiver(component.id, dataReceiver);
|
screenContext.registerDataReceiver(component.id, dataReceiver);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
screenContext.unregisterDataReceiver(component.id);
|
screenContext.unregisterDataReceiver(component.id);
|
||||||
};
|
};
|
||||||
|
|
@ -358,16 +627,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
|
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
|
|
||||||
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
|
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
|
||||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
|
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
position: splitPanelPosition,
|
position: splitPanelPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
|
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
|
||||||
receiverRef.current = dataReceiver;
|
receiverRef.current = dataReceiver;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
|
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
|
||||||
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
|
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
|
||||||
|
|
@ -380,13 +649,13 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
|
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
|
||||||
const { data, mode, mappingRules } = event.detail;
|
const { data, mode, mappingRules } = event.detail;
|
||||||
|
|
||||||
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
|
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
|
||||||
dataCount: data?.length,
|
dataCount: data?.length,
|
||||||
mode,
|
mode,
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
|
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
|
||||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
if (splitPanelPosition === "right" && data && data.length > 0) {
|
if (splitPanelPosition === "right" && data && data.length > 0) {
|
||||||
|
|
@ -395,51 +664,113 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||||
};
|
};
|
||||||
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
|
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
|
||||||
|
|
||||||
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
||||||
const handleRepeaterChange = useCallback((newValue: any[]) => {
|
const handleRepeaterChange = useCallback(
|
||||||
// 배열을 JSON 문자열로 변환하여 저장
|
(newValue: any[]) => {
|
||||||
const jsonValue = JSON.stringify(newValue);
|
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
|
||||||
onChange?.(jsonValue);
|
let valueWithMeta = newValue;
|
||||||
|
|
||||||
// 🆕 groupedData 상태도 업데이트
|
if (isRightPanel && effectiveTargetTable) {
|
||||||
setGroupedData(newValue);
|
valueWithMeta = newValue.map((item: any) => {
|
||||||
|
const itemWithMeta = {
|
||||||
// 🆕 SplitPanelContext의 addedItemIds 동기화
|
...item,
|
||||||
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
|
_targetTable: effectiveTargetTable,
|
||||||
// 현재 항목들의 ID 목록
|
};
|
||||||
const currentIds = newValue
|
|
||||||
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
|
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
|
||||||
.filter(Boolean);
|
if (fkColumn && fkValue && item._isNewItem) {
|
||||||
|
itemWithMeta[fkColumn] = fkValue;
|
||||||
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
|
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
|
||||||
const addedIds = splitPanelContext.addedItemIds;
|
fkColumn,
|
||||||
const removedIds = Array.from(addedIds).filter(id => !currentIds.includes(id));
|
fkValue,
|
||||||
|
});
|
||||||
if (removedIds.length > 0) {
|
}
|
||||||
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
|
|
||||||
splitPanelContext.removeItemIds(removedIds);
|
return itemWithMeta;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로 추가된 ID가 있으면 등록
|
// 배열을 JSON 문자열로 변환하여 저장
|
||||||
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
|
const jsonValue = JSON.stringify(valueWithMeta);
|
||||||
if (newIds.length > 0) {
|
console.log("📤 [RepeaterFieldGroup] 데이터 변경:", {
|
||||||
console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
|
fieldName,
|
||||||
splitPanelContext.addItemIds(newIds);
|
itemCount: valueWithMeta.length,
|
||||||
|
isRightPanel,
|
||||||
|
hasScreenContextUpdateFormData: !!screenContext?.updateFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 분할 패널 우측에서는 ScreenContext.updateFormData만 사용
|
||||||
|
// (중복 저장 방지: onChange/onFormDataChange는 부모에게 전달되어 다시 formData로 돌아옴)
|
||||||
|
if (isRightPanel && screenContext?.updateFormData) {
|
||||||
|
screenContext.updateFormData(fieldName, jsonValue);
|
||||||
|
console.log("📤 [RepeaterFieldGroup] screenContext.updateFormData 호출 (우측 패널):", { fieldName });
|
||||||
|
} else {
|
||||||
|
// 분할 패널이 아니거나 좌측 패널인 경우 기존 방식 사용
|
||||||
|
onChange?.(jsonValue);
|
||||||
|
if (onFormDataChange) {
|
||||||
|
onFormDataChange(fieldName, jsonValue);
|
||||||
|
console.log("📤 [RepeaterFieldGroup] onFormDataChange(props) 호출:", { fieldName });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [onChange, splitPanelContext, screenContext?.splitPanelPosition]);
|
// 🆕 groupedData 상태도 업데이트
|
||||||
|
setGroupedData(valueWithMeta);
|
||||||
|
|
||||||
|
// 🆕 SplitPanelContext의 addedItemIds 동기화
|
||||||
|
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
|
||||||
|
// 현재 항목들의 ID 목록
|
||||||
|
const currentIds = newValue
|
||||||
|
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
|
||||||
|
const addedIds = splitPanelContext.addedItemIds;
|
||||||
|
const removedIds = Array.from(addedIds).filter((id) => !currentIds.includes(id));
|
||||||
|
|
||||||
|
if (removedIds.length > 0) {
|
||||||
|
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
|
||||||
|
splitPanelContext.removeItemIds(removedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로 추가된 ID가 있으면 등록
|
||||||
|
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
|
||||||
|
splitPanelContext.addItemIds(newIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
onChange,
|
||||||
|
onFormDataChange,
|
||||||
|
splitPanelContext,
|
||||||
|
screenContext?.splitPanelPosition,
|
||||||
|
screenContext?.updateFormData,
|
||||||
|
isRightPanel,
|
||||||
|
effectiveTargetTable,
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
fieldName,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 config에 effectiveTargetTable 병합 (linkedFilters에서 추출된 테이블도 포함)
|
||||||
|
const effectiveConfig = {
|
||||||
|
...config,
|
||||||
|
targetTable: effectiveTargetTable || config.targetTable,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RepeaterInput
|
<RepeaterInput
|
||||||
value={parsedValue}
|
value={parsedValue}
|
||||||
onChange={handleRepeaterChange}
|
onChange={handleRepeaterChange}
|
||||||
config={config}
|
config={effectiveConfig}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
menuObjid={menuObjid}
|
menuObjid={menuObjid}
|
||||||
|
|
|
||||||
|
|
@ -100,3 +100,4 @@
|
||||||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||||
- [split-panel-layout (v1)](../split-panel-layout/README.md)
|
- [split-panel-layout (v1)](../split-panel-layout/README.md)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer
|
||||||
// 자동 등록 실행
|
// 자동 등록 실행
|
||||||
SplitPanelLayout2Renderer.registerSelf();
|
SplitPanelLayout2Renderer.registerSelf();
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
|
|
||||||
// 자동생성된 값 상태
|
// 자동생성된 값 상태
|
||||||
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
|
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
|
||||||
|
|
||||||
// API 호출 중복 방지를 위한 ref
|
// API 호출 중복 방지를 위한 ref
|
||||||
const isGeneratingRef = React.useRef(false);
|
const isGeneratingRef = React.useRef(false);
|
||||||
const hasGeneratedRef = React.useRef(false);
|
const hasGeneratedRef = React.useRef(false);
|
||||||
|
|
@ -104,7 +104,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
const currentFormValue = formData?.[component.columnName];
|
const currentFormValue = formData?.[component.columnName];
|
||||||
const currentComponentValue = component.value;
|
const currentComponentValue = component.value;
|
||||||
|
|
||||||
|
|
||||||
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
|
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
|
||||||
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
|
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
|
||||||
isGeneratingRef.current = true; // 생성 시작 플래그
|
isGeneratingRef.current = true; // 생성 시작 플래그
|
||||||
|
|
@ -145,7 +144,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
if (isInteractive && onFormDataChange && component.columnName) {
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
console.log("📝 formData 업데이트:", component.columnName, generatedValue);
|
console.log("📝 formData 업데이트:", component.columnName, generatedValue);
|
||||||
onFormDataChange(component.columnName, generatedValue);
|
onFormDataChange(component.columnName, generatedValue);
|
||||||
|
|
||||||
// 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함)
|
// 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함)
|
||||||
if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) {
|
if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) {
|
||||||
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
||||||
|
|
@ -181,12 +180,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
width: "100%",
|
width: "100%",
|
||||||
// 숨김 기능: 편집 모드에서만 연하게 표시
|
// 숨김 기능: 편집 모드에서만 연하게 표시
|
||||||
...(isHidden &&
|
...(isHidden &&
|
||||||
isDesignMode && {
|
isDesignMode && {
|
||||||
opacity: 0.4,
|
opacity: 0.4,
|
||||||
backgroundColor: "hsl(var(--muted))",
|
backgroundColor: "hsl(var(--muted))",
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
@ -361,7 +360,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && <span className="text-destructive">*</span>}
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -386,15 +385,17 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* @ 구분자 */}
|
{/* @ 구분자 */}
|
||||||
<span className="text-base font-medium text-muted-foreground">@</span>
|
<span className="text-muted-foreground text-base font-medium">@</span>
|
||||||
|
|
||||||
{/* 도메인 선택/입력 (Combobox) */}
|
{/* 도메인 선택/입력 (Combobox) */}
|
||||||
<Popover open={emailDomainOpen} onOpenChange={setEmailDomainOpen}>
|
<Popover open={emailDomainOpen} onOpenChange={setEmailDomainOpen}>
|
||||||
|
|
@ -406,14 +407,18 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
disabled={componentConfig.disabled || false}
|
disabled={componentConfig.disabled || false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full flex-1 items-center justify-between rounded-md border px-3 py-2 text-sm transition-all duration-200",
|
"flex h-full flex-1 items-center justify-between rounded-md border px-3 py-2 text-sm transition-all duration-200",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "cursor-not-allowed bg-muted text-muted-foreground opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
"hover:border-ring/80",
|
"hover:border-ring/80",
|
||||||
emailDomainOpen && "border-ring ring-2 ring-ring/50",
|
emailDomainOpen && "border-ring ring-ring/50 ring-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn("truncate", !emailDomain && "text-muted-foreground")}>{emailDomain || "도메인 선택"}</span>
|
<span className={cn("truncate", !emailDomain && "text-muted-foreground")}>
|
||||||
|
{emailDomain || "도메인 선택"}
|
||||||
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -470,7 +475,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && <span className="text-destructive">*</span>}
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -496,14 +501,16 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
|
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="text-base font-medium text-muted-foreground">-</span>
|
<span className="text-muted-foreground text-base font-medium">-</span>
|
||||||
|
|
||||||
{/* 두 번째 부분 */}
|
{/* 두 번째 부분 */}
|
||||||
<input
|
<input
|
||||||
|
|
@ -524,14 +531,16 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
|
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="text-base font-medium text-muted-foreground">-</span>
|
<span className="text-muted-foreground text-base font-medium">-</span>
|
||||||
|
|
||||||
{/* 세 번째 부분 */}
|
{/* 세 번째 부분 */}
|
||||||
<input
|
<input
|
||||||
|
|
@ -552,10 +561,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
|
"h-full flex-1 rounded-md border px-3 py-2 text-center text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -569,7 +580,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && <span className="text-destructive">*</span>}
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -591,10 +602,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-[100px] cursor-pointer rounded-md border px-2 py-2 text-sm transition-all duration-200 outline-none",
|
"h-full w-[100px] cursor-pointer rounded-md border px-2 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<option value="https://">https://</option>
|
<option value="https://">https://</option>
|
||||||
|
|
@ -619,10 +632,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -636,7 +651,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && <span className="text-destructive">*</span>}
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -669,11 +684,13 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"box-border h-full w-full max-w-full resize-none rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"box-border h-full w-full max-w-full resize-none rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -692,13 +709,15 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
|
|
||||||
{/* 수동/자동 모드 표시 배지 */}
|
{/* 수동/자동 모드 표시 배지 */}
|
||||||
{testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && (
|
{testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && (
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 items-center gap-1">
|
||||||
<span className={cn(
|
<span
|
||||||
"text-[10px] px-2 py-0.5 rounded-full font-medium",
|
className={cn(
|
||||||
isManualMode
|
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||||
? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
isManualMode
|
||||||
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
||||||
)}>
|
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{isManualMode ? "수동" : "자동"}
|
{isManualMode ? "수동" : "자동"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -706,12 +725,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type={inputType}
|
type={inputType}
|
||||||
defaultValue={(() => {
|
value={(() => {
|
||||||
let displayValue = "";
|
let displayValue = "";
|
||||||
|
|
||||||
if (isInteractive && formData && component.columnName) {
|
if (isInteractive && formData && component.columnName) {
|
||||||
// 인터랙티브 모드: formData 우선, 없으면 자동생성 값
|
// 인터랙티브 모드: formData 우선, 없으면 자동생성 값
|
||||||
const rawValue = formData[component.columnName] || autoGeneratedValue || "";
|
const rawValue = formData[component.columnName] ?? autoGeneratedValue ?? "";
|
||||||
// 객체인 경우 빈 문자열로 변환 (에러 방지)
|
// 객체인 경우 빈 문자열로 변환 (에러 방지)
|
||||||
displayValue = typeof rawValue === "object" ? "" : String(rawValue);
|
displayValue = typeof rawValue === "object" ? "" : String(rawValue);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -724,31 +743,33 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
})()}
|
})()}
|
||||||
placeholder={
|
placeholder={
|
||||||
testAutoGeneration.enabled && testAutoGeneration.type !== "none"
|
testAutoGeneration.enabled && testAutoGeneration.type !== "none"
|
||||||
? isManualMode
|
? isManualMode
|
||||||
? "수동 입력 모드"
|
? "수동 입력 모드"
|
||||||
: `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
|
: `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
|
||||||
: componentConfig.placeholder || defaultPlaceholder
|
: componentConfig.placeholder || defaultPlaceholder
|
||||||
}
|
}
|
||||||
pattern={validationPattern}
|
pattern={validationPattern}
|
||||||
title={
|
title={
|
||||||
webType === "tel"
|
webType === "tel"
|
||||||
? "전화번호 형식: 010-1234-5678"
|
? "전화번호 형식: 010-1234-5678"
|
||||||
: isManualMode
|
: isManualMode
|
||||||
? `${component.label} (수동 입력 모드 - 채번 규칙 미적용)`
|
? `${component.label} (수동 입력 모드 - 채번 규칙 미적용)`
|
||||||
: component.label
|
: component.label
|
||||||
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
|
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
|
||||||
: component.columnName || undefined
|
: component.columnName || undefined
|
||||||
}
|
}
|
||||||
disabled={componentConfig.disabled || false}
|
disabled={componentConfig.disabled || false}
|
||||||
required={componentConfig.required || false}
|
required={componentConfig.required || false}
|
||||||
readOnly={componentConfig.readonly || false}
|
readOnly={componentConfig.readonly || false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none",
|
"box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
handleClick(e);
|
handleClick(e);
|
||||||
|
|
@ -774,9 +795,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
console.log("🔄 수동 모드로 전환:", {
|
console.log("🔄 수동 모드로 전환:", {
|
||||||
field: component.columnName,
|
field: component.columnName,
|
||||||
original: originalAutoGeneratedValue,
|
original: originalAutoGeneratedValue,
|
||||||
modified: newValue
|
modified: newValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함)
|
// 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함)
|
||||||
if (isInteractive && onFormDataChange && component.columnName) {
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
||||||
|
|
@ -789,9 +810,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
setIsManualMode(false);
|
setIsManualMode(false);
|
||||||
console.log("🔄 자동 모드로 복구:", {
|
console.log("🔄 자동 모드로 복구:", {
|
||||||
field: component.columnName,
|
field: component.columnName,
|
||||||
value: newValue
|
value: newValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 채번 규칙 ID 복구
|
// 채번 규칙 ID 복구
|
||||||
if (isInteractive && onFormDataChange && component.columnName) {
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
const ruleId = testAutoGeneration.options?.numberingRuleId;
|
const ruleId = testAutoGeneration.options?.numberingRuleId;
|
||||||
|
|
|
||||||
|
|
@ -840,3 +840,4 @@ export function FieldDetailSettingsModal({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -794,3 +794,4 @@ export function SaveSettingsModal({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -514,3 +514,4 @@ export function SectionLayoutModal({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -379,10 +379,41 @@ export class ButtonActionExecutor {
|
||||||
/**
|
/**
|
||||||
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
|
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
|
||||||
*/
|
*/
|
||||||
|
private static saveCallCount = 0; // 🆕 호출 횟수 추적
|
||||||
|
private static saveLock: Map<string, number> = new Map(); // 🆕 중복 호출 방지 락
|
||||||
|
|
||||||
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
|
this.saveCallCount++;
|
||||||
|
const callId = this.saveCallCount;
|
||||||
|
|
||||||
const { formData, originalData, tableName, screenId, onSave } = context;
|
const { formData, originalData, tableName, screenId, onSave } = context;
|
||||||
|
|
||||||
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave });
|
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
|
||||||
|
const formDataHash = JSON.stringify(Object.keys(formData).sort());
|
||||||
|
const lockKey = `${screenId}-${tableName}-${formDataHash}`;
|
||||||
|
const lastCallTime = this.saveLock.get(lockKey) || 0;
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDiff = now - lastCallTime;
|
||||||
|
|
||||||
|
console.log(`🔒 [handleSave #${callId}] 락 체크:`, { lockKey: lockKey.slice(0, 50), timeDiff, threshold: 2000 });
|
||||||
|
|
||||||
|
if (timeDiff < 2000) {
|
||||||
|
console.log(`⏭️ [handleSave #${callId}] 중복 호출 무시 (2초 내 재호출):`, {
|
||||||
|
lockKey: lockKey.slice(0, 50),
|
||||||
|
timeDiff,
|
||||||
|
});
|
||||||
|
return true; // 중복 호출은 성공으로 처리
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveLock.set(lockKey, now);
|
||||||
|
|
||||||
|
console.log(`💾 [handleSave #${callId}] 저장 시작:`, {
|
||||||
|
callId,
|
||||||
|
formDataKeys: Object.keys(formData),
|
||||||
|
tableName,
|
||||||
|
screenId,
|
||||||
|
hasOnSave: !!onSave,
|
||||||
|
});
|
||||||
|
|
||||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||||
if (onSave) {
|
if (onSave) {
|
||||||
|
|
@ -807,6 +838,107 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 RepeaterFieldGroup 데이터 저장 처리 (_targetTable이 있는 배열 데이터)
|
||||||
|
// formData에서 _targetTable 메타데이터가 포함된 배열 필드 찾기
|
||||||
|
console.log("🔎 [handleSave] formData 키 목록:", Object.keys(context.formData));
|
||||||
|
console.log("🔎 [handleSave] formData 전체:", context.formData);
|
||||||
|
|
||||||
|
for (const [fieldKey, fieldValue] of Object.entries(context.formData)) {
|
||||||
|
console.log(`🔎 [handleSave] 필드 검사: ${fieldKey}`, {
|
||||||
|
type: typeof fieldValue,
|
||||||
|
isArray: Array.isArray(fieldValue),
|
||||||
|
valuePreview: typeof fieldValue === "string" ? fieldValue.slice(0, 100) : fieldValue,
|
||||||
|
});
|
||||||
|
// JSON 문자열인 경우 파싱
|
||||||
|
let parsedData = fieldValue;
|
||||||
|
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(fieldValue);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배열이고 첫 번째 항목에 _targetTable이 있는 경우만 처리
|
||||||
|
if (!Array.isArray(parsedData) || parsedData.length === 0) continue;
|
||||||
|
|
||||||
|
const firstItem = parsedData[0];
|
||||||
|
const repeaterTargetTable = firstItem?._targetTable;
|
||||||
|
|
||||||
|
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
|
||||||
|
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
|
||||||
|
|
||||||
|
console.log(`📦 [handleSave] RepeaterFieldGroup 데이터 저장: ${fieldKey} → ${repeaterTargetTable}`, {
|
||||||
|
itemCount: parsedData.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of parsedData) {
|
||||||
|
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
|
||||||
|
|
||||||
|
const {
|
||||||
|
_targetTable: _,
|
||||||
|
_isNewItem,
|
||||||
|
_existingRecord: __,
|
||||||
|
_originalItemIds: ___,
|
||||||
|
_deletedItemIds: ____,
|
||||||
|
...dataToSave
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
// 🆕 빈 id 필드 제거 (새 항목인 경우)
|
||||||
|
if (!dataToSave.id || dataToSave.id === "" || dataToSave.id === null) {
|
||||||
|
delete dataToSave.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 정보 추가
|
||||||
|
const dataWithMeta: Record<string, unknown> = {
|
||||||
|
...dataToSave,
|
||||||
|
created_by: context.userId,
|
||||||
|
updated_by: context.userId,
|
||||||
|
company_code: context.companyCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🆕 새 항목 판단: _isNewItem 플래그 또는 id가 없거나 빈 문자열인 경우
|
||||||
|
const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined;
|
||||||
|
|
||||||
|
if (isNewRecord) {
|
||||||
|
// INSERT (새 항목)
|
||||||
|
// id 필드 완전히 제거 (자동 생성되도록)
|
||||||
|
delete dataWithMeta.id;
|
||||||
|
// 빈 문자열 id도 제거
|
||||||
|
if ("id" in dataWithMeta && (dataWithMeta.id === "" || dataWithMeta.id === null)) {
|
||||||
|
delete dataWithMeta.id;
|
||||||
|
}
|
||||||
|
console.log(`📝 [handleSave] RepeaterFieldGroup INSERT (${repeaterTargetTable}):`, dataWithMeta);
|
||||||
|
const insertResult = await apiClient.post(
|
||||||
|
`/table-management/tables/${repeaterTargetTable}/add`,
|
||||||
|
dataWithMeta,
|
||||||
|
);
|
||||||
|
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
|
||||||
|
} else if (item.id) {
|
||||||
|
// UPDATE (기존 항목)
|
||||||
|
const originalData = { id: item.id };
|
||||||
|
const updatedData = { ...dataWithMeta, id: item.id };
|
||||||
|
console.log("📝 [handleSave] RepeaterFieldGroup UPDATE:", {
|
||||||
|
id: item.id,
|
||||||
|
table: repeaterTargetTable,
|
||||||
|
});
|
||||||
|
const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, {
|
||||||
|
originalData,
|
||||||
|
updatedData,
|
||||||
|
});
|
||||||
|
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as { response?: { data?: unknown }; message?: string };
|
||||||
|
console.error(
|
||||||
|
`❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`,
|
||||||
|
error.response?.data || error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
|
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
|
||||||
const repeatScreenModalKeys = Object.keys(context.formData).filter(
|
const repeatScreenModalKeys = Object.keys(context.formData).filter(
|
||||||
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
|
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
|
||||||
|
|
@ -814,11 +946,36 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
|
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
|
||||||
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
|
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
|
||||||
const shouldSkipMainSave = repeatScreenModalTables.includes(tableName);
|
|
||||||
|
// 🆕 RepeaterFieldGroup 테이블 목록 수집 (메인 저장 건너뛰기 판단용)
|
||||||
|
const repeaterFieldGroupTables: string[] = [];
|
||||||
|
for (const [, fieldValue] of Object.entries(context.formData)) {
|
||||||
|
let parsedData = fieldValue;
|
||||||
|
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(fieldValue);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsedData) && parsedData.length > 0 && parsedData[0]?._targetTable) {
|
||||||
|
repeaterFieldGroupTables.push(parsedData[0]._targetTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 저장 건너뛰기 조건: RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
|
||||||
|
const shouldSkipMainSave =
|
||||||
|
repeatScreenModalTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName);
|
||||||
|
|
||||||
if (shouldSkipMainSave) {
|
if (shouldSkipMainSave) {
|
||||||
console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`);
|
console.log(
|
||||||
saveResult = { success: true, message: "RepeatScreenModal에서 처리" };
|
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`,
|
||||||
|
{
|
||||||
|
repeatScreenModalTables,
|
||||||
|
repeaterFieldGroupTables,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" };
|
||||||
} else {
|
} else {
|
||||||
saveResult = await DynamicFormApi.saveFormData({
|
saveResult = await DynamicFormApi.saveFormData({
|
||||||
screenId,
|
screenId,
|
||||||
|
|
@ -1623,16 +1780,15 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모달 액션 처리
|
* 모달 액션 처리
|
||||||
* 🔧 modal 액션은 항상 신규 등록(INSERT) 모드로 동작
|
* 선택된 데이터가 있으면 함께 전달 (출하계획 등에서 사용)
|
||||||
* edit 액션만 수정(UPDATE) 모드로 동작해야 함
|
|
||||||
*/
|
*/
|
||||||
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
// 모달 열기 로직
|
// 모달 열기 로직
|
||||||
console.log("모달 열기 (신규 등록 모드):", {
|
console.log("모달 열기:", {
|
||||||
title: config.modalTitle,
|
title: config.modalTitle,
|
||||||
size: config.modalSize,
|
size: config.modalSize,
|
||||||
targetScreenId: config.targetScreenId,
|
targetScreenId: config.targetScreenId,
|
||||||
// 🔧 selectedRowsData는 modal 액션에서 사용하지 않음 (신규 등록이므로)
|
selectedRowsData: context.selectedRowsData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.targetScreenId) {
|
if (config.targetScreenId) {
|
||||||
|
|
@ -1649,11 +1805,10 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔧 modal 액션은 신규 등록이므로 selectedData를 전달하지 않음
|
// 선택된 행 데이터 수집
|
||||||
// selectedData가 있으면 ScreenModal에서 originalData로 인식하여 UPDATE 모드로 동작하게 됨
|
const selectedData = context.selectedRowsData || [];
|
||||||
// edit 액션만 selectedData/editData를 사용하여 UPDATE 모드로 동작
|
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
|
||||||
console.log("📦 [handleModal] 신규 등록 모드 - selectedData 전달하지 않음");
|
console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData);
|
||||||
console.log("📦 [handleModal] 분할 패널 부모 데이터 (초기값으로 사용):", context.splitPanelParentData);
|
|
||||||
|
|
||||||
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
||||||
const modalEvent = new CustomEvent("openScreenModal", {
|
const modalEvent = new CustomEvent("openScreenModal", {
|
||||||
|
|
@ -1662,11 +1817,10 @@ export class ButtonActionExecutor {
|
||||||
title: config.modalTitle || "화면",
|
title: config.modalTitle || "화면",
|
||||||
description: description,
|
description: description,
|
||||||
size: config.modalSize || "md",
|
size: config.modalSize || "md",
|
||||||
// 🔧 신규 등록이므로 selectedData/selectedIds를 전달하지 않음
|
// 선택된 행 데이터 전달
|
||||||
// edit 액션에서만 이 데이터를 사용
|
selectedData: selectedData,
|
||||||
selectedData: [],
|
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
|
||||||
selectedIds: [],
|
// 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용)
|
||||||
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 초기값으로 사용)
|
|
||||||
splitPanelParentData: context.splitPanelParentData || {},
|
splitPanelParentData: context.splitPanelParentData || {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -1866,11 +2020,18 @@ export class ButtonActionExecutor {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 modalDataStore에서 선택된 전체 데이터 가져오기 (RepeatScreenModal에서 사용)
|
||||||
|
const modalData = dataRegistry[dataSourceId] || [];
|
||||||
|
const selectedData = modalData.map((item: any) => item.originalData || item);
|
||||||
|
const selectedIds = selectedData.map((row: any) => row.id).filter(Boolean);
|
||||||
|
|
||||||
console.log("📦 [openModalWithData] 부모 데이터 전달:", {
|
console.log("📦 [openModalWithData] 부모 데이터 전달:", {
|
||||||
dataSourceId,
|
dataSourceId,
|
||||||
rawParentData,
|
rawParentData,
|
||||||
mappedParentData: parentData,
|
mappedParentData: parentData,
|
||||||
fieldMappings: config.fieldMappings,
|
fieldMappings: config.fieldMappings,
|
||||||
|
selectedDataCount: selectedData.length,
|
||||||
|
selectedIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
|
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
|
||||||
|
|
@ -1882,6 +2043,9 @@ export class ButtonActionExecutor {
|
||||||
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
|
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
|
||||||
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
|
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
|
||||||
splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용)
|
splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용)
|
||||||
|
// 🆕 선택된 데이터 전달 (RepeatScreenModal에서 groupedData로 사용)
|
||||||
|
selectedData: selectedData,
|
||||||
|
selectedIds: selectedIds,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2135,6 +2299,8 @@ export class ButtonActionExecutor {
|
||||||
editData: rowData,
|
editData: rowData,
|
||||||
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
|
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
|
||||||
tableName: context.tableName, // 🆕 테이블명 전달
|
tableName: context.tableName, // 🆕 테이블명 전달
|
||||||
|
buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용)
|
||||||
|
buttonContext: context, // 🆕 버튼 컨텍스트 전달 (screenId, userId 등)
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
context.onRefresh?.();
|
context.onRefresh?.();
|
||||||
},
|
},
|
||||||
|
|
@ -2670,9 +2836,10 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장 후 제어 실행 (After Timing)
|
* 저장 후 제어 실행 (After Timing)
|
||||||
|
* EditModal 등 외부에서도 호출 가능하도록 public으로 변경
|
||||||
* 다중 제어 순차 실행 지원
|
* 다중 제어 순차 실행 지원
|
||||||
*/
|
*/
|
||||||
private static async executeAfterSaveControl(
|
public static async executeAfterSaveControl(
|
||||||
config: ButtonActionConfig,
|
config: ButtonActionConfig,
|
||||||
context: ButtonActionContext,
|
context: ButtonActionContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
@ -4116,39 +4283,80 @@ export class ButtonActionExecutor {
|
||||||
try {
|
try {
|
||||||
console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context });
|
console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context });
|
||||||
|
|
||||||
// 추적 중인지 확인
|
// 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정)
|
||||||
if (!this.trackingIntervalId) {
|
const isTrackingActive = !!this.trackingIntervalId;
|
||||||
toast.warning("진행 중인 위치 추적이 없습니다.");
|
|
||||||
return false;
|
if (!isTrackingActive) {
|
||||||
|
// 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원)
|
||||||
|
console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행");
|
||||||
|
} else {
|
||||||
|
// 타이머 정리 (추적 중인 경우에만)
|
||||||
|
clearInterval(this.trackingIntervalId);
|
||||||
|
this.trackingIntervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 타이머 정리
|
|
||||||
clearInterval(this.trackingIntervalId);
|
|
||||||
this.trackingIntervalId = null;
|
|
||||||
|
|
||||||
const tripId = this.currentTripId;
|
const tripId = this.currentTripId;
|
||||||
|
|
||||||
// 마지막 위치 저장 (trip_status를 completed로)
|
// 🆕 DB에서 출발지/목적지 조회 (운전자가 중간에 바꿔도 원래 값 사용)
|
||||||
const departure =
|
let dbDeparture: string | null = null;
|
||||||
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
|
let dbArrival: string | null = null;
|
||||||
const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
|
let dbVehicleId: string | null = null;
|
||||||
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
|
|
||||||
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
|
const userId = context.userId || this.trackingUserId;
|
||||||
const vehicleId =
|
if (userId) {
|
||||||
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
|
try {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles";
|
||||||
|
const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id";
|
||||||
|
|
||||||
|
// DB에서 현재 차량 정보 조회
|
||||||
|
const vehicleResponse = await apiClient.post(
|
||||||
|
`/table-management/tables/${statusTableName}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
search: { [keyField]: userId },
|
||||||
|
autoFilter: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0];
|
||||||
|
if (vehicleData) {
|
||||||
|
dbDeparture = vehicleData.departure || null;
|
||||||
|
dbArrival = vehicleData.arrival || null;
|
||||||
|
dbVehicleId = vehicleData.id || vehicleData.vehicle_id || null;
|
||||||
|
console.log("📍 [handleTrackingStop] DB에서 출발지/목적지 조회:", { dbDeparture, dbArrival, dbVehicleId });
|
||||||
|
}
|
||||||
|
} catch (dbError) {
|
||||||
|
console.warn("⚠️ [handleTrackingStop] DB 조회 실패, formData 사용:", dbError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.saveLocationToHistory(
|
// 마지막 위치 저장 (추적 중이었던 경우에만)
|
||||||
tripId,
|
if (isTrackingActive) {
|
||||||
departure,
|
// DB 값 우선, 없으면 formData 사용
|
||||||
arrival,
|
const departure = dbDeparture ||
|
||||||
departureName,
|
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
|
||||||
destinationName,
|
const arrival = dbArrival ||
|
||||||
vehicleId,
|
this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
|
||||||
"completed",
|
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
|
||||||
);
|
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
|
||||||
|
const vehicleId = dbVehicleId ||
|
||||||
|
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
|
||||||
|
|
||||||
// 🆕 거리/시간 계산 및 저장
|
await this.saveLocationToHistory(
|
||||||
if (tripId) {
|
tripId,
|
||||||
|
departure,
|
||||||
|
arrival,
|
||||||
|
departureName,
|
||||||
|
destinationName,
|
||||||
|
vehicleId,
|
||||||
|
"completed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 거리/시간 계산 및 저장 (추적 중이었던 경우에만)
|
||||||
|
if (isTrackingActive && tripId) {
|
||||||
try {
|
try {
|
||||||
const tripStats = await this.calculateTripStats(tripId);
|
const tripStats = await this.calculateTripStats(tripId);
|
||||||
console.log("📊 운행 통계:", tripStats);
|
console.log("📊 운행 통계:", tripStats);
|
||||||
|
|
@ -4260,9 +4468,9 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 변경 (vehicles 테이블 등)
|
// 상태 변경 (vehicles 테이블 등) - 새로고침 후에도 동작하도록 config 우선 사용
|
||||||
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
|
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig || config;
|
||||||
const effectiveContext = context.userId ? context : this.trackingContext;
|
const effectiveContext = context.userId ? context : this.trackingContext || context;
|
||||||
|
|
||||||
if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) {
|
if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,21 @@
|
||||||
/**
|
/**
|
||||||
* 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들
|
* 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들
|
||||||
*/
|
*/
|
||||||
export type RepeaterFieldType =
|
export type RepeaterFieldType =
|
||||||
| "text" // 텍스트
|
| "text" // 텍스트
|
||||||
| "number" // 숫자
|
| "number" // 숫자
|
||||||
| "textarea" // 텍스트영역
|
| "textarea" // 텍스트영역
|
||||||
| "date" // 날짜
|
| "date" // 날짜
|
||||||
| "select" // 선택박스
|
| "select" // 선택박스
|
||||||
| "checkbox" // 체크박스
|
| "checkbox" // 체크박스
|
||||||
| "radio" // 라디오
|
| "radio" // 라디오
|
||||||
| "category" // 카테고리
|
| "category" // 카테고리
|
||||||
| "entity" // 엔티티 참조
|
| "entity" // 엔티티 참조
|
||||||
| "code" // 공통코드
|
| "code" // 공통코드
|
||||||
| "image" // 이미지
|
| "image" // 이미지
|
||||||
| "direct" // 직접입력
|
| "direct" // 직접입력
|
||||||
| "calculated" // 계산식 필드
|
| "calculated" // 계산식 필드
|
||||||
| string; // 기타 커스텀 타입 허용
|
| string; // 기타 커스텀 타입 허용
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 계산식 연산자
|
* 계산식 연산자
|
||||||
|
|
@ -32,11 +32,11 @@ export type CalculationOperator = "+" | "-" | "*" | "/" | "%" | "round" | "floor
|
||||||
* 예: { field1: "amount", operator: "round", decimalPlaces: 2 } → round(amount, 2)
|
* 예: { field1: "amount", operator: "round", decimalPlaces: 2 } → round(amount, 2)
|
||||||
*/
|
*/
|
||||||
export interface CalculationFormula {
|
export interface CalculationFormula {
|
||||||
field1: string; // 첫 번째 필드명
|
field1: string; // 첫 번째 필드명
|
||||||
operator: CalculationOperator; // 연산자
|
operator: CalculationOperator; // 연산자
|
||||||
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
||||||
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
||||||
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -84,6 +84,7 @@ export interface RepeaterFieldGroupConfig {
|
||||||
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
||||||
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
|
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
|
||||||
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
|
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
|
||||||
|
fkColumn?: string; // 분할 패널에서 좌측 선택 데이터와 연결할 FK 컬럼 (예: "serial_no")
|
||||||
minItems?: number; // 최소 항목 수
|
minItems?: number; // 최소 항목 수
|
||||||
maxItems?: number; // 최대 항목 수
|
maxItems?: number; // 최대 항목 수
|
||||||
addButtonText?: string; // 추가 버튼 텍스트
|
addButtonText?: string; // 추가 버튼 텍스트
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue