Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs
2025-10-13 19:17:44 +09:00
14 changed files with 2580 additions and 227 deletions

View File

@@ -1,10 +1,22 @@
"use client";
import React, { useState, useCallback, useRef, useEffect } from "react";
import dynamic from "next/dynamic";
import { DashboardElement, QueryResult } from "./types";
import { ChartRenderer } from "./charts/ChartRenderer";
import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils";
// 위젯 동적 임포트
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), {
ssr: false,
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
interface CanvasElementProps {
element: DashboardElement;
isSelected: boolean;
@@ -330,16 +342,27 @@ export function CanvasElement({
/>
)}
</div>
) : element.type === "widget" && element.subtype === "weather" ? (
// 날씨 위젯 렌더링
<div className="h-full w-full">
<WeatherWidget city={element.config?.city || "서울"} refreshInterval={600000} />
</div>
) : element.type === "widget" && element.subtype === "exchange" ? (
// 환율 위젯 렌더링
<div className="h-full w-full">
<ExchangeWidget
baseCurrency={element.config?.baseCurrency || "KRW"}
targetCurrency={element.config?.targetCurrency || "USD"}
refreshInterval={600000}
/>
</div>
) : (
// 위젯 렌더링 (기존 방식)
// 기타 위젯 렌더링
<div
className={`flex h-full w-full items-center justify-center p-5 text-center text-sm font-medium text-white ${getContentClass()} `}
>
<div>
<div className="mb-2 text-4xl">
{element.type === "widget" && element.subtype === "exchange" && "💱"}
{element.type === "widget" && element.subtype === "weather" && "☁️"}
</div>
<div className="mb-2 text-4xl">🔧</div>
<div className="whitespace-pre-line">{element.content}</div>
</div>
</div>

View File

@@ -0,0 +1,237 @@
'use client';
/**
* 환율 위젯 컴포넌트
* - 실시간 환율 정보를 표시
* - 한국은행(BOK) API 연동
*/
import React, { useEffect, useState } from 'react';
import { getExchangeRate, ExchangeRateData } from '@/lib/api/openApi';
import { TrendingUp, TrendingDown, RefreshCw, ArrowRightLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface ExchangeWidgetProps {
baseCurrency?: string;
targetCurrency?: string;
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
}
export default function ExchangeWidget({
baseCurrency = 'KRW',
targetCurrency = 'USD',
refreshInterval = 600000,
}: ExchangeWidgetProps) {
const [base, setBase] = useState(baseCurrency);
const [target, setTarget] = useState(targetCurrency);
const [exchangeRate, setExchangeRate] = useState<ExchangeRateData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
// 지원 통화 목록
const currencies = [
{ value: 'KRW', label: '🇰🇷 KRW (원)', symbol: '₩' },
{ value: 'USD', label: '🇺🇸 USD (달러)', symbol: '$' },
{ value: 'EUR', label: '🇪🇺 EUR (유로)', symbol: '€' },
{ value: 'JPY', label: '🇯🇵 JPY (엔)', symbol: '¥' },
{ value: 'CNY', label: '🇨🇳 CNY (위안)', symbol: '¥' },
{ value: 'GBP', label: '🇬🇧 GBP (파운드)', symbol: '£' },
];
// 환율 조회
const fetchExchangeRate = async () => {
try {
setError(null);
setLoading(true);
const data = await getExchangeRate(base, target);
setExchangeRate(data);
setLastUpdated(new Date());
} catch (err: any) {
console.error('환율 조회 실패:', err);
let errorMessage = '환율 정보를 가져오는 중 오류가 발생했습니다.';
if (err.response?.status === 503) {
errorMessage = 'API 키가 설정되지 않았습니다. 관리자에게 문의하세요.';
} else if (err.response?.status === 401) {
errorMessage = 'API 키가 유효하지 않습니다.';
} else if (err.response?.data?.message) {
errorMessage = err.response.data.message;
}
setError(errorMessage);
} finally {
setLoading(false);
}
};
// 초기 로딩 및 자동 새로고침
useEffect(() => {
fetchExchangeRate();
const interval = setInterval(fetchExchangeRate, refreshInterval);
return () => clearInterval(interval);
}, [base, target, refreshInterval]);
// 통화 스왑
const handleSwap = () => {
setBase(target);
setTarget(base);
};
// 통화 기호 가져오기
const getCurrencySymbol = (currency: string) => {
return currencies.find((c) => c.value === currency)?.symbol || currency;
};
// 로딩 상태
if (loading && !exchangeRate) {
return (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
<div className="flex flex-col items-center gap-2">
<RefreshCw className="h-8 w-8 animate-spin text-green-500" />
<p className="text-sm text-gray-600"> ...</p>
</div>
</div>
);
}
// 에러 상태
if (error || !exchangeRate) {
return (
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
<TrendingDown className="h-12 w-12 text-gray-400 mb-2" />
<p className="text-sm text-gray-600 text-center mb-3">{error || '환율 정보를 불러올 수 없습니다.'}</p>
<Button
variant="outline"
size="sm"
onClick={fetchExchangeRate}
className="gap-1"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
);
}
return (
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
{/* 헤더 */}
<div className="flex items-center justify-between mb-4">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-1">💱 </h3>
<p className="text-xs text-gray-500">
{lastUpdated
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
})}`
: ''}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={fetchExchangeRate}
disabled={loading}
className="h-8 w-8 p-0"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 통화 선택 */}
<div className="flex items-center gap-2 mb-6">
<Select value={base} onValueChange={setBase}>
<SelectTrigger className="flex-1 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((currency) => (
<SelectItem key={currency.value} value={currency.value}>
{currency.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
onClick={handleSwap}
className="h-10 w-10 p-0 rounded-full hover:bg-white"
>
<ArrowRightLeft className="h-4 w-4" />
</Button>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger className="flex-1 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((currency) => (
<SelectItem key={currency.value} value={currency.value}>
{currency.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 환율 표시 */}
<div className="bg-white rounded-lg border p-4 mb-4">
<div className="text-center">
<div className="text-sm text-gray-600 mb-2">
{exchangeRate.base === 'KRW' ? '1,000' : '1'} {getCurrencySymbol(exchangeRate.base)} =
</div>
<div className="text-3xl font-bold text-gray-900 mb-1">
{exchangeRate.base === 'KRW'
? (exchangeRate.rate * 1000).toLocaleString('ko-KR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: exchangeRate.rate.toLocaleString('ko-KR', {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
})}
</div>
<div className="text-sm text-gray-600">{getCurrencySymbol(exchangeRate.target)}</div>
</div>
</div>
{/* 계산 예시 */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-lg border p-3">
<div className="text-xs text-gray-500 mb-1">10,000 {base}</div>
<div className="text-lg font-semibold text-gray-900">
{(10000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}{' '}
{target}
</div>
</div>
<div className="bg-white rounded-lg border p-3">
<div className="text-xs text-gray-500 mb-1">100,000 {base}</div>
<div className="text-lg font-semibold text-gray-900">
{(100000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}{' '}
{target}
</div>
</div>
</div>
{/* 데이터 출처 */}
<div className="mt-4 pt-3 border-t text-center">
<p className="text-xs text-gray-400">: {exchangeRate.source}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,405 @@
'use client';
/**
* 날씨 위젯 컴포넌트
* - 실시간 날씨 정보를 표시
*/
import React, { useEffect, useState } from 'react';
import { getWeather, WeatherData } from '@/lib/api/openApi';
import {
Cloud,
CloudRain,
Sun,
CloudSnow,
Wind,
Droplets,
Gauge,
RefreshCw,
Check,
ChevronsUpDown,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { cn } from '@/lib/utils';
interface WeatherWidgetProps {
city?: string;
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
}
export default function WeatherWidget({
city = '서울',
refreshInterval = 600000,
}: WeatherWidgetProps) {
const [open, setOpen] = useState(false);
const [selectedCity, setSelectedCity] = useState(city);
const [weather, setWeather] = useState<WeatherData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
// 도시 목록 (전국 시/군/구 단위)
const cities = [
// 서울특별시 (25개 구)
{ value: '서울', label: '서울' },
{ value: '종로구', label: '서울 종로구' },
{ value: '중구', label: '서울 중구' },
{ value: '용산구', label: '서울 용산구' },
{ value: '성동구', label: '서울 성동구' },
{ value: '광진구', label: '서울 광진구' },
{ value: '동대문구', label: '서울 동대문구' },
{ value: '중랑구', label: '서울 중랑구' },
{ value: '성북구', label: '서울 성북구' },
{ value: '강북구', label: '서울 강북구' },
{ value: '도봉구', label: '서울 도봉구' },
{ value: '노원구', label: '서울 노원구' },
{ value: '은평구', label: '서울 은평구' },
{ value: '서대문구', label: '서울 서대문구' },
{ value: '마포구', label: '서울 마포구' },
{ value: '양천구', label: '서울 양천구' },
{ value: '강서구', label: '서울 강서구' },
{ value: '구로구', label: '서울 구로구' },
{ value: '금천구', label: '서울 금천구' },
{ value: '영등포구', label: '서울 영등포구' },
{ value: '동작구', label: '서울 동작구' },
{ value: '관악구', label: '서울 관악구' },
{ value: '서초구', label: '서울 서초구' },
{ value: '강남구', label: '서울 강남구' },
{ value: '송파구', label: '서울 송파구' },
{ value: '강동구', label: '서울 강동구' },
// 부산광역시
{ value: '부산', label: '부산' },
{ value: '해운대구', label: '부산 해운대구' },
{ value: '부산진구', label: '부산 부산진구' },
{ value: '동래구', label: '부산 동래구' },
{ value: '사하구', label: '부산 사하구' },
{ value: '금정구', label: '부산 금정구' },
{ value: '사상구', label: '부산 사상구' },
// 인천광역시
{ value: '인천', label: '인천' },
{ value: '부평구', label: '인천 부평구' },
{ value: '계양구', label: '인천 계양구' },
{ value: '남동구', label: '인천 남동구' },
// 대구광역시
{ value: '대구', label: '대구' },
{ value: '수성구', label: '대구 수성구' },
{ value: '달서구', label: '대구 달서구' },
// 광주광역시
{ value: '광주', label: '광주' },
{ value: '광산구', label: '광주 광산구' },
// 대전광역시
{ value: '대전', label: '대전' },
{ value: '유성구', label: '대전 유성구' },
// 울산광역시
{ value: '울산', label: '울산' },
// 세종특별자치시
{ value: '세종', label: '세종' },
// 경기도 (주요 도시)
{ value: '수원', label: '수원' },
{ value: '성남', label: '성남' },
{ value: '고양', label: '고양' },
{ value: '용인', label: '용인' },
{ value: '부천', label: '부천' },
{ value: '안산', label: '안산' },
{ value: '안양', label: '안양' },
{ value: '남양주', label: '남양주' },
{ value: '화성', label: '화성' },
{ value: '평택', label: '평택' },
{ value: '의정부', label: '의정부' },
{ value: '시흥', label: '시흥' },
{ value: '파주', label: '파주' },
{ value: '김포', label: '김포' },
{ value: '광명', label: '광명' },
// 강원도
{ value: '춘천', label: '춘천' },
{ value: '원주', label: '원주' },
{ value: '강릉', label: '강릉' },
{ value: '속초', label: '속초' },
{ value: '동해', label: '동해' },
{ value: '태백', label: '태백' },
{ value: '삼척', label: '삼척' },
// 충청북도
{ value: '청주', label: '청주' },
{ value: '충주', label: '충주' },
{ value: '제천', label: '제천' },
// 충청남도
{ value: '천안', label: '천안' },
{ value: '공주', label: '공주' },
{ value: '보령', label: '보령' },
{ value: '아산', label: '아산' },
{ value: '서산', label: '서산' },
{ value: '논산', label: '논산' },
{ value: '당진', label: '당진' },
// 전라북도
{ value: '전주', label: '전주' },
{ value: '군산', label: '군산' },
{ value: '익산', label: '익산' },
{ value: '정읍', label: '정읍' },
{ value: '남원', label: '남원' },
{ value: '김제', label: '김제' },
// 전라남도
{ value: '목포', label: '목포' },
{ value: '여수', label: '여수' },
{ value: '순천', label: '순천' },
{ value: '나주', label: '나주' },
{ value: '광양', label: '광양' },
// 경상북도
{ value: '포항', label: '포항' },
{ value: '경주', label: '경주' },
{ value: '김천', label: '김천' },
{ value: '안동', label: '안동' },
{ value: '구미', label: '구미' },
{ value: '영주', label: '영주' },
{ value: '영천', label: '영천' },
{ value: '상주', label: '상주' },
{ value: '문경', label: '문경' },
{ value: '경산', label: '경산' },
{ value: '울릉도', label: '울릉도' },
// 경상남도
{ value: '창원', label: '창원' },
{ value: '진주', label: '진주' },
{ value: '통영', label: '통영' },
{ value: '사천', label: '사천' },
{ value: '김해', label: '김해' },
{ value: '밀양', label: '밀양' },
{ value: '거제', label: '거제' },
{ value: '양산', label: '양산' },
// 제주특별자치도
{ value: '제주', label: '제주' },
{ value: '서귀포', label: '서귀포' },
];
// 날씨 정보 가져오기
const fetchWeather = async () => {
try {
setLoading(true);
setError(null);
const data = await getWeather(selectedCity, 'metric', 'kr');
setWeather(data);
setLastUpdated(new Date());
} catch (err: any) {
console.error('날씨 조회 실패:', err);
// 에러 메시지 추출
let errorMessage = '날씨 정보를 가져오는 중 오류가 발생했습니다.';
if (err.response?.status === 503) {
errorMessage = 'API 키가 설정되지 않았습니다. 관리자에게 문의하세요.';
} else if (err.response?.status === 401) {
errorMessage = 'API 키가 유효하지 않습니다.';
} else if (err.response?.status === 404) {
errorMessage = `도시를 찾을 수 없습니다: ${city}`;
} else if (err.response?.data?.message) {
errorMessage = err.response.data.message;
}
setError(errorMessage);
} finally {
setLoading(false);
}
};
// 초기 로딩 및 자동 새로고침
useEffect(() => {
fetchWeather();
const interval = setInterval(fetchWeather, refreshInterval);
return () => clearInterval(interval);
}, [selectedCity, refreshInterval]);
// 도시 변경 핸들러
const handleCityChange = (newCity: string) => {
setSelectedCity(newCity);
};
// 날씨 아이콘 선택
const getWeatherIcon = (weatherMain: string) => {
switch (weatherMain.toLowerCase()) {
case 'clear':
return <Sun className="h-12 w-12 text-yellow-500" />;
case 'clouds':
return <Cloud className="h-12 w-12 text-gray-400" />;
case 'rain':
case 'drizzle':
return <CloudRain className="h-12 w-12 text-blue-500" />;
case 'snow':
return <CloudSnow className="h-12 w-12 text-blue-300" />;
default:
return <Cloud className="h-12 w-12 text-gray-400" />;
}
};
// 로딩 상태
if (loading && !weather) {
return (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-6">
<div className="flex flex-col items-center gap-2">
<RefreshCw className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-sm text-gray-600"> ...</p>
</div>
</div>
);
}
// 에러 상태
if (error || !weather) {
return (
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
<Cloud className="h-12 w-12 text-gray-400 mb-2" />
<p className="text-sm text-gray-600 text-center mb-3">{error || '날씨 정보를 불러올 수 없습니다.'}</p>
<Button
variant="outline"
size="sm"
onClick={fetchWeather}
className="gap-1"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
);
}
return (
<div className="h-full bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-6">
{/* 헤더 */}
<div className="flex items-center justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className="justify-between text-lg font-semibold text-gray-900 hover:bg-white/50 h-auto py-1 px-2"
>
{cities.find((city) => city.value === selectedCity)?.label || '도시 선택'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="도시 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{cities.map((city) => (
<CommandItem
key={city.value}
value={city.value}
onSelect={(currentValue) => {
handleCityChange(currentValue === selectedCity ? selectedCity : currentValue);
setOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedCity === city.value ? 'opacity-100' : 'opacity-0'
)}
/>
{city.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<p className="text-xs text-gray-500 pl-2">
{lastUpdated
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
})}`
: ''}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={fetchWeather}
disabled={loading}
className="h-8 w-8 p-0"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 날씨 아이콘 및 온도 */}
<div className="flex items-center justify-center mb-6">
<div className="flex items-center gap-4">
{getWeatherIcon(weather.weatherMain)}
<div>
<div className="text-5xl font-bold text-gray-900">
{weather.temperature}°C
</div>
<p className="text-sm text-gray-600 capitalize">
{weather.weatherDescription}
</p>
</div>
</div>
</div>
{/* 상세 정보 */}
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
<Wind className="h-5 w-5 text-blue-500" />
<div>
<p className="text-xs text-gray-500"> </p>
<p className="text-sm font-semibold text-gray-900">
{weather.feelsLike}°C
</p>
</div>
</div>
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
<Droplets className="h-5 w-5 text-blue-500" />
<div>
<p className="text-xs text-gray-500"></p>
<p className="text-sm font-semibold text-gray-900">
{weather.humidity}%
</p>
</div>
</div>
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
<Wind className="h-5 w-5 text-blue-500" />
<div>
<p className="text-xs text-gray-500"></p>
<p className="text-sm font-semibold text-gray-900">
{weather.windSpeed} m/s
</p>
</div>
</div>
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
<Gauge className="h-5 w-5 text-blue-500" />
<div>
<p className="text-xs text-gray-500"></p>
<p className="text-sm font-semibold text-gray-900">
{weather.pressure} hPa
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -6,7 +6,6 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig } from "@/types/report";
import { CanvasComponent } from "./CanvasComponent";
import { Ruler } from "./Ruler";
import { GridLayer } from "./GridLayer";
import { v4 as uuidv4 } from "uuid";
export function ReportDesignerCanvas() {
@@ -33,7 +32,6 @@ export function ReportDesignerCanvas() {
undo,
redo,
showRuler,
gridConfig,
} = useReportDesigner();
const [{ isOver }, drop] = useDrop(() => ({
@@ -333,16 +331,16 @@ export function ReportDesignerCanvas() {
style={{
width: `${canvasWidth}mm`,
minHeight: `${canvasHeight}mm`,
backgroundImage: showGrid
? `
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
`
: undefined,
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
}}
onClick={handleCanvasClick}
>
{/* 그리드 레이어 */}
<GridLayer
gridConfig={gridConfig}
pageWidth={canvasWidth * 3.7795} // mm to px
pageHeight={canvasHeight * 3.7795}
/>
{/* 페이지 여백 가이드 */}
{currentPage && (
<div

View File

@@ -13,7 +13,6 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { QueryManager } from "./QueryManager";
import { SignaturePad } from "./SignaturePad";
import { SignatureGenerator } from "./SignatureGenerator";
import { GridSettingsPanel } from "./GridSettingsPanel";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
@@ -103,7 +102,7 @@ export function ReportDesignerRightPanel() {
<div className="w-[450px] border-l bg-white">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
<div className="border-b p-2">
<TabsList className="grid w-full grid-cols-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="page" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
@@ -112,10 +111,6 @@ export function ReportDesignerRightPanel() {
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="grid" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="queries" className="gap-1 text-xs">
<Database className="h-3 w-3" />
@@ -1401,15 +1396,6 @@ export function ReportDesignerRightPanel() {
</TabsContent>
{/* 쿼리 탭 */}
{/* 그리드 탭 */}
<TabsContent value="grid" className="mt-0 h-[calc(100vh-120px)]">
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
<GridSettingsPanel />
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="queries" className="mt-0 h-[calc(100vh-120px)]">
<QueryManager />
</TabsContent>