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:
commit
4e3dbd4bc8
|
|
@ -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",
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue