화면 분할 패널 수정모드 기능

This commit is contained in:
kjs 2025-11-28 18:35:34 +09:00
parent c78ba865b6
commit 627c5a5173
11 changed files with 963 additions and 51 deletions

View File

@ -120,10 +120,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
};
};
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
const modalOpenedAtRef = React.useRef<number>(0);
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
const { screenId, title, description, size, urlParams } = event.detail;
const { screenId, title, description, size, urlParams, editData } = event.detail;
// 🆕 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now();
console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current);
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
if (urlParams && typeof window !== "undefined") {
@ -136,6 +143,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
console.log("✅ URL 파라미터 추가:", urlParams);
}
// 🆕 editData가 있으면 formData로 설정 (수정 모드)
if (editData) {
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
setFormData(editData);
}
setModalState({
isOpen: true,
screenId,
@ -171,6 +184,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 저장 성공 이벤트 처리 (연속 등록 모드 지원)
const handleSaveSuccess = () => {
// 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지)
const timeSinceOpen = Date.now() - modalOpenedAtRef.current;
if (timeSinceOpen < 500) {
console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
return;
}
const isContinuousMode = continuousMode;
console.log("💾 저장 성공 이벤트 수신");
console.log("📌 현재 연속 모드 상태:", isContinuousMode);
@ -581,6 +601,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
},
};
// 🆕 formData 전달 확인 로그
console.log("📝 [ScreenModal] InteractiveScreenViewerDynamic에 formData 전달:", {
componentId: component.id,
componentType: component.type,
componentComponentType: (component as any).componentType, // 🆕 실제 componentType 확인
hasFormData: !!formData,
formDataKeys: formData ? Object.keys(formData) : [],
});
return (
<InteractiveScreenViewerDynamic
key={`${component.id}-${resetKey}`}

View File

@ -20,30 +20,54 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
import { screenApi } from "@/lib/api/screen";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { useAuth } from "@/hooks/useAuth";
interface EmbeddedScreenProps {
embedding: ScreenEmbedding;
onSelectionChanged?: (selectedRows: any[]) => void;
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
}
/**
*
*/
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
({ embedding, onSelectionChanged, position }, ref) => {
({ embedding, onSelectionChanged, position, initialFormData }, ref) => {
const [layout, setLayout] = useState<ComponentData[]>([]);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [screenInfo, setScreenInfo] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>({}); // 폼 데이터 상태 추가
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
// 컴포넌트 참조 맵
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
const splitPanelContext = useSplitPanelContext();
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
const { userId, userName, companyCode } = useAuth();
// 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해)
const contentBounds = React.useMemo(() => {
if (layout.length === 0) return { width: 0, height: 0 };
let maxRight = 0;
let maxBottom = 0;
layout.forEach((component) => {
const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component;
const right = (compPosition.x || 0) + (size.width || 200);
const bottom = (compPosition.y || 0) + (size.height || 40);
if (right > maxRight) maxRight = right;
if (bottom > maxBottom) maxBottom = bottom;
});
return { width: maxRight, height: maxBottom };
}, [layout]);
// 필드 값 변경 핸들러
const handleFieldChange = useCallback((fieldName: string, value: any) => {
@ -59,6 +83,14 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
loadScreenData();
}, [embedding.childScreenId]);
// 🆕 initialFormData 변경 시 formData 업데이트 (수정 모드)
useEffect(() => {
if (initialFormData && Object.keys(initialFormData).length > 0) {
console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData);
setFormData(initialFormData);
}
}, [initialFormData]);
// 선택 변경 이벤트 전파
useEffect(() => {
onSelectionChanged?.(selectedRows);
@ -72,10 +104,21 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
setLoading(true);
setError(null);
// 화면 정보 로드
const screenResponse = await screenApi.getScreen(embedding.childScreenId);
if (screenResponse.success && screenResponse.data) {
setScreenInfo(screenResponse.data);
// 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환)
const screenData = await screenApi.getScreen(embedding.childScreenId);
console.log("📋 [EmbeddedScreen] 화면 정보 API 응답:", {
screenId: embedding.childScreenId,
hasData: !!screenData,
tableName: screenData?.tableName,
screenName: screenData?.name || screenData?.screenName,
position,
});
if (screenData) {
setScreenInfo(screenData);
} else {
console.warn("⚠️ [EmbeddedScreen] 화면 정보 로드 실패:", {
screenId: embedding.childScreenId,
});
}
// 화면 레이아웃 로드 (별도 API)
@ -306,27 +349,38 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
tableName={screenInfo?.tableName}
splitPanelPosition={position}
>
<div className="relative h-full w-full overflow-auto">
<div className="relative h-full w-full overflow-auto p-4">
{layout.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="relative" style={{ minHeight: "100%", minWidth: "100%" }}>
<div
className="relative w-full"
style={{
minHeight: contentBounds.height + 20, // 여유 공간 추가
}}
>
{layout.map((component) => {
const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
// 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정
// 부모 컨테이너의 100%를 기준으로 계산
const componentStyle: React.CSSProperties = {
left: compPosition.x || 0,
top: compPosition.y || 0,
width: size.width || 200,
height: size.height || 40,
zIndex: compPosition.z || 1,
// 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정
maxWidth: `calc(100% - ${compPosition.x || 0}px)`,
};
return (
<div
key={component.id}
className="absolute"
style={{
left: compPosition.x || 0,
top: compPosition.y || 0,
width: size.width || 200,
height: size.height || 40,
zIndex: compPosition.z || 1,
}}
style={componentStyle}
>
<DynamicComponentRenderer
component={component}
@ -336,6 +390,9 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
formData={formData}
onFormDataChange={handleFieldChange}
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
userId={userId}
userName={userName}
companyCode={companyCode}
/>
</div>
);

View File

@ -16,13 +16,14 @@ import { SplitPanelProvider } from "@/contexts/SplitPanelContext";
interface ScreenSplitPanelProps {
screenId?: number;
config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable)
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
}
/**
*
* .
*/
export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) {
export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSplitPanelProps) {
// config에서 splitRatio 추출 (기본값 50)
const configSplitRatio = config?.splitRatio ?? 50;
@ -35,6 +36,13 @@ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) {
configKeys: config ? Object.keys(config) : [],
});
// 🆕 initialFormData 별도 로그 (명확한 확인)
console.log("📝 [ScreenSplitPanel] initialFormData 확인:", {
hasInitialFormData: !!initialFormData,
initialFormDataKeys: initialFormData ? Object.keys(initialFormData) : [],
initialFormData: initialFormData,
});
// 드래그로 조절 가능한 splitRatio 상태
const [splitRatio, setSplitRatio] = useState(configSplitRatio);
@ -122,7 +130,7 @@ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) {
{/* 좌측 패널 */}
<div style={{ width: `${splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden border-r">
{hasLeftScreen ? (
<EmbeddedScreen embedding={leftEmbedding!} position="left" />
<EmbeddedScreen embedding={leftEmbedding!} position="left" initialFormData={initialFormData} />
) : (
<div className="flex h-full items-center justify-center bg-muted/30">
<p className="text-muted-foreground text-sm"> </p>
@ -162,7 +170,7 @@ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) {
{/* 우측 패널 */}
<div style={{ width: `${100 - splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden">
{hasRightScreen ? (
<EmbeddedScreen embedding={rightEmbedding!} position="right" />
<EmbeddedScreen embedding={rightEmbedding!} position="right" initialFormData={initialFormData} />
) : (
<div className="flex h-full items-center justify-center bg-muted/30">
<p className="text-muted-foreground text-sm"> </p>

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -10,7 +10,7 @@ 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 } 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";
@ -78,6 +78,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 접힌 상태 관리 (각 항목별)
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
const initialCalcDoneRef = useRef(false);
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
const deletedItemIdsRef = useRef<string[]>([]);
// 빈 항목 생성
function createEmptyItem(): RepeaterItemData {
@ -88,10 +94,39 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
return item;
}
// 외부 value 변경 시 동기화
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
useEffect(() => {
if (value.length > 0) {
setItems(value);
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
const calculatedFields = fields.filter(f => f.type === "calculated");
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
const updatedValue = value.map(item => {
const updatedItem = { ...item };
let hasChange = false;
calculatedFields.forEach(calcField => {
const calculatedValue = calculateValue(calcField.formula, updatedItem);
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
updatedItem[calcField.name] = calculatedValue;
hasChange = true;
}
});
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 {
setItems(value);
}
}
}, [value]);
@ -117,14 +152,32 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (items.length <= minItems) {
return;
}
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
const removedItem = items[index];
if (removedItem?.id) {
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
}
const newItems = items.filter((_, i) => i !== index);
setItems(newItems);
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
const currentDeletedIds = deletedItemIdsRef.current;
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
const dataWithMeta = config.targetTable
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
? newItems.map((item, idx) => ({
...item,
_targetTable: config.targetTable,
// 첫 번째 항목에만 삭제 ID 목록 포함
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
}))
: newItems;
console.log("🗑️ [RepeaterInput] onChange 호출 - dataWithMeta:", dataWithMeta);
onChange?.(dataWithMeta);
// 접힌 상태도 업데이트
@ -140,6 +193,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
...newItems[itemIndex],
[fieldName]: value,
};
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
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;
}
});
setItems(newItems);
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
itemIndex,
@ -149,8 +212,15 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
});
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
// 🆕 삭제된 항목 ID 목록도 유지
const currentDeletedIds = deletedItemIdsRef.current;
const dataWithMeta = config.targetTable
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
? newItems.map((item, idx) => ({
...item,
_targetTable: config.targetTable,
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
}))
: newItems;
onChange?.(dataWithMeta);
@ -198,6 +268,95 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
setDraggedIndex(null);
};
/**
*
* @param formula
* @param item
* @returns
*/
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
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);
let result: number;
switch (formula.operator) {
case "+":
result = value1 + value2;
break;
case "-":
result = value1 - value2;
break;
case "*":
result = value1 * value2;
break;
case "/":
result = value2 !== 0 ? value1 / value2 : 0;
break;
case "%":
result = value2 !== 0 ? value1 % value2 : 0;
break;
case "round":
const decimalPlaces = formula.decimalPlaces ?? 0;
const multiplier = Math.pow(10, decimalPlaces);
result = Math.round(value1 * multiplier) / multiplier;
break;
case "floor":
const floorMultiplier = Math.pow(10, formula.decimalPlaces ?? 0);
result = Math.floor(value1 * floorMultiplier) / floorMultiplier;
break;
case "ceil":
const ceilMultiplier = Math.pow(10, formula.decimalPlaces ?? 0);
result = Math.ceil(value1 * ceilMultiplier) / ceilMultiplier;
break;
case "abs":
result = Math.abs(value1);
break;
default:
result = value1;
}
return result;
};
/**
*
* @param value
* @param format
* @returns
*/
const formatNumber = (
value: number | null,
format?: RepeaterFieldDefinition["numberFormat"]
): string => {
if (value === null || isNaN(value)) return "-";
let formattedValue = value;
// 소수점 자릿수 적용
if (format?.decimalPlaces !== undefined) {
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
}
// 천 단위 구분자
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;
if (format?.suffix) result = result + format.suffix;
return result;
};
// 개별 필드 렌더링
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
const isReadonly = disabled || readonly || field.readonly;
@ -209,6 +368,19 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
required: field.required,
};
// 계산식 필드: 자동으로 계산된 값을 표시 (읽기 전용)
if (field.type === "calculated") {
const item = items[itemIndex];
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>
);
}
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
if (field.type === "category") {
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
@ -272,7 +444,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)}
disabled={isReadonly}
>
<SelectTrigger className="w-full">
<SelectTrigger className="w-full min-w-[80px]">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
@ -291,7 +463,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps}
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
rows={3}
className="resize-none"
className="resize-none min-w-[100px]"
/>
);
@ -301,10 +473,45 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps}
type="date"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
className="min-w-[120px]"
/>
);
case "number":
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
const numValue = parseFloat(value) || 0;
const formattedDisplay = formatNumber(numValue, field.numberFormat);
// 읽기 전용이면 포맷팅된 텍스트만 표시
if (isReadonly) {
return (
<span className="text-sm min-w-[80px] inline-block">
{formattedDisplay}
</span>
);
}
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
return (
<div className="relative min-w-[80px]">
<Input
{...commonProps}
type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="pr-1"
/>
{value && (
<div className="text-muted-foreground text-[10px] mt-0.5">
{formattedDisplay}
</div>
)}
</div>
);
}
return (
<Input
{...commonProps}
@ -312,6 +519,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="min-w-[80px]"
/>
);
@ -321,6 +529,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps}
type="email"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
className="min-w-[120px]"
/>
);
@ -330,6 +539,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps}
type="tel"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
className="min-w-[100px]"
/>
);
@ -340,6 +550,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
type="text"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
maxLength={field.validation?.maxLength}
className="min-w-[80px]"
/>
);
}
@ -444,18 +655,18 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<TableHeader>
<TableRow className="bg-background">
{showIndex && (
<TableHead className="h-12 w-12 px-6 py-3 text-center text-sm font-semibold">#</TableHead>
<TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold">#</TableHead>
)}
{allowReorder && (
<TableHead className="h-12 w-12 px-6 py-3 text-center text-sm font-semibold"></TableHead>
<TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
)}
{fields.map((field) => (
<TableHead key={field.name} className="h-12 px-6 py-3 text-sm font-semibold">
<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>}
</TableHead>
))}
<TableHead className="h-12 w-20 px-6 py-3 text-center text-sm font-semibold"></TableHead>
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -474,27 +685,27 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
>
{/* 인덱스 번호 */}
{showIndex && (
<TableCell className="h-16 px-6 py-3 text-center text-sm font-medium">
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
{itemIndex + 1}
</TableCell>
)}
{/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<TableCell className="h-16 px-6 py-3 text-center">
<TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
</TableCell>
)}
{/* 필드들 */}
{fields.map((field) => (
<TableCell key={field.name} className="h-16 px-6 py-3">
<TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])}
</TableCell>
))}
{/* 삭제 버튼 */}
<TableCell className="h-16 px-6 py-3 text-center">
<TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && items.length > minItems && (
<Button
type="button"

View File

@ -9,8 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, GripVertical, Check, ChevronsUpDown } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType } from "@/types/repeater";
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
import { ColumnInfo } from "@/types/screen";
import { cn } from "@/lib/utils";
@ -192,6 +192,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<p className="text-xs text-gray-500"> .</p>
</div>
{/* 그룹화 컬럼 설정 */}
<div className="space-y-2">
<Label className="text-sm font-semibold"> ()</Label>
<Select
value={config.groupByColumn || "__none__"}
onValueChange={(value) => handleChange("groupByColumn", value === "__none__" ? undefined : value)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="그룹화 컬럼 선택 (선택사항)" />
</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 />
: 입고번호를 .
</p>
</div>
{/* 필드 정의 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
@ -319,6 +345,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<SelectItem value="code"> (code)</SelectItem>
<SelectItem value="image"> (image)</SelectItem>
<SelectItem value="direct"> (direct)</SelectItem>
<SelectItem value="calculated">
<span className="flex items-center gap-1">
<Calculator className="h-3 w-3" />
(calculated)
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
@ -335,6 +367,253 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</div>
</div>
{/* 계산식 타입일 때 계산식 설정 */}
{field.type === "calculated" && (
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
<div className="flex items-center gap-2">
<Calculator className="h-4 w-4 text-blue-600" />
<Label className="text-xs font-semibold text-blue-800"> </Label>
</div>
{/* 필드 1 선택 */}
<div className="space-y-1">
<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
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
{localFields
.filter((f, i) => i !== index && f.type !== "calculated" && f.type !== "category")
.map((f) => (
<SelectItem key={f.name} value={f.name} className="text-xs">
{f.label || f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 연산자 선택 */}
<div className="space-y-1">
<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
})}
>
<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>
</SelectContent>
</Select>
</div>
{/* 두 번째 필드 또는 상수값 */}
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
<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}` : "")}
onValueChange={(value) => {
if (value.startsWith("__const__")) {
updateField(index, {
formula: {
...field.formula,
field2: undefined,
constantValue: 0
} as CalculationFormula
});
} else {
updateField(index, {
formula: {
...field.formula,
field2: value,
constantValue: undefined
} as CalculationFormula
});
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
{localFields
.filter((f, i) => i !== index && f.type !== "calculated" && f.type !== "category")
.map((f) => (
<SelectItem key={f.name} value={f.name} className="text-xs">
{f.label || f.name}
</SelectItem>
))}
<SelectItem value="__const__0" className="text-xs text-blue-600">
</SelectItem>
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 릿</Label>
<Input
type="number"
min={0}
max={10}
value={field.formula?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
})}
className="h-8 text-xs"
/>
</div>
)}
{/* 상수값 입력 필드 */}
{field.formula?.constantValue !== undefined && (
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"></Label>
<Input
type="number"
value={field.formula.constantValue}
onChange={(e) => updateField(index, {
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
})}
placeholder="숫자 입력"
className="h-8 text-xs"
/>
</div>
)}
{/* 숫자 포맷 설정 */}
<div className="space-y-2 border-t border-blue-200 pt-2">
<Label className="text-[10px] text-blue-700"> </Label>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id={`thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? true}
onCheckedChange={(checked) => updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
})}
/>
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
</Label>
</div>
<div className="flex items-center gap-1">
<Label className="text-[10px]">:</Label>
<Input
value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
})}
type="number"
min={0}
max={10}
className="h-6 w-12 text-[10px]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Input
value={field.numberFormat?.prefix || ""}
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 }
})}
placeholder="접미사 (원)"
className="h-7 text-[10px]"
/>
</div>
</div>
{/* 계산식 미리보기 */}
<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")
}
</code>
</div>
</div>
)}
{/* 숫자 타입일 때 숫자 표시 형식 설정 */}
{field.type === "number" && (
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
<Label className="text-xs font-semibold text-gray-700"> </Label>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id={`number-thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? false}
onCheckedChange={(checked) => updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
})}
/>
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
</Label>
</div>
<div className="flex items-center gap-1">
<Label className="text-[10px]">:</Label>
<Input
value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
})}
type="number"
min={0}
max={10}
className="h-6 w-12 text-[10px]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Input
value={field.numberFormat?.prefix || ""}
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 }
})}
placeholder="접미사 (원)"
className="h-7 text-[10px]"
/>
</div>
</div>
)}
{/* 카테고리 타입일 때 카테고리 코드 입력 */}
{field.type === "category" && (
<div className="space-y-1">

View File

@ -224,6 +224,19 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 1. 새 컴포넌트 시스템에서 먼저 조회
const newComponent = ComponentRegistry.getComponent(componentType);
// 🔍 디버깅: screen-split-panel 조회 결과 확인
if (componentType === "screen-split-panel") {
console.log("🔍 [DynamicComponentRenderer] screen-split-panel 조회:", {
componentType,
found: !!newComponent,
componentId: component.id,
componentConfig: component.componentConfig,
hasFormData: !!props.formData,
formDataKeys: props.formData ? Object.keys(props.formData) : [],
registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id),
});
}
// 🔍 디버깅: select-basic 조회 결과 확인
if (componentType === "select-basic") {
console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", {
@ -308,6 +321,19 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
} else {
currentValue = formData?.[fieldName] || "";
}
// 🆕 디버깅: text-input 값 추출 확인
if (componentType === "text-input" && formData && Object.keys(formData).length > 0) {
console.log("🔍 [DynamicComponentRenderer] text-input 값 추출:", {
componentId: component.id,
componentLabel: component.label,
columnName: (component as any).columnName,
fieldName,
currentValue,
hasFormData: !!formData,
formDataKeys: Object.keys(formData).slice(0, 10), // 처음 10개만
});
}
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
const handleChange = (value: any) => {

View File

@ -105,6 +105,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
const splitPanelPosition = screenContext?.splitPanelPosition;
// 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기
const effectiveTableName = tableName || screenContext?.tableName;
const effectiveScreenId = screenId || screenContext?.screenId;
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
const finalOnSave = onSave || propsOnSave;
@ -677,11 +681,21 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
}
// 🆕 디버깅: tableName 확인
console.log("🔍 [ButtonPrimaryComponent] context 생성:", {
propsTableName: tableName,
contextTableName: screenContext?.tableName,
effectiveTableName,
propsScreenId: screenId,
contextScreenId: screenContext?.screenId,
effectiveScreenId,
});
const context: ButtonActionContext = {
formData: formData || {},
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
screenId,
tableName,
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드

View File

@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useRef, useCallback, useMemo } from "react";
import React, { useEffect, useRef, useCallback, useMemo, useState } from "react";
import { Layers } from "lucide-react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ComponentDefinition, ComponentCategory, ComponentRendererProps } from "@/types/component";
@ -10,6 +10,7 @@ import { useScreenContextOptional, DataReceivable } from "@/contexts/ScreenConte
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { applyMappingRules } from "@/lib/utils/dataMapping";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
/**
* Repeater Field Group
@ -19,27 +20,149 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
const screenContext = useScreenContextOptional();
const splitPanelContext = useSplitPanelContext();
const receiverRef = useRef<DataReceivable | null>(null);
// 🆕 그룹화된 데이터를 저장하는 상태
const [groupedData, setGroupedData] = useState<any[] | null>(null);
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
const groupDataLoadedRef = useRef(false);
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
// 컴포넌트의 필드명 (formData 키)
const fieldName = (component as any).columnName || component.id;
// repeaterConfig 또는 componentConfig에서 설정 가져오기
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
const groupByColumn = config.groupByColumn;
const targetTable = config.targetTable;
// formData에서 값 가져오기 (value prop보다 우선)
const rawValue = formData?.[fieldName] ?? value;
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
const isEditMode = formData?.id && !rawValue && !value;
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
const configFields = config.fields || [];
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);
// 🆕 그룹 키 값 (예: formData.inbound_number)
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
fieldName,
hasFormData: !!formData,
formDataId: formData?.id,
formDataValue: formData?.[fieldName],
propsValue: value,
rawValue,
isEditMode,
hasRepeaterFieldsInFormData,
configFieldNames: configFields.map((f: any) => f.name),
formDataKeys: formData ? Object.keys(formData) : [],
matchingFieldNames: matchingFields.map((f: any) => f.name),
groupByColumn,
groupKeyValue,
targetTable,
hasGroupedData: groupedData !== null,
groupedDataLength: groupedData?.length,
});
// 🆕 수정 모드에서 그룹화된 데이터 로드
useEffect(() => {
const loadGroupedData = async () => {
// 이미 로드했거나 조건이 맞지 않으면 스킵
if (groupDataLoadedRef.current) return;
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
groupByColumn,
groupKeyValue,
targetTable,
});
setIsLoadingGroupData(true);
groupDataLoadedRef.current = true;
try {
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
// search 파라미터 사용 (filters가 아닌 search)
const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, {
page: 1,
size: 100, // 충분히 큰 값
search: { [groupByColumn]: groupKeyValue },
});
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
success: response.data?.success,
hasData: !!response.data?.data,
dataType: typeof response.data?.data,
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
});
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
if (response.data?.success && response.data?.data?.data) {
const items = response.data.data.data; // 실제 데이터 배열
console.log("✅ [RepeaterFieldGroup] 그룹 데이터 로드 완료:", {
count: items.length,
groupByColumn,
groupKeyValue,
firstItem: items[0],
});
setGroupedData(items);
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
const itemIds = items.map((item: any) => item.id).filter(Boolean);
setOriginalItemIds(itemIds);
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
// onChange 호출하여 부모에게 알림
if (onChange && items.length > 0) {
const dataWithMeta = items.map((item: any) => ({
...item,
_targetTable: targetTable,
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
}));
onChange(dataWithMeta);
}
} else {
console.warn("⚠️ [RepeaterFieldGroup] 그룹 데이터 로드 실패:", response.data);
setGroupedData([]);
}
} catch (error) {
console.error("❌ [RepeaterFieldGroup] 그룹 데이터 로드 오류:", error);
setGroupedData([]);
} finally {
setIsLoadingGroupData(false);
}
};
loadGroupedData();
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
// 값이 JSON 문자열인 경우 파싱
let parsedValue: any[] = [];
if (typeof rawValue === "string") {
// 🆕 그룹화된 데이터가 있으면 우선 사용
if (groupedData !== null && groupedData.length > 0) {
parsedValue = groupedData;
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
// 그룹화 설정이 없는 경우에만 단일 행 사용
console.log("📝 [RepeaterFieldGroup] 수정 모드 - formData를 초기 데이터로 사용", {
formDataId: formData?.id,
matchingFieldsCount: matchingFields.length,
});
parsedValue = [{ ...formData }];
} else if (typeof rawValue === "string" && rawValue.trim() !== "") {
// 빈 문자열이 아닌 경우에만 JSON 파싱 시도
try {
parsedValue = JSON.parse(rawValue);
} catch {
@ -65,6 +188,10 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
const fieldNameRef = useRef(fieldName);
fieldNameRef.current = fieldName;
// config를 ref로 관리
const configRef = useRef(config);
configRef.current = config;
// 데이터 수신 핸들러
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
@ -92,14 +219,34 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
return item;
});
// 🆕 정의된 필드만 필터링 (불필요한 필드 제거)
// 반복 필드 그룹에 정의된 필드 + 시스템 필드만 유지
const definedFields = configRef.current.fields || [];
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
// 시스템 필드 및 필수 필드 추가
const systemFields = new Set(['id', '_targetTable', 'created_date', 'updated_date', 'writer', 'company_code']);
const filteredData = normalizedData.map((item: any) => {
const filteredItem: Record<string, any> = {};
Object.keys(item).forEach(key => {
// 정의된 필드이거나 시스템 필드인 경우만 포함
if (definedFieldNames.has(key) || systemFields.has(key)) {
filteredItem[key] = item[key];
}
});
return filteredItem;
});
console.log("📥 [RepeaterFieldGroup] 정규화된 데이터:", normalizedData);
console.log("📥 [RepeaterFieldGroup] 필터링된 데이터:", filteredData);
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
const currentValue = parsedValueRef.current;
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
// 🆕 필터링된 데이터 사용
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
const newItems = mode === "replace" ? normalizedData : [...currentValue, ...normalizedData];
const newItems = mode === "replace" ? filteredData : [...currentValue, ...filteredData];
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { currentValue, newItems, mode });
@ -121,7 +268,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
onChangeRef.current(jsonValue);
}
toast.success(`${normalizedData.length}개 항목이 추가되었습니다`);
toast.success(`${filteredData.length}개 항목이 추가되었습니다`);
}, []);
// DataReceivable 인터페이스 구현

View File

@ -19,7 +19,7 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
description: "좌우 화면 임베딩 및 데이터 전달 기능을 제공하는 분할 패널",
category: ComponentCategory.LAYOUT,
webType: "text", // 레이아웃 컴포넌트는 기본 webType 사용
component: ScreenSplitPanel, // React 컴포넌트
component: ScreenSplitPanelRenderer, // 🆕 Renderer 클래스 자체를 등록 (ScreenSplitPanel 아님)
configPanel: ScreenSplitPanelConfigPanel, // 설정 패널
tags: ["split", "panel", "embed", "data-transfer", "layout"],
defaultSize: {
@ -68,7 +68,7 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
render() {
console.log("🚀 [ScreenSplitPanelRenderer] render() 호출됨!", this.props);
const { component, style = {}, componentConfig, config, screenId } = this.props as any;
const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any;
// componentConfig 또는 config 또는 component.componentConfig 사용
const finalConfig = componentConfig || config || component?.componentConfig || {};
@ -78,16 +78,27 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
hasConfig: !!config,
hasComponentComponentConfig: !!component?.componentConfig,
finalConfig,
splitRatio: finalConfig.splitRatio, // 🆕 splitRatio 확인
splitRatio: finalConfig.splitRatio,
leftScreenId: finalConfig.leftScreenId,
rightScreenId: finalConfig.rightScreenId,
componentType: component?.componentType,
componentId: component?.id,
});
// 🆕 formData 별도 로그 (명확한 확인)
console.log("📝 [ScreenSplitPanelRenderer] formData 확인:", {
hasFormData: !!formData,
formDataKeys: formData ? Object.keys(formData) : [],
formData: formData,
});
return (
<div style={{ width: "100%", height: "100%", ...style }}>
<ScreenSplitPanel screenId={screenId || finalConfig.screenId} config={finalConfig} />
<ScreenSplitPanel
screenId={screenId || finalConfig.screenId}
config={finalConfig}
initialFormData={formData} // 🆕 수정 데이터 전달
/>
</div>
);
}

View File

@ -471,6 +471,66 @@ export class ButtonActionExecutor {
}
}
// 🆕 반복 필드 그룹에서 삭제된 항목 처리
// formData의 각 필드에서 _deletedItemIds가 있는지 확인
console.log("🔍 [handleSave] 삭제 항목 검색 시작 - dataWithUserInfo 키:", Object.keys(dataWithUserInfo));
for (const [key, value] of Object.entries(dataWithUserInfo)) {
console.log(`🔍 [handleSave] 필드 검사: ${key}`, {
type: typeof value,
isArray: Array.isArray(value),
isString: typeof value === "string",
valuePreview: typeof value === "string" ? value.substring(0, 100) : value,
});
let parsedValue = value;
// JSON 문자열인 경우 파싱 시도
if (typeof value === "string" && value.startsWith("[")) {
try {
parsedValue = JSON.parse(value);
console.log(`🔍 [handleSave] JSON 파싱 성공: ${key}`, parsedValue);
} catch (e) {
// 파싱 실패하면 원본 값 유지
}
}
if (Array.isArray(parsedValue) && parsedValue.length > 0) {
const firstItem = parsedValue[0];
const deletedItemIds = firstItem?._deletedItemIds;
const targetTable = firstItem?._targetTable;
console.log(`🔍 [handleSave] 배열 필드 분석: ${key}`, {
firstItemKeys: firstItem ? Object.keys(firstItem) : [],
deletedItemIds,
targetTable,
});
if (deletedItemIds && deletedItemIds.length > 0 && targetTable) {
console.log("🗑️ [handleSave] 삭제할 항목 발견:", {
fieldKey: key,
targetTable,
deletedItemIds,
});
// 삭제 API 호출
for (const itemId of deletedItemIds) {
try {
console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable);
if (deleteResult.success) {
console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`);
} else {
console.warn(`⚠️ [handleSave] 항목 삭제 실패: ${itemId}`, deleteResult.message);
}
} catch (deleteError) {
console.error(`❌ [handleSave] 항목 삭제 오류: ${itemId}`, deleteError);
}
}
}
}
}
saveResult = await DynamicFormApi.saveFormData({
screenId,
tableName,
@ -1398,16 +1458,59 @@ export class ButtonActionExecutor {
let description = config.editModalDescription || "";
// 2. config에 없으면 화면 정보에서 가져오기
if (!description && config.targetScreenId) {
let screenInfo: any = null;
if (config.targetScreenId) {
try {
const screenInfo = await screenApi.getScreen(config.targetScreenId);
description = screenInfo?.description || "";
screenInfo = await screenApi.getScreen(config.targetScreenId);
if (!description) {
description = screenInfo?.description || "";
}
} catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error);
}
}
// 🔧 항상 EditModal 사용 (groupByColumns는 EditModal에서 처리)
// 🆕 화면이 분할 패널을 포함하는지 확인 (레이아웃에 screen-split-panel 컴포넌트가 있는지)
let hasSplitPanel = false;
if (config.targetScreenId) {
try {
const layoutData = await screenApi.getLayout(config.targetScreenId);
if (layoutData?.components) {
hasSplitPanel = layoutData.components.some(
(comp: any) =>
comp.type === "screen-split-panel" ||
comp.componentType === "screen-split-panel" ||
comp.type === "split-panel-layout" ||
comp.componentType === "split-panel-layout"
);
}
console.log("🔍 [openEditModal] 분할 패널 확인:", {
targetScreenId: config.targetScreenId,
hasSplitPanel,
componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [],
});
} catch (error) {
console.warn("레이아웃 정보를 가져오지 못했습니다:", error);
}
}
// 🆕 분할 패널 화면인 경우 ScreenModal 사용 (editData 전달)
if (hasSplitPanel) {
console.log("📋 [openEditModal] 분할 패널 화면 - ScreenModal 사용");
const screenModalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.editModalTitle || "데이터 수정",
description: description,
size: config.modalSize || "lg",
editData: rowData, // 🆕 수정 데이터 전달
},
});
window.dispatchEvent(screenModalEvent);
return;
}
// 🔧 일반 화면은 EditModal 사용 (groupByColumns는 EditModal에서 처리)
const modalEvent = new CustomEvent("openEditModal", {
detail: {
screenId: config.targetScreenId,

View File

@ -18,8 +18,27 @@ export type RepeaterFieldType =
| "code" // 공통코드
| "image" // 이미지
| "direct" // 직접입력
| "calculated" // 계산식 필드
| string; // 기타 커스텀 타입 허용
/**
*
*/
export type CalculationOperator = "+" | "-" | "*" | "/" | "%" | "round" | "floor" | "ceil" | "abs";
/**
*
* : { field1: "order_qty", operator: "*", field2: "unit_price" } order_qty * unit_price
* : { field1: "amount", operator: "round", decimalPlaces: 2 } round(amount, 2)
*/
export interface CalculationFormula {
field1: string; // 첫 번째 필드명
operator: CalculationOperator; // 연산자
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
constantValue?: number; // 상수값 (field2 대신 사용 가능)
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
}
/**
*
* - input: 입력 ( )
@ -42,6 +61,13 @@ export interface RepeaterFieldDefinition {
width?: string; // 필드 너비 (예: "200px", "50%")
displayMode?: RepeaterFieldDisplayMode; // 표시 모드: input(입력), readonly(읽기전용)
categoryCode?: string; // category 타입일 때 사용할 카테고리 코드
formula?: CalculationFormula; // 계산식 (type이 "calculated"일 때 사용)
numberFormat?: {
useThousandSeparator?: boolean; // 천 단위 구분자 사용
prefix?: string; // 접두사 (예: "₩")
suffix?: string; // 접미사 (예: "원")
decimalPlaces?: number; // 소수점 자릿수
};
validation?: {
minLength?: number;
maxLength?: number;
@ -57,6 +83,7 @@ export interface RepeaterFieldDefinition {
export interface RepeaterFieldGroupConfig {
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
minItems?: number; // 최소 항목 수
maxItems?: number; // 최대 항목 수
addButtonText?: string; // 추가 버튼 텍스트