Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs 2025-12-16 10:46:15 +09:00
commit b3e6613d66
20 changed files with 1895 additions and 623 deletions

View File

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

View File

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

View File

@ -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 || "수정에 실패했습니다.");

View File

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

View File

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

View File

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

View File

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

View File

@ -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에서 가져온 값 사용

View File

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

View File

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

View File

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

View File

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

View File

@ -100,3 +100,4 @@
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
- [split-panel-layout (v1)](../split-panel-layout/README.md)

View File

@ -40,3 +40,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer
// 자동 등록 실행
SplitPanelLayout2Renderer.registerSelf();

View File

@ -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 복구

View File

@ -840,3 +840,4 @@ export function FieldDetailSettingsModal({
);
}

View File

@ -794,3 +794,4 @@ export function SaveSettingsModal({
);
}

View File

@ -514,3 +514,4 @@ export function SectionLayoutModal({
);
}

View File

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

View File

@ -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; // 추가 버튼 텍스트