화면 분할 패널 수정모드 기능
This commit is contained in:
parent
c78ba865b6
commit
627c5a5173
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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, // 🆕 회사 코드
|
||||
|
|
|
|||
|
|
@ -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 인터페이스 구현
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; // 추가 버튼 텍스트
|
||||
|
|
|
|||
Loading…
Reference in New Issue