Merge pull request 'feature/screen-management' (#147) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/147
This commit is contained in:
kjs 2025-10-24 16:40:06 +09:00
commit 4e3dbd4bc8
7 changed files with 434 additions and 381 deletions

View File

@ -52,9 +52,8 @@ export default function ScreenViewPage() {
modalDescription?: string; modalDescription?: string;
}>({}); }>({});
// 자동 스케일 조정 (사용자 화면 크기에 맞춤)
const [scale, setScale] = useState(1);
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
useEffect(() => { useEffect(() => {
const initComponents = async () => { const initComponents = async () => {
@ -140,32 +139,37 @@ export default function ScreenViewPage() {
} }
}, [screenId]); }, [screenId]);
// 자동 스케일 조정 useEffect (항상 화면에 꽉 차게) // 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일)
useEffect(() => { useEffect(() => {
const updateScale = () => { const updateScale = () => {
if (containerRef.current && layout) { if (containerRef.current && layout) {
const screenWidth = layout?.screenResolution?.width || 1200; const designWidth = layout?.screenResolution?.width || 1200;
const designHeight = layout?.screenResolution?.height || 800;
const containerWidth = containerRef.current.offsetWidth; const containerWidth = containerRef.current.offsetWidth;
const availableWidth = containerWidth - 32; // 좌우 패딩 16px * 2 const containerHeight = containerRef.current.offsetHeight;
// 항상 화면에 맞춰서 스케일 조정 (늘리거나 줄임) // 가로/세로 비율 중 작은 것을 선택 (화면에 맞게)
const newScale = availableWidth / screenWidth; const scaleX = containerWidth / designWidth;
const scaleY = containerHeight / designHeight;
const newScale = Math.min(scaleX, scaleY);
console.log("📏 스케일 계산 (화면 꽉 차게):", { console.log("📏 캔버스 스케일 계산:", {
screenWidth, designWidth,
designHeight,
containerWidth, containerWidth,
availableWidth, containerHeight,
scale: newScale, scaleX,
scaleY,
finalScale: newScale,
}); });
setScale(newScale); setScale(newScale);
} }
}; };
// 초기 측정 (DOM이 완전히 렌더링된 후) // 초기 측정
const timer = setTimeout(() => { const timer = setTimeout(updateScale, 100);
updateScale();
}, 100);
window.addEventListener("resize", updateScale); window.addEventListener("resize", updateScale);
return () => { return () => {
@ -207,17 +211,16 @@ export default function ScreenViewPage() {
const screenHeight = layout?.screenResolution?.height || 800; const screenHeight = layout?.screenResolution?.height || 800;
return ( return (
<div ref={containerRef} className="bg-background flex h-full w-full flex-col overflow-hidden"> <div ref={containerRef} className="bg-background flex h-full w-full items-start justify-start overflow-hidden">
{/* 절대 위치 기반 렌더링 */} {/* 절대 위치 기반 렌더링 */}
{layout && layout.components.length > 0 ? ( {layout && layout.components.length > 0 ? (
<div <div
className="bg-background relative flex-1" className="bg-background relative origin-top-left"
style={{ style={{
width: screenWidth, width: layout?.screenResolution?.width || 1200,
height: "100%", height: layout?.screenResolution?.height || 800,
transform: `scale(${scale})`, transform: `scale(${scale})`,
transformOrigin: "top left", transformOrigin: "top left",
overflow: "hidden",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}

View File

@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
@ -34,10 +35,10 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
const [displayName, setDisplayName] = useState(data.displayName || data.tableName); const [displayName, setDisplayName] = useState(data.displayName || data.tableName);
const [tableName, setTableName] = useState(data.tableName); const [tableName, setTableName] = useState(data.tableName);
// 🆕 데이터 소스 타입 (기본값: context-data) // 🆕 데이터 소스 타입 (기본값: context-data)
const [dataSourceType, setDataSourceType] = useState<"context-data" | "table-all">( const [dataSourceType, setDataSourceType] = useState<"context-data" | "table-all">(
(data as any).dataSourceType || "context-data" (data as any).dataSourceType || "context-data",
); );
// 테이블 선택 관련 상태 // 테이블 선택 관련 상태
@ -167,171 +168,168 @@ export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesPro
return ( return (
<div className="space-y-4 p-4 pb-8"> <div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */} {/* 기본 정보 */}
<div> <div>
<h3 className="mb-3 text-sm font-semibold"> </h3> <h3 className="mb-3 text-sm font-semibold"> </h3>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<Label htmlFor="displayName" className="text-xs"> <Label htmlFor="displayName" className="text-xs">
</Label> </Label>
<Input <Input
id="displayName" id="displayName"
value={displayName} value={displayName}
onChange={(e) => handleDisplayNameChange(e.target.value)} onChange={(e) => handleDisplayNameChange(e.target.value)}
className="mt-1" className="mt-1"
placeholder="노드 표시 이름" placeholder="노드 표시 이름"
/> />
</div> </div>
{/* 테이블 선택 Combobox */} {/* 테이블 선택 Combobox */}
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="mt-1 w-full justify-between" className="mt-1 w-full justify-between"
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
<span className="text-muted-foreground"> ...</span> <span className="text-muted-foreground"> ...</span>
) : tableName ? ( ) : tableName ? (
<span className="truncate">{selectedTableLabel}</span> <span className="truncate">{selectedTableLabel}</span>
) : ( ) : (
<span className="text-muted-foreground"> </span> <span className="text-muted-foreground"> </span>
)} )}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start"> <PopoverContent className="w-[320px] p-0" align="start">
<Command> <Command>
<CommandInput placeholder="테이블 검색..." className="h-9" /> <CommandInput placeholder="테이블 검색..." className="h-9" />
<CommandList> <CommandList>
<CommandEmpty> .</CommandEmpty> <CommandEmpty> .</CommandEmpty>
<CommandGroup> <CommandGroup>
<ScrollArea className="h-[300px]"> <ScrollArea className="h-[300px]">
{tables.map((table) => ( {tables.map((table) => (
<CommandItem <CommandItem
key={table.tableName} key={table.tableName}
value={`${table.label} ${table.tableName} ${table.description}`} value={`${table.label} ${table.tableName} ${table.description}`}
onSelect={() => handleTableSelect(table.tableName)} onSelect={() => handleTableSelect(table.tableName)}
className="cursor-pointer" className="cursor-pointer"
> >
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
tableName === table.tableName ? "opacity-100" : "opacity-0", tableName === table.tableName ? "opacity-100" : "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{table.label}</span> <span className="font-medium">{table.label}</span>
{table.label !== table.tableName && ( {table.label !== table.tableName && (
<span className="text-muted-foreground text-xs">{table.tableName}</span> <span className="text-muted-foreground text-xs">{table.tableName}</span>
)} )}
{table.description && ( {table.description && (
<span className="text-muted-foreground text-xs">{table.description}</span> <span className="text-muted-foreground text-xs">{table.description}</span>
)} )}
</div> </div>
</CommandItem> </CommandItem>
))} ))}
</ScrollArea> </ScrollArea>
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{tableName && selectedTableLabel !== tableName && ( {tableName && selectedTableLabel !== tableName && (
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-muted-foreground mt-1 text-xs">
: <code className="rounded bg-gray-100 px-1 py-0.5">{tableName}</code> : <code className="rounded bg-gray-100 px-1 py-0.5">{tableName}</code>
</p> </p>
)}
</div>
</div>
</div>
{/* 🆕 데이터 소스 설정 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
<div className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select value={dataSourceType} onValueChange={handleDataSourceTypeChange}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="데이터 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="context-data">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-muted-foreground text-xs">
(, )
</span>
</div>
</div>
</SelectItem>
<SelectItem value="table-all">
<div className="flex items-center gap-2">
<Table className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-muted-foreground text-xs"> ( )</span>
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
{/* 설명 텍스트 */}
<div className="mt-2 rounded bg-blue-50 p-3 text-xs text-blue-700">
{dataSourceType === "context-data" ? (
<>
<p className="mb-1 font-medium">💡 </p>
<p> ( , ) .</p>
<p className="mt-1 text-blue-600"> 데이터: 1개 </p>
<p className="text-blue-600"> 선택: N개 </p>
</>
) : (
<>
<p className="mb-1 font-medium">📊 </p>
<p> ** ** .</p>
<p className="mt-1 font-medium text-orange-600"> </p>
</>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
{/* 🆕 데이터 소스 설정 */} {/* 필드 정보 */}
<div> <div>
<h3 className="mb-3 text-sm font-semibold"> </h3> <h3 className="mb-3 text-sm font-semibold">
{data.fields && data.fields.length > 0 && `(${data.fields.length}개)`}
<div className="space-y-3"> </h3>
<div> {data.fields && data.fields.length > 0 ? (
<Label className="text-xs"> </Label> <div className="max-h-[300px] space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
<Select value={dataSourceType} onValueChange={handleDataSourceTypeChange}> {data.fields.map((field) => (
<SelectTrigger className="mt-1"> <div key={field.name} className="flex items-center justify-between rounded bg-white px-2 py-1.5 text-xs">
<SelectValue placeholder="데이터 소스 선택" /> <span className="truncate font-mono text-gray-700" title={field.name}>
</SelectTrigger> {field.name}
<SelectContent> </span>
<SelectItem value="context-data"> <span className="ml-2 shrink-0 text-gray-400">{field.type}</span>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-muted-foreground text-xs">
(, )
</span>
</div>
</div>
</SelectItem>
<SelectItem value="table-all">
<div className="flex items-center gap-2">
<Table className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-muted-foreground text-xs">
( )
</span>
</div>
</div>
</SelectItem>
</SelectContent>
</Select>
{/* 설명 텍스트 */}
<div className="mt-2 rounded bg-blue-50 p-3 text-xs text-blue-700">
{dataSourceType === "context-data" ? (
<>
<p className="font-medium mb-1">💡 </p>
<p> ( , ) .</p>
<p className="mt-1 text-blue-600"> 데이터: 1개 </p>
<p className="text-blue-600"> 선택: N개 </p>
</>
) : (
<>
<p className="font-medium mb-1">📊 </p>
<p> ** ** .</p>
<p className="mt-1 text-orange-600 font-medium"> </p>
</>
)}
</div> </div>
</div> ))}
</div> </div>
</div> ) : (
<div className="rounded border p-4 text-center text-xs text-gray-400"> </div>
{/* 필드 정보 */} )}
<div> </div>
<h3 className="mb-3 text-sm font-semibold">
{data.fields && data.fields.length > 0 && `(${data.fields.length}개)`}
</h3>
{data.fields && data.fields.length > 0 ? (
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
{data.fields.map((field) => (
<div key={field.name} className="flex items-center justify-between rounded bg-white px-2 py-1.5 text-xs">
<span className="truncate font-mono text-gray-700" title={field.name}>
{field.name}
</span>
<span className="ml-2 shrink-0 text-gray-400">{field.type}</span>
</div>
))}
</div>
) : (
<div className="rounded border p-4 text-center text-xs text-gray-400"> </div>
)}
</div>
</div> </div>
); );
} }

View File

@ -334,7 +334,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
console.log("🔍 InteractiveScreenViewer 최종 flowComponent:", flowComponent); console.log("🔍 InteractiveScreenViewer 최종 flowComponent:", flowComponent);
return ( return (
<div className="h-full w-full"> <div className="w-full">
<FlowWidget component={flowComponent as any} /> <FlowWidget component={flowComponent as any} />
</div> </div>
); );

View File

@ -34,7 +34,7 @@ interface RealtimePreviewProps {
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러 onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러
onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러 onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러
onConfigChange?: (config: any) => void; // 설정 변경 핸들러 onConfigChange?: (config: any) => void; // 설정 변경 핸들러
// 버튼 액션을 위한 props // 버튼 액션을 위한 props
screenId?: number; screenId?: number;
tableName?: string; tableName?: string;
@ -47,7 +47,7 @@ interface RealtimePreviewProps {
onRefresh?: () => void; onRefresh?: () => void;
flowRefreshKey?: number; flowRefreshKey?: number;
onFlowRefresh?: () => void; onFlowRefresh?: () => void;
// 폼 데이터 관련 props // 폼 데이터 관련 props
formData?: Record<string, any>; formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void; onFormDataChange?: (fieldName: string, value: any) => void;
@ -115,24 +115,24 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// 플로우 위젯의 실제 높이 측정 // 플로우 위젯의 실제 높이 측정
React.useEffect(() => { React.useEffect(() => {
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget"; const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
if (isFlowWidget && contentRef.current) { if (isFlowWidget && contentRef.current) {
const measureHeight = () => { const measureHeight = () => {
if (contentRef.current) { if (contentRef.current) {
// getBoundingClientRect()로 실제 렌더링된 높이 측정 // getBoundingClientRect()로 실제 렌더링된 높이 측정
const rect = contentRef.current.getBoundingClientRect(); const rect = contentRef.current.getBoundingClientRect();
const measured = rect.height; const measured = rect.height;
// scrollHeight도 함께 확인하여 더 큰 값 사용 // scrollHeight도 함께 확인하여 더 큰 값 사용
const scrollHeight = contentRef.current.scrollHeight; const scrollHeight = contentRef.current.scrollHeight;
const rawHeight = Math.max(measured, scrollHeight); const rawHeight = Math.max(measured, scrollHeight);
// 40px 단위로 올림 // 40px 단위로 올림
const finalHeight = Math.ceil(rawHeight / 40) * 40; const finalHeight = Math.ceil(rawHeight / 40) * 40;
if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) { if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) {
setActualHeight(finalHeight); setActualHeight(finalHeight);
// 컴포넌트의 실제 size.height도 업데이트 (중복 업데이트 방지) // 컴포넌트의 실제 size.height도 업데이트 (중복 업데이트 방지)
if (onConfigChange && finalHeight !== lastUpdatedHeight.current && finalHeight !== component.size?.height) { if (onConfigChange && finalHeight !== lastUpdatedHeight.current && finalHeight !== component.size?.height) {
lastUpdatedHeight.current = finalHeight; lastUpdatedHeight.current = finalHeight;
@ -142,11 +142,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
newHeight: finalHeight, newHeight: finalHeight,
}); });
// size는 별도 속성이므로 직접 업데이트 // size는 별도 속성이므로 직접 업데이트
const event = new CustomEvent('updateComponentSize', { const event = new CustomEvent("updateComponentSize", {
detail: { detail: {
componentId: component.id, componentId: component.id,
height: finalHeight height: finalHeight,
} },
}); });
window.dispatchEvent(event); window.dispatchEvent(event);
} }
@ -276,10 +276,10 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
> >
{/* 동적 컴포넌트 렌더링 */} {/* 동적 컴포넌트 렌더링 */}
<div <div
ref={component.type === "component" && (component as any).componentType === "flow-widget" ? contentRef : undefined} ref={
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} w-full max-w-full ${ component.type === "component" && (component as any).componentType === "flow-widget" ? contentRef : undefined
component.componentConfig?.type === "table-list" ? "overflow-hidden" : "overflow-visible" }
}`} className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} w-full max-w-full overflow-visible`}
> >
<DynamicComponentRenderer <DynamicComponentRenderer
component={component} component={component}

View File

@ -13,7 +13,7 @@ import { Input } from "@/components/ui/input";
import { Workflow, Info, CheckCircle, XCircle, Loader2, ArrowRight, ArrowDown } from "lucide-react"; import { Workflow, Info, CheckCircle, XCircle, Loader2, ArrowRight, ArrowDown } from "lucide-react";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { FlowVisibilityConfig } from "@/types/control-management"; import { FlowVisibilityConfig } from "@/types/control-management";
import { getFlowById } from "@/lib/api/flow"; import { getFlowById, getFlowSteps } from "@/lib/api/flow";
import type { FlowDefinition, FlowStep } from "@/types/flow"; import type { FlowDefinition, FlowStep } from "@/types/flow";
import { toast } from "sonner"; import { toast } from "sonner";
@ -25,7 +25,7 @@ interface FlowVisibilityConfigPanelProps {
/** /**
* *
* *
* , . * , .
*/ */
export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps> = ({ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps> = ({
@ -40,8 +40,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
const flowWidgets = useMemo(() => { const flowWidgets = useMemo(() => {
return allComponents.filter((comp) => { return allComponents.filter((comp) => {
const isFlowWidget = const isFlowWidget =
comp.type === "flow" || comp.type === "flow" || (comp.type === "component" && (comp as any).componentConfig?.type === "flow-widget");
(comp.type === "component" && (comp as any).componentConfig?.type === "flow-widget");
return isFlowWidget; return isFlowWidget;
}); });
}, [allComponents]); }, [allComponents]);
@ -49,23 +48,23 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
// State // State
const [enabled, setEnabled] = useState(currentConfig?.enabled || false); const [enabled, setEnabled] = useState(currentConfig?.enabled || false);
const [selectedFlowComponentId, setSelectedFlowComponentId] = useState<string | null>( const [selectedFlowComponentId, setSelectedFlowComponentId] = useState<string | null>(
currentConfig?.targetFlowComponentId || null currentConfig?.targetFlowComponentId || null,
); );
const [mode, setMode] = useState<"whitelist" | "blacklist" | "all">(currentConfig?.mode || "whitelist"); const [mode, setMode] = useState<"whitelist" | "blacklist" | "all">(currentConfig?.mode || "whitelist");
const [visibleSteps, setVisibleSteps] = useState<number[]>(currentConfig?.visibleSteps || []); const [visibleSteps, setVisibleSteps] = useState<number[]>(currentConfig?.visibleSteps || []);
const [hiddenSteps, setHiddenSteps] = useState<number[]>(currentConfig?.hiddenSteps || []); const [hiddenSteps, setHiddenSteps] = useState<number[]>(currentConfig?.hiddenSteps || []);
const [layoutBehavior, setLayoutBehavior] = useState<"preserve-position" | "auto-compact">( const [layoutBehavior, setLayoutBehavior] = useState<"preserve-position" | "auto-compact">(
currentConfig?.layoutBehavior || "auto-compact" currentConfig?.layoutBehavior || "auto-compact",
); );
// 🆕 그룹 설정 (auto-compact 모드에서만 사용) // 🆕 그룹 설정 (auto-compact 모드에서만 사용)
const [groupId, setGroupId] = useState<string>(currentConfig?.groupId || `group-${Date.now()}`); const [groupId, setGroupId] = useState<string>(currentConfig?.groupId || `group-${Date.now()}`);
const [groupDirection, setGroupDirection] = useState<"horizontal" | "vertical">( const [groupDirection, setGroupDirection] = useState<"horizontal" | "vertical">(
currentConfig?.groupDirection || "horizontal" currentConfig?.groupDirection || "horizontal",
); );
const [groupGap, setGroupGap] = useState<number>(currentConfig?.groupGap ?? 8); const [groupGap, setGroupGap] = useState<number>(currentConfig?.groupGap ?? 8);
const [groupAlign, setGroupAlign] = useState<"start" | "center" | "end" | "space-between" | "space-around">( const [groupAlign, setGroupAlign] = useState<"start" | "center" | "end" | "space-between" | "space-around">(
currentConfig?.groupAlign || "start" currentConfig?.groupAlign || "start",
); );
// 선택된 플로우의 스텝 목록 // 선택된 플로우의 스텝 목록
@ -127,13 +126,12 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
setFlowInfo(flowResponse.data); setFlowInfo(flowResponse.data);
// 스텝 목록 조회 // 스텝 목록 조회
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`); const stepsResponse = await getFlowSteps(flowId);
if (!stepsResponse.ok) { if (!stepsResponse.success) {
throw new Error("스텝 목록을 불러올 수 없습니다"); throw new Error("스텝 목록을 불러올 수 없습니다");
} }
const stepsData = await stepsResponse.json(); if (stepsResponse.data) {
if (stepsData.success && stepsData.data) { const sortedSteps = stepsResponse.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
const sortedSteps = stepsData.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
setFlowSteps(sortedSteps); setFlowSteps(sortedSteps);
} }
} catch (error: any) { } catch (error: any) {
@ -346,12 +344,10 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
</div> </div>
{/* 스텝 체크박스 목록 */} {/* 스텝 체크박스 목록 */}
<div className="space-y-2 rounded-lg border bg-muted/30 p-3"> <div className="bg-muted/30 space-y-2 rounded-lg border p-3">
{flowSteps.map((step) => { {flowSteps.map((step) => {
const isChecked = const isChecked =
mode === "whitelist" mode === "whitelist" ? visibleSteps.includes(step.id) : hiddenSteps.includes(step.id);
? visibleSteps.includes(step.id)
: hiddenSteps.includes(step.id);
return ( return (
<div key={step.id} className="flex items-center gap-2"> <div key={step.id} className="flex items-center gap-2">
@ -366,7 +362,9 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
</Badge> </Badge>
<span>{step.stepName}</span> <span>{step.stepName}</span>
{isChecked && ( {isChecked && (
<CheckCircle className={`ml-auto h-4 w-4 ${mode === "whitelist" ? "text-green-500" : "text-red-500"}`} /> <CheckCircle
className={`ml-auto h-4 w-4 ${mode === "whitelist" ? "text-green-500" : "text-red-500"}`}
/>
)} )}
</Label> </Label>
</div> </div>
@ -403,14 +401,12 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
{/* 🆕 그룹 설정 (auto-compact 모드일 때만 표시) */} {/* 🆕 그룹 설정 (auto-compact 모드일 때만 표시) */}
{layoutBehavior === "auto-compact" && ( {layoutBehavior === "auto-compact" && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-4"> <div className="space-y-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
</Badge> </Badge>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs"> ID를 </p>
ID를
</p>
</div> </div>
{/* 그룹 ID */} {/* 그룹 ID */}
@ -425,7 +421,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
placeholder="group-1" placeholder="group-1"
className="h-8 text-xs sm:h-9 sm:text-sm" className="h-8 text-xs sm:h-9 sm:text-sm"
/> />
<p className="text-[10px] text-muted-foreground"> <p className="text-muted-foreground text-[10px]">
ID를 ID를
</p> </p>
</div> </div>
@ -577,4 +573,3 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
</Card> </Card>
); );
}; };

View File

@ -5,10 +5,18 @@ import { FlowComponent } from "@/types/screen-management";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AlertCircle, Loader2, ChevronUp, History } from "lucide-react"; import { AlertCircle, Loader2, ChevronUp, History } from "lucide-react";
import { getFlowById, getAllStepCounts, getStepDataList, getFlowAuditLogs } from "@/lib/api/flow"; import {
getFlowById,
getAllStepCounts,
getStepDataList,
getFlowAuditLogs,
getFlowSteps,
getFlowConnections,
} from "@/lib/api/flow";
import type { FlowDefinition, FlowStep, FlowAuditLog } from "@/types/flow"; import type { FlowDefinition, FlowStep, FlowAuditLog } from "@/types/flow";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Dialog, Dialog,
@ -63,7 +71,7 @@ export function FlowWidget({
// 🆕 스텝 데이터 페이지네이션 상태 // 🆕 스텝 데이터 페이지네이션 상태
const [stepDataPage, setStepDataPage] = useState(1); const [stepDataPage, setStepDataPage] = useState(1);
const [stepDataPageSize] = useState(20); const [stepDataPageSize, setStepDataPageSize] = useState(10);
// 오딧 로그 상태 // 오딧 로그 상태
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]); const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
@ -161,22 +169,18 @@ export function FlowWidget({
setFlowData(flowResponse.data); setFlowData(flowResponse.data);
// 스텝 목록 조회 // 스텝 목록 조회
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`); const stepsResponse = await getFlowSteps(flowId);
if (!stepsResponse.ok) { if (!stepsResponse.success) {
throw new Error("스텝 목록을 불러올 수 없습니다"); throw new Error("스텝 목록을 불러올 수 없습니다");
} }
const stepsData = await stepsResponse.json(); if (stepsResponse.data) {
if (stepsData.success && stepsData.data) { const sortedSteps = stepsResponse.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
const sortedSteps = stepsData.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
setSteps(sortedSteps); setSteps(sortedSteps);
// 연결 정보 조회 // 연결 정보 조회
const connectionsResponse = await fetch(`/api/flow/connections/${flowId}`); const connectionsResponse = await getFlowConnections(flowId);
if (connectionsResponse.ok) { if (connectionsResponse.success && connectionsResponse.data) {
const connectionsData = await connectionsResponse.json(); setConnections(connectionsResponse.data);
if (connectionsData.success && connectionsData.data) {
setConnections(connectionsData.data);
}
} }
// 스텝별 데이터 건수 조회 // 스텝별 데이터 건수 조회
@ -385,7 +389,7 @@ export function FlowWidget({
: "flex flex-col items-center gap-4"; : "flex flex-col items-center gap-4";
return ( return (
<div className="@container flex h-full w-full flex-col p-2 sm:p-4 lg:p-6"> <div className="@container flex w-full flex-col p-2 sm:p-4 lg:p-6">
{/* 플로우 제목 */} {/* 플로우 제목 */}
<div className="mb-3 flex-shrink-0 sm:mb-4"> <div className="mb-3 flex-shrink-0 sm:mb-4">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
@ -647,8 +651,8 @@ export function FlowWidget({
{/* 선택된 스텝의 데이터 리스트 */} {/* 선택된 스텝의 데이터 리스트 */}
{selectedStepId !== null && ( {selectedStepId !== null && (
<div className="bg-muted/30 mt-4 flex min-h-0 w-full flex-1 flex-col rounded-lg border sm:mt-6 lg:mt-8"> <div className="bg-muted/30 mt-4 flex w-full flex-col rounded-lg border sm:mt-6 lg:mt-8">
{/* 헤더 */} {/* 헤더 - 자동 높이 */}
<div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4"> <div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
<h4 className="text-foreground text-base font-semibold sm:text-lg"> <h4 className="text-foreground text-base font-semibold sm:text-lg">
{steps.find((s) => s.id === selectedStepId)?.stepName} {steps.find((s) => s.id === selectedStepId)?.stepName}
@ -661,34 +665,34 @@ export function FlowWidget({
</p> </p>
</div> </div>
{/* 데이터 영역 - 스크롤 가능 */} {/* 데이터 영역 - 고정 높이 + 스크롤 */}
<div className="min-h-0 flex-1 overflow-auto"> {stepDataLoading ? (
{stepDataLoading ? ( <div className="flex h-64 items-center justify-center">
<div className="flex h-full items-center justify-center py-12"> <Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" />
<Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" /> <span className="text-muted-foreground ml-2 text-sm"> ...</span>
<span className="text-muted-foreground ml-2 text-sm"> ...</span> </div>
</div> ) : stepData.length === 0 ? (
) : stepData.length === 0 ? ( <div className="flex h-64 flex-col items-center justify-center">
<div className="flex h-full flex-col items-center justify-center py-12"> <svg
<svg className="text-muted-foreground/50 mb-3 h-12 w-12"
className="text-muted-foreground/50 mb-3 h-12 w-12" fill="none"
fill="none" viewBox="0 0 24 24"
viewBox="0 0 24 24" stroke="currentColor"
stroke="currentColor" >
> <path
<path strokeLinecap="round"
strokeLinecap="round" strokeLinejoin="round"
strokeLinejoin="round" strokeWidth={1.5}
strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
/> </svg>
</svg> <span className="text-muted-foreground text-sm"> </span>
<span className="text-muted-foreground text-sm"> </span> </div>
</div> ) : (
) : ( <>
<> {/* 모바일: 카드 뷰 - 고정 높이 + 스크롤 */}
{/* 모바일: 카드 뷰 */} <div className="overflow-y-auto @sm:hidden" style={{ height: "450px" }}>
<div className="space-y-2 p-3 @sm:hidden"> <div className="space-y-2 p-3">
{paginatedStepData.map((row, pageIndex) => { {paginatedStepData.map((row, pageIndex) => {
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
return ( return (
@ -725,132 +729,159 @@ export function FlowWidget({
); );
})} })}
</div> </div>
</div>
{/* 데스크톱: 테이블 뷰 */} {/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */}
<div className="hidden @sm:block"> <div className="hidden overflow-auto @sm:block" style={{ height: "450px" }}>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50"> <TableRow className="bg-muted/50 hover:bg-muted/50">
{allowDataMove && ( {allowDataMove && (
<TableHead className="bg-muted/50 sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-sm"> <TableHead className="bg-muted/50 sticky top-0 left-0 z-20 w-12 border-b px-3 py-2 text-center shadow-sm">
<Checkbox <Checkbox
checked={selectedRows.size === stepData.length && stepData.length > 0} checked={selectedRows.size === stepData.length && stepData.length > 0}
onCheckedChange={toggleAllRows} onCheckedChange={toggleAllRows}
/> />
</TableHead> </TableHead>
)} )}
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<TableHead <TableHead
key={col} key={col}
className="bg-muted/50 sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap sm:text-sm" className="bg-muted/50 sticky top-0 z-10 border-b px-3 py-2 text-xs font-semibold whitespace-nowrap sm:text-sm"
> >
{col} {col}
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{paginatedStepData.map((row, pageIndex) => { {paginatedStepData.map((row, pageIndex) => {
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
return ( return (
<TableRow <TableRow
key={actualIndex} key={actualIndex}
className={`hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`} className={`hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
> >
{allowDataMove && ( {allowDataMove && (
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center"> <TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
<Checkbox <Checkbox
checked={selectedRows.has(actualIndex)} checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)} onCheckedChange={() => toggleRowSelection(actualIndex)}
/> />
</TableCell> </TableCell>
)} )}
{stepDataColumns.map((col) => ( {stepDataColumns.map((col) => (
<TableCell key={col} className="border-b px-3 py-2 text-xs whitespace-nowrap sm:text-sm"> <TableCell key={col} className="border-b px-3 py-2 text-xs whitespace-nowrap sm:text-sm">
{row[col] !== null && row[col] !== undefined ? ( {row[col] !== null && row[col] !== undefined ? (
String(row[col]) String(row[col])
) : ( ) : (
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
); );
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</> </>
)} )}
</div>
{/* 페이지네이션 푸터 */} {/* 페이지네이션 - 항상 하단에 고정 */}
{!stepDataLoading && stepData.length > 0 && totalStepDataPages > 1 && ( {!stepDataLoading && stepData.length > 0 && (
<div className="bg-background flex-shrink-0 border-t px-4 py-3 sm:px-6"> <div className="bg-background flex-shrink-0 border-t px-4 py-3 sm:px-6">
<div className="flex flex-col items-center justify-between gap-3 sm:flex-row"> <div className="flex flex-col items-center justify-between gap-3 sm:flex-row">
<div className="text-muted-foreground text-xs sm:text-sm"> {/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
{stepDataPage} / {totalStepDataPages} ( {stepData.length}) <div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
<div className="text-muted-foreground text-xs sm:text-sm">
{stepDataPage} / {totalStepDataPages} ( {stepData.length})
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs"> :</span>
<Select
value={stepDataPageSize.toString()}
onValueChange={(value) => {
setStepDataPageSize(Number(value));
setStepDataPage(1); // 페이지 크기 변경 시 첫 페이지로
}}
>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<Pagination>
<PaginationContent> {/* 오른쪽: 페이지네이션 */}
<PaginationItem> {totalStepDataPages > 1 && (
<PaginationPrevious <Pagination>
onClick={() => setStepDataPage((p) => Math.max(1, p - 1))} <PaginationContent>
className={stepDataPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} <PaginationItem>
/> <PaginationPrevious
</PaginationItem> onClick={() => setStepDataPage((p) => Math.max(1, p - 1))}
{totalStepDataPages <= 7 ? ( className={stepDataPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => ( />
<PaginationItem key={page}> </PaginationItem>
<PaginationLink {totalStepDataPages <= 7 ? (
onClick={() => setStepDataPage(page)} Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => (
isActive={stepDataPage === page} <PaginationItem key={page}>
className="cursor-pointer" <PaginationLink
> onClick={() => setStepDataPage(page)}
{page} isActive={stepDataPage === page}
</PaginationLink> className="cursor-pointer"
</PaginationItem> >
)) {page}
) : ( </PaginationLink>
<> </PaginationItem>
{Array.from({ length: totalStepDataPages }, (_, i) => i + 1) ))
.filter((page) => { ) : (
return ( <>
page === 1 || {Array.from({ length: totalStepDataPages }, (_, i) => i + 1)
page === totalStepDataPages || .filter((page) => {
(page >= stepDataPage - 2 && page <= stepDataPage + 2) return (
); page === 1 ||
}) page === totalStepDataPages ||
.map((page, idx, arr) => ( (page >= stepDataPage - 2 && page <= stepDataPage + 2)
<React.Fragment key={page}> );
{idx > 0 && arr[idx - 1] !== page - 1 && ( })
.map((page, idx, arr) => (
<React.Fragment key={page}>
{idx > 0 && arr[idx - 1] !== page - 1 && (
<PaginationItem>
<span className="text-muted-foreground px-2">...</span>
</PaginationItem>
)}
<PaginationItem> <PaginationItem>
<span className="text-muted-foreground px-2">...</span> <PaginationLink
onClick={() => setStepDataPage(page)}
isActive={stepDataPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem> </PaginationItem>
)} </React.Fragment>
<PaginationItem> ))}
<PaginationLink </>
onClick={() => setStepDataPage(page)} )}
isActive={stepDataPage === page} <PaginationItem>
className="cursor-pointer" <PaginationNext
> onClick={() => setStepDataPage((p) => Math.min(totalStepDataPages, p + 1))}
{page} className={
</PaginationLink> stepDataPage === totalStepDataPages ? "pointer-events-none opacity-50" : "cursor-pointer"
</PaginationItem> }
</React.Fragment> />
))} </PaginationItem>
</> </PaginationContent>
)} </Pagination>
<PaginationItem> )}
<PaginationNext
onClick={() => setStepDataPage((p) => Math.min(totalStepDataPages, p + 1))}
className={
stepDataPage === totalStepDataPages ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div> </div>
</div> </div>
)} )}

View File

@ -19,7 +19,33 @@ import {
ApiResponse, ApiResponse,
} from "@/types/flow"; } from "@/types/flow";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "/api"; // API URL 동적 설정
const getApiBaseUrl = (): string => {
// 1. 환경변수가 있으면 우선 사용
if (process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL;
}
// 2. 클라이언트 사이드에서 동적 설정
if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
// 프로덕션 환경: v1.vexplor.com → api.vexplor.com
if (currentHost === "v1.vexplor.com") {
return "https://api.vexplor.com/api";
}
// 로컬 개발환경
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
return "http://localhost:8080/api";
}
}
// 3. 기본값
return "/api";
};
const API_BASE = getApiBaseUrl();
// 토큰 가져오기 // 토큰 가져오기
function getAuthToken(): string | null { function getAuthToken(): string | null {