581 lines
24 KiB
TypeScript
581 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo } from "react";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Workflow, Info, CheckCircle, XCircle, Loader2, ArrowRight, ArrowDown } from "lucide-react";
|
|
import { ComponentData } from "@/types/screen";
|
|
import { FlowVisibilityConfig } from "@/types/control-management";
|
|
import { getFlowById } from "@/lib/api/flow";
|
|
import type { FlowDefinition, FlowStep } from "@/types/flow";
|
|
import { toast } from "sonner";
|
|
|
|
interface FlowVisibilityConfigPanelProps {
|
|
component: ComponentData; // 현재 선택된 버튼
|
|
allComponents: ComponentData[]; // 화면의 모든 컴포넌트
|
|
onUpdateProperty: (path: string, value: any) => void;
|
|
}
|
|
|
|
/**
|
|
* 플로우 단계별 버튼 표시 설정 패널
|
|
*
|
|
* 플로우 위젯이 화면에 있을 때, 버튼이 특정 플로우 단계에서만 표시되도록 설정할 수 있습니다.
|
|
*/
|
|
export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps> = ({
|
|
component,
|
|
allComponents,
|
|
onUpdateProperty,
|
|
}) => {
|
|
// 현재 설정
|
|
const currentConfig: FlowVisibilityConfig | undefined = (component as any).webTypeConfig?.flowVisibilityConfig;
|
|
|
|
// 화면의 모든 플로우 위젯 찾기
|
|
const flowWidgets = useMemo(() => {
|
|
return allComponents.filter((comp) => {
|
|
const isFlowWidget =
|
|
comp.type === "flow" ||
|
|
(comp.type === "component" && (comp as any).componentConfig?.type === "flow-widget");
|
|
return isFlowWidget;
|
|
});
|
|
}, [allComponents]);
|
|
|
|
// State
|
|
const [enabled, setEnabled] = useState(currentConfig?.enabled || false);
|
|
const [selectedFlowComponentId, setSelectedFlowComponentId] = useState<string | null>(
|
|
currentConfig?.targetFlowComponentId || null
|
|
);
|
|
const [mode, setMode] = useState<"whitelist" | "blacklist" | "all">(currentConfig?.mode || "whitelist");
|
|
const [visibleSteps, setVisibleSteps] = useState<number[]>(currentConfig?.visibleSteps || []);
|
|
const [hiddenSteps, setHiddenSteps] = useState<number[]>(currentConfig?.hiddenSteps || []);
|
|
const [layoutBehavior, setLayoutBehavior] = useState<"preserve-position" | "auto-compact">(
|
|
currentConfig?.layoutBehavior || "auto-compact"
|
|
);
|
|
|
|
// 🆕 그룹 설정 (auto-compact 모드에서만 사용)
|
|
const [groupId, setGroupId] = useState<string>(currentConfig?.groupId || `group-${Date.now()}`);
|
|
const [groupDirection, setGroupDirection] = useState<"horizontal" | "vertical">(
|
|
currentConfig?.groupDirection || "horizontal"
|
|
);
|
|
const [groupGap, setGroupGap] = useState<number>(currentConfig?.groupGap ?? 8);
|
|
const [groupAlign, setGroupAlign] = useState<"start" | "center" | "end" | "space-between" | "space-around">(
|
|
currentConfig?.groupAlign || "start"
|
|
);
|
|
|
|
// 선택된 플로우의 스텝 목록
|
|
const [flowSteps, setFlowSteps] = useState<FlowStep[]>([]);
|
|
const [flowInfo, setFlowInfo] = useState<FlowDefinition | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// 플로우가 없을 때
|
|
if (flowWidgets.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Workflow className="h-4 w-4" />
|
|
플로우 단계별 표시 설정
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Alert>
|
|
<Info className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">
|
|
화면에 플로우 위젯을 추가하면 단계별 버튼 표시 제어가 가능합니다.
|
|
</AlertDescription>
|
|
</Alert>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// 선택된 플로우의 스텝 로드
|
|
useEffect(() => {
|
|
if (!selectedFlowComponentId) {
|
|
setFlowSteps([]);
|
|
setFlowInfo(null);
|
|
return;
|
|
}
|
|
|
|
const loadFlowSteps = async () => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
// 선택된 플로우 위젯 찾기
|
|
const flowWidget = flowWidgets.find((fw) => fw.id === selectedFlowComponentId);
|
|
if (!flowWidget) return;
|
|
|
|
// flowId 추출
|
|
const flowConfig = (flowWidget as any).componentConfig || {};
|
|
const flowId = flowConfig.flowId;
|
|
if (!flowId) {
|
|
toast.error("플로우 ID를 찾을 수 없습니다");
|
|
return;
|
|
}
|
|
|
|
// 플로우 정보 조회
|
|
const flowResponse = await getFlowById(flowId);
|
|
if (!flowResponse.success || !flowResponse.data) {
|
|
throw new Error("플로우를 찾을 수 없습니다");
|
|
}
|
|
setFlowInfo(flowResponse.data);
|
|
|
|
// 스텝 목록 조회
|
|
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
|
if (!stepsResponse.ok) {
|
|
throw new Error("스텝 목록을 불러올 수 없습니다");
|
|
}
|
|
const stepsData = await stepsResponse.json();
|
|
if (stepsData.success && stepsData.data) {
|
|
const sortedSteps = stepsData.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
|
|
setFlowSteps(sortedSteps);
|
|
}
|
|
} catch (error: any) {
|
|
console.error("플로우 스텝 로딩 실패:", error);
|
|
toast.error(error.message || "플로우 정보를 불러오는데 실패했습니다");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadFlowSteps();
|
|
}, [selectedFlowComponentId, flowWidgets]);
|
|
|
|
// 🆕 설정 자동 저장 (즉시 적용) - 오버라이드 가능한 파라미터 지원
|
|
const applyConfig = (overrides?: Partial<FlowVisibilityConfig>) => {
|
|
const config: FlowVisibilityConfig = {
|
|
enabled,
|
|
targetFlowComponentId: selectedFlowComponentId || "",
|
|
targetFlowId: flowInfo?.id,
|
|
targetFlowName: flowInfo?.name,
|
|
mode,
|
|
visibleSteps: mode === "whitelist" ? visibleSteps : undefined,
|
|
hiddenSteps: mode === "blacklist" ? hiddenSteps : undefined,
|
|
layoutBehavior,
|
|
// 🆕 그룹 설정 (auto-compact 모드일 때만)
|
|
...(layoutBehavior === "auto-compact" && {
|
|
groupId,
|
|
groupDirection,
|
|
groupGap,
|
|
groupAlign,
|
|
}),
|
|
// 오버라이드 적용
|
|
...overrides,
|
|
};
|
|
|
|
console.log("💾 [FlowVisibilityConfig] 설정 자동 저장:", {
|
|
componentId: component.id,
|
|
config,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
|
|
onUpdateProperty("webTypeConfig.flowVisibilityConfig", config);
|
|
};
|
|
|
|
// 체크박스 토글
|
|
const toggleStep = (stepId: number) => {
|
|
if (mode === "whitelist") {
|
|
const newSteps = visibleSteps.includes(stepId)
|
|
? visibleSteps.filter((id) => id !== stepId)
|
|
: [...visibleSteps, stepId];
|
|
setVisibleSteps(newSteps);
|
|
// 🆕 새 상태값을 직접 전달하여 즉시 저장
|
|
applyConfig({ visibleSteps: newSteps });
|
|
} else if (mode === "blacklist") {
|
|
const newSteps = hiddenSteps.includes(stepId)
|
|
? hiddenSteps.filter((id) => id !== stepId)
|
|
: [...hiddenSteps, stepId];
|
|
setHiddenSteps(newSteps);
|
|
// 🆕 새 상태값을 직접 전달하여 즉시 저장
|
|
applyConfig({ hiddenSteps: newSteps });
|
|
}
|
|
};
|
|
|
|
// 빠른 선택
|
|
const selectAll = () => {
|
|
if (mode === "whitelist") {
|
|
const newSteps = flowSteps.map((s) => s.id);
|
|
setVisibleSteps(newSteps);
|
|
applyConfig({ visibleSteps: newSteps });
|
|
} else if (mode === "blacklist") {
|
|
setHiddenSteps([]);
|
|
applyConfig({ hiddenSteps: [] });
|
|
}
|
|
};
|
|
|
|
const selectNone = () => {
|
|
if (mode === "whitelist") {
|
|
setVisibleSteps([]);
|
|
applyConfig({ visibleSteps: [] });
|
|
} else if (mode === "blacklist") {
|
|
const newSteps = flowSteps.map((s) => s.id);
|
|
setHiddenSteps(newSteps);
|
|
applyConfig({ hiddenSteps: newSteps });
|
|
}
|
|
};
|
|
|
|
const invertSelection = () => {
|
|
if (mode === "whitelist") {
|
|
const allStepIds = flowSteps.map((s) => s.id);
|
|
const newSteps = allStepIds.filter((id) => !visibleSteps.includes(id));
|
|
setVisibleSteps(newSteps);
|
|
applyConfig({ visibleSteps: newSteps });
|
|
} else if (mode === "blacklist") {
|
|
const allStepIds = flowSteps.map((s) => s.id);
|
|
const newSteps = allStepIds.filter((id) => !hiddenSteps.includes(id));
|
|
setHiddenSteps(newSteps);
|
|
applyConfig({ hiddenSteps: newSteps });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Workflow className="h-4 w-4" />
|
|
플로우 단계별 표시 설정
|
|
</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-4">
|
|
{/* 활성화 체크박스 */}
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="flow-control-enabled"
|
|
checked={enabled}
|
|
onCheckedChange={(checked) => {
|
|
setEnabled(!!checked);
|
|
setTimeout(() => applyConfig(), 0);
|
|
}}
|
|
/>
|
|
<Label htmlFor="flow-control-enabled" className="text-sm font-medium">
|
|
플로우 단계에 따라 버튼 표시 제어
|
|
</Label>
|
|
</div>
|
|
|
|
{enabled && (
|
|
<>
|
|
{/* 대상 플로우 선택 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">대상 플로우</Label>
|
|
<Select
|
|
value={selectedFlowComponentId || ""}
|
|
onValueChange={(value) => {
|
|
setSelectedFlowComponentId(value);
|
|
setTimeout(() => applyConfig(), 0);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue placeholder="플로우 위젯 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{flowWidgets.map((fw) => {
|
|
const flowConfig = (fw as any).componentConfig || {};
|
|
const flowName = flowConfig.flowName || `플로우 ${fw.id}`;
|
|
return (
|
|
<SelectItem key={fw.id} value={fw.id}>
|
|
{flowName}
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 플로우가 선택되면 스텝 목록 표시 */}
|
|
{selectedFlowComponentId && flowSteps.length > 0 && (
|
|
<>
|
|
{/* 모드 선택 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">표시 모드</Label>
|
|
<RadioGroup
|
|
value={mode}
|
|
onValueChange={(value: any) => {
|
|
setMode(value);
|
|
setTimeout(() => applyConfig(), 0);
|
|
}}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="whitelist" id="mode-whitelist" />
|
|
<Label htmlFor="mode-whitelist" className="text-sm font-normal">
|
|
화이트리스트 (선택한 단계에서만 표시)
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="blacklist" id="mode-blacklist" />
|
|
<Label htmlFor="mode-blacklist" className="text-sm font-normal">
|
|
블랙리스트 (선택한 단계에서 숨김)
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="all" id="mode-all" />
|
|
<Label htmlFor="mode-all" className="text-sm font-normal">
|
|
모든 단계에서 표시
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
{/* 단계 선택 (all 모드가 아닐 때만) */}
|
|
{mode !== "all" && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-sm font-medium">
|
|
{mode === "whitelist" ? "표시할 단계" : "숨길 단계"}
|
|
</Label>
|
|
<div className="flex gap-1">
|
|
<Button variant="ghost" size="sm" onClick={selectAll} className="h-7 px-2 text-xs">
|
|
모두 선택
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={selectNone} className="h-7 px-2 text-xs">
|
|
모두 해제
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={invertSelection} className="h-7 px-2 text-xs">
|
|
반전
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 스텝 체크박스 목록 */}
|
|
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
|
|
{flowSteps.map((step) => {
|
|
const isChecked =
|
|
mode === "whitelist"
|
|
? visibleSteps.includes(step.id)
|
|
: hiddenSteps.includes(step.id);
|
|
|
|
return (
|
|
<div key={step.id} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`step-${step.id}`}
|
|
checked={isChecked}
|
|
onCheckedChange={() => toggleStep(step.id)}
|
|
/>
|
|
<Label htmlFor={`step-${step.id}`} className="flex flex-1 items-center gap-2 text-sm">
|
|
<Badge variant="outline" className="text-xs">
|
|
Step {step.stepOrder}
|
|
</Badge>
|
|
<span>{step.stepName}</span>
|
|
{isChecked && (
|
|
<CheckCircle className={`ml-auto h-4 w-4 ${mode === "whitelist" ? "text-green-500" : "text-red-500"}`} />
|
|
)}
|
|
</Label>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 레이아웃 옵션 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">레이아웃 동작</Label>
|
|
<RadioGroup
|
|
value={layoutBehavior}
|
|
onValueChange={(value: any) => {
|
|
setLayoutBehavior(value);
|
|
setTimeout(() => applyConfig(), 0);
|
|
}}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="preserve-position" id="layout-preserve" />
|
|
<Label htmlFor="layout-preserve" className="text-sm font-normal">
|
|
원래 위치 유지 (빈 공간 가능)
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="auto-compact" id="layout-compact" />
|
|
<Label htmlFor="layout-compact" className="text-sm font-normal">
|
|
자동 정렬 (빈 공간 제거) ⭐ 권장
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
{/* 🆕 그룹 설정 (auto-compact 모드일 때만 표시) */}
|
|
{layoutBehavior === "auto-compact" && (
|
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary" className="text-xs">
|
|
그룹 설정
|
|
</Badge>
|
|
<p className="text-xs text-muted-foreground">
|
|
같은 그룹 ID를 가진 버튼들이 자동으로 정렬됩니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* 그룹 ID */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="group-id" className="text-sm font-medium">
|
|
그룹 ID
|
|
</Label>
|
|
<Input
|
|
id="group-id"
|
|
value={groupId}
|
|
onChange={(e) => setGroupId(e.target.value)}
|
|
placeholder="group-1"
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
같은 그룹 ID를 가진 버튼들이 하나의 그룹으로 묶입니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* 정렬 방향 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm font-medium">정렬 방향</Label>
|
|
<RadioGroup
|
|
value={groupDirection}
|
|
onValueChange={(value: any) => {
|
|
setGroupDirection(value);
|
|
setTimeout(() => applyConfig(), 0);
|
|
}}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="horizontal" id="direction-horizontal" />
|
|
<Label htmlFor="direction-horizontal" className="flex items-center gap-2 text-sm font-normal">
|
|
<ArrowRight className="h-4 w-4" />
|
|
가로 정렬
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="vertical" id="direction-vertical" />
|
|
<Label htmlFor="direction-vertical" className="flex items-center gap-2 text-sm font-normal">
|
|
<ArrowDown className="h-4 w-4" />
|
|
세로 정렬
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
{/* 버튼 간격 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="group-gap" className="text-sm font-medium">
|
|
버튼 간격 (px)
|
|
</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
id="group-gap"
|
|
type="number"
|
|
min={0}
|
|
max={100}
|
|
value={groupGap}
|
|
onChange={(e) => {
|
|
setGroupGap(Number(e.target.value));
|
|
setTimeout(() => applyConfig(), 0);
|
|
}}
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
|
/>
|
|
<Badge variant="outline" className="text-xs">
|
|
{groupGap}px
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 정렬 방식 */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="group-align" className="text-sm font-medium">
|
|
정렬 방식
|
|
</Label>
|
|
<Select
|
|
value={groupAlign}
|
|
onValueChange={(value: any) => {
|
|
setGroupAlign(value);
|
|
setTimeout(() => applyConfig(), 0);
|
|
}}
|
|
>
|
|
<SelectTrigger id="group-align" className="h-8 text-xs sm:h-9 sm:text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="start">시작점 정렬</SelectItem>
|
|
<SelectItem value="center">중앙 정렬</SelectItem>
|
|
<SelectItem value="end">끝점 정렬</SelectItem>
|
|
<SelectItem value="space-between">양 끝 정렬</SelectItem>
|
|
<SelectItem value="space-around">균등 배분</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 미리보기 */}
|
|
<Alert>
|
|
<Info className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">
|
|
{mode === "whitelist" && visibleSteps.length > 0 && (
|
|
<div>
|
|
<p className="font-medium">표시 단계:</p>
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
{visibleSteps.map((stepId) => {
|
|
const step = flowSteps.find((s) => s.id === stepId);
|
|
return (
|
|
<Badge key={stepId} variant="secondary" className="text-xs">
|
|
{step?.stepName || `Step ${stepId}`}
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{mode === "blacklist" && hiddenSteps.length > 0 && (
|
|
<div>
|
|
<p className="font-medium">숨김 단계:</p>
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
{hiddenSteps.map((stepId) => {
|
|
const step = flowSteps.find((s) => s.id === stepId);
|
|
return (
|
|
<Badge key={stepId} variant="destructive" className="text-xs">
|
|
{step?.stepName || `Step ${stepId}`}
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{mode === "all" && <p>이 버튼은 모든 단계에서 표시됩니다.</p>}
|
|
{mode === "whitelist" && visibleSteps.length === 0 && <p>표시할 단계를 선택해주세요.</p>}
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
{/* 🆕 자동 저장 안내 */}
|
|
<Alert className="border-green-200 bg-green-50">
|
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
|
<AlertDescription className="text-xs text-green-800">
|
|
설정이 자동으로 저장됩니다. 화면 저장 시 함께 적용됩니다.
|
|
</AlertDescription>
|
|
</Alert>
|
|
</>
|
|
)}
|
|
|
|
{/* 플로우 선택 안내 */}
|
|
{selectedFlowComponentId && flowSteps.length === 0 && !loading && (
|
|
<Alert variant="destructive">
|
|
<XCircle className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">선택한 플로우에 단계가 없습니다.</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-4">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
<span className="text-muted-foreground text-xs">플로우 정보를 불러오는 중...</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|