플로우 페이지네이션 안보임

This commit is contained in:
kjs
2025-10-24 15:40:08 +09:00
parent 0a57a2cef1
commit 7d6281d289
21 changed files with 2101 additions and 469 deletions

View File

@@ -435,7 +435,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
{/* 컴포넌트 타입별 렌더링 */}
<div
ref={isFlowWidget ? contentRef : undefined}
className={isFlowWidget ? "h-auto w-full" : "h-full w-full"}
className="h-full w-full"
>
{/* 영역 타입 */}
{type === "area" && renderArea(component, children)}

View File

@@ -17,7 +17,7 @@ export interface ToolbarButton {
interface LeftUnifiedToolbarProps {
buttons: ToolbarButton[];
panelStates: Record<string, { isOpen: boolean }>;
panelStates: Record<string, { isOpen: boolean; badge?: number }>;
onTogglePanel: (panelId: string) => void;
}
@@ -28,6 +28,7 @@ export const LeftUnifiedToolbar: React.FC<LeftUnifiedToolbarProps> = ({ buttons,
const renderButton = (button: ToolbarButton) => {
const isActive = panelStates[button.id]?.isOpen || false;
const badge = panelStates[button.id]?.badge;
return (
<Button
@@ -45,6 +46,11 @@ export const LeftUnifiedToolbar: React.FC<LeftUnifiedToolbarProps> = ({ buttons,
<div className="relative">
{button.icon}
{isActive && <div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-white" />}
{badge !== undefined && badge > 0 && (
<div className="absolute -top-2 -right-2 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white shadow-md">
{badge > 99 ? "99+" : badge}
</div>
)}
</div>
<span className="text-[10px] font-medium">{button.label}</span>
</Button>

View File

@@ -36,7 +36,13 @@ interface FlowWidgetProps {
onFlowRefresh?: () => void; // 새로고침 완료 콜백
}
export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowRefreshKey, onFlowRefresh }: FlowWidgetProps) {
export function FlowWidget({
component,
onStepClick,
onSelectedDataChange,
flowRefreshKey,
onFlowRefresh,
}: FlowWidgetProps) {
// 🆕 전역 상태 관리
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
const resetFlow = useFlowStepStore((state) => state.resetFlow);
@@ -55,6 +61,10 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
const [stepDataLoading, setStepDataLoading] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// 🆕 스텝 데이터 페이지네이션 상태
const [stepDataPage, setStepDataPage] = useState(1);
const [stepDataPageSize] = useState(20);
// 오딧 로그 상태
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
const [auditLogsLoading, setAuditLogsLoading] = useState(false);
@@ -73,7 +83,6 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
// 🆕 플로우 컴포넌트 ID (버튼이 이 플로우를 참조할 때 사용)
const flowComponentId = component.id;
// 선택된 스텝의 데이터를 다시 로드하는 함수
const refreshStepData = async () => {
if (!flowId) return;
@@ -82,7 +91,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
// 스텝 카운트는 항상 업데이트 (선택된 스텝 유무와 관계없이)
const countsResponse = await getAllStepCounts(flowId);
console.log("📊 스텝 카운트 API 응답:", countsResponse);
if (countsResponse.success && countsResponse.data) {
// Record 형태로 변환
const countsMap: Record<number, number> = {};
@@ -90,10 +99,10 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
countsResponse.data.forEach((item: any) => {
countsMap[item.stepId] = item.count;
});
} else if (typeof countsResponse.data === 'object') {
} else if (typeof countsResponse.data === "object") {
Object.assign(countsMap, countsResponse.data);
}
console.log("✅ 스텝 카운트 업데이트:", countsMap);
setStepCounts(countsMap);
}
@@ -101,7 +110,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
// 선택된 스텝이 있으면 해당 스텝의 데이터도 새로고침
if (selectedStepId) {
setStepDataLoading(true);
const response = await getStepDataList(flowId, selectedStepId, 1, 100);
if (!response.success) {
@@ -224,6 +233,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
setStepData([]);
setStepDataColumns([]);
setSelectedRows(new Set());
setStepDataPage(1); // 🆕 페이지 리셋
onSelectedDataChange?.([], null);
console.log("🔄 [FlowWidget] 단계 선택 해제:", { flowComponentId, stepId });
@@ -235,6 +245,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
setSelectedStep(flowComponentId, stepId); // 🆕 전역 상태 업데이트
setStepDataLoading(true);
setSelectedRows(new Set());
setStepDataPage(1); // 🆕 페이지 리셋
onSelectedDataChange?.([], stepId);
console.log("✅ [FlowWidget] 단계 선택:", { flowComponentId, stepId, stepName });
@@ -272,7 +283,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
newSelected.add(rowIndex);
}
setSelectedRows(newSelected);
// 선택된 데이터를 상위로 전달
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", {
@@ -294,13 +305,12 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
newSelected = new Set(stepData.map((_, index) => index));
}
setSelectedRows(newSelected);
// 선택된 데이터를 상위로 전달
const selectedData = Array.from(newSelected).map((index) => stepData[index]);
onSelectedDataChange?.(selectedData, selectedStepId);
};
// 오딧 로그 로드
const loadAuditLogs = async () => {
if (!flowId) return;
@@ -330,6 +340,10 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
const paginatedAuditLogs = auditLogs.slice((auditPage - 1) * auditPageSize, auditPage * auditPageSize);
const totalAuditPages = Math.ceil(auditLogs.length / auditPageSize);
// 🆕 페이지네이션된 스텝 데이터
const paginatedStepData = stepData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize);
const totalStepDataPages = Math.ceil(stepData.length / stepDataPageSize);
if (loading) {
return (
<div className="flex items-center justify-center p-8">
@@ -371,9 +385,9 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
: "flex flex-col items-center gap-4";
return (
<div className="@container min-h-full w-full p-2 sm:p-4 lg:p-6">
<div className="@container flex h-full w-full flex-col p-2 sm:p-4 lg:p-6">
{/* 플로우 제목 */}
<div className="mb-3 sm:mb-4">
<div className="mb-3 flex-shrink-0 sm:mb-4">
<div className="flex items-center justify-center gap-2">
<h3 className="text-foreground text-base font-semibold sm:text-lg lg:text-xl">{flowData.name}</h3>
@@ -566,7 +580,7 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
</div>
{/* 플로우 스텝 목록 */}
<div className={containerClass}>
<div className={`${containerClass} flex-shrink-0`}>
{steps.map((step, index) => (
<React.Fragment key={step.id}>
{/* 스텝 카드 */}
@@ -633,132 +647,212 @@ export function FlowWidget({ component, onStepClick, onSelectedDataChange, flowR
{/* 선택된 스텝의 데이터 리스트 */}
{selectedStepId !== null && (
<div className="bg-muted/30 mt-4 w-full rounded-lg p-4 sm:mt-6 sm:rounded-xl sm:p-5 lg:mt-8 lg:p-6">
<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="mb-4 sm:mb-6">
<div className="flex-1">
<h4 className="text-foreground text-base font-semibold sm:text-lg">
{steps.find((s) => s.id === selectedStepId)?.stepName}
</h4>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
{stepData.length}
{selectedRows.size > 0 && (
<span className="text-primary ml-2 font-medium">({selectedRows.size} )</span>
)}
</p>
</div>
<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">
{steps.find((s) => s.id === selectedStepId)?.stepName}
</h4>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
{stepData.length}
{selectedRows.size > 0 && (
<span className="text-primary ml-2 font-medium">({selectedRows.size} )</span>
)}
</p>
</div>
{/* 데이터 테이블 */}
{stepDataLoading ? (
<div className="flex items-center justify-center py-8 sm:py-12">
<Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" />
<span className="text-muted-foreground ml-2 text-xs sm:ml-3 sm:text-sm"> ...</span>
</div>
) : stepData.length === 0 ? (
<div className="bg-card flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8 sm:py-12">
<svg
className="text-muted-foreground/50 mb-2 h-10 w-10 sm:mb-3 sm:h-12 sm:w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
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"
/>
</svg>
<span className="text-muted-foreground text-xs sm:text-sm"> </span>
</div>
) : (
<>
{/* 모바일: 카드 뷰 (컨테이너 640px 미만) */}
<div className="space-y-3 @sm:hidden">
{stepData.map((row, index) => (
<div
key={index}
className={`bg-card rounded-lg border p-3 transition-colors ${
selectedRows.has(index) ? "border-primary bg-primary/5" : "border-border"
}`}
>
{/* 체크박스 헤더 */}
{allowDataMove && (
<div className="mb-2 flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground text-xs font-medium"></span>
<Checkbox checked={selectedRows.has(index)} onCheckedChange={() => toggleRowSelection(index)} />
</div>
)}
{/* 데이터 필드들 */}
<div className="space-y-2">
{stepDataColumns.map((col) => (
<div key={col} className="flex justify-between gap-2">
<span className="text-muted-foreground text-xs font-medium">{col}:</span>
<span className="text-foreground truncate text-xs">
{row[col] !== null && row[col] !== undefined ? (
String(row[col])
) : (
<span className="text-muted-foreground">-</span>
)}
</span>
</div>
))}
</div>
</div>
))}
{/* 데이터 영역 - 스크롤 가능 */}
<div className="min-h-0 flex-1 overflow-auto">
{stepDataLoading ? (
<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" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
{/* 데스크톱: 테이블 뷰 (컨테이너 640px 이상) */}
<div className="bg-card hidden overflow-x-auto rounded-lg border shadow-sm @sm:block">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="hover:bg-transparent">
{allowDataMove && (
<TableHead className="w-12">
<Checkbox
checked={selectedRows.size === stepData.length && stepData.length > 0}
onCheckedChange={toggleAllRows}
/>
</TableHead>
)}
{stepDataColumns.map((col) => (
<TableHead key={col} className="text-xs font-semibold whitespace-nowrap sm:text-sm">
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{stepData.map((row, index) => (
<TableRow
key={index}
className={`transition-colors ${selectedRows.has(index) ? "bg-primary/5" : "hover:bg-muted/50"}`}
) : stepData.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center py-12">
<svg
className="text-muted-foreground/50 mb-3 h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
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"
/>
</svg>
<span className="text-muted-foreground text-sm"> </span>
</div>
) : (
<>
{/* 모바일: 카드 뷰 */}
<div className="space-y-2 p-3 @sm:hidden">
{paginatedStepData.map((row, pageIndex) => {
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
return (
<div
key={actualIndex}
className={`bg-card rounded-md border p-3 transition-colors ${
selectedRows.has(actualIndex) ? "bg-primary/5 border-primary/30" : ""
}`}
>
{allowDataMove && (
<TableCell className="w-12">
<div className="mb-2 flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground text-xs font-medium"></span>
<Checkbox
checked={selectedRows.has(index)}
onCheckedChange={() => toggleRowSelection(index)}
checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)}
/>
</TableCell>
</div>
)}
<div className="space-y-1.5">
{stepDataColumns.map((col) => (
<div key={col} className="flex justify-between gap-2 text-xs">
<span className="text-muted-foreground font-medium">{col}:</span>
<span className="text-foreground truncate">
{row[col] !== null && row[col] !== undefined ? (
String(row[col])
) : (
<span className="text-muted-foreground">-</span>
)}
</span>
</div>
))}
</div>
</div>
);
})}
</div>
{/* 데스크톱: 테이블 뷰 */}
<div className="hidden @sm:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
{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">
<Checkbox
checked={selectedRows.size === stepData.length && stepData.length > 0}
onCheckedChange={toggleAllRows}
/>
</TableHead>
)}
{stepDataColumns.map((col) => (
<TableCell key={col} className="font-mono text-xs whitespace-nowrap sm:text-sm">
{row[col] !== null && row[col] !== undefined ? (
String(row[col])
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableHead
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"
>
{col}
</TableHead>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{paginatedStepData.map((row, pageIndex) => {
const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex;
return (
<TableRow
key={actualIndex}
className={`hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
>
{allowDataMove && (
<TableCell className="bg-background sticky left-0 z-10 border-b px-3 py-2 text-center">
<Checkbox
checked={selectedRows.has(actualIndex)}
onCheckedChange={() => toggleRowSelection(actualIndex)}
/>
</TableCell>
)}
{stepDataColumns.map((col) => (
<TableCell key={col} className="border-b px-3 py-2 text-xs whitespace-nowrap sm:text-sm">
{row[col] !== null && row[col] !== undefined ? (
String(row[col])
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</>
)}
</div>
{/* 페이지네이션 푸터 */}
{!stepDataLoading && stepData.length > 0 && totalStepDataPages > 1 && (
<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="text-muted-foreground text-xs sm:text-sm">
{stepDataPage} / {totalStepDataPages} ( {stepData.length})
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setStepDataPage((p) => Math.max(1, p - 1))}
className={stepDataPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{totalStepDataPages <= 7 ? (
Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setStepDataPage(page)}
isActive={stepDataPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))
) : (
<>
{Array.from({ length: totalStepDataPages }, (_, i) => i + 1)
.filter((page) => {
return (
page === 1 ||
page === totalStepDataPages ||
(page >= stepDataPage - 2 && page <= stepDataPage + 2)
);
})
.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>
<PaginationLink
onClick={() => setStepDataPage(page)}
isActive={stepDataPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
</React.Fragment>
))}
</>
)}
<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>
)}