Merge: 충돌 해결 - CustomMetricWidget 실제 코드 유지
This commit is contained in:
commit
d21764ba51
|
|
@ -449,7 +449,10 @@ export class BatchService {
|
|||
// 기존 배치 설정 확인 (회사 권한 체크 포함)
|
||||
const existing = await this.getBatchConfigById(id, userCompanyCode);
|
||||
if (!existing.success) {
|
||||
return existing as ApiResponse<void>;
|
||||
return {
|
||||
success: false,
|
||||
message: existing.message,
|
||||
};
|
||||
}
|
||||
|
||||
const existingConfig = await queryOne<any>(
|
||||
|
|
|
|||
|
|
@ -389,9 +389,9 @@ export function CanvasElement({
|
|||
let newX = resizeStart.elementX;
|
||||
let newY = resizeStart.elementY;
|
||||
|
||||
// 최소 크기 설정: 달력은 2x3, 나머지는 2x2
|
||||
const minWidthCells = 2;
|
||||
const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2;
|
||||
// 최소 크기 설정: 모든 위젯 1x1
|
||||
const minWidthCells = 1;
|
||||
const minHeightCells = 1;
|
||||
const minWidth = cellSize * minWidthCells;
|
||||
const minHeight = cellSize * minHeightCells;
|
||||
|
||||
|
|
@ -757,7 +757,7 @@ export function CanvasElement({
|
|||
<div
|
||||
ref={elementRef}
|
||||
data-element-id={element.id}
|
||||
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
||||
className={`absolute min-h-[120px] min-w-[120px] cursor-move overflow-hidden rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
||||
style={{
|
||||
left: displayPosition.x,
|
||||
top: displayPosition.y,
|
||||
|
|
@ -768,7 +768,7 @@ export function CanvasElement({
|
|||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex cursor-move items-center justify-between px-4 py-2">
|
||||
<div className="flex cursor-move items-center justify-between px-2 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 차트 타입 전환 드롭다운 (차트일 경우만) */}
|
||||
{element.type === "chart" && (
|
||||
|
|
@ -779,7 +779,7 @@ export function CanvasElement({
|
|||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-7 w-[140px] text-xs"
|
||||
className="h-6 w-[120px] text-[11px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
|
@ -808,7 +808,7 @@ export function CanvasElement({
|
|||
)}
|
||||
{/* 제목 */}
|
||||
{!element.type || element.type !== "chart" ? (
|
||||
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
|
||||
<span className="text-xs font-bold text-gray-800">{element.customTitle || element.title}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
|
|
@ -816,18 +816,18 @@ export function CanvasElement({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="element-close hover:bg-destructive h-6 w-6 text-gray-400 hover:text-white"
|
||||
className="element-close hover:bg-destructive h-5 w-5 text-gray-400 hover:text-white"
|
||||
onClick={handleRemove}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title="삭제"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="relative h-[calc(100%-50px)] px-4 pb-4">
|
||||
<div className="relative h-[calc(100%-32px)] px-2 pb-2">
|
||||
{element.type === "chart" ? (
|
||||
// 차트 렌더링
|
||||
<div className="h-full w-full bg-white">
|
||||
|
|
@ -843,7 +843,7 @@ export function CanvasElement({
|
|||
element={element}
|
||||
data={chartData || undefined}
|
||||
width={element.size.width}
|
||||
height={element.size.height - 45}
|
||||
height={element.size.height - 32}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -277,6 +277,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
setElements((prev) => [...prev, newElement]);
|
||||
setElementCounter((prev) => prev + 1);
|
||||
setSelectedElement(newElement.id);
|
||||
|
||||
// 새 요소 생성 시 자동으로 설정 사이드바 열기
|
||||
setSidebarElement(newElement);
|
||||
setSidebarOpen(true);
|
||||
},
|
||||
[elementCounter, canvasConfig],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -242,12 +242,12 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende
|
|||
// D3 차트 렌더링
|
||||
const actualWidth = width !== undefined ? width : containerWidth;
|
||||
|
||||
// 원형 차트는 더 큰 크기가 필요 (최소 400px)
|
||||
// 최소 크기 제약 완화 (1x1 위젯 지원)
|
||||
const isCircularChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const minWidth = isCircularChart ? 400 : 200;
|
||||
const finalWidth = Math.max(actualWidth - 20, minWidth);
|
||||
// 원형 차트는 범례 공간을 위해 더 많은 여백 필요
|
||||
const finalHeight = Math.max(height - (isCircularChart ? 60 : 20), 300);
|
||||
const minWidth = 35; // 최소 너비 35px
|
||||
const finalWidth = Math.max(actualWidth - 4, minWidth);
|
||||
// 최소 높이도 35px로 설정
|
||||
const finalHeight = Math.max(height - (isCircularChart ? 10 : 4), 35);
|
||||
|
||||
console.log("🎨 ChartRenderer:", {
|
||||
elementSubtype: element.subtype,
|
||||
|
|
@ -263,7 +263,7 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende
|
|||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center bg-white p-2">
|
||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center bg-white p-0.5">
|
||||
<div className="flex items-center justify-center">
|
||||
<Chart
|
||||
chartType={element.subtype}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa
|
|||
.attr("x", 0)
|
||||
.attr("y", 18)
|
||||
.attr("text-anchor", "middle") // 텍스트 중앙 정렬
|
||||
.style("font-size", "10px")
|
||||
.style("font-size", "8px")
|
||||
.style("fill", "#333")
|
||||
.text(`${d.label} (${d.value})`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP
|
|||
const timezoneLabel = timezone ? getTimezoneLabel(timezone) : "";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-2">
|
||||
<svg viewBox="0 0 200 200" className="h-full max-h-[200px] w-full max-w-[200px]">
|
||||
<div className="flex h-full flex-col items-center justify-center p-0.5">
|
||||
<svg viewBox="0 0 200 200" className="h-full w-full">
|
||||
{/* 시계판 배경 */}
|
||||
<circle cx="100" cy="100" r="98" fill={colors.background} stroke={colors.border} strokeWidth="2" />
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP
|
|||
y={y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="20"
|
||||
fontSize="16"
|
||||
fontWeight="bold"
|
||||
fill={colors.number}
|
||||
>
|
||||
|
|
@ -86,7 +86,7 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP
|
|||
x2={100 + 40 * Math.cos((hourAngle * Math.PI) / 180)}
|
||||
y2={100 + 40 * Math.sin((hourAngle * Math.PI) / 180)}
|
||||
stroke={colors.hourHand}
|
||||
strokeWidth="6"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP
|
|||
x2={100 + 60 * Math.cos((minuteAngle * Math.PI) / 180)}
|
||||
y2={100 + 60 * Math.sin((minuteAngle * Math.PI) / 180)}
|
||||
stroke={colors.minuteHand}
|
||||
strokeWidth="4"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
|
|
@ -108,18 +108,18 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP
|
|||
x2={100 + 75 * Math.cos((secondAngle * Math.PI) / 180)}
|
||||
y2={100 + 75 * Math.sin((secondAngle * Math.PI) / 180)}
|
||||
stroke={colors.secondHand}
|
||||
strokeWidth="2"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 중심점 */}
|
||||
<circle cx="100" cy="100" r="6" fill={colors.center} />
|
||||
<circle cx="100" cy="100" r="3" fill={colors.background} />
|
||||
<circle cx="100" cy="100" r="4" fill={colors.center} />
|
||||
<circle cx="100" cy="100" r="2" fill={colors.background} />
|
||||
</svg>
|
||||
|
||||
{/* 타임존 표시 */}
|
||||
{timezoneLabel && (
|
||||
<div className="mt-1 text-center text-xs font-medium" style={{ color: colors.number }}>
|
||||
<div className="mt-0 text-center text-[8px] font-medium" style={{ color: colors.number }}>
|
||||
{timezoneLabel}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -56,21 +56,21 @@ export function DigitalClock({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full flex-col items-center justify-center ${compact ? "p-1" : "p-4"} text-center ${themeClasses.container}`}
|
||||
className={`flex h-full flex-col items-center justify-center ${compact ? "p-0.5" : "p-2"} text-center ${themeClasses.container}`}
|
||||
style={themeClasses.style}
|
||||
>
|
||||
{/* 날짜 표시 (compact 모드에서는 숨김) */}
|
||||
{!compact && showDate && dateString && (
|
||||
<div className={`mb-3 text-sm font-medium ${themeClasses.date}`}>{dateString}</div>
|
||||
<div className={`mb-1 text-[10px] leading-tight font-medium ${themeClasses.date}`}>{dateString}</div>
|
||||
)}
|
||||
|
||||
{/* 시간 표시 */}
|
||||
<div className={`font-bold tabular-nums ${themeClasses.time} ${compact ? "text-xl" : "text-5xl"}`}>
|
||||
<div className={`leading-none font-bold tabular-nums ${themeClasses.time} ${compact ? "text-sm" : "text-2xl"}`}>
|
||||
{timeString}
|
||||
</div>
|
||||
|
||||
{/* 타임존 표시 */}
|
||||
<div className={`${compact ? "mt-0.5" : "mt-3"} text-xs font-medium ${themeClasses.timezone}`}>
|
||||
<div className={`${compact ? "mt-0" : "mt-1"} text-[9px] font-medium ${themeClasses.timezone}`}>
|
||||
{timezoneLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -94,24 +94,19 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
|
|||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
|
||||
// 쿼리 결과의 컬럼을 자동으로 listConfig.columns에 추가 (기존 컬럼은 유지)
|
||||
setListConfig((prev) => {
|
||||
const existingFields = prev.columns.map((col) => col.field);
|
||||
const newColumns = result.columns
|
||||
.filter((col) => !existingFields.includes(col))
|
||||
.map((col, idx) => ({
|
||||
id: `col_${Date.now()}_${idx}`,
|
||||
field: col,
|
||||
label: col,
|
||||
visible: true,
|
||||
align: "left" as const,
|
||||
}));
|
||||
// 쿼리 실행 시마다 컬럼 설정 초기화 (새로운 쿼리 결과로 덮어쓰기)
|
||||
const newColumns = result.columns.map((col, idx) => ({
|
||||
id: `col_${Date.now()}_${idx}`,
|
||||
field: col,
|
||||
label: col,
|
||||
visible: true,
|
||||
align: "left" as const,
|
||||
}));
|
||||
|
||||
return {
|
||||
...prev,
|
||||
columns: [...prev.columns, ...newColumns],
|
||||
};
|
||||
});
|
||||
setListConfig((prev) => ({
|
||||
...prev,
|
||||
columns: newColumns,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 컬럼 설정 변경
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ interface Yard3DCanvasProps {
|
|||
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
|
||||
gridSize?: number; // 그리드 크기 (기본값: 5)
|
||||
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
|
||||
focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID
|
||||
}
|
||||
|
||||
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
||||
|
|
@ -467,6 +468,75 @@ function MaterialBox({
|
|||
}
|
||||
|
||||
// 3D 씬 컴포넌트
|
||||
// 카메라 포커스 컨트롤러
|
||||
function CameraFocusController({
|
||||
focusOnPlacementId,
|
||||
placements,
|
||||
orbitControlsRef,
|
||||
}: {
|
||||
focusOnPlacementId?: number | null;
|
||||
placements: YardPlacement[];
|
||||
orbitControlsRef: React.RefObject<any>;
|
||||
}) {
|
||||
const { camera } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
console.log("🎥 CameraFocusController triggered");
|
||||
console.log(" - focusOnPlacementId:", focusOnPlacementId);
|
||||
console.log(" - orbitControlsRef.current:", orbitControlsRef.current);
|
||||
console.log(" - placements count:", placements.length);
|
||||
|
||||
if (focusOnPlacementId && orbitControlsRef.current) {
|
||||
const targetPlacement = placements.find((p) => p.id === focusOnPlacementId);
|
||||
console.log(" - targetPlacement:", targetPlacement);
|
||||
|
||||
if (targetPlacement) {
|
||||
console.log("✅ Starting camera animation to:", targetPlacement.material_name || targetPlacement.id);
|
||||
|
||||
const controls = orbitControlsRef.current;
|
||||
const targetPosition = new THREE.Vector3(
|
||||
targetPlacement.position_x,
|
||||
targetPlacement.position_y,
|
||||
targetPlacement.position_z,
|
||||
);
|
||||
|
||||
// 카메라 위치 계산 (요소 위에서 약간 비스듬히)
|
||||
const cameraOffset = new THREE.Vector3(15, 15, 15);
|
||||
const newCameraPosition = targetPosition.clone().add(cameraOffset);
|
||||
|
||||
// 부드러운 애니메이션으로 카메라 이동
|
||||
const startPos = camera.position.clone();
|
||||
const startTarget = controls.target.clone();
|
||||
const duration = 1000; // 1초
|
||||
const startTime = Date.now();
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// easeInOutCubic 이징 함수
|
||||
const eased = progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2;
|
||||
|
||||
// 카메라 위치 보간
|
||||
camera.position.lerpVectors(startPos, newCameraPosition, eased);
|
||||
|
||||
// 카메라 타겟 보간
|
||||
controls.target.lerpVectors(startTarget, targetPosition, eased);
|
||||
controls.update();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
}
|
||||
}, [focusOnPlacementId, placements, camera, orbitControlsRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Scene({
|
||||
placements,
|
||||
selectedPlacementId,
|
||||
|
|
@ -474,12 +544,20 @@ function Scene({
|
|||
onPlacementDrag,
|
||||
gridSize = 5,
|
||||
onCollisionDetected,
|
||||
focusOnPlacementId,
|
||||
}: Yard3DCanvasProps) {
|
||||
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
||||
const orbitControlsRef = useRef<any>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 카메라 포커스 컨트롤러 */}
|
||||
<CameraFocusController
|
||||
focusOnPlacementId={focusOnPlacementId}
|
||||
placements={placements}
|
||||
orbitControlsRef={orbitControlsRef}
|
||||
/>
|
||||
|
||||
{/* 조명 */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
|
|
@ -551,6 +629,7 @@ export default function Yard3DCanvas({
|
|||
onPlacementDrag,
|
||||
gridSize = 5,
|
||||
onCollisionDetected,
|
||||
focusOnPlacementId,
|
||||
}: Yard3DCanvasProps) {
|
||||
const handleCanvasClick = (e: any) => {
|
||||
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
||||
|
|
@ -577,6 +656,7 @@ export default function Yard3DCanvas({
|
|||
onPlacementDrag={onPlacementDrag}
|
||||
gridSize={gridSize}
|
||||
onCollisionDetected={onCollisionDetected}
|
||||
focusOnPlacementId={focusOnPlacementId}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
const [placements, setPlacements] = useState<YardPlacement[]>([]);
|
||||
const [originalPlacements, setOriginalPlacements] = useState<YardPlacement[]>([]); // 원본 데이터 보관
|
||||
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
|
||||
const [focusPlacementId, setFocusPlacementId] = useState<number | null>(null); // 카메라 포커스할 요소 ID
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showConfigPanel, setShowConfigPanel] = useState(false);
|
||||
|
|
@ -203,9 +204,30 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
};
|
||||
|
||||
// 요소 선택 (3D 캔버스 또는 목록에서)
|
||||
const handleSelectPlacement = (placement: YardPlacement) => {
|
||||
const handleSelectPlacement = (placement: YardPlacement | null) => {
|
||||
console.log("📍 handleSelectPlacement called with:", placement);
|
||||
|
||||
if (!placement) {
|
||||
// 빈 공간 클릭 시 선택 해제
|
||||
console.log(" → Deselecting (null placement)");
|
||||
setSelectedPlacement(null);
|
||||
setShowConfigPanel(false);
|
||||
setFocusPlacementId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(" → Selecting placement:", placement.id, placement.material_name);
|
||||
setSelectedPlacement(placement);
|
||||
setShowConfigPanel(false); // 선택 시에는 설정 패널 닫기
|
||||
|
||||
console.log(" → Setting focusPlacementId to:", placement.id);
|
||||
setFocusPlacementId(placement.id); // 카메라 포커스
|
||||
|
||||
// 카메라 애니메이션 완료 후 focusPlacementId 초기화 (재클릭 시 다시 포커스 가능)
|
||||
setTimeout(() => {
|
||||
console.log(" → Clearing focusPlacementId");
|
||||
setFocusPlacementId(null);
|
||||
}, 1100); // 애니메이션 시간(1000ms)보다 약간 길게
|
||||
};
|
||||
|
||||
// 설정 버튼 클릭
|
||||
|
|
@ -500,8 +522,9 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
<Yard3DCanvas
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedPlacement?.id || null}
|
||||
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)}
|
||||
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement | null)}
|
||||
onPlacementDrag={handlePlacementDrag}
|
||||
focusOnPlacementId={focusPlacementId}
|
||||
onCollisionDetected={() => {
|
||||
toast({
|
||||
title: "배치 불가",
|
||||
|
|
|
|||
|
|
@ -400,8 +400,8 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
style={{ minHeight: "300px" }}
|
||||
>
|
||||
{element.showHeader !== false && (
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{element.customTitle || element.title}</h3>
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<h3 className="text-xs font-semibold text-gray-800">{element.customTitle || element.title}</h3>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
|
|
@ -409,7 +409,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
title="새로고침"
|
||||
>
|
||||
<svg
|
||||
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
|
|
@ -424,7 +424,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={element.showHeader !== false ? "p-4" : "p-4"} style={{ minHeight: "250px" }}>
|
||||
<div className={element.showHeader !== false ? "p-2" : "p-2"} style={{ minHeight: "250px" }}>
|
||||
{!isMounted ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
|
|
@ -462,8 +462,8 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
}}
|
||||
>
|
||||
{element.showHeader !== false && (
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{element.customTitle || element.title}</h3>
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<h3 className="text-xs font-semibold text-gray-800">{element.customTitle || element.title}</h3>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
|
|
@ -471,7 +471,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
title="새로고침"
|
||||
>
|
||||
<svg
|
||||
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
|
|
@ -486,7 +486,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={element.showHeader !== false ? "h-[calc(100%-50px)] w-full" : "h-full w-full"}>
|
||||
<div className={element.showHeader !== false ? "h-[calc(100%-32px)] w-full" : "h-full w-full"}>
|
||||
{!isMounted ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
|
|
@ -496,7 +496,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
element={element}
|
||||
data={data}
|
||||
width={undefined}
|
||||
height={element.showHeader !== false ? element.size.height - 50 : element.size.height}
|
||||
height={element.showHeader !== false ? element.size.height - 32 : element.size.height}
|
||||
/>
|
||||
) : (
|
||||
renderWidget(element)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,425 @@
|
|||
/*
|
||||
* ⚠️ DEPRECATED - 이 위젯은 더 이상 사용되지 않습니다.
|
||||
*
|
||||
* 이 파일은 2025-10-28에 주석 처리되었습니다.
|
||||
* 새로운 버전: CustomMetricTestWidget.tsx (subtype: custom-metric-v2)
|
||||
*
|
||||
* 변경 이유:
|
||||
* - 다중 데이터 소스 지원 (REST API + Database 혼합)
|
||||
* - 컬럼 매핑 기능 추가
|
||||
* - 자동 새로고침 간격 설정 가능
|
||||
* - 상세 정보 모달 (클릭 시 원본 데이터 표시)
|
||||
* - Group By Mode 지원
|
||||
*
|
||||
* 이 파일은 복구를 위해 보관 중이며,
|
||||
* 향후 문제 발생 시 참고용으로 사용될 수 있습니다.
|
||||
*
|
||||
* 롤백 방법:
|
||||
* 1. 이 파일의 주석 제거
|
||||
* 2. types.ts에서 "custom-metric" 활성화
|
||||
* 3. "custom-metric-v2" 주석 처리
|
||||
*/
|
||||
"use client";
|
||||
|
||||
// "use client";
|
||||
//
|
||||
// ... (전체 코드 주석 처리됨)
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
||||
interface CustomMetricWidgetProps {
|
||||
element?: DashboardElement;
|
||||
}
|
||||
|
||||
// 집계 함수 실행
|
||||
const calculateMetric = (rows: any[], field: string, aggregation: string): number => {
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
switch (aggregation) {
|
||||
case "count":
|
||||
return rows.length;
|
||||
case "sum": {
|
||||
return rows.reduce((sum, row) => sum + (parseFloat(row[field]) || 0), 0);
|
||||
}
|
||||
case "avg": {
|
||||
const sum = rows.reduce((s, row) => s + (parseFloat(row[field]) || 0), 0);
|
||||
return rows.length > 0 ? sum / rows.length : 0;
|
||||
}
|
||||
case "min": {
|
||||
return Math.min(...rows.map((row) => parseFloat(row[field]) || 0));
|
||||
}
|
||||
case "max": {
|
||||
return Math.max(...rows.map((row) => parseFloat(row[field]) || 0));
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 색상 스타일 매핑
|
||||
const colorMap = {
|
||||
indigo: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-200" },
|
||||
green: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200" },
|
||||
blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" },
|
||||
purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" },
|
||||
orange: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200" },
|
||||
gray: { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" },
|
||||
};
|
||||
|
||||
export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
|
||||
const [metrics, setMetrics] = useState<any[]>([]);
|
||||
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [element]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 그룹별 카드 데이터 로드
|
||||
if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) {
|
||||
await loadGroupByData();
|
||||
}
|
||||
|
||||
// 일반 지표 데이터 로드
|
||||
if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) {
|
||||
await loadMetricsData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("데이터 로드 실패:", err);
|
||||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 그룹별 카드 데이터 로드
|
||||
const loadGroupByData = async () => {
|
||||
const groupByDS = element?.customMetricConfig?.groupByDataSource;
|
||||
if (!groupByDS) return;
|
||||
|
||||
const dataSourceType = groupByDS.type;
|
||||
|
||||
// Database 타입
|
||||
if (dataSourceType === "database") {
|
||||
if (!groupByDS.query) return;
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: groupByDS.query,
|
||||
connectionType: groupByDS.connectionType || "current",
|
||||
connectionId: (groupByDS as any).connectionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("그룹별 카드 데이터 로딩 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data?.rows) {
|
||||
const rows = result.data.rows;
|
||||
if (rows.length > 0) {
|
||||
const columns = result.data.columns || Object.keys(rows[0]);
|
||||
const labelColumn = columns[0];
|
||||
const valueColumn = columns[1];
|
||||
|
||||
const cards = rows.map((row: any) => ({
|
||||
label: String(row[labelColumn] || ""),
|
||||
value: parseFloat(row[valueColumn]) || 0,
|
||||
}));
|
||||
|
||||
setGroupedCards(cards);
|
||||
}
|
||||
}
|
||||
}
|
||||
// API 타입
|
||||
else if (dataSourceType === "api") {
|
||||
if (!groupByDS.endpoint) return;
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
method: (groupByDS as any).method || "GET",
|
||||
url: groupByDS.endpoint,
|
||||
headers: (groupByDS as any).headers || {},
|
||||
body: (groupByDS as any).body,
|
||||
authType: (groupByDS as any).authType,
|
||||
authConfig: (groupByDS as any).authConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("그룹별 카드 API 호출 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
let rows: any[] = [];
|
||||
if (Array.isArray(result.data)) {
|
||||
rows = result.data;
|
||||
} else if (result.data.results && Array.isArray(result.data.results)) {
|
||||
rows = result.data.results;
|
||||
} else if (result.data.items && Array.isArray(result.data.items)) {
|
||||
rows = result.data.items;
|
||||
} else if (result.data.data && Array.isArray(result.data.data)) {
|
||||
rows = result.data.data;
|
||||
} else {
|
||||
rows = [result.data];
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
const labelColumn = columns[0];
|
||||
const valueColumn = columns[1];
|
||||
|
||||
const cards = rows.map((row: any) => ({
|
||||
label: String(row[labelColumn] || ""),
|
||||
value: parseFloat(row[valueColumn]) || 0,
|
||||
}));
|
||||
|
||||
setGroupedCards(cards);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 일반 지표 데이터 로드
|
||||
const loadMetricsData = async () => {
|
||||
const dataSourceType = element?.dataSource?.type;
|
||||
|
||||
// Database 타입
|
||||
if (dataSourceType === "database") {
|
||||
if (!element?.dataSource?.query) {
|
||||
setMetrics([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: element.dataSource.query,
|
||||
connectionType: element.dataSource.connectionType || "current",
|
||||
connectionId: (element.dataSource as any).connectionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("데이터 로딩 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data?.rows) {
|
||||
const rows = result.data.rows;
|
||||
|
||||
const calculatedMetrics =
|
||||
element.customMetricConfig?.metrics.map((metric) => {
|
||||
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
||||
return {
|
||||
...metric,
|
||||
calculatedValue: value,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
setMetrics(calculatedMetrics);
|
||||
} else {
|
||||
throw new Error(result.message || "데이터 로드 실패");
|
||||
}
|
||||
}
|
||||
// API 타입
|
||||
else if (dataSourceType === "api") {
|
||||
if (!element?.dataSource?.endpoint) {
|
||||
setMetrics([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
method: (element.dataSource as any).method || "GET",
|
||||
url: element.dataSource.endpoint,
|
||||
headers: (element.dataSource as any).headers || {},
|
||||
body: (element.dataSource as any).body,
|
||||
authType: (element.dataSource as any).authType,
|
||||
authConfig: (element.dataSource as any).authConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("API 호출 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
// API 응답 데이터 구조 확인 및 처리
|
||||
let rows: any[] = [];
|
||||
|
||||
// result.data가 배열인 경우
|
||||
if (Array.isArray(result.data)) {
|
||||
rows = result.data;
|
||||
}
|
||||
// result.data.results가 배열인 경우 (일반적인 API 응답 구조)
|
||||
else if (result.data.results && Array.isArray(result.data.results)) {
|
||||
rows = result.data.results;
|
||||
}
|
||||
// result.data.items가 배열인 경우
|
||||
else if (result.data.items && Array.isArray(result.data.items)) {
|
||||
rows = result.data.items;
|
||||
}
|
||||
// result.data.data가 배열인 경우
|
||||
else if (result.data.data && Array.isArray(result.data.data)) {
|
||||
rows = result.data.data;
|
||||
}
|
||||
// 그 외의 경우 단일 객체를 배열로 래핑
|
||||
else {
|
||||
rows = [result.data];
|
||||
}
|
||||
|
||||
const calculatedMetrics =
|
||||
element.customMetricConfig?.metrics.map((metric) => {
|
||||
const value = calculateMetric(rows, metric.field, metric.aggregation);
|
||||
return {
|
||||
...metric,
|
||||
calculatedValue: value,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
setMetrics(calculatedMetrics);
|
||||
} else {
|
||||
throw new Error("API 응답 형식 오류");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-gray-500">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-red-600">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 소스 체크
|
||||
const hasMetricsDataSource =
|
||||
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
|
||||
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
|
||||
|
||||
const hasGroupByDataSource =
|
||||
isGroupByMode &&
|
||||
element?.customMetricConfig?.groupByDataSource &&
|
||||
((element.customMetricConfig.groupByDataSource.type === "database" &&
|
||||
element.customMetricConfig.groupByDataSource.query) ||
|
||||
(element.customMetricConfig.groupByDataSource.type === "api" &&
|
||||
element.customMetricConfig.groupByDataSource.endpoint));
|
||||
|
||||
const hasMetricsConfig = element?.customMetricConfig?.metrics && element.customMetricConfig.metrics.length > 0;
|
||||
|
||||
// 둘 다 없으면 빈 화면 표시
|
||||
const shouldShowEmpty =
|
||||
(!hasGroupByDataSource && !hasMetricsConfig) || (!hasGroupByDataSource && !hasMetricsDataSource);
|
||||
|
||||
if (shouldShowEmpty) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white p-4">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<h3 className="text-sm font-bold text-gray-900">사용자 커스텀 카드</h3>
|
||||
<div className="space-y-1.5 text-xs text-gray-600">
|
||||
<p className="font-medium">📊 맞춤형 지표 위젯</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• SQL 쿼리로 데이터를 불러옵니다</li>
|
||||
<li>• 선택한 컬럼의 데이터로 지표를 계산합니다</li>
|
||||
<li>• COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원</li>
|
||||
<li>• 사용자 정의 단위 설정 가능</li>
|
||||
<li>
|
||||
• <strong>그룹별 카드 생성 모드</strong>로 간편하게 사용 가능
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||
<p className="font-medium">⚙️ 설정 방법</p>
|
||||
<p className="mb-1">
|
||||
{isGroupByMode
|
||||
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
|
||||
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
|
||||
</p>
|
||||
{isGroupByMode && <p className="text-[9px]">💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 위젯 높이에 따라 레이아웃 결정 (세로 1칸이면 가로, 2칸 이상이면 세로)
|
||||
// 실제 측정된 1칸 높이: 119px
|
||||
const isHorizontalLayout = element?.size?.height && element.size.height <= 130; // 1칸 여유 (119px + 약간의 마진)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full w-full overflow-hidden bg-white p-0.5 ${
|
||||
isHorizontalLayout ? "flex-row gap-0.5" : "flex-col gap-0.5"
|
||||
}`}
|
||||
>
|
||||
{/* 그룹별 카드 (활성화 시) */}
|
||||
{isGroupByMode &&
|
||||
groupedCards.map((card, index) => {
|
||||
// 색상 순환 (6가지 색상)
|
||||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
||||
const colorKey = colorKeys[index % colorKeys.length];
|
||||
const colors = colorMap[colorKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`group-${index}`}
|
||||
className={`flex flex-1 flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
|
||||
>
|
||||
<div className="text-[8px] leading-tight text-gray-600">{card.label}</div>
|
||||
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 일반 지표 카드 (항상 표시) */}
|
||||
{metrics.map((metric) => {
|
||||
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
||||
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={`flex flex-1 flex-col items-center justify-center rounded border ${colors.bg} ${colors.border} p-0.5`}
|
||||
>
|
||||
<div className="text-[8px] leading-tight text-gray-600">{metric.label}</div>
|
||||
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>
|
||||
{formattedValue}
|
||||
<span className="ml-0 text-[8px]">{metric.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue