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 {
|
||||
const [rows] = await this.pool.execute(sql, params);
|
||||
return rows;
|
||||
const [rows] = await this.pool.execute(sql, params);
|
||||
return rows;
|
||||
} catch (error: any) {
|
||||
// 연결 닫힘 오류 감지
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [screenInfo, setScreenInfo] = useState<any>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
|
||||
const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용)
|
||||
|
||||
// 컴포넌트 참조 맵
|
||||
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
||||
|
|
@ -47,6 +48,10 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
|
||||
// 🆕 selectedLeftData 참조 안정화 (실제 값이 바뀔 때만 업데이트)
|
||||
const selectedLeftData = splitPanelContext?.selectedLeftData;
|
||||
const prevSelectedLeftDataRef = useRef<string>("");
|
||||
|
||||
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
|
||||
const { userId, userName, companyCode } = useAuth();
|
||||
|
||||
|
|
@ -92,26 +97,53 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
}, [initialFormData]);
|
||||
|
||||
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
|
||||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
||||
// 🆕 좌측 선택 데이터가 변경되면 우측 formData를 업데이트
|
||||
useEffect(() => {
|
||||
// 우측 화면인 경우에만 적용
|
||||
if (position !== "right" || !splitPanelContext) return;
|
||||
|
||||
// 자동 데이터 전달이 비활성화된 경우 스킵
|
||||
if (splitPanelContext.disableAutoDataTransfer) {
|
||||
console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달");
|
||||
if (position !== "right" || !splitPanelContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedData = splitPanelContext.getMappedParentData();
|
||||
if (Object.keys(mappedData).length > 0) {
|
||||
console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
...mappedData,
|
||||
}));
|
||||
// 자동 데이터 전달이 비활성화된 경우 스킵
|
||||
if (splitPanelContext.disableAutoDataTransfer) {
|
||||
return;
|
||||
}
|
||||
}, [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(() => {
|
||||
|
|
@ -399,11 +431,7 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
className="absolute"
|
||||
style={componentStyle}
|
||||
>
|
||||
<div key={`${component.id}-${formDataVersion}`} className="absolute" style={componentStyle}>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isInteractive={true}
|
||||
|
|
|
|||
|
|
@ -26,12 +26,56 @@ interface EditModalState {
|
|||
onSave?: () => void;
|
||||
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
|
||||
tableName?: string; // 🆕 테이블명 (그룹 조회용)
|
||||
buttonConfig?: any; // 🆕 버튼 설정 (제어로직 실행용)
|
||||
buttonContext?: any; // 🆕 버튼 컨텍스트 (screenId, userId 등)
|
||||
saveButtonConfig?: {
|
||||
enableDataflowControl?: boolean;
|
||||
dataflowConfig?: any;
|
||||
dataflowTiming?: string;
|
||||
}; // 🆕 모달 내부 저장 버튼의 제어로직 설정
|
||||
}
|
||||
|
||||
interface EditModalProps {
|
||||
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 }) => {
|
||||
const { user } = useAuth();
|
||||
const [modalState, setModalState] = useState<EditModalState>({
|
||||
|
|
@ -44,6 +88,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
onSave: undefined,
|
||||
groupByColumns: undefined,
|
||||
tableName: undefined,
|
||||
buttonConfig: undefined,
|
||||
buttonContext: undefined,
|
||||
saveButtonConfig: undefined,
|
||||
});
|
||||
|
||||
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(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } =
|
||||
event.detail;
|
||||
const handleOpenEditModal = async (event: CustomEvent) => {
|
||||
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext } = event.detail;
|
||||
|
||||
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
|
||||
let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined;
|
||||
if (screenId) {
|
||||
const config = await loadSaveButtonConfig(screenId);
|
||||
if (config) {
|
||||
saveButtonConfig = config;
|
||||
}
|
||||
}
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
|
|
@ -131,6 +255,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
onSave,
|
||||
groupByColumns, // 🆕 그룹핑 컬럼
|
||||
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();
|
||||
} else {
|
||||
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();
|
||||
} else {
|
||||
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();
|
||||
} else {
|
||||
throw new Error(response.message || "수정에 실패했습니다.");
|
||||
|
|
|
|||
|
|
@ -333,23 +333,73 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
const loadModalMappingColumns = async () => {
|
||||
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
|
||||
// allComponents에서 split-panel-layout 또는 table-list 찾기
|
||||
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) {
|
||||
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
||||
const compConfig = (comp as any).componentConfig || {};
|
||||
|
||||
// 분할 패널 타입들 (다양한 경로에서 테이블명 추출)
|
||||
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
|
||||
// 분할 패널의 좌측 테이블명
|
||||
sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName ||
|
||||
(comp as any).componentConfig?.leftTableName;
|
||||
break;
|
||||
sourceTableName = compConfig?.leftPanel?.tableName ||
|
||||
compConfig?.leftTableName ||
|
||||
compConfig?.tableName;
|
||||
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") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명)
|
||||
if (!sourceTableName && currentTableName) {
|
||||
sourceTableName = currentTableName;
|
||||
console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`);
|
||||
}
|
||||
|
||||
if (!sourceTableName) {
|
||||
console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 소스 테이블 컬럼 로드
|
||||
if (sourceTableName) {
|
||||
try {
|
||||
|
|
@ -361,11 +411,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
name: col.name || col.columnName || col.column_name,
|
||||
label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
|
||||
}));
|
||||
setModalSourceColumns(columns);
|
||||
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length);
|
||||
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -379,8 +429,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
try {
|
||||
// 타겟 화면 정보 가져오기
|
||||
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
|
||||
console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data);
|
||||
|
||||
if (screenResponse.data.success && screenResponse.data.data) {
|
||||
const targetTableName = screenResponse.data.data.tableName;
|
||||
console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName);
|
||||
|
||||
if (targetTableName) {
|
||||
const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
|
||||
if (columnResponse.data.success) {
|
||||
|
|
@ -390,23 +444,27 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
name: col.name || col.columnName || col.column_name,
|
||||
label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name,
|
||||
}));
|
||||
setModalTargetColumns(columns);
|
||||
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length);
|
||||
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다.");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
} else {
|
||||
console.warn("[openModalWithData] 타겟 화면 ID가 없습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
loadModalMappingColumns();
|
||||
}, [config.action?.type, config.action?.targetScreenId, allComponents]);
|
||||
}, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]);
|
||||
|
||||
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
||||
useEffect(() => {
|
||||
|
|
@ -1158,11 +1216,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
|
||||
{/* 소스 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<div key={index} className="rounded-md border bg-background p-3 space-y-2">
|
||||
{/* 소스 필드 선택 (Combobox) - 세로 배치 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">소스 컬럼</Label>
|
||||
<Popover
|
||||
open={modalSourcePopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
|
|
@ -1171,15 +1230,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{mapping.sourceField
|
||||
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
|
||||
: "소스 컬럼 선택"}
|
||||
<span className="truncate">
|
||||
{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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
|
|
@ -1187,7 +1248,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
value={modalSourceSearch[index] || ""}
|
||||
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalSourceColumns.map((col) => (
|
||||
|
|
@ -1208,9 +1269,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
mapping.sourceField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
<span className="truncate">{col.label}</span>
|
||||
{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>
|
||||
))}
|
||||
|
|
@ -1221,10 +1282,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</Popover>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
{/* 화살표 표시 */}
|
||||
<div className="flex justify-center">
|
||||
<span className="text-xs text-muted-foreground">↓</span>
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
{/* 타겟 필드 선택 (Combobox) - 세로 배치 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">타겟 컬럼</Label>
|
||||
<Popover
|
||||
open={modalTargetPopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
|
|
@ -1233,15 +1298,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{mapping.targetField
|
||||
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
|
||||
: "타겟 컬럼 선택"}
|
||||
<span className="truncate">
|
||||
{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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] max-w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
|
|
@ -1249,7 +1316,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
value={modalTargetSearch[index] || ""}
|
||||
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalTargetColumns.map((col) => (
|
||||
|
|
@ -1270,9 +1337,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
mapping.targetField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
<span className="truncate">{col.label}</span>
|
||||
{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>
|
||||
))}
|
||||
|
|
@ -1284,19 +1351,22 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
const mappings = [...(config.action?.fieldMappings || [])];
|
||||
mappings.splice(index, 1);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
const mappings = [...(config.action?.fieldMappings || [])];
|
||||
mappings.splice(index, 1);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
||||
|
|
@ -46,7 +52,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
const breakpoint = previewBreakpoint || globalBreakpoint;
|
||||
|
||||
// 카테고리 매핑 데이터 (값 -> {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 {
|
||||
|
|
@ -98,47 +106,60 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
||||
useEffect(() => {
|
||||
if (value.length > 0) {
|
||||
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
||||
// 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음)
|
||||
if (value.length === 0) {
|
||||
// minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화
|
||||
if (minItems > 0) {
|
||||
const emptyItems = Array(minItems)
|
||||
.fill(null)
|
||||
.map(() => createEmptyItem());
|
||||
setItems(emptyItems);
|
||||
} else {
|
||||
setItems([]);
|
||||
}
|
||||
initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행
|
||||
return;
|
||||
}
|
||||
|
||||
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
||||
const updatedValue = value.map(item => {
|
||||
const updatedItem = { ...item };
|
||||
let hasChange = false;
|
||||
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
||||
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||
|
||||
calculatedFields.forEach(calcField => {
|
||||
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
||||
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
||||
updatedItem[calcField.name] = calculatedValue;
|
||||
hasChange = true;
|
||||
}
|
||||
});
|
||||
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
||||
const updatedValue = value.map((item) => {
|
||||
const updatedItem = { ...item };
|
||||
let hasChange = false;
|
||||
|
||||
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
||||
if (updatedItem.id) {
|
||||
updatedItem._existingRecord = true;
|
||||
calculatedFields.forEach((calcField) => {
|
||||
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
||||
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
||||
updatedItem[calcField.name] = calculatedValue;
|
||||
hasChange = true;
|
||||
}
|
||||
|
||||
return hasChange ? updatedItem : item;
|
||||
});
|
||||
|
||||
setItems(updatedValue);
|
||||
initialCalcDoneRef.current = true;
|
||||
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
||||
if (updatedItem.id) {
|
||||
updatedItem._existingRecord = 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);
|
||||
}
|
||||
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]);
|
||||
|
||||
|
|
@ -161,9 +182,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 항목 제거
|
||||
const handleRemoveItem = (index: number) => {
|
||||
if (items.length <= minItems) {
|
||||
return;
|
||||
}
|
||||
// 🆕 항목이 1개 이하일 때도 삭제 가능 (빈 상태 허용)
|
||||
// minItems 체크 제거 - 모든 항목 삭제 허용
|
||||
|
||||
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
||||
const removedItem = items[index];
|
||||
|
|
@ -207,8 +227,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
};
|
||||
|
||||
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
||||
calculatedFields.forEach(calcField => {
|
||||
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||
calculatedFields.forEach((calcField) => {
|
||||
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
||||
if (calculatedValue !== null) {
|
||||
newItems[itemIndex][calcField.name] = calculatedValue;
|
||||
|
|
@ -290,9 +310,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (!formula || !formula.field1) return null;
|
||||
|
||||
const value1 = parseFloat(item[formula.field1]) || 0;
|
||||
const value2 = formula.field2
|
||||
? (parseFloat(item[formula.field2]) || 0)
|
||||
: (formula.constantValue ?? 0);
|
||||
const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0);
|
||||
|
||||
let result: number;
|
||||
|
||||
|
|
@ -341,10 +359,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
* @param format 포맷 설정
|
||||
* @returns 포맷된 문자열
|
||||
*/
|
||||
const formatNumber = (
|
||||
value: number | null,
|
||||
format?: RepeaterFieldDefinition["numberFormat"]
|
||||
): string => {
|
||||
const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => {
|
||||
if (value === null || isNaN(value)) return "-";
|
||||
|
||||
let formattedValue = value;
|
||||
|
|
@ -355,12 +370,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
}
|
||||
|
||||
// 천 단위 구분자
|
||||
let result = format?.useThousandSeparator !== false
|
||||
? formattedValue.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||
})
|
||||
: formattedValue.toString();
|
||||
let result =
|
||||
format?.useThousandSeparator !== false
|
||||
? formattedValue.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||
})
|
||||
: formattedValue.toString();
|
||||
|
||||
// 접두사/접미사 추가
|
||||
if (format?.prefix) result = format.prefix + result;
|
||||
|
|
@ -373,10 +389,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||
const isReadonly = disabled || readonly || field.readonly;
|
||||
|
||||
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
|
||||
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
|
||||
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
|
||||
|
||||
const commonProps = {
|
||||
value: value || "",
|
||||
disabled: isReadonly,
|
||||
placeholder: field.placeholder,
|
||||
placeholder: defaultPlaceholder,
|
||||
required: field.required,
|
||||
};
|
||||
|
||||
|
|
@ -386,11 +406,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
const calculatedValue = calculateValue(field.formula, item);
|
||||
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
||||
|
||||
return (
|
||||
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
|
||||
{formattedValue}
|
||||
</span>
|
||||
);
|
||||
return <span className="inline-block min-w-[80px] text-sm font-medium text-blue-700">{formattedValue}</span>;
|
||||
}
|
||||
|
||||
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
||||
|
|
@ -436,7 +452,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (field.displayMode === "readonly") {
|
||||
// select 타입인 경우 옵션에서 라벨 찾기
|
||||
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>;
|
||||
}
|
||||
|
||||
|
|
@ -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 (
|
||||
<span className="text-sm text-foreground">
|
||||
{value || "-"}
|
||||
</span>
|
||||
);
|
||||
return <span className="text-foreground text-sm">{value || "-"}</span>;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
|
|
@ -500,19 +512,43 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
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 (
|
||||
<Input
|
||||
{...commonProps}
|
||||
value={dateValue}
|
||||
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]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "number":
|
||||
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
||||
|
|
@ -522,11 +558,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
||||
if (isReadonly) {
|
||||
return (
|
||||
<span className="text-sm min-w-[80px] inline-block">
|
||||
{formattedDisplay}
|
||||
</span>
|
||||
);
|
||||
return <span className="inline-block min-w-[80px] text-sm">{formattedDisplay}</span>;
|
||||
}
|
||||
|
||||
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
||||
|
|
@ -540,11 +572,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
max={field.validation?.max}
|
||||
className="pr-1"
|
||||
/>
|
||||
{value && (
|
||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||
{formattedDisplay}
|
||||
</div>
|
||||
)}
|
||||
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -597,8 +625,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||
useEffect(() => {
|
||||
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
||||
const categoryFields = fields.filter(f => f.type === "category");
|
||||
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text");
|
||||
const categoryFields = fields.filter((f) => f.type === "category");
|
||||
const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text");
|
||||
|
||||
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
||||
|
||||
|
|
@ -632,7 +660,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
setCategoryMappings((prev) => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
|
|
@ -644,12 +672,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
||||
// material, division 등 조인된 테이블의 카테고리 필드
|
||||
const joinedTableFields = ['material', 'division', 'status', 'currency_code'];
|
||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name));
|
||||
const joinedTableFields = ["material", "division", "status", "currency_code"];
|
||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name));
|
||||
|
||||
if (fieldsToLoadFromJoinedTable.length > 0) {
|
||||
// item_info 테이블에서 카테고리 매핑 로드
|
||||
const joinedTableName = 'item_info';
|
||||
const joinedTableName = "item_info";
|
||||
|
||||
for (const field of fieldsToLoadFromJoinedTable) {
|
||||
const columnName = field.name;
|
||||
|
|
@ -674,7 +702,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
setCategoryMappings((prev) => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
|
|
@ -694,9 +722,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (fields.length === 0) {
|
||||
return (
|
||||
<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">
|
||||
<p className="text-sm font-medium text-destructive">필드가 정의되지 않았습니다</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">속성 패널에서 필드를 추가하세요.</p>
|
||||
<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-destructive text-sm font-medium">필드가 정의되지 않았습니다</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">속성 패널에서 필드를 추가하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -706,8 +734,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (items.length === 0) {
|
||||
return (
|
||||
<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">
|
||||
<p className="mb-4 text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
<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="text-muted-foreground mb-4 text-sm">{emptyMessage}</p>
|
||||
{!readonly && !disabled && items.length < maxItems && (
|
||||
<Button type="button" onClick={handleAddItem} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
|
|
@ -740,7 +768,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{fields.map((field) => (
|
||||
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</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
|
||||
key={itemIndex}
|
||||
className={cn(
|
||||
"bg-background transition-colors hover:bg-muted/50",
|
||||
"bg-background hover:bg-muted/50 transition-colors",
|
||||
draggedIndex === itemIndex && "opacity-50",
|
||||
)}
|
||||
draggable={allowReorder && !readonly && !disabled}
|
||||
|
|
@ -762,15 +790,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
>
|
||||
{/* 인덱스 번호 */}
|
||||
{showIndex && (
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
|
||||
{itemIndex + 1}
|
||||
</TableCell>
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
|
||||
)}
|
||||
|
||||
{/* 드래그 핸들 */}
|
||||
{allowReorder && !readonly && !disabled && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -783,13 +809,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
{/* 삭제 버튼 */}
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||
{!readonly && !disabled && items.length > minItems && (
|
||||
{!readonly && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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="항목 제거"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -829,12 +855,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
<div className="flex items-center gap-2">
|
||||
{/* 드래그 핸들 */}
|
||||
{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 && (
|
||||
<CardTitle className="text-sm font-semibold text-foreground">항목 {itemIndex + 1}</CardTitle>
|
||||
<CardTitle className="text-foreground text-sm font-semibold">항목 {itemIndex + 1}</CardTitle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -853,13 +879,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
)}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{!readonly && !disabled && items.length > minItems && (
|
||||
{!readonly && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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="항목 제거"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -873,9 +899,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
<div className={getFieldsLayoutClass()}>
|
||||
{fields.map((field) => (
|
||||
<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.required && <span className="ml-1 text-destructive">*</span>}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</label>
|
||||
{renderField(field, itemIndex, item[field.name])}
|
||||
</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>
|
||||
(최소: {minItems}, 최대: {maxItems})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -88,13 +94,13 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
// 필드 수정 (입력 중 - 로컬 상태만)
|
||||
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
|
||||
setLocalInputs(prev => ({
|
||||
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
|
||||
setLocalInputs((prev) => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
...prev[index],
|
||||
[field]: value
|
||||
}
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
@ -106,7 +112,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
newFields[index] = {
|
||||
...newFields[index],
|
||||
label: localInput.label,
|
||||
placeholder: localInput.placeholder
|
||||
placeholder: localInput.placeholder,
|
||||
};
|
||||
handleFieldsChange(newFields);
|
||||
}
|
||||
|
|
@ -218,6 +224,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
</p>
|
||||
</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">
|
||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||
|
|
@ -263,7 +295,8 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
onSelect={() => {
|
||||
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
||||
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] 필드 타입 결정:", {
|
||||
columnName: column.columnName,
|
||||
|
|
@ -280,12 +313,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
type: fieldType as RepeaterFieldType,
|
||||
});
|
||||
// 로컬 입력 상태도 업데이트
|
||||
setLocalInputs(prev => ({
|
||||
setLocalInputs((prev) => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
label: column.columnLabel || column.columnName,
|
||||
placeholder: prev[index]?.placeholder || ""
|
||||
}
|
||||
placeholder: prev[index]?.placeholder || "",
|
||||
},
|
||||
}));
|
||||
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
||||
}}
|
||||
|
|
@ -313,7 +346,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Label className="text-xs">라벨</Label>
|
||||
<Input
|
||||
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)}
|
||||
placeholder="필드 라벨"
|
||||
className="h-8 w-full text-xs"
|
||||
|
|
@ -358,8 +391,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-xs">Placeholder</Label>
|
||||
<Input
|
||||
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")}
|
||||
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
|
||||
value={
|
||||
localInputs[index]?.placeholder !== undefined
|
||||
? localInputs[index].placeholder
|
||||
: field.placeholder || ""
|
||||
}
|
||||
onChange={(e) => updateFieldLocal(index, "placeholder", e.target.value)}
|
||||
onBlur={() => handleFieldBlur(index)}
|
||||
placeholder="입력 안내"
|
||||
className="h-8 w-full text-xs"
|
||||
|
|
@ -380,9 +417,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
||||
<Select
|
||||
value={field.formula?.field1 || ""}
|
||||
onValueChange={(value) => updateField(index, {
|
||||
formula: { ...field.formula, field1: value } as CalculationFormula
|
||||
})}
|
||||
onValueChange={(value) =>
|
||||
updateField(index, {
|
||||
formula: { ...field.formula, field1: value } as CalculationFormula,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
|
|
@ -404,22 +443,40 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Label className="text-[10px] text-blue-700">연산자</Label>
|
||||
<Select
|
||||
value={field.formula?.operator || "+"}
|
||||
onValueChange={(value) => updateField(index, {
|
||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
|
||||
})}
|
||||
onValueChange={(value) =>
|
||||
updateField(index, {
|
||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="+" className="text-xs">+ 더하기</SelectItem>
|
||||
<SelectItem value="-" className="text-xs">- 빼기</SelectItem>
|
||||
<SelectItem value="*" className="text-xs">× 곱하기</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>
|
||||
<SelectItem value="+" className="text-xs">
|
||||
+ 더하기
|
||||
</SelectItem>
|
||||
<SelectItem value="-" className="text-xs">
|
||||
- 빼기
|
||||
</SelectItem>
|
||||
<SelectItem value="*" className="text-xs">
|
||||
× 곱하기
|
||||
</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>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -429,23 +486,26 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
||||
<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) => {
|
||||
if (value.startsWith("__const__")) {
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: undefined,
|
||||
constantValue: 0
|
||||
} as CalculationFormula
|
||||
constantValue: 0,
|
||||
} as CalculationFormula,
|
||||
});
|
||||
} else {
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: value,
|
||||
constantValue: undefined
|
||||
} as CalculationFormula
|
||||
constantValue: undefined,
|
||||
} as CalculationFormula,
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
@ -475,9 +535,14 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
min={0}
|
||||
max={10}
|
||||
value={field.formula?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
decimalPlaces: parseInt(e.target.value) || 0,
|
||||
} as CalculationFormula,
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -490,9 +555,14 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Input
|
||||
type="number"
|
||||
value={field.formula.constantValue}
|
||||
onChange={(e) => updateField(index, {
|
||||
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
constantValue: parseFloat(e.target.value) || 0,
|
||||
} as CalculationFormula,
|
||||
})
|
||||
}
|
||||
placeholder="숫자 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
|
|
@ -507,9 +577,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Checkbox
|
||||
id={`thousand-sep-${index}`}
|
||||
checked={field.numberFormat?.useThousandSeparator ?? true}
|
||||
onCheckedChange={(checked) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
||||
})}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<Input
|
||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||
})
|
||||
}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
|
|
@ -532,17 +606,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={field.numberFormat?.prefix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접두사 (₩)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
<Input
|
||||
value={field.numberFormat?.suffix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접미사 (원)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
|
|
@ -553,10 +631,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="rounded bg-white p-2 text-xs">
|
||||
<span className="text-gray-500">계산식: </span>
|
||||
<code className="font-mono text-blue-700">
|
||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
|
||||
field.formula?.field2 ||
|
||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
|
||||
}
|
||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"}{" "}
|
||||
{field.formula?.field2 ||
|
||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -571,9 +648,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Checkbox
|
||||
id={`number-thousand-sep-${index}`}
|
||||
checked={field.numberFormat?.useThousandSeparator ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
||||
})}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<Input
|
||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||
})
|
||||
}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
|
|
@ -596,17 +677,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={field.numberFormat?.prefix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접두사 (₩)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
<Input
|
||||
value={field.numberFormat?.suffix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접미사 (원)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
|
|
@ -624,7 +709,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
"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 { logger } from "@/lib/utils/logger";
|
||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
|
|
@ -15,6 +15,10 @@ interface ScreenContextValue {
|
|||
tableName?: string;
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||
|
||||
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||
formData: Record<string, any>;
|
||||
updateFormData: (fieldName: string, value: any) => void;
|
||||
|
||||
// 컴포넌트 등록
|
||||
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
||||
unregisterDataProvider: (componentId: string) => void;
|
||||
|
|
@ -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 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) => {
|
||||
dataProvidersRef.current.set(componentId, provider);
|
||||
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
||||
|
|
@ -83,31 +108,38 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
|
|||
}, []);
|
||||
|
||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||
const value = React.useMemo<ScreenContextValue>(() => ({
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
}), [
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
]);
|
||||
const value = React.useMemo<ScreenContextValue>(
|
||||
() => ({
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
formData,
|
||||
updateFormData,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
}),
|
||||
[
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
formData,
|
||||
updateFormData,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
],
|
||||
);
|
||||
|
||||
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
||||
}
|
||||
|
|
@ -130,4 +162,3 @@ export function useScreenContext() {
|
|||
export function useScreenContextOptional() {
|
||||
return useContext(ScreenContext);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -376,6 +376,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 🔥 제어관리 설정 추가 (webTypeConfig에서 가져옴)
|
||||
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
||||
dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
||||
dataflowTiming: component.webTypeConfig?.dataflowTiming,
|
||||
};
|
||||
} else if (componentConfig.action && typeof componentConfig.action === "object") {
|
||||
// 🔥 이미 객체인 경우에도 제어관리 설정 추가
|
||||
|
|
@ -383,9 +384,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
...componentConfig.action,
|
||||
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
||||
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)의 높이를 따라감
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
|
|
@ -868,6 +880,44 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
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 등)
|
||||
const componentConfigs: Record<string, any> = {};
|
||||
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 = {
|
||||
formData: formData || {},
|
||||
formData: effectiveFormData,
|
||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
|
|
|
|||
|
|
@ -166,8 +166,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}
|
||||
|
||||
// ISO 8601 날짜 (2023-12-31T00:00:00.000Z 등)
|
||||
// 🆕 UTC 시간을 로컬 시간으로 변환하여 날짜 추출 (타임존 이슈 해결)
|
||||
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 객체 처리
|
||||
|
|
@ -276,7 +281,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{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.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
|
|
@ -299,16 +304,18 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
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",
|
||||
"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
|
||||
|
|
@ -326,11 +333,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
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",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -344,7 +353,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{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.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
|
|
@ -368,11 +377,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
}}
|
||||
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",
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
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",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -400,14 +411,16 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
||||
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",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"placeholder:text-muted-foreground",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
)}
|
||||
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",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
"placeholder:text-muted-foreground",
|
||||
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",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ export interface LocationSwapSelectorProps {
|
|||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (field: string, value: any) => void;
|
||||
|
||||
// 🆕 사용자 정보 (DB에서 초기값 로드용)
|
||||
userId?: string;
|
||||
|
||||
// componentConfig (화면 디자이너에서 전달)
|
||||
componentConfig?: {
|
||||
dataSource?: DataSourceConfig;
|
||||
|
|
@ -65,6 +68,10 @@ export interface LocationSwapSelectorProps {
|
|||
showSwapButton?: boolean;
|
||||
swapButtonPosition?: "center" | "right";
|
||||
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 = {},
|
||||
onFormDataChange,
|
||||
componentConfig,
|
||||
userId,
|
||||
} = props;
|
||||
|
||||
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
||||
|
|
@ -94,6 +102,11 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
||||
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[] = [
|
||||
{ value: "pohang", label: "포항" },
|
||||
|
|
@ -104,6 +117,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSwapping, setIsSwapping] = useState(false);
|
||||
const [dbLoaded, setDbLoaded] = useState(false); // DB 로드 완료 여부
|
||||
|
||||
// 로컬 선택 상태 (Select 컴포넌트용)
|
||||
const [localDeparture, setLocalDeparture] = useState<string>("");
|
||||
|
|
@ -193,8 +207,89 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
loadOptions();
|
||||
}, [dataSource, isDesignMode]);
|
||||
|
||||
// formData에서 초기값 동기화
|
||||
// 🆕 DB에서 초기값 로드 (새로고침 시에도 출발지/목적지 유지)
|
||||
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 destVal = formData[destinationField];
|
||||
|
||||
|
|
@ -204,7 +299,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
if (destVal && options.some(o => o.value === destVal)) {
|
||||
setLocalDestination(destVal);
|
||||
}
|
||||
}, [formData, departureField, destinationField, options]);
|
||||
}, [formData, departureField, destinationField, options, loadFromDb, userId, dbLoaded]);
|
||||
|
||||
// 출발지 변경
|
||||
const handleDepartureChange = (selectedValue: string) => {
|
||||
|
|
|
|||
|
|
@ -470,6 +470,58 @@ export function LocationSwapSelectorConfigPanel({
|
|||
</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">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
|
|
@ -480,6 +532,8 @@ export function LocationSwapSelectorConfigPanel({
|
|||
2. 출발지/도착지 값이 저장될 필드를 지정합니다
|
||||
<br />
|
||||
3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다
|
||||
<br />
|
||||
4. DB 초기값 로드를 활성화하면 새로고침 후에도 값이 유지됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,15 +29,47 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
|
||||
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
|
||||
|
||||
// 🆕 DB에서 로드한 컬럼 정보 (webType 등)
|
||||
const [columnInfo, setColumnInfo] = useState<Record<string, any>>({});
|
||||
|
||||
// 컴포넌트의 필드명 (formData 키)
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
|
||||
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
||||
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
const rawConfig = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
|
||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||
const groupByColumn = config.groupByColumn;
|
||||
const targetTable = config.targetTable;
|
||||
const groupByColumn = rawConfig.groupByColumn;
|
||||
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보다 우선)
|
||||
const rawValue = formData?.[fieldName] ?? value;
|
||||
|
|
@ -48,8 +80,8 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
|
||||
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
|
||||
const configFields = config.fields || [];
|
||||
const hasRepeaterFieldsInFormData = configFields.length > 0 &&
|
||||
configFields.some((field: any) => formData?.[field.name] !== undefined);
|
||||
const hasRepeaterFieldsInFormData =
|
||||
configFields.length > 0 && configFields.some((field: any) => formData?.[field.name] !== undefined);
|
||||
|
||||
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
||||
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
||||
|
|
@ -57,6 +89,112 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
||||
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
||||
|
||||
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
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,
|
||||
|
|
@ -72,8 +210,24 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
groupByColumn,
|
||||
groupKeyValue,
|
||||
targetTable,
|
||||
linkedFilterTargetTable,
|
||||
effectiveTargetTable,
|
||||
hasGroupedData: groupedData !== null,
|
||||
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,
|
||||
});
|
||||
|
||||
// 🆕 수정 모드에서 그룹화된 데이터 로드
|
||||
|
|
@ -154,11 +308,122 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
loadGroupedData();
|
||||
}, [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 문자열인 경우 파싱
|
||||
let parsedValue: any[] = [];
|
||||
|
||||
// 🆕 그룹화된 데이터가 있으면 우선 사용
|
||||
if (groupedData !== null && groupedData.length > 0) {
|
||||
// 🆕 그룹화된 데이터가 설정되어 있으면 우선 사용 (빈 배열 포함!)
|
||||
// groupedData가 null이 아니면 (빈 배열이라도) 해당 값을 사용
|
||||
if (groupedData !== null) {
|
||||
parsedValue = groupedData;
|
||||
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
||||
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
||||
|
|
@ -230,13 +495,20 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
const definedFields = configRef.current.fields || [];
|
||||
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
|
||||
// 시스템 필드 및 필수 필드 추가 (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 filteredItem: Record<string, any> = {};
|
||||
Object.keys(item).forEach(key => {
|
||||
Object.keys(item).forEach((key) => {
|
||||
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
|
||||
if (key === 'id') {
|
||||
if (key === "id") {
|
||||
return; // id 필드 제외
|
||||
}
|
||||
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
||||
|
|
@ -267,11 +539,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
addedCount = filteredData.length;
|
||||
} else {
|
||||
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
|
||||
const existingItemCodes = new Set(
|
||||
currentValue
|
||||
.map((item: any) => item.item_code)
|
||||
.filter(Boolean)
|
||||
);
|
||||
const existingItemCodes = new Set(currentValue.map((item: any) => item.item_code).filter(Boolean));
|
||||
|
||||
const uniqueNewItems = filteredData.filter((item: any) => {
|
||||
const itemCode = item.item_code;
|
||||
|
|
@ -300,9 +568,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
|
||||
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
|
||||
if (splitPanelContext?.addItemIds && addedCount > 0) {
|
||||
const newItemCodes = newItems
|
||||
.map((item: any) => String(item.item_code))
|
||||
.filter(Boolean);
|
||||
const newItemCodes = newItems.map((item: any) => String(item.item_code)).filter(Boolean);
|
||||
splitPanelContext.addItemIds(newItemCodes);
|
||||
}
|
||||
|
||||
|
|
@ -337,11 +603,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
}, []);
|
||||
|
||||
// DataReceivable 인터페이스 구현
|
||||
const dataReceiver = useMemo<DataReceivable>(() => ({
|
||||
componentId: component.id,
|
||||
componentType: "repeater-field-group",
|
||||
receiveData: handleReceiveData,
|
||||
}), [component.id, handleReceiveData]);
|
||||
const dataReceiver = useMemo<DataReceivable>(
|
||||
() => ({
|
||||
componentId: component.id,
|
||||
componentType: "repeater-field-group",
|
||||
receiveData: handleReceiveData,
|
||||
}),
|
||||
[component.id, handleReceiveData],
|
||||
);
|
||||
|
||||
// ScreenContext에 데이터 수신자로 등록
|
||||
useEffect(() => {
|
||||
|
|
@ -402,44 +671,106 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
|
||||
|
||||
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
||||
const handleRepeaterChange = useCallback((newValue: any[]) => {
|
||||
// 배열을 JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newValue);
|
||||
onChange?.(jsonValue);
|
||||
const handleRepeaterChange = useCallback(
|
||||
(newValue: any[]) => {
|
||||
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
|
||||
let valueWithMeta = newValue;
|
||||
|
||||
// 🆕 groupedData 상태도 업데이트
|
||||
setGroupedData(newValue);
|
||||
if (isRightPanel && effectiveTargetTable) {
|
||||
valueWithMeta = newValue.map((item: any) => {
|
||||
const itemWithMeta = {
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable,
|
||||
};
|
||||
|
||||
// 🆕 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);
|
||||
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
|
||||
if (fkColumn && fkValue && item._isNewItem) {
|
||||
itemWithMeta[fkColumn] = fkValue;
|
||||
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
|
||||
fkColumn,
|
||||
fkValue,
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 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);
|
||||
return itemWithMeta;
|
||||
});
|
||||
}
|
||||
|
||||
// 새로 추가된 ID가 있으면 등록
|
||||
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
|
||||
if (newIds.length > 0) {
|
||||
console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
|
||||
splitPanelContext.addItemIds(newIds);
|
||||
// 배열을 JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(valueWithMeta);
|
||||
console.log("📤 [RepeaterFieldGroup] 데이터 변경:", {
|
||||
fieldName,
|
||||
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 (
|
||||
<RepeaterInput
|
||||
value={parsedValue}
|
||||
onChange={handleRepeaterChange}
|
||||
config={config}
|
||||
config={effectiveConfig}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
menuObjid={menuObjid}
|
||||
|
|
|
|||
|
|
@ -100,3 +100,4 @@
|
|||
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
|
||||
- [split-panel-layout (v1)](../split-panel-layout/README.md)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -40,3 +40,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer
|
|||
// 자동 등록 실행
|
||||
SplitPanelLayout2Renderer.registerSelf();
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
const currentFormValue = formData?.[component.columnName];
|
||||
const currentComponentValue = component.value;
|
||||
|
||||
|
||||
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
|
||||
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
|
||||
isGeneratingRef.current = true; // 생성 시작 플래그
|
||||
|
|
@ -181,12 +180,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
// 숨김 기능: 편집 모드에서만 연하게 표시
|
||||
...(isHidden &&
|
||||
isDesignMode && {
|
||||
opacity: 0.4,
|
||||
backgroundColor: "hsl(var(--muted))",
|
||||
pointerEvents: "auto",
|
||||
}),
|
||||
...(isHidden &&
|
||||
isDesignMode && {
|
||||
opacity: 0.4,
|
||||
backgroundColor: "hsl(var(--muted))",
|
||||
pointerEvents: "auto",
|
||||
}),
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
|
@ -361,7 +360,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{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.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
|
|
@ -386,15 +385,17 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
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",
|
||||
"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) */}
|
||||
<Popover open={emailDomainOpen} onOpenChange={setEmailDomainOpen}>
|
||||
|
|
@ -406,14 +407,18 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
disabled={componentConfig.disabled || false}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "cursor-not-allowed bg-muted text-muted-foreground opacity-50" : "bg-background text-foreground",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
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",
|
||||
"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" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -470,7 +475,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{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.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
|
|
@ -496,14 +501,16 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
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",
|
||||
"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
|
||||
|
|
@ -524,14 +531,16 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
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",
|
||||
"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
|
||||
|
|
@ -552,10 +561,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
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",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -569,7 +580,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{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.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
|
|
@ -591,10 +602,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
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",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<option value="https://">https://</option>
|
||||
|
|
@ -619,10 +632,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
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",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -636,7 +651,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
<div className={`relative w-full ${className || ""}`} style={componentStyle} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{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.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
|
|
@ -669,11 +684,13 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
}}
|
||||
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",
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
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",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -692,13 +709,15 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
|
||||
{/* 수동/자동 모드 표시 배지 */}
|
||||
{testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<span className={cn(
|
||||
"text-[10px] px-2 py-0.5 rounded-full font-medium",
|
||||
isManualMode
|
||||
? "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"
|
||||
)}>
|
||||
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
isManualMode
|
||||
? "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 ? "수동" : "자동"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -706,12 +725,12 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
|
||||
<input
|
||||
type={inputType}
|
||||
defaultValue={(() => {
|
||||
value={(() => {
|
||||
let displayValue = "";
|
||||
|
||||
if (isInteractive && formData && component.columnName) {
|
||||
// 인터랙티브 모드: formData 우선, 없으면 자동생성 값
|
||||
const rawValue = formData[component.columnName] || autoGeneratedValue || "";
|
||||
const rawValue = formData[component.columnName] ?? autoGeneratedValue ?? "";
|
||||
// 객체인 경우 빈 문자열로 변환 (에러 방지)
|
||||
displayValue = typeof rawValue === "object" ? "" : String(rawValue);
|
||||
} else {
|
||||
|
|
@ -735,20 +754,22 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
? "전화번호 형식: 010-1234-5678"
|
||||
: isManualMode
|
||||
? `${component.label} (수동 입력 모드 - 채번 규칙 미적용)`
|
||||
: component.label
|
||||
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
|
||||
: component.columnName || undefined
|
||||
: component.label
|
||||
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
|
||||
: component.columnName || undefined
|
||||
}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
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",
|
||||
"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",
|
||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed"
|
||||
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",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
handleClick(e);
|
||||
|
|
@ -774,7 +795,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
console.log("🔄 수동 모드로 전환:", {
|
||||
field: component.columnName,
|
||||
original: originalAutoGeneratedValue,
|
||||
modified: newValue
|
||||
modified: newValue,
|
||||
});
|
||||
|
||||
// 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함)
|
||||
|
|
@ -789,7 +810,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
setIsManualMode(false);
|
||||
console.log("🔄 자동 모드로 복구:", {
|
||||
field: component.columnName,
|
||||
value: newValue
|
||||
value: newValue,
|
||||
});
|
||||
|
||||
// 채번 규칙 ID 복구
|
||||
|
|
|
|||
|
|
@ -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 기반)
|
||||
*/
|
||||
private static saveCallCount = 0; // 🆕 호출 횟수 추적
|
||||
private static saveLock: Map<string, number> = new Map(); // 🆕 중복 호출 방지 락
|
||||
|
||||
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
this.saveCallCount++;
|
||||
const callId = this.saveCallCount;
|
||||
|
||||
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 콜백이 있으면 우선 사용
|
||||
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의 외부 테이블 데이터 저장 처리
|
||||
const repeatScreenModalKeys = Object.keys(context.formData).filter(
|
||||
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
|
||||
|
|
@ -814,11 +946,36 @@ export class ButtonActionExecutor {
|
|||
|
||||
// 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) {
|
||||
console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`);
|
||||
saveResult = { success: true, message: "RepeatScreenModal에서 처리" };
|
||||
console.log(
|
||||
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`,
|
||||
{
|
||||
repeatScreenModalTables,
|
||||
repeaterFieldGroupTables,
|
||||
},
|
||||
);
|
||||
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" };
|
||||
} else {
|
||||
saveResult = await DynamicFormApi.saveFormData({
|
||||
screenId,
|
||||
|
|
@ -1623,16 +1780,15 @@ export class ButtonActionExecutor {
|
|||
|
||||
/**
|
||||
* 모달 액션 처리
|
||||
* 🔧 modal 액션은 항상 신규 등록(INSERT) 모드로 동작
|
||||
* edit 액션만 수정(UPDATE) 모드로 동작해야 함
|
||||
* 선택된 데이터가 있으면 함께 전달 (출하계획 등에서 사용)
|
||||
*/
|
||||
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
// 모달 열기 로직
|
||||
console.log("모달 열기 (신규 등록 모드):", {
|
||||
console.log("모달 열기:", {
|
||||
title: config.modalTitle,
|
||||
size: config.modalSize,
|
||||
targetScreenId: config.targetScreenId,
|
||||
// 🔧 selectedRowsData는 modal 액션에서 사용하지 않음 (신규 등록이므로)
|
||||
selectedRowsData: context.selectedRowsData,
|
||||
});
|
||||
|
||||
if (config.targetScreenId) {
|
||||
|
|
@ -1649,11 +1805,10 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
// 🔧 modal 액션은 신규 등록이므로 selectedData를 전달하지 않음
|
||||
// selectedData가 있으면 ScreenModal에서 originalData로 인식하여 UPDATE 모드로 동작하게 됨
|
||||
// edit 액션만 selectedData/editData를 사용하여 UPDATE 모드로 동작
|
||||
console.log("📦 [handleModal] 신규 등록 모드 - selectedData 전달하지 않음");
|
||||
console.log("📦 [handleModal] 분할 패널 부모 데이터 (초기값으로 사용):", context.splitPanelParentData);
|
||||
// 선택된 행 데이터 수집
|
||||
const selectedData = context.selectedRowsData || [];
|
||||
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
|
||||
console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData);
|
||||
|
||||
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
||||
const modalEvent = new CustomEvent("openScreenModal", {
|
||||
|
|
@ -1662,11 +1817,10 @@ export class ButtonActionExecutor {
|
|||
title: config.modalTitle || "화면",
|
||||
description: description,
|
||||
size: config.modalSize || "md",
|
||||
// 🔧 신규 등록이므로 selectedData/selectedIds를 전달하지 않음
|
||||
// edit 액션에서만 이 데이터를 사용
|
||||
selectedData: [],
|
||||
selectedIds: [],
|
||||
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 초기값으로 사용)
|
||||
// 선택된 행 데이터 전달
|
||||
selectedData: selectedData,
|
||||
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
|
||||
// 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용)
|
||||
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] 부모 데이터 전달:", {
|
||||
dataSourceId,
|
||||
rawParentData,
|
||||
mappedParentData: parentData,
|
||||
fieldMappings: config.fieldMappings,
|
||||
selectedDataCount: selectedData.length,
|
||||
selectedIds,
|
||||
});
|
||||
|
||||
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
|
||||
|
|
@ -1882,6 +2043,9 @@ export class ButtonActionExecutor {
|
|||
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
|
||||
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
|
||||
splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용)
|
||||
// 🆕 선택된 데이터 전달 (RepeatScreenModal에서 groupedData로 사용)
|
||||
selectedData: selectedData,
|
||||
selectedIds: selectedIds,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -2135,6 +2299,8 @@ export class ButtonActionExecutor {
|
|||
editData: rowData,
|
||||
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
|
||||
tableName: context.tableName, // 🆕 테이블명 전달
|
||||
buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용)
|
||||
buttonContext: context, // 🆕 버튼 컨텍스트 전달 (screenId, userId 등)
|
||||
onSave: () => {
|
||||
context.onRefresh?.();
|
||||
},
|
||||
|
|
@ -2670,9 +2836,10 @@ export class ButtonActionExecutor {
|
|||
|
||||
/**
|
||||
* 저장 후 제어 실행 (After Timing)
|
||||
* EditModal 등 외부에서도 호출 가능하도록 public으로 변경
|
||||
* 다중 제어 순차 실행 지원
|
||||
*/
|
||||
private static async executeAfterSaveControl(
|
||||
public static async executeAfterSaveControl(
|
||||
config: ButtonActionConfig,
|
||||
context: ButtonActionContext,
|
||||
): Promise<void> {
|
||||
|
|
@ -4116,39 +4283,80 @@ export class ButtonActionExecutor {
|
|||
try {
|
||||
console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context });
|
||||
|
||||
// 추적 중인지 확인
|
||||
if (!this.trackingIntervalId) {
|
||||
toast.warning("진행 중인 위치 추적이 없습니다.");
|
||||
return false;
|
||||
}
|
||||
// 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정)
|
||||
const isTrackingActive = !!this.trackingIntervalId;
|
||||
|
||||
// 타이머 정리
|
||||
clearInterval(this.trackingIntervalId);
|
||||
this.trackingIntervalId = null;
|
||||
if (!isTrackingActive) {
|
||||
// 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원)
|
||||
console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행");
|
||||
} else {
|
||||
// 타이머 정리 (추적 중인 경우에만)
|
||||
clearInterval(this.trackingIntervalId);
|
||||
this.trackingIntervalId = null;
|
||||
}
|
||||
|
||||
const tripId = this.currentTripId;
|
||||
|
||||
// 마지막 위치 저장 (trip_status를 completed로)
|
||||
const departure =
|
||||
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
|
||||
const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
|
||||
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
|
||||
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
|
||||
const vehicleId =
|
||||
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
|
||||
// 🆕 DB에서 출발지/목적지 조회 (운전자가 중간에 바꿔도 원래 값 사용)
|
||||
let dbDeparture: string | null = null;
|
||||
let dbArrival: string | null = null;
|
||||
let dbVehicleId: string | null = null;
|
||||
|
||||
await this.saveLocationToHistory(
|
||||
tripId,
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
vehicleId,
|
||||
"completed",
|
||||
);
|
||||
const userId = context.userId || this.trackingUserId;
|
||||
if (userId) {
|
||||
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";
|
||||
|
||||
// 🆕 거리/시간 계산 및 저장
|
||||
if (tripId) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 위치 저장 (추적 중이었던 경우에만)
|
||||
if (isTrackingActive) {
|
||||
// DB 값 우선, 없으면 formData 사용
|
||||
const departure = dbDeparture ||
|
||||
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
|
||||
const arrival = dbArrival ||
|
||||
this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
|
||||
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(
|
||||
tripId,
|
||||
departure,
|
||||
arrival,
|
||||
departureName,
|
||||
destinationName,
|
||||
vehicleId,
|
||||
"completed",
|
||||
);
|
||||
}
|
||||
|
||||
// 🆕 거리/시간 계산 및 저장 (추적 중이었던 경우에만)
|
||||
if (isTrackingActive && tripId) {
|
||||
try {
|
||||
const tripStats = await this.calculateTripStats(tripId);
|
||||
console.log("📊 운행 통계:", tripStats);
|
||||
|
|
@ -4260,9 +4468,9 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
// 상태 변경 (vehicles 테이블 등)
|
||||
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
|
||||
const effectiveContext = context.userId ? context : this.trackingContext;
|
||||
// 상태 변경 (vehicles 테이블 등) - 새로고침 후에도 동작하도록 config 우선 사용
|
||||
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig || config;
|
||||
const effectiveContext = context.userId ? context : this.trackingContext || context;
|
||||
|
||||
if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -6,20 +6,20 @@
|
|||
* 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들
|
||||
*/
|
||||
export type RepeaterFieldType =
|
||||
| "text" // 텍스트
|
||||
| "number" // 숫자
|
||||
| "textarea" // 텍스트영역
|
||||
| "date" // 날짜
|
||||
| "select" // 선택박스
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오
|
||||
| "category" // 카테고리
|
||||
| "entity" // 엔티티 참조
|
||||
| "code" // 공통코드
|
||||
| "image" // 이미지
|
||||
| "direct" // 직접입력
|
||||
| "text" // 텍스트
|
||||
| "number" // 숫자
|
||||
| "textarea" // 텍스트영역
|
||||
| "date" // 날짜
|
||||
| "select" // 선택박스
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오
|
||||
| "category" // 카테고리
|
||||
| "entity" // 엔티티 참조
|
||||
| "code" // 공통코드
|
||||
| "image" // 이미지
|
||||
| "direct" // 직접입력
|
||||
| "calculated" // 계산식 필드
|
||||
| string; // 기타 커스텀 타입 허용
|
||||
| string; // 기타 커스텀 타입 허용
|
||||
|
||||
/**
|
||||
* 계산식 연산자
|
||||
|
|
@ -32,11 +32,11 @@ export type CalculationOperator = "+" | "-" | "*" | "/" | "%" | "round" | "floor
|
|||
* 예: { field1: "amount", operator: "round", decimalPlaces: 2 } → round(amount, 2)
|
||||
*/
|
||||
export interface CalculationFormula {
|
||||
field1: string; // 첫 번째 필드명
|
||||
field1: string; // 첫 번째 필드명
|
||||
operator: CalculationOperator; // 연산자
|
||||
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
||||
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
||||
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
||||
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
||||
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
||||
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -84,6 +84,7 @@ export interface RepeaterFieldGroupConfig {
|
|||
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
||||
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
|
||||
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
|
||||
fkColumn?: string; // 분할 패널에서 좌측 선택 데이터와 연결할 FK 컬럼 (예: "serial_no")
|
||||
minItems?: number; // 최소 항목 수
|
||||
maxItems?: number; // 최대 항목 수
|
||||
addButtonText?: string; // 추가 버튼 텍스트
|
||||
|
|
|
|||
Loading…
Reference in New Issue