jskim-node #394
|
|
@ -87,10 +87,12 @@ function ScreenViewPage() {
|
||||||
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
||||||
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
// 🆕 레이어 시스템 지원
|
// 레이어 시스템 지원
|
||||||
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
||||||
// 🆕 조건부 영역(Zone) 목록
|
// 조건부 영역(Zone) 목록
|
||||||
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
||||||
|
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
|
||||||
|
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// 편집 모달 상태
|
// 편집 모달 상태
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
|
@ -378,11 +380,51 @@ function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return newActiveIds;
|
// 강제 활성화된 레이어 ID 병합
|
||||||
}, [formData, conditionalLayers, layout]);
|
for (const forcedId of forceActivatedLayerIds) {
|
||||||
|
if (!newActiveIds.includes(forcedId)) {
|
||||||
|
newActiveIds.push(forcedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
return newActiveIds;
|
||||||
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
|
}, [formData, conditionalLayers, layout, forceActivatedLayerIds]);
|
||||||
|
|
||||||
|
// 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const handleActivateLayer = (e: Event) => {
|
||||||
|
const { componentId, targetLayerId } = (e as CustomEvent).detail || {};
|
||||||
|
if (!componentId && !targetLayerId) return;
|
||||||
|
|
||||||
|
// targetLayerId가 직접 지정된 경우
|
||||||
|
if (targetLayerId) {
|
||||||
|
setForceActivatedLayerIds((prev) =>
|
||||||
|
prev.includes(targetLayerId) ? prev : [...prev, targetLayerId],
|
||||||
|
);
|
||||||
|
console.log(`🔓 [레이어 강제 활성화] layerId: ${targetLayerId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// componentId로 해당 컴포넌트가 속한 레이어를 찾아 활성화
|
||||||
|
for (const layer of conditionalLayers) {
|
||||||
|
const found = layer.components.some((comp) => comp.id === componentId);
|
||||||
|
if (found) {
|
||||||
|
setForceActivatedLayerIds((prev) =>
|
||||||
|
prev.includes(layer.id) ? prev : [...prev, layer.id],
|
||||||
|
);
|
||||||
|
console.log(`🔓 [레이어 강제 활성화] componentId: ${componentId} → layerId: ${layer.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("activateLayerForComponent", handleActivateLayer);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("activateLayerForComponent", handleActivateLayer);
|
||||||
|
};
|
||||||
|
}, [conditionalLayers]);
|
||||||
|
|
||||||
|
// 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadMainTableData = async () => {
|
const loadMainTableData = async () => {
|
||||||
if (!screen || !layout || !layout.components || !companyCode) {
|
if (!screen || !layout || !layout.components || !companyCode) {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||||
|
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||||
|
|
||||||
interface ScreenModalState {
|
interface ScreenModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -1025,6 +1026,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : screenData ? (
|
) : screenData ? (
|
||||||
|
<ScreenContextProvider
|
||||||
|
screenId={modalState.screenId || undefined}
|
||||||
|
tableName={screenData.screenInfo?.tableName}
|
||||||
|
>
|
||||||
<ActiveTabProvider>
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div
|
<div
|
||||||
|
|
@ -1254,6 +1259,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</TableOptionsProvider>
|
</TableOptionsProvider>
|
||||||
</ActiveTabProvider>
|
</ActiveTabProvider>
|
||||||
|
</ScreenContextProvider>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||||
|
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||||
|
|
||||||
interface EditModalState {
|
interface EditModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -1385,12 +1386,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : screenData ? (
|
) : screenData ? (
|
||||||
|
<ScreenContextProvider
|
||||||
|
screenId={modalState.screenId || undefined}
|
||||||
|
tableName={screenData.screenInfo?.tableName}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
data-screen-runtime="true"
|
data-screen-runtime="true"
|
||||||
className="relative bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: screenDimensions?.width || 800,
|
width: screenDimensions?.width || 800,
|
||||||
// 🆕 조건부 레이어가 활성화되면 높이 자동 확장
|
// 조건부 레이어가 활성화되면 높이 자동 확장
|
||||||
height: (() => {
|
height: (() => {
|
||||||
const baseHeight = (screenDimensions?.height || 600) + 30;
|
const baseHeight = (screenDimensions?.height || 600) + 30;
|
||||||
if (activeConditionalComponents.length > 0) {
|
if (activeConditionalComponents.length > 0) {
|
||||||
|
|
@ -1546,6 +1551,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</ScreenContextProvider>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||||
|
|
|
||||||
|
|
@ -551,9 +551,12 @@ export default function ScreenDesigner({
|
||||||
originalRegion: null,
|
originalRegion: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
|
// 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
|
||||||
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
|
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
|
||||||
|
|
||||||
|
// 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용)
|
||||||
|
const [otherLayerComponents, setOtherLayerComponents] = useState<ComponentData[]>([]);
|
||||||
|
|
||||||
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
|
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
|
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
|
||||||
|
|
@ -578,6 +581,41 @@ export default function ScreenDesigner({
|
||||||
findZone();
|
findZone();
|
||||||
}, [activeLayerId, selectedScreen?.screenId, zones]);
|
}, [activeLayerId, selectedScreen?.screenId, zones]);
|
||||||
|
|
||||||
|
// 다른 레이어의 컴포넌트 메타 정보 로드 (데이터 전달 타겟 선택용)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedScreen?.screenId) return;
|
||||||
|
const loadOtherLayerComponents = async () => {
|
||||||
|
try {
|
||||||
|
const allLayers = await screenApi.getScreenLayers(selectedScreen.screenId);
|
||||||
|
const currentLayerId = activeLayerIdRef.current || 1;
|
||||||
|
const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0);
|
||||||
|
|
||||||
|
const components: ComponentData[] = [];
|
||||||
|
for (const layerInfo of otherLayers) {
|
||||||
|
try {
|
||||||
|
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerInfo.layer_id);
|
||||||
|
const rawComps = layerData?.components;
|
||||||
|
if (rawComps && Array.isArray(rawComps)) {
|
||||||
|
for (const comp of rawComps) {
|
||||||
|
components.push({
|
||||||
|
...comp,
|
||||||
|
_layerName: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
|
||||||
|
_layerId: String(layerInfo.layer_id),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 개별 레이어 로드 실패 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOtherLayerComponents(components);
|
||||||
|
} catch {
|
||||||
|
setOtherLayerComponents([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadOtherLayerComponents();
|
||||||
|
}, [selectedScreen?.screenId, activeLayerId]);
|
||||||
|
|
||||||
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
|
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
|
||||||
const visibleComponents = useMemo(() => {
|
const visibleComponents = useMemo(() => {
|
||||||
return layout.components;
|
return layout.components;
|
||||||
|
|
@ -6516,8 +6554,8 @@ export default function ScreenDesigner({
|
||||||
updateComponentProperty(selectedComponent.id, "style", style);
|
updateComponentProperty(selectedComponent.id, "style", style);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
allComponents={layout.components} // 🆕 플로우 위젯 감지용
|
allComponents={[...layout.components, ...otherLayerComponents]}
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -2966,11 +2966,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{/* 데이터 제공 가능한 컴포넌트 필터링 */}
|
{/* 자동 탐색 옵션 (레이어별 테이블이 다를 때 유용) */}
|
||||||
|
<SelectItem value="__auto__">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">자동 탐색 (현재 활성 테이블)</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">(auto)</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
{/* 데이터 제공 가능한 컴포넌트 필터링 (모든 레이어 포함) */}
|
||||||
{allComponents
|
{allComponents
|
||||||
.filter((comp: any) => {
|
.filter((comp: any) => {
|
||||||
const type = comp.componentType || comp.type || "";
|
const type = comp.componentType || comp.type || "";
|
||||||
// 데이터를 제공할 수 있는 컴포넌트 타입들
|
|
||||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||||
type.includes(t),
|
type.includes(t),
|
||||||
);
|
);
|
||||||
|
|
@ -2978,11 +2984,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
.map((comp: any) => {
|
.map((comp: any) => {
|
||||||
const compType = comp.componentType || comp.type || "unknown";
|
const compType = comp.componentType || comp.type || "unknown";
|
||||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||||
|
const layerName = comp._layerName;
|
||||||
return (
|
return (
|
||||||
<SelectItem key={comp.id} value={comp.id}>
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium">{compLabel}</span>
|
<span className="text-xs font-medium">{compLabel}</span>
|
||||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||||
|
{layerName && (
|
||||||
|
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{layerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
|
|
@ -2999,7 +3011,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-muted-foreground mt-1 text-xs">테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트</p>
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -3037,33 +3051,47 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={config.action?.dataTransfer?.targetComponentId || ""}
|
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value)
|
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value);
|
||||||
}
|
// 선택한 컴포넌트가 다른 레이어에 있으면 targetLayerId도 저장
|
||||||
|
const selectedComp = allComponents.find((c: any) => c.id === value);
|
||||||
|
if (selectedComp && (selectedComp as any)._layerId) {
|
||||||
|
onUpdateProperty(
|
||||||
|
"componentConfig.action.dataTransfer.targetLayerId",
|
||||||
|
(selectedComp as any)._layerId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{/* 데이터 수신 가능한 컴포넌트 필터링 (소스와 다른 컴포넌트만) */}
|
{/* 데이터 수신 가능한 컴포넌트 필터링 (모든 레이어 포함, 소스와 다른 컴포넌트만) */}
|
||||||
{allComponents
|
{allComponents
|
||||||
.filter((comp: any) => {
|
.filter((comp: any) => {
|
||||||
const type = comp.componentType || comp.type || "";
|
const type = comp.componentType || comp.type || "";
|
||||||
// 데이터를 받을 수 있는 컴포넌트 타입들
|
|
||||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||||
(t) => type.includes(t),
|
(t) => type.includes(t),
|
||||||
);
|
);
|
||||||
// 소스와 다른 컴포넌트만
|
|
||||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||||
})
|
})
|
||||||
.map((comp: any) => {
|
.map((comp: any) => {
|
||||||
const compType = comp.componentType || comp.type || "unknown";
|
const compType = comp.componentType || comp.type || "unknown";
|
||||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||||
|
const layerName = comp._layerName;
|
||||||
return (
|
return (
|
||||||
<SelectItem key={comp.id} value={comp.id}>
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium">{compLabel}</span>
|
<span className="text-xs font-medium">{compLabel}</span>
|
||||||
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||||
|
{layerName && (
|
||||||
|
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{layerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
|
|
@ -3261,25 +3289,77 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="additional-field-name" className="text-xs">
|
<Label htmlFor="additional-field-name" className="text-xs">
|
||||||
필드명 (선택사항)
|
타겟 필드명 (선택사항)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Popover>
|
||||||
id="additional-field-name"
|
<PopoverTrigger asChild>
|
||||||
placeholder="예: inbound_type (비워두면 전체 데이터)"
|
<Button
|
||||||
value={config.action?.dataTransfer?.additionalSources?.[0]?.fieldName || ""}
|
variant="outline"
|
||||||
onChange={(e) => {
|
role="combobox"
|
||||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
className="h-8 w-full justify-between text-xs"
|
||||||
const newSources = [...currentSources];
|
>
|
||||||
if (newSources.length === 0) {
|
{(() => {
|
||||||
newSources.push({ componentId: "", fieldName: e.target.value });
|
const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
|
||||||
} else {
|
if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
|
||||||
newSources[0] = { ...newSources[0], fieldName: e.target.value };
|
const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
|
||||||
}
|
const found = cols.find((c) => c.name === fieldName);
|
||||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
return found ? `${found.label || found.name}` : fieldName;
|
||||||
}}
|
})()}
|
||||||
className="h-8 text-xs"
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
/>
|
</Button>
|
||||||
<p className="text-muted-foreground mt-1 text-xs">타겟 테이블에 저장될 필드명</p>
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[240px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="__none__"
|
||||||
|
onSelect={() => {
|
||||||
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||||
|
const newSources = [...currentSources];
|
||||||
|
if (newSources.length === 0) {
|
||||||
|
newSources.push({ componentId: "", fieldName: "" });
|
||||||
|
} else {
|
||||||
|
newSources[0] = { ...newSources[0], fieldName: "" };
|
||||||
|
}
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", !config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="text-muted-foreground">선택 안 함 (전체 데이터 병합)</span>
|
||||||
|
</CommandItem>
|
||||||
|
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label || ""} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||||
|
const newSources = [...currentSources];
|
||||||
|
if (newSources.length === 0) {
|
||||||
|
newSources.push({ componentId: "", fieldName: col.name });
|
||||||
|
} else {
|
||||||
|
newSources[0] = { ...newSources[0], fieldName: col.name };
|
||||||
|
}
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="font-medium">{col.label || col.name}</span>
|
||||||
|
{col.label && col.label !== col.name && (
|
||||||
|
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">추가 데이터가 저장될 타겟 테이블 컬럼</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import {
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||||
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||||
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
|
import { DataReceivable } from "@/types/data-transfer";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// modal-repeater-table 컴포넌트 재사용
|
// modal-repeater-table 컴포넌트 재사용
|
||||||
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
|
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
|
||||||
|
|
@ -38,6 +41,7 @@ declare global {
|
||||||
|
|
||||||
export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
config: propConfig,
|
config: propConfig,
|
||||||
|
componentId,
|
||||||
parentId,
|
parentId,
|
||||||
data: initialData,
|
data: initialData,
|
||||||
onDataChange,
|
onDataChange,
|
||||||
|
|
@ -48,6 +52,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
|
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
|
||||||
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
|
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
|
||||||
|
|
||||||
|
// componentId 결정: 직접 전달 또는 component 객체에서 추출
|
||||||
|
const effectiveComponentId = componentId || (restProps as any).component?.id;
|
||||||
|
|
||||||
|
// ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null)
|
||||||
|
const screenContext = useScreenContextOptional();
|
||||||
// 설정 병합
|
// 설정 병합
|
||||||
const config: V2RepeaterConfig = useMemo(
|
const config: V2RepeaterConfig = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -68,6 +78,57 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
||||||
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
||||||
|
|
||||||
|
// ScreenContext DataReceiver 등록 (데이터 전달 액션 수신)
|
||||||
|
const onDataChangeRef = useRef(onDataChange);
|
||||||
|
onDataChangeRef.current = onDataChange;
|
||||||
|
|
||||||
|
const handleReceiveData = useCallback(
|
||||||
|
async (incomingData: any[], configOrMode?: any) => {
|
||||||
|
console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode });
|
||||||
|
|
||||||
|
if (!incomingData || incomingData.length === 0) {
|
||||||
|
toast.warning("전달할 데이터가 없습니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 정규화: {0: {...}} 형태 처리
|
||||||
|
const normalizedData = incomingData.map((item: any) => {
|
||||||
|
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
||||||
|
const { 0: originalData, ...additionalFields } = item;
|
||||||
|
return { ...originalData, ...additionalFields };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mode = configOrMode?.mode || configOrMode || "append";
|
||||||
|
|
||||||
|
setData((prev) => {
|
||||||
|
const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData];
|
||||||
|
onDataChangeRef.current?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screenContext && effectiveComponentId) {
|
||||||
|
const receiver: DataReceivable = {
|
||||||
|
componentId: effectiveComponentId,
|
||||||
|
componentType: "v2-repeater",
|
||||||
|
receiveData: handleReceiveData,
|
||||||
|
};
|
||||||
|
console.log("📋 [V2Repeater] ScreenContext에 데이터 수신자 등록:", effectiveComponentId);
|
||||||
|
screenContext.registerDataReceiver(effectiveComponentId, receiver);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
screenContext.unregisterDataReceiver(effectiveComponentId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [screenContext, effectiveComponentId, handleReceiveData]);
|
||||||
|
|
||||||
// 소스 테이블 컬럼 라벨 매핑
|
// 소스 테이블 컬럼 라벨 매핑
|
||||||
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
|
@ -512,17 +573,26 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
});
|
});
|
||||||
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
||||||
|
|
||||||
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
|
// 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지
|
||||||
|
const allCategoryColumns = useMemo(() => {
|
||||||
|
const fromConfig = config.columns
|
||||||
|
.filter((col) => col.inputType === "category")
|
||||||
|
.map((col) => col.key);
|
||||||
|
const merged = new Set([...sourceCategoryColumns, ...fromConfig]);
|
||||||
|
return Array.from(merged);
|
||||||
|
}, [sourceCategoryColumns, config.columns]);
|
||||||
|
|
||||||
|
// 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCategoryLabels = async () => {
|
const loadCategoryLabels = async () => {
|
||||||
if (sourceCategoryColumns.length === 0 || data.length === 0) {
|
if (allCategoryColumns.length === 0 || data.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
|
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
|
||||||
const allCodes = new Set<string>();
|
const allCodes = new Set<string>();
|
||||||
for (const row of data) {
|
for (const row of data) {
|
||||||
for (const col of sourceCategoryColumns) {
|
for (const col of allCategoryColumns) {
|
||||||
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
|
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
|
||||||
const val = row[`_display_${col}`] || row[col];
|
const val = row[`_display_${col}`] || row[col];
|
||||||
if (val && typeof val === "string") {
|
if (val && typeof val === "string") {
|
||||||
|
|
@ -531,7 +601,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
.map((c: string) => c.trim())
|
.map((c: string) => c.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
for (const code of codes) {
|
for (const code of codes) {
|
||||||
if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) {
|
if (!categoryLabelMap[code] && code.length > 0) {
|
||||||
allCodes.add(code);
|
allCodes.add(code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -560,7 +630,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCategoryLabels();
|
loadCategoryLabels();
|
||||||
}, [data, sourceCategoryColumns]);
|
}, [data, allCategoryColumns]);
|
||||||
|
|
||||||
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
|
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
|
||||||
const applyCalculationRules = useCallback(
|
const applyCalculationRules = useCallback(
|
||||||
|
|
@ -1112,7 +1182,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
onSelectionChange={setSelectedRows}
|
onSelectionChange={setSelectedRows}
|
||||||
equalizeWidthsTrigger={autoWidthTrigger}
|
equalizeWidthsTrigger={autoWidthTrigger}
|
||||||
categoryColumns={sourceCategoryColumns}
|
categoryColumns={allCategoryColumns}
|
||||||
categoryLabelMap={categoryLabelMap}
|
categoryLabelMap={categoryLabelMap}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1214,13 +1214,21 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
|
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 편집 가능 체크박스 */}
|
{/* 편집 가능 토글 */}
|
||||||
{!col.isSourceDisplay && (
|
{!col.isSourceDisplay && (
|
||||||
<Checkbox
|
<button
|
||||||
checked={col.editable ?? true}
|
type="button"
|
||||||
onCheckedChange={(checked) => updateColumnProp(col.key, "editable", !!checked)}
|
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
|
||||||
title="편집 가능"
|
className={cn(
|
||||||
/>
|
"shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors",
|
||||||
|
(col.editable ?? true)
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||||
|
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
|
||||||
|
)}
|
||||||
|
title={(col.editable ?? true) ? "편집 가능 (클릭하여 읽기 전용으로 변경)" : "읽기 전용 (클릭하여 편집 가능으로 변경)"}
|
||||||
|
>
|
||||||
|
{(col.editable ?? true) ? "편집" : "읽기"}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||||
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||||||
import { logger } from "@/lib/utils/logger";
|
import { logger } from "@/lib/utils/logger";
|
||||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대기 중인 데이터 전달 항목
|
||||||
|
* 타겟 컴포넌트가 아직 마운트되지 않은 경우 (조건부 레이어 등) 버퍼에 저장
|
||||||
|
*/
|
||||||
|
export interface PendingTransfer {
|
||||||
|
targetComponentId: string;
|
||||||
|
data: any[];
|
||||||
|
config: DataReceiverConfig;
|
||||||
|
timestamp: number;
|
||||||
|
targetLayerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ScreenContextValue {
|
interface ScreenContextValue {
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요)
|
menuObjid?: number;
|
||||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
splitPanelPosition?: SplitPanelPosition;
|
||||||
|
|
||||||
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
|
||||||
formData: Record<string, any>;
|
formData: Record<string, any>;
|
||||||
updateFormData: (fieldName: string, value: any) => void;
|
updateFormData: (fieldName: string, value: any) => void;
|
||||||
|
|
||||||
|
|
@ -33,6 +44,11 @@ interface ScreenContextValue {
|
||||||
// 모든 컴포넌트 조회
|
// 모든 컴포넌트 조회
|
||||||
getAllDataProviders: () => Map<string, DataProvidable>;
|
getAllDataProviders: () => Map<string, DataProvidable>;
|
||||||
getAllDataReceivers: () => Map<string, DataReceivable>;
|
getAllDataReceivers: () => Map<string, DataReceivable>;
|
||||||
|
|
||||||
|
// 대기 중인 데이터 전달 (레이어 내부 컴포넌트 미마운트 대응)
|
||||||
|
addPendingTransfer: (transfer: PendingTransfer) => void;
|
||||||
|
getPendingTransfer: (componentId: string) => PendingTransfer | undefined;
|
||||||
|
clearPendingTransfer: (componentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ScreenContext = createContext<ScreenContextValue | null>(null);
|
const ScreenContext = createContext<ScreenContextValue | null>(null);
|
||||||
|
|
@ -57,11 +73,10 @@ export function ScreenContextProvider({
|
||||||
}: ScreenContextProviderProps) {
|
}: ScreenContextProviderProps) {
|
||||||
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
||||||
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
||||||
|
const pendingTransfersRef = useRef<Map<string, PendingTransfer>>(new Map());
|
||||||
|
|
||||||
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
|
||||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 🆕 폼 데이터 업데이트 함수
|
|
||||||
const updateFormData = useCallback((fieldName: string, value: any) => {
|
const updateFormData = useCallback((fieldName: string, value: any) => {
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const updated = { ...prev, [fieldName]: value };
|
const updated = { ...prev, [fieldName]: value };
|
||||||
|
|
@ -87,6 +102,25 @@ export function ScreenContextProvider({
|
||||||
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
|
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
|
||||||
dataReceiversRef.current.set(componentId, receiver);
|
dataReceiversRef.current.set(componentId, receiver);
|
||||||
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
|
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
|
||||||
|
|
||||||
|
// 대기 중인 데이터 전달이 있으면 즉시 수신 처리
|
||||||
|
const pending = pendingTransfersRef.current.get(componentId);
|
||||||
|
if (pending) {
|
||||||
|
logger.info("대기 중인 데이터 전달 자동 수신", {
|
||||||
|
componentId,
|
||||||
|
dataCount: pending.data.length,
|
||||||
|
waitedMs: Date.now() - pending.timestamp,
|
||||||
|
});
|
||||||
|
receiver
|
||||||
|
.receiveData(pending.data, pending.config)
|
||||||
|
.then(() => {
|
||||||
|
pendingTransfersRef.current.delete(componentId);
|
||||||
|
logger.info("대기 데이터 전달 완료", { componentId });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error("대기 데이터 전달 실패", { componentId, error: err });
|
||||||
|
});
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const unregisterDataReceiver = useCallback((componentId: string) => {
|
const unregisterDataReceiver = useCallback((componentId: string) => {
|
||||||
|
|
@ -110,7 +144,24 @@ export function ScreenContextProvider({
|
||||||
return new Map(dataReceiversRef.current);
|
return new Map(dataReceiversRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
const addPendingTransfer = useCallback((transfer: PendingTransfer) => {
|
||||||
|
pendingTransfersRef.current.set(transfer.targetComponentId, transfer);
|
||||||
|
logger.info("데이터 전달 대기열 추가", {
|
||||||
|
targetComponentId: transfer.targetComponentId,
|
||||||
|
dataCount: transfer.data.length,
|
||||||
|
targetLayerId: transfer.targetLayerId,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPendingTransfer = useCallback((componentId: string) => {
|
||||||
|
return pendingTransfersRef.current.get(componentId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearPendingTransfer = useCallback((componentId: string) => {
|
||||||
|
pendingTransfersRef.current.delete(componentId);
|
||||||
|
logger.debug("대기 데이터 전달 클리어", { componentId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value = React.useMemo<ScreenContextValue>(
|
const value = React.useMemo<ScreenContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
screenId,
|
screenId,
|
||||||
|
|
@ -127,6 +178,9 @@ export function ScreenContextProvider({
|
||||||
getDataReceiver,
|
getDataReceiver,
|
||||||
getAllDataProviders,
|
getAllDataProviders,
|
||||||
getAllDataReceivers,
|
getAllDataReceivers,
|
||||||
|
addPendingTransfer,
|
||||||
|
getPendingTransfer,
|
||||||
|
clearPendingTransfer,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
screenId,
|
screenId,
|
||||||
|
|
@ -143,6 +197,9 @@ export function ScreenContextProvider({
|
||||||
getDataReceiver,
|
getDataReceiver,
|
||||||
getAllDataProviders,
|
getAllDataProviders,
|
||||||
getAllDataReceivers,
|
getAllDataReceivers,
|
||||||
|
addPendingTransfer,
|
||||||
|
getPendingTransfer,
|
||||||
|
clearPendingTransfer,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -724,17 +724,28 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 소스 컴포넌트에서 데이터 가져오기
|
// 1. 소스 컴포넌트에서 데이터 가져오기
|
||||||
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
let sourceProvider: import("@/types/data-transfer").DataProvidable | undefined;
|
||||||
|
|
||||||
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
const isAutoSource =
|
||||||
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
|
!dataTransferConfig.sourceComponentId || dataTransferConfig.sourceComponentId === "__auto__";
|
||||||
|
|
||||||
|
if (!isAutoSource) {
|
||||||
|
sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 탐색 모드이거나, 지정된 소스를 찾지 못한 경우
|
||||||
|
// 현재 마운트된 DataProvider 중에서 table-list를 자동 탐색
|
||||||
if (!sourceProvider) {
|
if (!sourceProvider) {
|
||||||
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
if (!isAutoSource) {
|
||||||
console.log("🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...");
|
console.log(
|
||||||
|
`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("🔍 [ButtonPrimary] 현재 활성 DataProvider 자동 탐색...");
|
||||||
|
|
||||||
const allProviders = screenContext.getAllDataProviders();
|
const allProviders = screenContext.getAllDataProviders();
|
||||||
|
|
||||||
// 테이블 리스트 우선 탐색
|
// table-list 우선 탐색
|
||||||
for (const [id, provider] of allProviders) {
|
for (const [id, provider] of allProviders) {
|
||||||
if (provider.componentType === "table-list") {
|
if (provider.componentType === "table-list") {
|
||||||
sourceProvider = provider;
|
sourceProvider = provider;
|
||||||
|
|
@ -743,7 +754,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
|
// table-list가 없으면 첫 번째 DataProvider 사용
|
||||||
if (!sourceProvider && allProviders.size > 0) {
|
if (!sourceProvider && allProviders.size > 0) {
|
||||||
const firstEntry = allProviders.entries().next().value;
|
const firstEntry = allProviders.entries().next().value;
|
||||||
if (firstEntry) {
|
if (firstEntry) {
|
||||||
|
|
@ -784,15 +795,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
const additionalValues = additionalProvider.getSelectedData();
|
const additionalValues = additionalProvider.getSelectedData();
|
||||||
|
|
||||||
if (additionalValues && additionalValues.length > 0) {
|
if (additionalValues && additionalValues.length > 0) {
|
||||||
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
|
|
||||||
const firstValue = additionalValues[0];
|
const firstValue = additionalValues[0];
|
||||||
|
|
||||||
// fieldName이 지정되어 있으면 그 필드만 추출
|
|
||||||
if (additionalSource.fieldName) {
|
if (additionalSource.fieldName) {
|
||||||
additionalData[additionalSource.fieldName] =
|
additionalData[additionalSource.fieldName] =
|
||||||
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||||
} else {
|
} else {
|
||||||
// fieldName이 없으면 전체 객체 병합
|
|
||||||
additionalData = { ...additionalData, ...firstValue };
|
additionalData = { ...additionalData, ...firstValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -802,6 +810,25 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
value: additionalData[additionalSource.fieldName || "all"],
|
value: additionalData[additionalSource.fieldName || "all"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (formData) {
|
||||||
|
// DataProvider로 등록되지 않은 컴포넌트(v2-select 등)는 formData에서 값을 가져옴
|
||||||
|
const comp = allComponents?.find((c: any) => c.id === additionalSource.componentId);
|
||||||
|
const columnName =
|
||||||
|
comp?.columnName ||
|
||||||
|
comp?.componentConfig?.columnName ||
|
||||||
|
comp?.overrides?.columnName;
|
||||||
|
|
||||||
|
if (columnName && formData[columnName] !== undefined && formData[columnName] !== "") {
|
||||||
|
const targetField = additionalSource.fieldName || columnName;
|
||||||
|
additionalData[targetField] = formData[columnName];
|
||||||
|
|
||||||
|
console.log("📦 추가 데이터 수집 (formData 폴백):", {
|
||||||
|
sourceId: additionalSource.componentId,
|
||||||
|
columnName,
|
||||||
|
targetField,
|
||||||
|
value: formData[columnName],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -881,33 +908,96 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 5. targetType / targetComponentId 기본값 및 자동 탐색
|
||||||
|
const effectiveTargetType = dataTransferConfig.targetType || "component";
|
||||||
|
let effectiveTargetComponentId = dataTransferConfig.targetComponentId;
|
||||||
|
|
||||||
|
// targetComponentId가 없으면 현재 화면에서 DataReceiver 자동 탐색
|
||||||
|
if (effectiveTargetType === "component" && !effectiveTargetComponentId) {
|
||||||
|
console.log("🔍 [ButtonPrimary] 타겟 컴포넌트 자동 탐색...");
|
||||||
|
const allReceivers = screenContext.getAllDataReceivers();
|
||||||
|
|
||||||
|
// repeater 계열 우선 탐색
|
||||||
|
for (const [id, receiver] of allReceivers) {
|
||||||
|
if (
|
||||||
|
receiver.componentType === "repeater-field-group" ||
|
||||||
|
receiver.componentType === "v2-repeater" ||
|
||||||
|
receiver.componentType === "repeater"
|
||||||
|
) {
|
||||||
|
effectiveTargetComponentId = id;
|
||||||
|
console.log(`✅ [ButtonPrimary] 리피터 자동 발견: ${id} (${receiver.componentType})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeater가 없으면 소스가 아닌 첫 번째 DataReceiver 사용
|
||||||
|
if (!effectiveTargetComponentId) {
|
||||||
|
for (const [id, receiver] of allReceivers) {
|
||||||
|
if (receiver.componentType === "table-list" || receiver.componentType === "data-table") {
|
||||||
|
effectiveTargetComponentId = id;
|
||||||
|
console.log(`✅ [ButtonPrimary] DataReceiver 자동 발견: ${id} (${receiver.componentType})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!effectiveTargetComponentId) {
|
||||||
|
toast.error("데이터를 받을 수 있는 타겟 컴포넌트를 찾을 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("📦 데이터 전달:", {
|
console.log("📦 데이터 전달:", {
|
||||||
sourceData,
|
sourceData,
|
||||||
mappedData,
|
mappedData,
|
||||||
targetType: dataTransferConfig.targetType,
|
targetType: effectiveTargetType,
|
||||||
targetComponentId: dataTransferConfig.targetComponentId,
|
targetComponentId: effectiveTargetComponentId,
|
||||||
targetScreenId: dataTransferConfig.targetScreenId,
|
targetScreenId: dataTransferConfig.targetScreenId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. 타겟으로 데이터 전달
|
// 6. 타겟으로 데이터 전달
|
||||||
if (dataTransferConfig.targetType === "component") {
|
if (effectiveTargetType === "component") {
|
||||||
// 같은 화면의 컴포넌트로 전달
|
const targetReceiver = screenContext.getDataReceiver(effectiveTargetComponentId);
|
||||||
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
|
|
||||||
|
const receiverConfig = {
|
||||||
|
targetComponentId: effectiveTargetComponentId,
|
||||||
|
targetComponentType: targetReceiver?.componentType || ("table" as const),
|
||||||
|
mode: dataTransferConfig.mode || ("append" as const),
|
||||||
|
mappingRules: dataTransferConfig.mappingRules || [],
|
||||||
|
};
|
||||||
|
|
||||||
if (!targetReceiver) {
|
if (!targetReceiver) {
|
||||||
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
|
// 타겟이 아직 마운트되지 않은 경우 (조건부 레이어 등)
|
||||||
|
// 버퍼에 저장하고 레이어 활성화 요청
|
||||||
|
console.log(
|
||||||
|
`⏳ [ButtonPrimary] 타겟 컴포넌트 미마운트, 대기열에 추가: ${effectiveTargetComponentId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
screenContext.addPendingTransfer({
|
||||||
|
targetComponentId: effectiveTargetComponentId,
|
||||||
|
data: mappedData,
|
||||||
|
config: receiverConfig,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
targetLayerId: dataTransferConfig.targetLayerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 레이어 활성화 이벤트 발행 (page.tsx에서 수신)
|
||||||
|
const activateEvent = new CustomEvent("activateLayerForComponent", {
|
||||||
|
detail: {
|
||||||
|
componentId: effectiveTargetComponentId,
|
||||||
|
targetLayerId: dataTransferConfig.targetLayerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.dispatchEvent(activateEvent);
|
||||||
|
|
||||||
|
toast.info(`타겟 레이어를 활성화하고 데이터 전달을 준비합니다...`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await targetReceiver.receiveData(mappedData, {
|
await targetReceiver.receiveData(mappedData, receiverConfig);
|
||||||
targetComponentId: dataTransferConfig.targetComponentId,
|
|
||||||
targetComponentType: targetReceiver.componentType,
|
|
||||||
mode: dataTransferConfig.mode || "append",
|
|
||||||
mappingRules: dataTransferConfig.mappingRules || [],
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||||
} else if (dataTransferConfig.targetType === "splitPanel") {
|
} else if (effectiveTargetType === "splitPanel") {
|
||||||
// 🆕 분할 패널의 반대편 화면으로 전달
|
// 🆕 분할 패널의 반대편 화면으로 전달
|
||||||
if (!splitPanelContext) {
|
if (!splitPanelContext) {
|
||||||
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
||||||
return (
|
return (
|
||||||
<V2Repeater
|
<V2Repeater
|
||||||
config={config}
|
config={config}
|
||||||
|
componentId={component?.id}
|
||||||
parentId={resolvedParentId}
|
parentId={resolvedParentId}
|
||||||
data={Array.isArray(data) ? data : undefined}
|
data={Array.isArray(data) ? data : undefined}
|
||||||
onDataChange={onDataChange}
|
onDataChange={onDataChange}
|
||||||
|
|
|
||||||
|
|
@ -6287,7 +6287,14 @@ export class ButtonActionExecutor {
|
||||||
const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer;
|
const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer;
|
||||||
|
|
||||||
if (targetType === "component" && targetComponentId) {
|
if (targetType === "component" && targetComponentId) {
|
||||||
// 같은 화면 내 컴포넌트로 전달
|
// 같은 화면 내 컴포넌트로 전달 + 레이어 활성화 이벤트 병행
|
||||||
|
const activateEvent = new CustomEvent("activateLayerForComponent", {
|
||||||
|
detail: {
|
||||||
|
componentId: targetComponentId,
|
||||||
|
targetLayerId: (dataTransfer as any).targetLayerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.dispatchEvent(activateEvent);
|
||||||
|
|
||||||
const transferEvent = new CustomEvent("componentDataTransfer", {
|
const transferEvent = new CustomEvent("componentDataTransfer", {
|
||||||
detail: {
|
detail: {
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,7 @@ export interface V2RepeaterConfig {
|
||||||
// 컴포넌트 Props
|
// 컴포넌트 Props
|
||||||
export interface V2RepeaterProps {
|
export interface V2RepeaterProps {
|
||||||
config: V2RepeaterConfig;
|
config: V2RepeaterConfig;
|
||||||
|
componentId?: string; // ScreenContext DataReceiver 등록용
|
||||||
parentId?: string | number; // 부모 레코드 ID
|
parentId?: string | number; // 부모 레코드 ID
|
||||||
data?: any[]; // 초기 데이터 (없으면 API로 로드)
|
data?: any[]; // 초기 데이터 (없으면 API로 로드)
|
||||||
onDataChange?: (data: any[]) => void;
|
onDataChange?: (data: any[]) => void;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue