Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
dea88dd42b
|
|
@ -19,6 +19,9 @@ services:
|
|||
CORS_CREDENTIALS: "true"
|
||||
LOG_LEVEL: info
|
||||
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||
KMA_API_KEY: ogdXr2e9T4iHV69nvV-IwA
|
||||
ITS_API_KEY: d6b9befec3114d648284674b8fddcc32
|
||||
EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-}
|
||||
volumes:
|
||||
- backend_uploads:/app/uploads
|
||||
- backend_data:/app/data
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ services:
|
|||
context: ../../backend-node
|
||||
dockerfile: ../docker/prod/backend.Dockerfile # 운영용 Dockerfile
|
||||
container_name: pms-backend-prod
|
||||
network_mode: "host" # 호스트 네트워크 모드
|
||||
ports:
|
||||
- "8080:8080" # 호스트:컨테이너 포트 매핑
|
||||
networks:
|
||||
- pms-network
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
|
|
@ -27,4 +30,4 @@ services:
|
|||
|
||||
networks:
|
||||
pms-network:
|
||||
driver: bridge
|
||||
external: true # 외부에서 생성된 네트워크 사용
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { Dashboard } from "@/lib/api/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
||||
|
||||
interface DashboardListClientProps {
|
||||
initialDashboards: Dashboard[];
|
||||
initialPagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 목록 클라이언트 컴포넌트
|
||||
* - 대시보드 목록 조회
|
||||
* - 대시보드 생성/수정/삭제/복사
|
||||
*/
|
||||
export default function DashboardListClient({ initialDashboards, initialPagination }: DashboardListClientProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>(initialDashboards);
|
||||
const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 페이지네이션 상태
|
||||
const [currentPage, setCurrentPage] = useState(initialPagination.page);
|
||||
const [pageSize, setPageSize] = useState(initialPagination.limit);
|
||||
const [totalCount, setTotalCount] = useState(initialPagination.total);
|
||||
|
||||
// 모달 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
||||
|
||||
// 대시보드 목록 로드
|
||||
const loadDashboards = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await dashboardApi.getMyDashboards({
|
||||
search: searchTerm,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
});
|
||||
setDashboards(result.dashboards);
|
||||
setTotalCount(result.pagination.total);
|
||||
} catch (err) {
|
||||
console.error("Failed to load dashboards:", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로드 여부 추적
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 초기 로드는 건너뛰기 (서버에서 이미 데이터를 가져왔음)
|
||||
if (isInitialLoad) {
|
||||
setIsInitialLoad(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 이후 검색어/페이지 변경 시에만 fetch
|
||||
loadDashboards();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchTerm, currentPage, pageSize]);
|
||||
|
||||
// 페이지네이션 정보 계산
|
||||
const paginationInfo: PaginationInfo = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
totalItems: totalCount,
|
||||
itemsPerPage: pageSize,
|
||||
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||||
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||
};
|
||||
|
||||
// 페이지 변경 핸들러
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// 페이지 크기 변경 핸들러
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||
};
|
||||
|
||||
// 대시보드 삭제 확인 모달 열기
|
||||
const handleDeleteClick = (id: string, title: string) => {
|
||||
setDeleteTarget({ id, title });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 대시보드 삭제 실행
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
try {
|
||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTarget(null);
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "대시보드가 삭제되었습니다.",
|
||||
});
|
||||
loadDashboards();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete dashboard:", err);
|
||||
setDeleteDialogOpen(false);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 삭제에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 대시보드 복사
|
||||
const handleCopy = async (dashboard: Dashboard) => {
|
||||
try {
|
||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||
|
||||
await dashboardApi.createDashboard({
|
||||
title: `${fullDashboard.title} (복사본)`,
|
||||
description: fullDashboard.description,
|
||||
elements: fullDashboard.elements || [],
|
||||
isPublic: false,
|
||||
tags: fullDashboard.tags,
|
||||
category: fullDashboard.category,
|
||||
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||
});
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "대시보드가 복사되었습니다.",
|
||||
});
|
||||
loadDashboards();
|
||||
} catch (err) {
|
||||
console.error("Failed to copy dashboard:", err);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 포맷팅 헬퍼
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 검색 및 액션 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="대시보드 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 대시보드 목록 */}
|
||||
{loading ? (
|
||||
<div className="bg-card flex h-64 items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">로딩 중...</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">대시보드 목록을 불러오고 있습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<AlertCircle className="text-destructive h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-destructive mb-2 text-lg font-semibold">데이터를 불러올 수 없습니다</h3>
|
||||
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
|
||||
</div>
|
||||
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : dashboards.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboards.map((dashboard) => (
|
||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||
className="gap-2 text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && dashboards.length > 0 && (
|
||||
<Pagination
|
||||
paginationInfo={paginationInfo}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
showPageSizeSelector={true}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">대시보드 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,307 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { Dashboard } from "@/lib/api/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical } from "lucide-react";
|
||||
import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
/**
|
||||
* 대시보드 관리 페이지
|
||||
* - 대시보드 목록 조회
|
||||
* - 대시보드 생성/수정/삭제/복사
|
||||
* 서버에서 초기 대시보드 목록 fetch
|
||||
*/
|
||||
export default function DashboardListPage() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
async function getInitialDashboards() {
|
||||
try {
|
||||
// 서버 사이드 전용: 백엔드 API 직접 호출
|
||||
// 도커 네트워크 내부에서는 서비스 이름 사용, 로컬에서는 127.0.0.1
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://backend:8080";
|
||||
|
||||
// 페이지네이션 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
// 쿠키에서 authToken 추출
|
||||
const cookieStore = await cookies();
|
||||
const authToken = cookieStore.get("authToken")?.value;
|
||||
|
||||
// 모달 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
||||
|
||||
// 대시보드 목록 로드
|
||||
const loadDashboards = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await dashboardApi.getMyDashboards({
|
||||
search: searchTerm,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
});
|
||||
setDashboards(result.dashboards);
|
||||
setTotalCount(result.pagination.total);
|
||||
} catch (err) {
|
||||
console.error("Failed to load dashboards:", err);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 목록을 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!authToken) {
|
||||
// 토큰이 없으면 빈 데이터 반환 (클라이언트에서 로드)
|
||||
return {
|
||||
dashboards: [],
|
||||
pagination: { total: 0, page: 1, limit: 10 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboards();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchTerm, currentPage, pageSize]);
|
||||
|
||||
// 페이지네이션 정보 계산
|
||||
const paginationInfo: PaginationInfo = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
totalItems: totalCount,
|
||||
itemsPerPage: pageSize,
|
||||
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||||
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||
};
|
||||
|
||||
// 페이지 변경 핸들러
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// 페이지 크기 변경 핸들러
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||
};
|
||||
|
||||
// 대시보드 삭제 확인 모달 열기
|
||||
const handleDeleteClick = (id: string, title: string) => {
|
||||
setDeleteTarget({ id, title });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 대시보드 삭제 실행
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
try {
|
||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTarget(null);
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "대시보드가 삭제되었습니다.",
|
||||
});
|
||||
loadDashboards();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete dashboard:", err);
|
||||
setDeleteDialogOpen(false);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 삭제에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 대시보드 복사
|
||||
const handleCopy = async (dashboard: Dashboard) => {
|
||||
try {
|
||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||
|
||||
await dashboardApi.createDashboard({
|
||||
title: `${fullDashboard.title} (복사본)`,
|
||||
description: fullDashboard.description,
|
||||
elements: fullDashboard.elements || [],
|
||||
isPublic: false,
|
||||
tags: fullDashboard.tags,
|
||||
category: fullDashboard.category,
|
||||
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||
});
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "대시보드가 복사되었습니다.",
|
||||
});
|
||||
loadDashboards();
|
||||
} catch (err) {
|
||||
console.error("Failed to copy dashboard:", err);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 포맷팅 헬퍼
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
const response = await fetch(`${backendUrl}/api/dashboards/my?page=1&limit=10`, {
|
||||
cache: "no-store", // 항상 최신 데이터
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`, // Authorization 헤더로 전달
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-card flex h-full items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">로딩 중...</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">대시보드 목록을 불러오고 있습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch dashboards: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
dashboards: data.data || [],
|
||||
pagination: data.pagination || { total: 0, page: 1, limit: 10 },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Server-side fetch error:", error);
|
||||
// 에러 발생 시 빈 데이터 반환 (클라이언트에서 재시도 가능)
|
||||
return {
|
||||
dashboards: [],
|
||||
pagination: { total: 0, page: 1, limit: 10 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 관리 페이지 (서버 컴포넌트)
|
||||
* - 페이지 헤더 + 초기 데이터를 서버에서 렌더링
|
||||
* - 클라이언트 컴포넌트로 초기 데이터 전달
|
||||
*/
|
||||
export default async function DashboardListPage() {
|
||||
const initialData = await getInitialDashboards();
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
{/* 페이지 헤더 (서버에서 렌더링) */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
||||
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 액션 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="대시보드 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 대시보드 목록 */}
|
||||
{dashboards.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboards.map((dashboard) => (
|
||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||
className="gap-2 text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && dashboards.length > 0 && (
|
||||
<Pagination
|
||||
paginationInfo={paginationInfo}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
showPageSizeSelector={true}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
{/* 나머지 컨텐츠 (클라이언트 컴포넌트 + 서버 데이터) */}
|
||||
<DashboardListClient initialDashboards={initialData.dashboards} initialPagination={initialData.pagination} />
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">대시보드 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
<DashboardViewer
|
||||
elements={dashboard.elements}
|
||||
dashboardId={dashboard.id}
|
||||
dashboardTitle={dashboard.title}
|
||||
backgroundColor={dashboard.settings?.backgroundColor}
|
||||
resolution={dashboard.settings?.resolution}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Dashboard {
|
||||
id: string;
|
||||
|
|
@ -23,7 +23,7 @@ interface Dashboard {
|
|||
export default function DashboardListPage() {
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 대시보드 목록 로딩
|
||||
useEffect(() => {
|
||||
|
|
@ -32,14 +32,14 @@ export default function DashboardListPage() {
|
|||
|
||||
const loadDashboards = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
|
||||
try {
|
||||
// 실제 API 호출 시도
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
try {
|
||||
const result = await dashboardApi.getDashboards({ page: 1, limit: 50 });
|
||||
|
||||
|
||||
// API에서 가져온 대시보드들을 Dashboard 형식으로 변환
|
||||
const apiDashboards: Dashboard[] = result.dashboards.map((dashboard: any) => ({
|
||||
id: dashboard.id,
|
||||
|
|
@ -49,48 +49,47 @@ export default function DashboardListPage() {
|
|||
createdAt: dashboard.createdAt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
isPublic: dashboard.isPublic,
|
||||
creatorName: dashboard.creatorName
|
||||
creatorName: dashboard.creatorName,
|
||||
}));
|
||||
|
||||
|
||||
setDashboards(apiDashboards);
|
||||
|
||||
} catch (apiError) {
|
||||
console.warn('API 호출 실패, 로컬 스토리지 및 샘플 데이터 사용:', apiError);
|
||||
|
||||
console.warn("API 호출 실패, 로컬 스토리지 및 샘플 데이터 사용:", apiError);
|
||||
|
||||
// API 실패 시 로컬 스토리지 + 샘플 데이터 사용
|
||||
const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]');
|
||||
|
||||
const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]");
|
||||
|
||||
// 샘플 대시보드들
|
||||
const sampleDashboards: Dashboard[] = [
|
||||
{
|
||||
id: 'sales-overview',
|
||||
title: '📊 매출 현황 대시보드',
|
||||
description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.',
|
||||
id: "sales-overview",
|
||||
title: "📊 매출 현황 대시보드",
|
||||
description: "월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.",
|
||||
elementsCount: 3,
|
||||
createdAt: '2024-09-30T10:00:00Z',
|
||||
updatedAt: '2024-09-30T14:30:00Z',
|
||||
isPublic: true
|
||||
createdAt: "2024-09-30T10:00:00Z",
|
||||
updatedAt: "2024-09-30T14:30:00Z",
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: 'user-analytics',
|
||||
title: '👥 사용자 분석 대시보드',
|
||||
description: '사용자 행동 패턴 및 가입 추이 분석',
|
||||
id: "user-analytics",
|
||||
title: "👥 사용자 분석 대시보드",
|
||||
description: "사용자 행동 패턴 및 가입 추이 분석",
|
||||
elementsCount: 1,
|
||||
createdAt: '2024-09-29T15:00:00Z',
|
||||
updatedAt: '2024-09-30T09:15:00Z',
|
||||
isPublic: false
|
||||
createdAt: "2024-09-29T15:00:00Z",
|
||||
updatedAt: "2024-09-30T09:15:00Z",
|
||||
isPublic: false,
|
||||
},
|
||||
{
|
||||
id: 'inventory-status',
|
||||
title: '📦 재고 현황 대시보드',
|
||||
description: '실시간 재고 현황 및 입출고 내역',
|
||||
id: "inventory-status",
|
||||
title: "📦 재고 현황 대시보드",
|
||||
description: "실시간 재고 현황 및 입출고 내역",
|
||||
elementsCount: 4,
|
||||
createdAt: '2024-09-28T11:30:00Z',
|
||||
updatedAt: '2024-09-29T16:45:00Z',
|
||||
isPublic: true
|
||||
}
|
||||
createdAt: "2024-09-28T11:30:00Z",
|
||||
updatedAt: "2024-09-29T16:45:00Z",
|
||||
isPublic: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// 저장된 대시보드를 Dashboard 형식으로 변환
|
||||
const userDashboards: Dashboard[] = savedDashboards.map((dashboard: any) => ({
|
||||
id: dashboard.id,
|
||||
|
|
@ -99,44 +98,45 @@ export default function DashboardListPage() {
|
|||
elementsCount: dashboard.elements?.length || 0,
|
||||
createdAt: dashboard.createdAt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
isPublic: false // 사용자가 만든 대시보드는 기본적으로 비공개
|
||||
isPublic: false, // 사용자가 만든 대시보드는 기본적으로 비공개
|
||||
}));
|
||||
|
||||
|
||||
// 사용자 대시보드를 맨 앞에 배치
|
||||
setDashboards([...userDashboards, ...sampleDashboards]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Dashboard loading error:', error);
|
||||
console.error("Dashboard loading error:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 필터링
|
||||
const filteredDashboards = dashboards.filter(dashboard =>
|
||||
dashboard.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
dashboard.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const filteredDashboards = dashboards.filter(
|
||||
(dashboard) =>
|
||||
dashboard.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
dashboard.description?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="border-b border-gray-200 bg-white">
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">📊 대시보드</h1>
|
||||
<p className="text-gray-600 mt-1">데이터를 시각화하고 인사이트를 얻어보세요</p>
|
||||
<p className="mt-1 text-gray-600">데이터를 시각화하고 인사이트를 얻어보세요</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium"
|
||||
className="rounded-lg bg-blue-500 px-6 py-3 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
➕ 새 대시보드 만들기
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 검색 바 */}
|
||||
<div className="mt-6">
|
||||
<div className="relative max-w-md">
|
||||
|
|
@ -145,31 +145,29 @@ export default function DashboardListPage() {
|
|||
placeholder="대시보드 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pr-4 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="absolute left-3 top-2.5 text-gray-400">
|
||||
🔍
|
||||
</div>
|
||||
<div className="absolute top-2.5 left-3 text-gray-400">🔍</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
{isLoading ? (
|
||||
// 로딩 상태
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div key={i} className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-3"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-2/3 mb-4"></div>
|
||||
<div className="h-32 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="mb-3 h-4 w-3/4 rounded bg-gray-200"></div>
|
||||
<div className="mb-2 h-3 w-full rounded bg-gray-200"></div>
|
||||
<div className="mb-4 h-3 w-2/3 rounded bg-gray-200"></div>
|
||||
<div className="mb-4 h-32 rounded bg-gray-200"></div>
|
||||
<div className="flex justify-between">
|
||||
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-3 w-1/4 rounded bg-gray-200"></div>
|
||||
<div className="h-3 w-1/4 rounded bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -177,20 +175,18 @@ export default function DashboardListPage() {
|
|||
</div>
|
||||
) : filteredDashboards.length === 0 ? (
|
||||
// 빈 상태
|
||||
<div className="text-center py-12">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<h3 className="text-xl font-medium text-gray-700 mb-2">
|
||||
{searchTerm ? '검색 결과가 없습니다' : '아직 대시보드가 없습니다'}
|
||||
<div className="py-12 text-center">
|
||||
<div className="mb-4 text-6xl">📊</div>
|
||||
<h3 className="mb-2 text-xl font-medium text-gray-700">
|
||||
{searchTerm ? "검색 결과가 없습니다" : "아직 대시보드가 없습니다"}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{searchTerm
|
||||
? '다른 검색어로 시도해보세요'
|
||||
: '첫 번째 대시보드를 만들어보세요'}
|
||||
<p className="mb-6 text-gray-500">
|
||||
{searchTerm ? "다른 검색어로 시도해보세요" : "첫 번째 대시보드를 만들어보세요"}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
className="inline-flex items-center px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium"
|
||||
className="inline-flex items-center rounded-lg bg-blue-500 px-6 py-3 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
➕ 대시보드 만들기
|
||||
</Link>
|
||||
|
|
@ -198,7 +194,7 @@ export default function DashboardListPage() {
|
|||
</div>
|
||||
) : (
|
||||
// 대시보드 그리드
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredDashboards.map((dashboard) => (
|
||||
<DashboardCard key={dashboard.id} dashboard={dashboard} />
|
||||
))}
|
||||
|
|
@ -218,64 +214,54 @@ interface DashboardCardProps {
|
|||
*/
|
||||
function DashboardCard({ dashboard }: DashboardCardProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow">
|
||||
<div className="rounded-lg border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-md">
|
||||
{/* 썸네일 영역 */}
|
||||
<div className="h-48 bg-gradient-to-br from-blue-50 to-indigo-100 rounded-t-lg flex items-center justify-center">
|
||||
<div className="flex h-48 items-center justify-center rounded-t-lg bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">📊</div>
|
||||
<div className="mb-2 text-4xl">📊</div>
|
||||
<div className="text-sm text-gray-600">{dashboard.elementsCount}개 요소</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 카드 내용 */}
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">
|
||||
{dashboard.title}
|
||||
</h3>
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<h3 className="line-clamp-1 text-lg font-semibold text-gray-900">{dashboard.title}</h3>
|
||||
{dashboard.isPublic ? (
|
||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
|
||||
공개
|
||||
</span>
|
||||
<span className="rounded-full bg-green-100 px-2 py-1 text-xs text-green-800">공개</span>
|
||||
) : (
|
||||
<span className="text-xs bg-gray-100 text-gray-800 px-2 py-1 rounded-full">
|
||||
비공개
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-800">비공개</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dashboard.description && (
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||
{dashboard.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
{dashboard.description && <p className="mb-4 line-clamp-2 text-sm text-gray-600">{dashboard.description}</p>}
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="text-xs text-gray-500 mb-4">
|
||||
<div className="mb-4 text-xs text-gray-500">
|
||||
<div>생성: {new Date(dashboard.createdAt).toLocaleDateString()}</div>
|
||||
<div>수정: {new Date(dashboard.updatedAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/dashboard/${dashboard.id}`}
|
||||
className="flex-1 px-4 py-2 bg-blue-500 text-white text-center rounded-lg hover:bg-blue-600 text-sm font-medium"
|
||||
className="flex-1 rounded-lg bg-blue-500 px-4 py-2 text-center text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
보기
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/dashboard?load=${dashboard.id}`}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
편집
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 복사 기능 구현
|
||||
console.log('Dashboard copy:', dashboard.id);
|
||||
console.log("Dashboard copy:", dashboard.id);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
title="복사"
|
||||
>
|
||||
📋
|
||||
|
|
@ -284,4 +270,4 @@ function DashboardCard({ dashboard }: DashboardCardProps) {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,8 +51,37 @@
|
|||
/* Font Families */
|
||||
--font-sans: var(--font-inter);
|
||||
--font-mono: var(--font-jetbrains-mono);
|
||||
|
||||
/* Border Radius */
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
|
|
|
|||
|
|
@ -26,124 +26,124 @@ import {
|
|||
// 위젯 동적 임포트
|
||||
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>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</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>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합)
|
||||
const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 🧪 테스트용 지도 위젯 (REST API 지원)
|
||||
const MapTestWidget = dynamic(() => import("@/components/dashboard/widgets/MapTestWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스)
|
||||
const MapTestWidgetV2 = dynamic(() => import("@/components/dashboard/widgets/MapTestWidgetV2"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 🧪 테스트용 차트 위젯 (다중 데이터 소스)
|
||||
const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/ChartTestWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const ListTestWidget = dynamic(
|
||||
() => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
},
|
||||
);
|
||||
|
||||
const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const RiskAlertTestWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertTestWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합)
|
||||
const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석
|
||||
/* const ListSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/ListSummaryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
}); */
|
||||
|
||||
// 개별 위젯들 (주석 처리 - StatusSummaryWidget으로 통합됨)
|
||||
// const DeliveryStatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusSummaryWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
// });
|
||||
// const DeliveryTodayStatsWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryTodayStatsWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
// });
|
||||
// const CargoListWidget = dynamic(() => import("@/components/dashboard/widgets/CargoListWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
// });
|
||||
// const CustomerIssuesWidget = dynamic(() => import("@/components/dashboard/widgets/CustomerIssuesWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
// });
|
||||
|
||||
const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const TaskWidget = dynamic(() => import("@/components/dashboard/widgets/TaskWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 시계 위젯 임포트
|
||||
|
|
@ -160,25 +160,25 @@ import { Button } from "@/components/ui/button";
|
|||
// 야드 관리 3D 위젯
|
||||
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 작업 이력 위젯
|
||||
const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 커스텀 통계 카드 위젯
|
||||
const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 사용자 커스텀 카드 위젯
|
||||
const CustomMetricWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-muted-foreground">로딩 중...</div>,
|
||||
});
|
||||
|
||||
interface CanvasElementProps {
|
||||
|
|
@ -712,33 +712,33 @@ export function CanvasElement({
|
|||
if (element.type === "chart") {
|
||||
switch (element.subtype) {
|
||||
case "bar":
|
||||
return "bg-gradient-to-br from-indigo-400 to-purple-600";
|
||||
return "bg-gradient-to-br from-primary to-purple-500";
|
||||
case "pie":
|
||||
return "bg-gradient-to-br from-pink-400 to-red-500";
|
||||
return "bg-gradient-to-br from-destructive to-destructive/80";
|
||||
case "line":
|
||||
return "bg-gradient-to-br from-blue-400 to-cyan-400";
|
||||
return "bg-gradient-to-br from-primary to-primary/80";
|
||||
default:
|
||||
return "bg-gray-200";
|
||||
return "bg-muted";
|
||||
}
|
||||
} else if (element.type === "widget") {
|
||||
switch (element.subtype) {
|
||||
case "exchange":
|
||||
return "bg-gradient-to-br from-pink-400 to-yellow-400";
|
||||
return "bg-gradient-to-br from-warning to-warning/80";
|
||||
case "weather":
|
||||
return "bg-gradient-to-br from-cyan-400 to-indigo-800";
|
||||
return "bg-gradient-to-br from-primary to-primary/80";
|
||||
case "clock":
|
||||
return "bg-gradient-to-br from-teal-400 to-cyan-600";
|
||||
return "bg-gradient-to-br from-primary to-primary/80";
|
||||
case "calendar":
|
||||
return "bg-gradient-to-br from-indigo-400 to-purple-600";
|
||||
return "bg-gradient-to-br from-primary to-purple-500";
|
||||
case "driver-management":
|
||||
return "bg-gradient-to-br from-blue-400 to-indigo-600";
|
||||
return "bg-gradient-to-br from-primary to-primary";
|
||||
case "list":
|
||||
return "bg-gradient-to-br from-cyan-400 to-blue-600";
|
||||
return "bg-gradient-to-br from-primary to-primary/80";
|
||||
default:
|
||||
return "bg-gray-200";
|
||||
return "bg-muted";
|
||||
}
|
||||
}
|
||||
return "bg-gray-200";
|
||||
return "bg-muted";
|
||||
};
|
||||
|
||||
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
||||
|
|
@ -758,7 +758,7 @@ export function CanvasElement({
|
|||
<div
|
||||
ref={elementRef}
|
||||
data-element-id={element.id}
|
||||
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"} `}
|
||||
className={`absolute min-h-[120px] min-w-[120px] cursor-move overflow-hidden rounded-lg border-2 bg-background shadow-lg ${isSelected ? "border-primary ring-2 ring-primary/20" : "border-border"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
||||
style={{
|
||||
left: displayPosition.x,
|
||||
top: displayPosition.y,
|
||||
|
|
@ -809,7 +809,7 @@ export function CanvasElement({
|
|||
)}
|
||||
{/* 제목 */}
|
||||
{!element.type || element.type !== "chart" ? (
|
||||
<span className="text-xs font-bold text-gray-800">{element.customTitle || element.title}</span>
|
||||
<span className="text-xs font-bold text-foreground">{element.customTitle || element.title}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
|
|
@ -817,7 +817,7 @@ export function CanvasElement({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="element-close hover:bg-destructive h-5 w-5 text-gray-400 hover:text-white"
|
||||
className="element-close hover:bg-destructive h-5 w-5 text-muted-foreground hover:text-white"
|
||||
onClick={handleRemove}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title="삭제"
|
||||
|
|
@ -831,9 +831,9 @@ export function CanvasElement({
|
|||
<div className="relative h-[calc(100%-32px)] px-2 pb-2">
|
||||
{element.type === "chart" ? (
|
||||
// 차트 렌더링
|
||||
<div className="h-full w-full bg-white">
|
||||
<div className="h-full w-full bg-background">
|
||||
{isLoadingData ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<div className="text-sm">데이터 로딩 중...</div>
|
||||
|
|
@ -926,7 +926,7 @@ export function CanvasElement({
|
|||
) : element.type === "widget" && element.subtype === "status-summary" ? (
|
||||
// 커스텀 상태 카드 - 범용 위젯
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget element={element} title="상태 요약" icon="📊" bgGradient="from-slate-50 to-blue-50" />
|
||||
<StatusSummaryWidget element={element} title="상태 요약" icon="📊" bgGradient="from-background to-primary/10" />
|
||||
</div>
|
||||
) : /* element.type === "widget" && element.subtype === "list-summary" ? (
|
||||
// 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석)
|
||||
|
|
@ -940,7 +940,7 @@ export function CanvasElement({
|
|||
element={element}
|
||||
title="배송/화물 현황"
|
||||
icon="📦"
|
||||
bgGradient="from-slate-50 to-blue-50"
|
||||
bgGradient="from-background to-primary/10"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "delivery-status-summary" ? (
|
||||
|
|
@ -950,7 +950,7 @@ export function CanvasElement({
|
|||
element={element}
|
||||
title="배송 상태 요약"
|
||||
icon="📊"
|
||||
bgGradient="from-slate-50 to-blue-50"
|
||||
bgGradient="from-background to-primary/10"
|
||||
statusConfig={{
|
||||
배송중: { label: "배송중", color: "blue" },
|
||||
완료: { label: "완료", color: "green" },
|
||||
|
|
@ -966,7 +966,7 @@ export function CanvasElement({
|
|||
element={element}
|
||||
title="오늘 처리 현황"
|
||||
icon="📈"
|
||||
bgGradient="from-slate-50 to-green-50"
|
||||
bgGradient="from-background to-success/10"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "cargo-list" ? (
|
||||
|
|
@ -976,7 +976,7 @@ export function CanvasElement({
|
|||
element={element}
|
||||
title="화물 목록"
|
||||
icon="📦"
|
||||
bgGradient="from-slate-50 to-orange-50"
|
||||
bgGradient="from-background to-warning/10"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "customer-issues" ? (
|
||||
|
|
@ -986,7 +986,7 @@ export function CanvasElement({
|
|||
element={element}
|
||||
title="고객 클레임/이슈"
|
||||
icon="⚠️"
|
||||
bgGradient="from-slate-50 to-red-50"
|
||||
bgGradient="from-background to-destructive/10"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "risk-alert" ? (
|
||||
|
|
@ -1111,7 +1111,7 @@ function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`resize-handle absolute h-3 w-3 border border-white bg-green-500 ${getPositionClass()} `}
|
||||
className={`resize-handle absolute h-3 w-3 border border-white bg-success ${getPositionClass()} `}
|
||||
onMouseDown={(e) => onMouseDown(e, position)}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -117,17 +117,17 @@ export function ChartConfigPanel({
|
|||
다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다:
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{complexColumns.map((col) => (
|
||||
<Badge key={col} variant="outline" className="bg-red-50">
|
||||
<Badge key={col} variant="outline" className="bg-destructive/10">
|
||||
{col} ({columnTypes[col]})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
<div className="mt-2 text-xs text-foreground">
|
||||
<strong>해결 방법:</strong> JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요.
|
||||
<br />
|
||||
예: <code className="rounded bg-gray-100 px-1">main</code> 또는{" "}
|
||||
<code className="rounded bg-gray-100 px-1">data.items</code>
|
||||
예: <code className="rounded bg-muted px-1">main</code> 또는{" "}
|
||||
<code className="rounded bg-muted px-1">data.items</code>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -135,7 +135,7 @@ export function ChartConfigPanel({
|
|||
|
||||
{/* 차트 제목 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">차트 제목</Label>
|
||||
<Label className="text-xs font-medium text-foreground">차트 제목</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={currentConfig.title || ""}
|
||||
|
|
@ -149,9 +149,9 @@ export function ChartConfigPanel({
|
|||
|
||||
{/* X축 설정 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
X축 (카테고리)
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={currentConfig.xAxis || undefined} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
|
|
@ -170,39 +170,39 @@ export function ChartConfigPanel({
|
|||
return (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
{previewText && <span className="ml-1.5 text-[10px] text-gray-500">(예: {previewText})</span>}
|
||||
{previewText && <span className="ml-1.5 text-[10px] text-muted-foreground">(예: {previewText})</span>}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-[11px] text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
<p className="text-[11px] text-destructive">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Y축 설정 (다중 선택 가능) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
Y축 (값) - 여러 개 선택 가능
|
||||
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
|
||||
{!isPieChart && !isApiSource && <span className="ml-1 text-destructive">*</span>}
|
||||
{(isPieChart || isApiSource) && (
|
||||
<span className="ml-1.5 text-[11px] text-gray-500">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
||||
)}
|
||||
</Label>
|
||||
<div className="max-h-48 overflow-y-auto rounded border border-gray-200 bg-gray-50 p-2">
|
||||
<div className="max-h-48 overflow-y-auto rounded border border-border bg-muted p-2">
|
||||
<div className="space-y-1.5">
|
||||
{/* 숫자 타입 우선 표시 */}
|
||||
{numericColumns.length > 0 && (
|
||||
<>
|
||||
<div className="mb-1.5 text-[11px] font-medium text-green-700">숫자 타입 (권장)</div>
|
||||
<div className="mb-1.5 text-[11px] font-medium text-success">숫자 타입 (권장)</div>
|
||||
{numericColumns.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-1.5 rounded border-green-500 bg-green-50 p-1.5">
|
||||
<div key={col} className="flex items-center gap-1.5 rounded border-success bg-success/10 p-1.5">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
|
|
@ -229,7 +229,7 @@ export function ChartConfigPanel({
|
|||
<Label className="flex-1 cursor-pointer text-xs font-normal">
|
||||
<span className="font-medium">{col}</span>
|
||||
{sampleData[col] !== undefined && (
|
||||
<span className="ml-1.5 text-[10px] text-gray-600">(예: {sampleData[col]})</span>
|
||||
<span className="ml-1.5 text-[10px] text-foreground">(예: {sampleData[col]})</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -242,7 +242,7 @@ export function ChartConfigPanel({
|
|||
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
|
||||
<>
|
||||
{numericColumns.length > 0 && <div className="my-1.5 border-t"></div>}
|
||||
<div className="mb-1.5 text-[11px] font-medium text-gray-600">기타 타입</div>
|
||||
<div className="mb-1.5 text-[11px] font-medium text-foreground">기타 타입</div>
|
||||
{simpleColumns
|
||||
.filter((col) => !numericColumns.includes(col))
|
||||
.map((col) => {
|
||||
|
|
@ -251,7 +251,7 @@ export function ChartConfigPanel({
|
|||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-1.5 rounded p-1.5 hover:bg-gray-50">
|
||||
<div key={col} className="flex items-center gap-1.5 rounded p-1.5 hover:bg-muted">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
|
|
@ -278,7 +278,7 @@ export function ChartConfigPanel({
|
|||
<Label className="flex-1 cursor-pointer text-xs font-normal">
|
||||
{col}
|
||||
{sampleData[col] !== undefined && (
|
||||
<span className="ml-1.5 text-[10px] text-gray-500">
|
||||
<span className="ml-1.5 text-[10px] text-muted-foreground">
|
||||
(예: {String(sampleData[col]).substring(0, 30)})
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -291,9 +291,9 @@ export function ChartConfigPanel({
|
|||
</div>
|
||||
</div>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-[11px] text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
<p className="text-[11px] text-destructive">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-500">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -302,9 +302,9 @@ export function ChartConfigPanel({
|
|||
|
||||
{/* 집계 함수 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
집계 함수
|
||||
<span className="ml-1.5 text-[11px] text-gray-500">(데이터 처리 방식)</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">(데이터 처리 방식)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentConfig.aggregation || "none"}
|
||||
|
|
@ -338,16 +338,16 @@ export function ChartConfigPanel({
|
|||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 그룹핑 필드 (선택사항) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
그룹핑 필드 (선택사항)
|
||||
<span className="ml-1.5 text-[11px] text-gray-500">(같은 값끼리 묶어서 집계)</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">(같은 값끼리 묶어서 집계)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentConfig.groupBy || undefined}
|
||||
|
|
@ -373,7 +373,7 @@ export function ChartConfigPanel({
|
|||
|
||||
{/* 차트 색상 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">차트 색상</Label>
|
||||
<Label className="text-xs font-medium text-foreground">차트 색상</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
["#3B82F6", "#EF4444", "#10B981", "#F59E0B"], // 기본
|
||||
|
|
@ -387,8 +387,8 @@ export function ChartConfigPanel({
|
|||
onClick={() => updateConfig({ colors: colorSet })}
|
||||
className={`flex h-8 rounded border-2 transition-colors ${
|
||||
JSON.stringify(currentConfig.colors) === JSON.stringify(colorSet)
|
||||
? "border-gray-800"
|
||||
: "border-gray-300 hover:border-gray-400"
|
||||
? "border-foreground"
|
||||
: "border-border hover:border-border/80"
|
||||
}`}
|
||||
>
|
||||
{colorSet.map((color, idx) => (
|
||||
|
|
|
|||
|
|
@ -466,7 +466,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative w-full ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
className={`dashboard-canvas relative w-full ${isDragOver ? "bg-primary/5" : ""} `}
|
||||
style={{
|
||||
backgroundColor,
|
||||
height: `${canvasHeight}px`,
|
||||
|
|
@ -512,7 +512,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
)}
|
||||
{/* 배치된 요소들 렌더링 */}
|
||||
{elements.length === 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="text-sm">상단 메뉴에서 차트나 위젯을 선택하세요</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -582,11 +582,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
// 로딩 중이면 로딩 화면 표시
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
|
||||
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
||||
<div className="mt-1 text-sm text-gray-500">잠시만 기다려주세요</div>
|
||||
<div className="text-lg font-medium text-foreground">대시보드 로딩 중...</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -594,7 +594,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
|
||||
return (
|
||||
<DashboardProvider>
|
||||
<div className="flex h-full flex-col bg-gray-50">
|
||||
<div className="flex h-full flex-col bg-muted">
|
||||
{/* 상단 메뉴바 */}
|
||||
<DashboardTopMenu
|
||||
onSaveLayout={saveLayout}
|
||||
|
|
@ -610,7 +610,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
|
||||
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
|
||||
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
|
||||
<div className="flex flex-1 items-start justify-center bg-gray-100 p-8">
|
||||
<div className="dashboard-canvas-container flex flex-1 items-start justify-center bg-muted p-8">
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
|
|
@ -679,8 +679,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-success/10">
|
||||
<CheckCircle2 className="h-6 w-6 text-success" />
|
||||
</div>
|
||||
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
||||
<DialogDescription className="text-center">대시보드가 성공적으로 저장되었습니다.</DialogDescription>
|
||||
|
|
@ -711,7 +711,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleClearConfirm} className="bg-red-600 hover:bg-red-700">
|
||||
<AlertDialogAction onClick={handleClearConfirm} className="bg-destructive hover:bg-destructive/90">
|
||||
초기화
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export function DashboardSaveModal({
|
|||
{/* 대시보드 이름 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">
|
||||
대시보드 이름 <span className="text-red-500">*</span>
|
||||
대시보드 이름 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
|
|
@ -235,7 +235,7 @@ export function DashboardSaveModal({
|
|||
|
||||
{/* 메뉴 할당 옵션 */}
|
||||
{assignToMenu && (
|
||||
<div className="ml-6 space-y-4 border-l-2 border-gray-200 pl-4">
|
||||
<div className="ml-6 space-y-4 border-l-2 border-border pl-4">
|
||||
{/* 메뉴 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>메뉴 타입</Label>
|
||||
|
|
@ -260,8 +260,8 @@ export function DashboardSaveModal({
|
|||
<Label>메뉴 선택</Label>
|
||||
{loadingMenus ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-sm text-gray-500">메뉴 목록 로딩 중...</span>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">메뉴 목록 로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -273,7 +273,7 @@ export function DashboardSaveModal({
|
|||
<SelectGroup>
|
||||
<SelectLabel>{menuType === "admin" ? "관리자 메뉴" : "사용자 메뉴"}</SelectLabel>
|
||||
{flatMenus.length === 0 ? (
|
||||
<div className="px-2 py-3 text-sm text-gray-500">사용 가능한 메뉴가 없습니다.</div>
|
||||
<div className="px-2 py-3 text-sm text-muted-foreground">사용 가능한 메뉴가 없습니다.</div>
|
||||
) : (
|
||||
flatMenus.map((menu) => (
|
||||
<SelectItem key={menu.uniqueKey} value={menu.id}>
|
||||
|
|
@ -285,7 +285,7 @@ export function DashboardSaveModal({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
{selectedMenuId && (
|
||||
<div className="rounded-md bg-gray-50 p-2 text-sm text-gray-700">
|
||||
<div className="rounded-md bg-muted p-2 text-sm text-foreground">
|
||||
선택된 메뉴:{" "}
|
||||
<span className="font-medium">{flatMenus.find((m) => m.id === selectedMenuId)?.label}</span>
|
||||
</div>
|
||||
|
|
@ -293,7 +293,7 @@ export function DashboardSaveModal({
|
|||
</div>
|
||||
)}
|
||||
{assignToMenu && selectedMenuId && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
선택한 메뉴의 URL이 이 대시보드로 자동 설정됩니다.
|
||||
{menuType === "admin" && " (관리자 모드 파라미터 포함)"}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface DashboardToolbarProps {
|
||||
onClearCanvas: () => void;
|
||||
onSaveLayout: () => void;
|
||||
canvasBackgroundColor: string;
|
||||
onCanvasBackgroundColorChange: (color: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 툴바 컴포넌트
|
||||
* - 전체 삭제, 레이아웃 저장 등 주요 액션 버튼
|
||||
*/
|
||||
export function DashboardToolbar({ onClearCanvas, onSaveLayout, canvasBackgroundColor, onCanvasBackgroundColorChange }: DashboardToolbarProps) {
|
||||
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||
return (
|
||||
<div className="absolute top-5 left-5 bg-white p-3 rounded-lg shadow-lg z-50 flex gap-3">
|
||||
<button
|
||||
onClick={onClearCanvas}
|
||||
className="
|
||||
px-4 py-2 border border-gray-300 bg-white rounded-md
|
||||
text-sm font-medium text-gray-700
|
||||
hover:bg-gray-50 hover:border-gray-400
|
||||
transition-colors duration-200
|
||||
"
|
||||
>
|
||||
🗑️ 전체 삭제
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSaveLayout}
|
||||
className="
|
||||
px-4 py-2 border border-gray-300 bg-white rounded-md
|
||||
text-sm font-medium text-gray-700
|
||||
hover:bg-gray-50 hover:border-gray-400
|
||||
transition-colors duration-200
|
||||
"
|
||||
>
|
||||
💾 레이아웃 저장
|
||||
</button>
|
||||
|
||||
{/* 캔버스 배경색 변경 버튼 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
className="
|
||||
px-4 py-2 border border-gray-300 bg-white rounded-md
|
||||
text-sm font-medium text-gray-700
|
||||
hover:bg-gray-50 hover:border-gray-400
|
||||
transition-colors duration-200
|
||||
flex items-center gap-2
|
||||
"
|
||||
>
|
||||
🎨 캔버스 색상
|
||||
<div
|
||||
className="w-4 h-4 rounded border border-gray-300"
|
||||
style={{ backgroundColor: canvasBackgroundColor }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 색상 선택 패널 */}
|
||||
{showColorPicker && (
|
||||
<div className="absolute top-full left-0 mt-2 bg-white p-4 rounded-lg shadow-xl z-50 border border-gray-200 w-[280px]">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="color"
|
||||
value={canvasBackgroundColor}
|
||||
onChange={(e) => onCanvasBackgroundColorChange(e.target.value)}
|
||||
className="h-10 w-16 border border-gray-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={canvasBackgroundColor}
|
||||
onChange={(e) => onCanvasBackgroundColorChange(e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 프리셋 색상 */}
|
||||
<div className="grid grid-cols-6 gap-2 mb-3">
|
||||
{[
|
||||
'#ffffff', '#f9fafb', '#f3f4f6', '#e5e7eb',
|
||||
'#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b',
|
||||
'#10b981', '#06b6d4', '#6366f1', '#84cc16',
|
||||
].map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => onCanvasBackgroundColorChange(color)}
|
||||
className={`h-8 rounded border-2 ${canvasBackgroundColor === color ? 'border-blue-500 ring-2 ring-blue-200' : 'border-gray-300'}`}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowColorPicker(false)}
|
||||
className="w-full px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,7 +11,13 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Save, Trash2, Palette } from "lucide-react";
|
||||
import { Save, Trash2, Palette, Download } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ElementType, ElementSubtype } from "./types";
|
||||
import { ResolutionSelector, Resolution } from "./ResolutionSelector";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -66,14 +72,206 @@ export function DashboardTopMenu({
|
|||
}
|
||||
};
|
||||
|
||||
// 대시보드 다운로드
|
||||
// 헬퍼 함수: dataUrl로 다운로드 처리
|
||||
const handleDownloadWithDataUrl = async (
|
||||
dataUrl: string,
|
||||
format: "png" | "pdf",
|
||||
canvasWidth: number,
|
||||
canvasHeight: number
|
||||
) => {
|
||||
if (format === "png") {
|
||||
console.log("💾 PNG 다운로드 시작...");
|
||||
const link = document.createElement("a");
|
||||
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`;
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
console.log("✅ PNG 다운로드 완료:", filename);
|
||||
} else {
|
||||
console.log("📄 PDF 생성 중...");
|
||||
const jsPDF = (await import("jspdf")).default;
|
||||
|
||||
// dataUrl에서 이미지 크기 계산
|
||||
const img = new Image();
|
||||
img.src = dataUrl;
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
|
||||
console.log("📐 이미지 실제 크기:", { width: img.width, height: img.height });
|
||||
console.log("📐 캔버스 계산 크기:", { width: canvasWidth, height: canvasHeight });
|
||||
|
||||
// PDF 크기 계산 (A4 기준)
|
||||
const imgWidth = 210; // A4 width in mm
|
||||
const actualHeight = canvasHeight;
|
||||
const actualWidth = canvasWidth;
|
||||
const imgHeight = (actualHeight * imgWidth) / actualWidth;
|
||||
|
||||
console.log("📄 PDF 크기:", { width: imgWidth, height: imgHeight });
|
||||
|
||||
const pdf = new jsPDF({
|
||||
orientation: imgHeight > imgWidth ? "portrait" : "landscape",
|
||||
unit: "mm",
|
||||
format: [imgWidth, imgHeight],
|
||||
});
|
||||
|
||||
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
|
||||
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`;
|
||||
pdf.save(filename);
|
||||
console.log("✅ PDF 다운로드 완료:", filename);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (format: "png" | "pdf") => {
|
||||
try {
|
||||
console.log("🔍 다운로드 시작:", format);
|
||||
|
||||
// 실제 위젯들이 있는 캔버스 찾기
|
||||
const canvas = document.querySelector(".dashboard-canvas") as HTMLElement;
|
||||
console.log("🔍 캔버스 찾기:", canvas);
|
||||
|
||||
if (!canvas) {
|
||||
alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📸 html-to-image 로딩 중...");
|
||||
// html-to-image 동적 import
|
||||
const { toPng, toJpeg } = await import("html-to-image");
|
||||
|
||||
console.log("📸 캔버스 캡처 중...");
|
||||
|
||||
// 3D/WebGL 렌더링 완료 대기
|
||||
console.log("⏳ 3D 렌더링 완료 대기 중...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존)
|
||||
console.log("🎨 WebGL 캔버스 처리 중...");
|
||||
const webglCanvases = canvas.querySelectorAll("canvas");
|
||||
const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = [];
|
||||
|
||||
webglCanvases.forEach((webglCanvas) => {
|
||||
try {
|
||||
const rect = webglCanvas.getBoundingClientRect();
|
||||
const dataUrl = webglCanvas.toDataURL("image/png");
|
||||
webglImages.push({ canvas: webglCanvas, dataUrl, rect });
|
||||
console.log("✅ WebGL 캔버스 캡처:", { width: rect.width, height: rect.height });
|
||||
} catch (error) {
|
||||
console.warn("⚠️ WebGL 캔버스 캡처 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// 캔버스의 실제 크기와 위치 가져오기
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasWidth = canvas.scrollWidth;
|
||||
|
||||
// 실제 콘텐츠의 최하단 위치 계산
|
||||
const children = canvas.querySelectorAll(".canvas-element");
|
||||
let maxBottom = 0;
|
||||
children.forEach((child) => {
|
||||
const childRect = child.getBoundingClientRect();
|
||||
const relativeBottom = childRect.bottom - rect.top;
|
||||
if (relativeBottom > maxBottom) {
|
||||
maxBottom = relativeBottom;
|
||||
}
|
||||
});
|
||||
|
||||
// 실제 콘텐츠 높이 + 여유 공간 (50px)
|
||||
const canvasHeight = maxBottom > 0 ? maxBottom + 50 : canvas.scrollHeight;
|
||||
|
||||
console.log("📐 캔버스 정보:", {
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
scroll: { width: canvasWidth, height: canvas.scrollHeight },
|
||||
calculated: { width: canvasWidth, height: canvasHeight },
|
||||
maxBottom: maxBottom,
|
||||
webglCount: webglImages.length
|
||||
});
|
||||
|
||||
// html-to-image로 캔버스 캡처 (WebGL 제외)
|
||||
const dataUrl = await toPng(canvas, {
|
||||
backgroundColor: backgroundColor || "#ffffff",
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
pixelRatio: 2, // 고해상도
|
||||
cacheBust: true,
|
||||
skipFonts: false,
|
||||
preferredFontFormat: 'woff2',
|
||||
filter: (node) => {
|
||||
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
|
||||
if (node instanceof HTMLCanvasElement) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// WebGL 캔버스를 이미지 위에 합성
|
||||
if (webglImages.length > 0) {
|
||||
console.log("🖼️ WebGL 이미지 합성 중...");
|
||||
const img = new Image();
|
||||
img.src = dataUrl;
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
|
||||
// 새 캔버스에 합성
|
||||
const compositeCanvas = document.createElement("canvas");
|
||||
compositeCanvas.width = img.width;
|
||||
compositeCanvas.height = img.height;
|
||||
const ctx = compositeCanvas.getContext("2d");
|
||||
|
||||
if (ctx) {
|
||||
// 기본 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// WebGL 이미지들을 위치에 맞게 그리기
|
||||
for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) {
|
||||
const webglImg = new Image();
|
||||
webglImg.src = webglDataUrl;
|
||||
await new Promise((resolve) => {
|
||||
webglImg.onload = resolve;
|
||||
});
|
||||
|
||||
// 상대 위치 계산 (pixelRatio 2 고려)
|
||||
const relativeX = (webglRect.left - rect.left) * 2;
|
||||
const relativeY = (webglRect.top - rect.top) * 2;
|
||||
const width = webglRect.width * 2;
|
||||
const height = webglRect.height * 2;
|
||||
|
||||
ctx.drawImage(webglImg, relativeX, relativeY, width, height);
|
||||
console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height });
|
||||
}
|
||||
|
||||
// 합성된 이미지를 dataUrl로 변환
|
||||
const compositeDataUrl = compositeCanvas.toDataURL("image/png");
|
||||
console.log("✅ 최종 합성 완료");
|
||||
|
||||
// 기존 dataUrl을 합성된 것으로 교체
|
||||
return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ 캡처 완료 (WebGL 없음)");
|
||||
|
||||
// WebGL이 없는 경우 기본 다운로드
|
||||
await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight);
|
||||
} catch (error) {
|
||||
console.error("❌ 다운로드 실패:", error);
|
||||
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-16 items-center justify-between border-b bg-white px-6 shadow-sm">
|
||||
<div className="flex h-16 items-center justify-between border-b bg-background px-6 shadow-sm">
|
||||
{/* 좌측: 대시보드 제목 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{dashboardTitle && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold text-gray-900">{dashboardTitle}</span>
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">편집 중</span>
|
||||
<span className="text-lg font-semibold text-foreground">{dashboardTitle}</span>
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">편집 중</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -89,7 +287,7 @@ export function DashboardTopMenu({
|
|||
/>
|
||||
)}
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* 배경색 선택 */}
|
||||
{onBackgroundColorChange && (
|
||||
|
|
@ -97,7 +295,7 @@ export function DashboardTopMenu({
|
|||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
<div className="h-4 w-4 rounded border border-gray-300" style={{ backgroundColor }} />
|
||||
<div className="h-4 w-4 rounded border border-border" style={{ backgroundColor }} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-[99999] w-64">
|
||||
|
|
@ -151,8 +349,8 @@ export function DashboardTopMenu({
|
|||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* 차트 선택 */}
|
||||
<Select value={chartValue} onValueChange={handleChartSelect}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
|
|
@ -185,7 +383,7 @@ export function DashboardTopMenu({
|
|||
<SelectGroup>
|
||||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="map-summary-v2">지도</SelectItem>
|
||||
{/* <SelectItem value="chart">차트</SelectItem> */} {/* 주석 처리: 2025-10-29, 시기상조 */}
|
||||
<SelectItem value="chart">테스트용 차트 위젯</SelectItem>
|
||||
<SelectItem value="list-v2">리스트</SelectItem>
|
||||
<SelectItem value="custom-metric-v2">통계 카드</SelectItem>
|
||||
<SelectItem value="risk-alert-v2">리스크/알림</SelectItem>
|
||||
|
|
@ -219,7 +417,7 @@ export function DashboardTopMenu({
|
|||
|
||||
{/* 우측: 액션 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClearCanvas} className="gap-2 text-red-600 hover:text-red-700">
|
||||
<Button variant="outline" size="sm" onClick={onClearCanvas} className="gap-2 text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
|
|
@ -227,6 +425,20 @@ export function DashboardTopMenu({
|
|||
<Save className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
|
||||
{/* 다운로드 버튼 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleDownload("png")}>PNG 이미지로 저장</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDownload("pdf")}>PDF 문서로 저장</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPan
|
|||
<Card className="p-4">
|
||||
<div className="flex cursor-pointer items-center justify-between" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-blue-600" />
|
||||
<Label className="cursor-pointer text-sm font-medium text-gray-700">데이터 필터 (선택)</Label>
|
||||
{dateFilter.enabled && <span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">활성</span>}
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
<Label className="cursor-pointer text-sm font-medium text-foreground">데이터 필터 (선택)</Label>
|
||||
{dateFilter.enabled && <span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">활성</span>}
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</div>
|
||||
|
|
@ -81,7 +81,7 @@ export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPan
|
|||
<>
|
||||
{/* 날짜 컬럼 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 text-sm font-medium text-gray-700">날짜 컬럼</Label>
|
||||
<Label className="mb-2 text-sm font-medium text-foreground">날짜 컬럼</Label>
|
||||
<Select
|
||||
value={dateFilter.dateColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -104,12 +104,12 @@ export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPan
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">감지된 날짜 컬럼: {dateColumns.join(", ")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">감지된 날짜 컬럼: {dateColumns.join(", ")}</p>
|
||||
</div>
|
||||
|
||||
{/* 빠른 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 text-sm font-medium text-gray-700">빠른 선택</Label>
|
||||
<Label className="mb-2 text-sm font-medium text-foreground">빠른 선택</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -149,7 +149,7 @@ export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPan
|
|||
{/* 직접 입력 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="mb-2 text-sm font-medium text-gray-700">시작일</Label>
|
||||
<Label className="mb-2 text-sm font-medium text-foreground">시작일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFilter.startDate || ""}
|
||||
|
|
@ -165,7 +165,7 @@ export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPan
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 text-sm font-medium text-gray-700">종료일</Label>
|
||||
<Label className="mb-2 text-sm font-medium text-foreground">종료일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFilter.endDate || ""}
|
||||
|
|
@ -184,7 +184,7 @@ export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPan
|
|||
|
||||
{/* 필터 정보 */}
|
||||
{dateFilter.startDate && dateFilter.endDate && (
|
||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-800">
|
||||
<div className="rounded-md bg-primary/10 p-3 text-sm text-primary">
|
||||
<strong>필터 적용:</strong> {dateFilter.dateColumn} 컬럼에서 {dateFilter.startDate}부터{" "}
|
||||
{dateFilter.endDate}까지 데이터를 가져옵니다.
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,427 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
|
||||
import { QueryEditor } from "./QueryEditor";
|
||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||
import { MapTestConfigPanel } from "./MapTestConfigPanel";
|
||||
import { DataSourceSelector } from "./data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { X, ChevronLeft, ChevronRight, Save } from "lucide-react";
|
||||
|
||||
interface ElementConfigModalProps {
|
||||
element: DashboardElement;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (element: DashboardElement) => void;
|
||||
onPreview?: (element: DashboardElement) => void; // 실시간 미리보기용 (저장 전)
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 설정 모달 컴포넌트 (리팩토링)
|
||||
* - 2단계 플로우: 데이터 소스 선택 → 데이터 설정 및 차트 설정
|
||||
* - 새로운 데이터 소스 컴포넌트 통합
|
||||
*/
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave, onPreview }: ElementConfigModalProps) {
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>(element.chartConfig || {});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
|
||||
const [customTitle, setCustomTitle] = useState<string>(element.customTitle || "");
|
||||
const [showHeader, setShowHeader] = useState<boolean>(element.showHeader !== false);
|
||||
|
||||
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
||||
const isSimpleWidget =
|
||||
element.subtype === "todo" || // To-Do 위젯
|
||||
element.subtype === "booking-alert" || // 예약 알림 위젯
|
||||
element.subtype === "maintenance" || // 정비 일정 위젯
|
||||
element.subtype === "document" || // 문서 위젯
|
||||
element.subtype === "risk-alert" || // 리스크 알림 위젯
|
||||
element.subtype === "vehicle-status" ||
|
||||
element.subtype === "vehicle-list" ||
|
||||
element.subtype === "status-summary" || // 커스텀 상태 카드
|
||||
// element.subtype === "list-summary" || // 커스텀 목록 카드 (다른 분 작업 중 - 임시 주석)
|
||||
element.subtype === "delivery-status" ||
|
||||
element.subtype === "delivery-status-summary" ||
|
||||
element.subtype === "delivery-today-stats" ||
|
||||
element.subtype === "cargo-list" ||
|
||||
element.subtype === "customer-issues" ||
|
||||
element.subtype === "driver-management" ||
|
||||
element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요)
|
||||
element.subtype === "transport-stats"; // 커스텀 통계 카드 위젯 (쿼리 필요)
|
||||
|
||||
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
||||
const isSelfContainedWidget =
|
||||
element.subtype === "weather" || // 날씨 위젯 (외부 API)
|
||||
element.subtype === "exchange" || // 환율 위젯 (외부 API)
|
||||
element.subtype === "calculator"; // 계산기 위젯 (자체 기능)
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test";
|
||||
|
||||
// 주석
|
||||
// 모달이 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const dataSourceToSet = element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 };
|
||||
setDataSource(dataSourceToSet);
|
||||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setCurrentStep(1);
|
||||
setCustomTitle(element.customTitle || "");
|
||||
setShowHeader(element.showHeader !== false); // showHeader 초기화
|
||||
|
||||
// 쿼리가 이미 있으면 자동 실행
|
||||
if (dataSourceToSet.type === "database" && dataSourceToSet.query) {
|
||||
console.log("🔄 기존 쿼리 자동 실행:", dataSourceToSet.query);
|
||||
executeQueryAutomatically(dataSourceToSet);
|
||||
}
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
// 쿼리 자동 실행 함수
|
||||
const executeQueryAutomatically = async (dataSourceToExecute: ChartDataSource) => {
|
||||
if (dataSourceToExecute.type !== "database" || !dataSourceToExecute.query) return;
|
||||
|
||||
try {
|
||||
const { queryApi } = await import("@/lib/api/query");
|
||||
const result = await queryApi.executeQuery({
|
||||
query: dataSourceToExecute.query,
|
||||
connectionType: dataSourceToExecute.connectionType || "current",
|
||||
externalConnectionId: dataSourceToExecute.externalConnectionId,
|
||||
});
|
||||
|
||||
console.log("✅ 쿼리 자동 실행 완료:", result);
|
||||
setQueryResult(result);
|
||||
} catch (error) {
|
||||
console.error("❌ 쿼리 자동 실행 실패:", error);
|
||||
// 실패해도 모달은 열리도록 (사용자가 다시 실행 가능)
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource({
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
} else {
|
||||
setDataSource({
|
||||
type: "api",
|
||||
method: "GET",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 데이터 소스 변경 시 쿼리 결과와 차트 설정 초기화
|
||||
setQueryResult(null);
|
||||
setChartConfig({});
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 차트 설정 변경 처리
|
||||
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
|
||||
// 🎯 실시간 미리보기: chartConfig 변경 시 즉시 부모에게 전달
|
||||
if (onPreview) {
|
||||
onPreview({
|
||||
...element,
|
||||
chartConfig: newConfig,
|
||||
dataSource: dataSource,
|
||||
customTitle: customTitle,
|
||||
showHeader: showHeader,
|
||||
});
|
||||
}
|
||||
}, [element, dataSource, customTitle, showHeader, onPreview]);
|
||||
|
||||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
|
||||
// 쿼리가 변경되었으므로 차트 설정 초기화 (X/Y축 리셋)
|
||||
// console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화");
|
||||
setChartConfig({});
|
||||
}, []);
|
||||
|
||||
// 다음 단계로 이동
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 1) {
|
||||
setCurrentStep(2);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// 이전 단계로 이동
|
||||
const handlePrev = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(() => {
|
||||
const updatedElement: DashboardElement = {
|
||||
...element,
|
||||
dataSource,
|
||||
chartConfig,
|
||||
customTitle: customTitle.trim() || undefined, // 빈 문자열이면 undefined
|
||||
showHeader, // 헤더 표시 여부
|
||||
};
|
||||
|
||||
// console.log(" 저장할 element:", updatedElement);
|
||||
|
||||
onSave(updatedElement);
|
||||
onClose();
|
||||
}, [element, dataSource, chartConfig, customTitle, showHeader, onSave, onClose]);
|
||||
|
||||
// 모달이 열려있지 않으면 렌더링하지 않음
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 시계, 달력, 날씨, 환율, 계산기 위젯은 헤더 설정만 가능
|
||||
const isHeaderOnlyWidget =
|
||||
element.type === "widget" &&
|
||||
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
|
||||
|
||||
// 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
|
||||
if (element.type === "widget" && element.subtype === "driver-management") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 저장 가능 여부 확인
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = dataSource.type === "api";
|
||||
|
||||
// Y축 검증 헬퍼
|
||||
const hasYAxis =
|
||||
chartConfig.yAxis &&
|
||||
(typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
||||
|
||||
// customTitle이 변경되었는지 확인
|
||||
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
|
||||
|
||||
// showHeader가 변경되었는지 확인
|
||||
const isHeaderChanged = showHeader !== (element.showHeader !== false);
|
||||
|
||||
const canSave =
|
||||
isTitleChanged || // 제목만 변경해도 저장 가능
|
||||
isHeaderChanged || // 헤더 표시 여부만 변경해도 저장 가능
|
||||
(isSimpleWidget
|
||||
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 (차트 설정 불필요)
|
||||
currentStep === 2 && queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? // 지도 위젯: 타일맵 URL 또는 위도/경도 매핑 필요
|
||||
element.subtype === "map-test"
|
||||
? // 🧪 지도 테스트 위젯: 타일맵 URL만 있으면 저장 가능
|
||||
currentStep === 2 && chartConfig.tileMapUrl
|
||||
: // 기존 지도 위젯: 쿼리 결과 + 위도/경도 필수
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.latitudeColumn &&
|
||||
chartConfig.longitudeColumn
|
||||
: // 차트: 기존 로직 (2단계에서 차트 설정 필요)
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource
|
||||
? // 파이/도넛 차트 또는 REST API
|
||||
chartConfig.aggregation === "count"
|
||||
? true // count는 Y축 없어도 됨
|
||||
: hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수
|
||||
: // 일반 차트 (DB): Y축 필수
|
||||
hasYAxis));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className={`flex flex-col rounded-xl border bg-white shadow-2xl ${
|
||||
currentStep === 1 && !isSimpleWidget ? "h-auto max-h-[70vh] w-full max-w-3xl" : "h-[85vh] w-full max-w-5xl"
|
||||
}`}
|
||||
>
|
||||
{/* 모달 헤더 */}
|
||||
<div className="border-b p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{element.title} 설정</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 커스텀 제목 입력 */}
|
||||
<div className="mt-4">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">위젯 제목 (선택사항)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"
|
||||
className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
비워두면 테이블명으로 자동 생성됩니다 (예: "maintenance_schedules 목록")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 옵션 */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showHeader"
|
||||
checked={showHeader}
|
||||
onChange={(e) => setShowHeader(e.target.checked)}
|
||||
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="showHeader" className="text-sm font-medium text-gray-700">
|
||||
위젯 헤더 표시 (제목 + 새로고침 버튼)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행 상황 표시 - 간단한 위젯과 헤더 전용 위젯은 표시 안 함 */}
|
||||
{!isSimpleWidget && !isHeaderOnlyWidget && (
|
||||
<div className="border-b bg-gray-50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
{!isHeaderOnlyWidget && (
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{currentStep === 1 && (
|
||||
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
|
||||
{/* 왼쪽: 데이터 설정 */}
|
||||
<div className="space-y-6">
|
||||
{dataSource.type === "database" ? (
|
||||
<>
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ApiConfig
|
||||
dataSource={dataSource}
|
||||
onChange={handleDataSourceUpdate}
|
||||
onTestResult={handleQueryTest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 설정 패널 */}
|
||||
{!isSimpleWidget && (
|
||||
<div>
|
||||
{isMapWidget ? (
|
||||
// 지도 위젯: 위도/경도 매핑 패널
|
||||
element.subtype === "map-test" ? (
|
||||
// 🧪 지도 테스트 위젯: 타일맵 URL 필수, 마커 데이터 선택사항
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult || undefined}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : queryResult && queryResult.rows.length > 0 ? (
|
||||
// 기존 지도 위젯: 쿼리 결과 필수
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 지도 설정이 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : // 차트: 차트 설정 패널
|
||||
queryResult && queryResult.rows.length > 0 ? (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
||||
<div>{queryResult && <Badge variant="default">{queryResult.rows.length}개 데이터 로드됨</Badge>}</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
{isHeaderOnlyWidget ? (
|
||||
// 헤더 전용 위젯: 바로 저장
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
) : currentStep === 1 ? (
|
||||
// 1단계: 다음 버튼 (차트 위젯, 간단한 위젯 모두)
|
||||
<Button onClick={handleNext}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
// 2단계: 저장 버튼
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,10 @@ import { X } from "lucide-react";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface ElementConfigSidebarProps {
|
||||
element: DashboardElement | null;
|
||||
|
|
@ -50,16 +54,11 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
// 사이드바가 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen && element) {
|
||||
console.log("🔄 ElementConfigSidebar 초기화 - element.id:", element.id);
|
||||
console.log("🔄 element.dataSources:", element.dataSources);
|
||||
console.log("🔄 element.chartConfig?.dataSources:", element.chartConfig?.dataSources);
|
||||
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
|
||||
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
||||
// ⚠️ 중요: 없으면 반드시 빈 배열로 초기화
|
||||
const initialDataSources = element.dataSources || element.chartConfig?.dataSources || [];
|
||||
console.log("🔄 초기화된 dataSources:", initialDataSources);
|
||||
setDataSources(initialDataSources);
|
||||
|
||||
setChartConfig(element.chartConfig || {});
|
||||
|
|
@ -69,7 +68,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
setShowHeader(element.showHeader !== false);
|
||||
} else if (!isOpen) {
|
||||
// 사이드바가 닫힐 때 모든 상태 초기화
|
||||
console.log("🧹 ElementConfigSidebar 닫힘 - 상태 초기화");
|
||||
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
setDataSources([]);
|
||||
setChartConfig({});
|
||||
|
|
@ -124,8 +122,8 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
(newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
|
||||
// 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-test 위젯용)
|
||||
if (element && element.subtype === "map-test" && newConfig.tileMapUrl) {
|
||||
// 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-summary-v2 위젯용)
|
||||
if (element && element.subtype === "map-summary-v2" && newConfig.tileMapUrl) {
|
||||
onApply({
|
||||
...element,
|
||||
chartConfig: newConfig,
|
||||
|
|
@ -148,10 +146,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
const handleApply = useCallback(() => {
|
||||
if (!element) return;
|
||||
|
||||
console.log("🔧 적용 버튼 클릭 - dataSource:", dataSource);
|
||||
console.log("🔧 적용 버튼 클릭 - dataSources:", dataSources);
|
||||
console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig);
|
||||
|
||||
// 다중 데이터 소스 위젯 체크
|
||||
const isMultiDS =
|
||||
element.subtype === "map-summary-v2" ||
|
||||
|
|
@ -170,7 +164,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
showHeader,
|
||||
};
|
||||
|
||||
console.log("🔧 적용할 요소:", updatedElement);
|
||||
onApply(updatedElement);
|
||||
// 사이드바는 열린 채로 유지 (연속 수정 가능)
|
||||
}, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]);
|
||||
|
|
@ -179,7 +172,7 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
if (!element) return null;
|
||||
|
||||
// 리스트 위젯은 별도 사이드바로 처리
|
||||
if (element.subtype === "list") {
|
||||
if (element.subtype === "list-v2") {
|
||||
return (
|
||||
<ListWidgetConfigSidebar
|
||||
element={element}
|
||||
|
|
@ -207,7 +200,7 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
}
|
||||
|
||||
// 사용자 커스텀 카드 위젯은 사이드바로 처리
|
||||
if (element.subtype === "custom-metric") {
|
||||
if (element.subtype === "custom-metric-v2") {
|
||||
return (
|
||||
<CustomMetricConfigSidebar
|
||||
element={element}
|
||||
|
|
@ -226,7 +219,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
element.subtype === "booking-alert" ||
|
||||
element.subtype === "maintenance" ||
|
||||
element.subtype === "document" ||
|
||||
element.subtype === "risk-alert" ||
|
||||
element.subtype === "vehicle-status" ||
|
||||
element.subtype === "vehicle-list" ||
|
||||
element.subtype === "status-summary" ||
|
||||
|
|
@ -244,8 +236,7 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget =
|
||||
element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test";
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary-v2";
|
||||
|
||||
// 헤더 전용 위젯
|
||||
const isHeaderOnlyWidget =
|
||||
|
|
@ -254,12 +245,11 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
|
||||
// 다중 데이터 소스 위젯
|
||||
const isMultiDataSourceWidget =
|
||||
element.subtype === "map-summary-v2" ||
|
||||
element.subtype === "chart" ||
|
||||
element.subtype === "list-v2" ||
|
||||
element.subtype === "custom-metric-v2" ||
|
||||
element.subtype === "status-summary-test" ||
|
||||
element.subtype === "risk-alert-v2";
|
||||
(element.subtype as string) === "map-summary-v2" ||
|
||||
(element.subtype as string) === "chart" ||
|
||||
(element.subtype as string) === "list-v2" ||
|
||||
(element.subtype as string) === "custom-metric-v2" ||
|
||||
(element.subtype as string) === "risk-alert-v2";
|
||||
|
||||
// 저장 가능 여부 확인
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
|
|
@ -280,8 +270,8 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
: isSimpleWidget
|
||||
? queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? element.subtype === "map-test"
|
||||
? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 🧪 지도 테스트 위젯: 타일맵 URL 또는 API 데이터
|
||||
? element.subtype === "map-summary-v2"
|
||||
? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 지도 위젯: 타일맵 URL 또는 API 데이터
|
||||
: queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
|
||||
: queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
|
|
@ -291,62 +281,58 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-72 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
"bg-muted fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-72 flex-col transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
||||
<div className="bg-background flex items-center justify-between px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
||||
<span className="text-primary text-xs font-bold">⚙</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-900">{element.title}</span>
|
||||
<span className="text-foreground text-xs font-semibold">{element.title}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
||||
</button>
|
||||
<Button onClick={onClose} variant="ghost" size="icon" className="h-6 w-6">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 본문: 스크롤 가능 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{/* 기본 설정 카드 */}
|
||||
<div className="mb-3 rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">기본 설정</div>
|
||||
<div className="bg-background mb-3 rounded-lg p-3 shadow-sm">
|
||||
<div className="text-muted-foreground mb-2 text-[10px] font-semibold tracking-wide uppercase">기본 설정</div>
|
||||
<div className="space-y-2">
|
||||
{/* 커스텀 제목 입력 */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="위젯 제목"
|
||||
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white focus:ring-1 focus:outline-none"
|
||||
className="bg-muted focus:bg-background h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 옵션 */}
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 transition-colors hover:border-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="border-border bg-muted flex items-center gap-2 rounded border px-2 py-1.5">
|
||||
<Checkbox
|
||||
id="showHeader"
|
||||
checked={showHeader}
|
||||
onChange={(e) => setShowHeader(e.target.checked)}
|
||||
className="text-primary focus:ring-primary h-3 w-3 rounded border-gray-300"
|
||||
onCheckedChange={(checked) => setShowHeader(checked === true)}
|
||||
/>
|
||||
<span className="text-xs text-gray-700">헤더 표시</span>
|
||||
</label>
|
||||
<Label htmlFor="showHeader" className="cursor-pointer text-xs font-normal">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다중 데이터 소스 위젯 */}
|
||||
{isMultiDataSourceWidget && (
|
||||
<>
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<MultiDataSourceConfig
|
||||
dataSources={dataSources}
|
||||
onChange={setDataSources}
|
||||
|
|
@ -357,13 +343,11 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
totalRows: result.rows.length,
|
||||
executionTime: 0,
|
||||
});
|
||||
console.log("📊 API 테스트 결과 수신:", result, "데이터 소스 ID:", dataSourceId);
|
||||
|
||||
// ChartTestWidget용: 각 데이터 소스의 테스트 결과 저장
|
||||
// 각 데이터 소스의 테스트 결과 저장
|
||||
setTestResults((prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(dataSourceId, result);
|
||||
console.log("📊 테스트 결과 저장:", dataSourceId, result);
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
|
|
@ -372,11 +356,11 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
|
||||
{/* 지도 위젯: 타일맵 URL 설정 */}
|
||||
{element.subtype === "map-summary-v2" && (
|
||||
<div className="rounded-lg bg-white shadow-sm">
|
||||
<div className="bg-background rounded-lg shadow-sm">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50">
|
||||
<summary className="hover:bg-muted flex cursor-pointer items-center justify-between p-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-wide text-gray-500 uppercase">
|
||||
<div className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
타일맵 설정 (선택사항)
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[10px]">기본 VWorld 타일맵 사용 중</div>
|
||||
|
|
@ -403,11 +387,13 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
|
||||
{/* 차트 위젯: 차트 설정 */}
|
||||
{element.subtype === "chart" && (
|
||||
<div className="rounded-lg bg-white shadow-sm">
|
||||
<div className="bg-background rounded-lg shadow-sm">
|
||||
<details className="group" open>
|
||||
<summary className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50">
|
||||
<summary className="hover:bg-muted flex cursor-pointer items-center justify-between p-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-wide text-gray-500 uppercase">차트 설정</div>
|
||||
<div className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
차트 설정
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
{testResults.size > 0
|
||||
? `${testResults.size}개 데이터 소스 • X축, Y축, 차트 타입 설정`
|
||||
|
|
@ -439,24 +425,26 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
|
||||
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
|
||||
{!isHeaderOnlyWidget && !isMultiDataSourceWidget && (
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스</div>
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<div className="text-muted-foreground mb-2 text-[10px] font-semibold tracking-wide uppercase">
|
||||
데이터 소스
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue={dataSource.type}
|
||||
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid h-7 w-full grid-cols-2 bg-gray-100 p-0.5">
|
||||
<TabsList className="bg-muted grid h-7 w-full grid-cols-2 p-0.5">
|
||||
<TabsTrigger
|
||||
value="database"
|
||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
||||
className="data-[state=active]:bg-background h-6 rounded text-[11px] data-[state=active]:shadow-sm"
|
||||
>
|
||||
데이터베이스
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="api"
|
||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
||||
className="data-[state=active]:bg-background h-6 rounded text-[11px] data-[state=active]:shadow-sm"
|
||||
>
|
||||
REST API
|
||||
</TabsTrigger>
|
||||
|
|
@ -472,10 +460,10 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
|
||||
{/* 차트/지도 설정 */}
|
||||
{!isSimpleWidget &&
|
||||
(element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && (
|
||||
(element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
element.subtype === "map-test" ? (
|
||||
element.subtype === "map-summary-v2" ? (
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult || undefined}
|
||||
|
|
@ -513,10 +501,10 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
|
||||
{/* 차트/지도 설정 */}
|
||||
{!isSimpleWidget &&
|
||||
(element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && (
|
||||
(element.subtype === "map-summary-v2" || (queryResult && queryResult.rows.length > 0)) && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
element.subtype === "map-test" ? (
|
||||
element.subtype === "map-summary-v2" ? (
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult || undefined}
|
||||
|
|
@ -552,11 +540,9 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
|
||||
{/* 데이터 로드 상태 */}
|
||||
{queryResult && (
|
||||
<div className="mt-2 flex items-center gap-1.5 rounded bg-green-50 px-2 py-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span className="text-[10px] font-medium text-green-700">
|
||||
{queryResult.rows.length}개 데이터 로드됨
|
||||
</span>
|
||||
<div className="bg-success/10 mt-2 flex items-center gap-1.5 rounded px-2 py-1">
|
||||
<div className="bg-success h-1.5 w-1.5 rounded-full" />
|
||||
<span className="text-success text-[10px] font-medium">{queryResult.rows.length}개 데이터 로드됨</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -564,20 +550,13 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
</div>
|
||||
|
||||
{/* 푸터: 적용 버튼 */}
|
||||
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
||||
>
|
||||
<div className="bg-background flex gap-2 p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<Button onClick={onClose} variant="outline" className="flex-1 text-xs">
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={isHeaderOnlyWidget ? false : !canApply}
|
||||
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={isHeaderOnlyWidget ? false : !canApply} className="flex-1 text-xs">
|
||||
적용
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -123,9 +123,9 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
<div className="space-y-3">
|
||||
{/* 타일맵 URL 설정 (외부 커넥션 또는 직접 입력) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
타일맵 소스 (지도 배경)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
|
||||
{/* 외부 커넥션 선택 */}
|
||||
|
|
@ -140,7 +140,7 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-md text-xs h-8 bg-white"
|
||||
className="w-full px-2 py-1.5 border border-border rounded-md text-xs h-8 bg-background"
|
||||
>
|
||||
<option value="">저장된 커넥션 선택</option>
|
||||
{connections.map((conn) => (
|
||||
|
|
@ -167,9 +167,9 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
{/* 타일맵 소스 목록 */}
|
||||
{/* <div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
<label className="block text-xs font-medium text-foreground">
|
||||
타일맵 소스 (REST API)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -184,14 +184,14 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
</div>
|
||||
|
||||
{tileMapSources.map((source, index) => (
|
||||
<div key={source.id} className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div key={source.id} className="space-y-2 rounded-lg border border-border bg-muted p-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
<label className="block text-xs font-medium text-foreground">
|
||||
외부 커넥션 선택 (선택사항)
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => loadFromConnection(source.id, e.target.value)}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-md text-xs h-8 bg-white"
|
||||
className="w-full px-2 py-1.5 border border-border rounded-md text-xs h-8 bg-background"
|
||||
>
|
||||
<option value="">직접 입력 또는 커넥션 선택</option>
|
||||
{connections.map((conn) => (
|
||||
|
|
@ -217,7 +217,7 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeTileMapSource(source.id)}
|
||||
className="h-8 w-8 text-gray-500 hover:text-red-600"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -233,7 +233,7 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
|
||||
{/* 지도 제목 */}
|
||||
{/* <div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">지도 제목</label>
|
||||
<label className="block text-xs font-medium text-foreground">지도 제목</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={currentConfig.title || ''}
|
||||
|
|
@ -245,7 +245,7 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
|
||||
{/* 구분선 */}
|
||||
{/* <div className="border-t pt-3">
|
||||
<h5 className="text-xs font-semibold text-gray-700 mb-2">📍 마커 데이터 설정 (선택사항)</h5>
|
||||
<h5 className="text-xs font-semibold text-foreground mb-2">📍 마커 데이터 설정 (선택사항)</h5>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
데이터 소스 탭에서 API 또는 데이터베이스를 연결하면 마커를 표시할 수 있습니다.
|
||||
</p>
|
||||
|
|
@ -253,8 +253,8 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
|
||||
{/* 쿼리 결과가 없을 때 */}
|
||||
{/* {!queryResult && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-yellow-800 text-xs">
|
||||
<div className="p-3 bg-warning/10 border border-warning rounded-lg">
|
||||
<div className="text-warning text-xs">
|
||||
💡 데이터 소스를 연결하고 쿼리를 실행하면 마커 설정이 가능합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -265,13 +265,13 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
<>
|
||||
{/* 위도 컬럼 설정 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
<label className="block text-xs font-medium text-foreground">
|
||||
위도 컬럼 (Latitude)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.latitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
className="w-full px-2 py-1.5 border border-border rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -284,13 +284,13 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
|
||||
{/* 경도 컬럼 설정 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
<label className="block text-xs font-medium text-foreground">
|
||||
경도 컬럼 (Longitude)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.longitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
className="w-full px-2 py-1.5 border border-border rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -303,13 +303,13 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
|
||||
{/* 라벨 컬럼 (선택사항) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
<label className="block text-xs font-medium text-foreground">
|
||||
라벨 컬럼 (마커 표시명)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.labelColumn || ''}
|
||||
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
className="w-full px-2 py-1.5 border border-border rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -322,13 +322,13 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
|
||||
{/* 상태 컬럼 (선택사항) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
<label className="block text-xs font-medium text-foreground">
|
||||
상태 컬럼 (마커 색상)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.statusColumn || ''}
|
||||
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
className="w-full px-2 py-1.5 border border-border rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -343,8 +343,8 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
|
||||
{/* 기상특보 데이터 안내 */}
|
||||
{queryResult && isWeatherAlertData && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-blue-800 text-xs">
|
||||
<div className="p-3 bg-primary/10 border border-primary rounded-lg">
|
||||
<div className="text-primary text-xs">
|
||||
🚨 기상특보 데이터가 감지되었습니다. 지역명(reg_ko)을 기준으로 자동으로 영역이 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -355,38 +355,38 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
|
||||
{/* 날씨 정보 표시 옵션 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeather || false}
|
||||
onChange={(e) => updateConfig({ showWeather: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
|
||||
className="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<span>날씨 정보 표시</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeatherAlerts || false}
|
||||
onChange={(e) => updateConfig({ showWeatherAlerts: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
|
||||
className="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<span>기상특보 영역 표시</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
현재 발효 중인 기상특보(주의보/경보)를 지도에 색상 영역으로 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 설정 미리보기 */}
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-xs font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<div className="text-xs font-medium text-foreground mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div><strong>타일맵:</strong> {currentConfig.tileMapUrl ? '✅ 설정됨' : '❌ 미설정'}</div>
|
||||
<div><strong>위도:</strong> {currentConfig.latitudeColumn || '미설정'}</div>
|
||||
|
|
@ -403,8 +403,8 @@ export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapT
|
|||
|
||||
{/* 필수 필드 확인 */}
|
||||
{/* {!currentConfig.tileMapUrl && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="text-red-800 text-xs">
|
||||
<div className="p-3 bg-destructive/10 border border-destructive rounded-lg">
|
||||
<div className="text-destructive text-xs">
|
||||
⚠️ 타일맵 URL을 입력해야 지도가 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
|||
<Label>할당할 메뉴 선택</Label>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Select value={selectedMenuId} onValueChange={setSelectedMenuId}>
|
||||
|
|
|
|||
|
|
@ -157,11 +157,14 @@ export function MultiChartConfigPanel({
|
|||
<SelectValue placeholder="차트 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="line">라인 차트</SelectItem>
|
||||
<SelectItem value="bar">바 차트</SelectItem>
|
||||
<SelectItem value="area">영역 차트</SelectItem>
|
||||
<SelectItem value="pie">파이 차트</SelectItem>
|
||||
<SelectItem value="donut">도넛 차트</SelectItem>
|
||||
<SelectItem value="line">📈 라인 차트</SelectItem>
|
||||
<SelectItem value="bar">📊 바 차트</SelectItem>
|
||||
<SelectItem value="horizontal-bar">📊 수평 바 차트</SelectItem>
|
||||
<SelectItem value="stacked-bar">📊 누적 바 차트</SelectItem>
|
||||
<SelectItem value="area">📉 영역 차트</SelectItem>
|
||||
<SelectItem value="pie">🥧 파이 차트</SelectItem>
|
||||
<SelectItem value="donut">🍩 도넛 차트</SelectItem>
|
||||
<SelectItem value="combo">🎨 콤보 차트</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -299,8 +302,8 @@ export function MultiChartConfigPanel({
|
|||
|
||||
{/* 안내 메시지 */}
|
||||
{dataSourceConfigs.length > 0 && (
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-900">
|
||||
<div className="rounded-lg bg-primary/10 p-3">
|
||||
<p className="text-xs text-primary">
|
||||
{mergeMode ? (
|
||||
<>
|
||||
🔗 {dataSourceConfigs.length}개의 데이터 소스가 하나의 라인/바로 병합되어 표시됩니다.
|
||||
|
|
|
|||
|
|
@ -168,8 +168,8 @@ ORDER BY 하위부서수 DESC`,
|
|||
{/* 쿼리 에디터 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Database className="h-3.5 w-3.5 text-blue-600" />
|
||||
<h4 className="text-xs font-semibold text-gray-800">SQL 쿼리 에디터</h4>
|
||||
<Database className="h-3.5 w-3.5 text-primary" />
|
||||
<h4 className="text-xs font-semibold text-foreground">SQL 쿼리 에디터</h4>
|
||||
</div>
|
||||
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm" className="h-7 text-xs">
|
||||
{isExecuting ? (
|
||||
|
|
@ -188,7 +188,7 @@ ORDER BY 하위부서수 DESC`,
|
|||
|
||||
{/* 샘플 쿼리 아코디언 */}
|
||||
<Collapsible open={sampleQueryOpen} onOpenChange={setSampleQueryOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100">
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded border border-border bg-muted px-2 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted">
|
||||
{sampleQueryOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
샘플 쿼리
|
||||
</CollapsibleTrigger>
|
||||
|
|
@ -196,33 +196,33 @@ ORDER BY 하위부서수 DESC`,
|
|||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => insertSampleQuery("users")}
|
||||
className="flex items-center gap-1 rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
||||
className="flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
부서별 사용자
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("dept")}
|
||||
className="flex items-center gap-1 rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
||||
className="flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
부서 정보
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("usersByDate")}
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
>
|
||||
월별 가입 추이
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("usersByPosition")}
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
>
|
||||
직급별 분포
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("deptHierarchy")}
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
>
|
||||
부서 계층
|
||||
</button>
|
||||
|
|
@ -300,15 +300,15 @@ ORDER BY 하위부서수 DESC`,
|
|||
{/* 쿼리 결과 미리보기 */}
|
||||
{queryResult && (
|
||||
<Card>
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-2 py-1.5">
|
||||
<div className="border-b border-border bg-muted px-2 py-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-gray-700">쿼리 결과</span>
|
||||
<span className="text-xs font-medium text-foreground">쿼리 결과</span>
|
||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
||||
{queryResult.rows.length}행
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-500">실행 시간: {queryResult.executionTime}ms</span>
|
||||
<span className="text-[10px] text-muted-foreground">실행 시간: {queryResult.executionTime}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -339,13 +339,13 @@ ORDER BY 하위부서수 DESC`,
|
|||
</Table>
|
||||
|
||||
{queryResult.rows.length > 10 && (
|
||||
<div className="mt-2 text-center text-[10px] text-gray-500">
|
||||
<div className="mt-2 text-center text-[10px] text-muted-foreground">
|
||||
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-xs text-gray-500">결과가 없습니다.</div>
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">결과가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -122,9 +122,9 @@ export function ResolutionSelector({ value, onChange, currentScreenResolution }:
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-gray-500" />
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
<Select value={value} onValueChange={(v) => onChange(v as Resolution)}>
|
||||
<SelectTrigger className={`w-[180px] ${isTooLarge ? "border-orange-500" : ""}`}>
|
||||
<SelectTrigger className={`w-[180px] ${isTooLarge ? "border-warning" : ""}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
|
|
@ -133,31 +133,31 @@ export function ResolutionSelector({ value, onChange, currentScreenResolution }:
|
|||
<SelectItem value="hd">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>HD</span>
|
||||
<span className="text-xs text-gray-500">1280x720</span>
|
||||
<span className="text-xs text-muted-foreground">1280x720</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="fhd">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Full HD</span>
|
||||
<span className="text-xs text-gray-500">1920x1080</span>
|
||||
<span className="text-xs text-muted-foreground">1920x1080</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="qhd">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>QHD</span>
|
||||
<span className="text-xs text-gray-500">2560x1440</span>
|
||||
<span className="text-xs text-muted-foreground">2560x1440</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="uhd">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>4K UHD</span>
|
||||
<span className="text-xs text-gray-500">3840x2160</span>
|
||||
<span className="text-xs text-muted-foreground">3840x2160</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isTooLarge && <span className="text-xs text-orange-600">⚠️ 현재 화면보다 큽니다</span>}
|
||||
{isTooLarge && <span className="text-xs text-warning">⚠️ 현재 화면보다 큽니다</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,12 +88,12 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold text-gray-800">🗺️ 지도 설정</h4>
|
||||
<h4 className="text-xs font-semibold text-foreground">🗺️ 지도 설정</h4>
|
||||
|
||||
{/* 쿼리 결과가 없을 때 */}
|
||||
{!queryResult && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3">
|
||||
<div className="text-xs text-yellow-800">
|
||||
<div className="rounded-lg border border-warning bg-warning/10 p-3">
|
||||
<div className="text-xs text-warning">
|
||||
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 지도를 설정할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -104,26 +104,26 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
<>
|
||||
{/* 지도 제목 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">지도 제목</label>
|
||||
<label className="block text-xs font-medium text-foreground">지도 제목</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentConfig.title || ""}
|
||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||
placeholder="차량 위치 지도"
|
||||
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
className="w-full rounded-lg border border-border px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 위도 컬럼 설정 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
<label className="block text-xs font-medium text-foreground">
|
||||
위도 컬럼 (Latitude)
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.latitudeColumn || ""}
|
||||
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
className="w-full rounded-lg border border-border px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -136,14 +136,14 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
{/* 경도 컬럼 설정 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
<label className="block text-xs font-medium text-foreground">
|
||||
경도 컬럼 (Longitude)
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.longitudeColumn || ""}
|
||||
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
className="w-full rounded-lg border border-border px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -156,11 +156,11 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
{/* 라벨 컬럼 (선택사항) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">라벨 컬럼 (마커 표시명)</label>
|
||||
<label className="block text-xs font-medium text-foreground">라벨 컬럼 (마커 표시명)</label>
|
||||
<select
|
||||
value={currentConfig.labelColumn || ""}
|
||||
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
className="w-full rounded-lg border border-border px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -173,19 +173,19 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
{/* 마커 색상 설정 */}
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<h5 className="text-xs font-semibold text-gray-800">🎨 마커 색상 설정</h5>
|
||||
<h5 className="text-xs font-semibold text-foreground">🎨 마커 색상 설정</h5>
|
||||
|
||||
{/* 색상 모드 선택 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">색상 모드</label>
|
||||
<label className="block text-xs font-medium text-foreground">색상 모드</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMarkerColorModeChange("single")}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-xs transition-colors ${
|
||||
(currentConfig.markerColorMode || "single") === "single"
|
||||
? "border-blue-300 bg-blue-50 font-medium text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
? "border-primary bg-primary/10 font-medium text-primary"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
단일 색상
|
||||
|
|
@ -195,8 +195,8 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
onClick={() => handleMarkerColorModeChange("conditional")}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-xs transition-colors ${
|
||||
currentConfig.markerColorMode === "conditional"
|
||||
? "border-blue-300 bg-blue-50 font-medium text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
|
||||
? "border-primary bg-primary/10 font-medium text-primary"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
조건부 색상
|
||||
|
|
@ -206,40 +206,40 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
{/* 단일 색상 모드 */}
|
||||
{(currentConfig.markerColorMode || "single") === "single" && (
|
||||
<div className="space-y-1.5 rounded-lg bg-gray-50 p-3">
|
||||
<label className="block text-xs font-medium text-gray-700">마커 색상</label>
|
||||
<div className="space-y-1.5 rounded-lg bg-muted p-3">
|
||||
<label className="block text-xs font-medium text-foreground">마커 색상</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={currentConfig.markerDefaultColor || "#3b82f6"}
|
||||
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
|
||||
className="h-8 w-12 cursor-pointer rounded border border-gray-300"
|
||||
className="h-8 w-12 cursor-pointer rounded border border-border"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentConfig.markerDefaultColor || "#3b82f6"}
|
||||
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
|
||||
placeholder="#3b82f6"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
className="flex-1 rounded-lg border border-border px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">모든 마커가 동일한 색상으로 표시됩니다</p>
|
||||
<p className="text-xs text-muted-foreground">모든 마커가 동일한 색상으로 표시됩니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조건부 색상 모드 */}
|
||||
{currentConfig.markerColorMode === "conditional" && (
|
||||
<div className="space-y-2 rounded-lg bg-gray-50 p-3">
|
||||
<div className="space-y-2 rounded-lg bg-muted p-3">
|
||||
{/* 색상 조건 컬럼 선택 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
<label className="block text-xs font-medium text-foreground">
|
||||
색상 조건 컬럼
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.markerColorColumn || ""}
|
||||
onChange={(e) => updateConfig({ markerColorColumn: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
className="w-full rounded-lg border border-border px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -248,38 +248,38 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500">이 컬럼의 값에 따라 마커 색상이 결정됩니다</p>
|
||||
<p className="text-xs text-muted-foreground">이 컬럼의 값에 따라 마커 색상이 결정됩니다</p>
|
||||
</div>
|
||||
|
||||
{/* 기본 색상 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">기본 색상</label>
|
||||
<label className="block text-xs font-medium text-foreground">기본 색상</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={currentConfig.markerDefaultColor || "#6b7280"}
|
||||
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
|
||||
className="h-8 w-12 cursor-pointer rounded border border-gray-300"
|
||||
className="h-8 w-12 cursor-pointer rounded border border-border"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={currentConfig.markerDefaultColor || "#6b7280"}
|
||||
onChange={(e) => updateConfig({ markerDefaultColor: e.target.value })}
|
||||
placeholder="#6b7280"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
|
||||
className="flex-1 rounded-lg border border-border px-2 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">규칙에 매칭되지 않는 경우 사용할 색상</p>
|
||||
<p className="text-xs text-muted-foreground">규칙에 매칭되지 않는 경우 사용할 색상</p>
|
||||
</div>
|
||||
|
||||
{/* 색상 규칙 목록 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-medium text-gray-700">색상 규칙</label>
|
||||
<label className="block text-xs font-medium text-foreground">색상 규칙</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addColorRule}
|
||||
className="flex items-center gap-1 rounded-lg bg-blue-500 px-2 py-1 text-xs text-white transition-colors hover:bg-blue-600"
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-2 py-1 text-xs text-white transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
|
|
@ -288,20 +288,20 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
{/* 규칙 리스트 */}
|
||||
{(currentConfig.markerColorRules || []).length === 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3 text-center">
|
||||
<p className="text-xs text-gray-500">추가 버튼을 눌러 색상 규칙을 만드세요</p>
|
||||
<div className="rounded-lg border border-border bg-background p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">추가 버튼을 눌러 색상 규칙을 만드세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(currentConfig.markerColorRules || []).map((rule) => (
|
||||
<div key={rule.id} className="space-y-2 rounded-lg border border-gray-200 bg-white p-2">
|
||||
<div key={rule.id} className="space-y-2 rounded-lg border border-border bg-background p-2">
|
||||
{/* 규칙 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">규칙</span>
|
||||
<span className="text-xs font-medium text-foreground">규칙</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteColorRule(rule.id)}
|
||||
className="text-red-500 transition-colors hover:text-red-700"
|
||||
className="text-destructive transition-colors hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -309,45 +309,45 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
{/* 조건 값 */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-gray-600">값 (조건)</label>
|
||||
<label className="block text-xs font-medium text-foreground">값 (조건)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.value}
|
||||
onChange={(e) => updateColorRule(rule.id, { value: e.target.value })}
|
||||
placeholder="예: active, inactive"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
className="w-full rounded border border-border px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 색상 */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-gray-600">색상</label>
|
||||
<label className="block text-xs font-medium text-foreground">색상</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => updateColorRule(rule.id, { color: e.target.value })}
|
||||
className="h-8 w-12 cursor-pointer rounded border border-gray-300"
|
||||
className="h-8 w-12 cursor-pointer rounded border border-border"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.color}
|
||||
onChange={(e) => updateColorRule(rule.id, { color: e.target.value })}
|
||||
placeholder="#3b82f6"
|
||||
className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
className="flex-1 rounded border border-border px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 라벨 (선택사항) */}
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-gray-600">라벨 (선택)</label>
|
||||
<label className="block text-xs font-medium text-foreground">라벨 (선택)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.label || ""}
|
||||
onChange={(e) => updateColorRule(rule.id, { label: e.target.value })}
|
||||
placeholder="예: 활성, 비활성"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
className="w-full rounded border border-border px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -361,36 +361,36 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
{/* 날씨 정보 표시 옵션 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-gray-700">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeather || false}
|
||||
onChange={(e) => updateConfig({ showWeather: e.target.checked })}
|
||||
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300 focus:ring-2"
|
||||
className="text-primary focus:ring-primary h-4 w-4 rounded border-border focus:ring-2"
|
||||
/>
|
||||
<span>날씨 정보 표시</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-gray-500">마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다</p>
|
||||
<p className="ml-6 text-xs text-muted-foreground">마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-gray-700">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeatherAlerts || false}
|
||||
onChange={(e) => updateConfig({ showWeatherAlerts: e.target.checked })}
|
||||
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300 focus:ring-2"
|
||||
className="text-primary focus:ring-primary h-4 w-4 rounded border-border focus:ring-2"
|
||||
/>
|
||||
<span>기상특보 영역 표시</span>
|
||||
</label>
|
||||
<p className="ml-6 text-xs text-gray-500">
|
||||
<p className="ml-6 text-xs text-muted-foreground">
|
||||
현재 발효 중인 기상특보(주의보/경보)를 지도에 색상 영역으로 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 설정 미리보기 */}
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-700">📋 설정 미리보기</div>
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<div className="mb-2 text-xs font-medium text-foreground">📋 설정 미리보기</div>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<div>
|
||||
<strong>위도:</strong> {currentConfig.latitudeColumn || "미설정"}
|
||||
|
|
@ -428,8 +428,8 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
{/* 필수 필드 확인 */}
|
||||
{(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="text-xs text-red-800">
|
||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-3">
|
||||
<div className="text-xs text-destructive">
|
||||
⚠️ 위도와 경도 컬럼을 반드시 선택해야 지도에 표시할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@ export function Chart({ chartType, data, config, width, height }: ChartProps) {
|
|||
if (!data || !data.labels.length || !data.datasets.length) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50"
|
||||
className="flex items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted"
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📊</div>
|
||||
<div className="text-sm font-medium text-gray-600">데이터를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">차트 설정에서 데이터 소스와 축을 설정하세요</div>
|
||||
<div className="text-sm font-medium text-foreground">데이터를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">차트 설정에서 데이터 소스와 축을 설정하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -68,13 +68,13 @@ export function Chart({ chartType, data, config, width, height }: ChartProps) {
|
|||
default:
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50"
|
||||
className="flex items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted"
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">❓</div>
|
||||
<div className="text-sm font-medium text-gray-600">지원하지 않는 차트 타입</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{chartType}</div>
|
||||
<div className="text-sm font-medium text-foreground">지원하지 않는 차트 타입</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{chartType}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
|||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { applyQueryFilters } from "../utils/queryHelpers";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
||||
interface ChartRendererProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -84,7 +85,7 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende
|
|||
});
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -202,9 +203,9 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende
|
|||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<div className="text-sm">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -214,7 +215,7 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende
|
|||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-red-500">
|
||||
<div className="flex h-full w-full items-center justify-center text-destructive">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
<div className="text-sm font-medium">오류 발생</div>
|
||||
|
|
@ -231,7 +232,7 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende
|
|||
|
||||
if (!chartData || !element.chartConfig?.xAxis || (needsYAxis && !element.chartConfig?.yAxis)) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="text-sm">데이터를 설정해주세요</div>
|
||||
</div>
|
||||
|
|
@ -263,7 +264,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-0.5">
|
||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center bg-background p-0.5">
|
||||
<div className="flex items-center justify-center">
|
||||
<Chart
|
||||
chartType={element.subtype}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function ComboChartComponent({ data, config, width = 250, height = 200 }:
|
|||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
<div className="text-center text-sm font-semibold text-foreground mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function StackedBarChartComponent({ data, config, width = 250, height = 2
|
|||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
<div className="text-center text-sm font-semibold text-foreground mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Plus, X, Play, AlertCircle } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
||||
// 개별 API 소스 인터페이스
|
||||
interface ApiSource {
|
||||
|
|
@ -254,7 +255,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
});
|
||||
|
||||
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -373,7 +374,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
<div className="space-y-4">
|
||||
{/* 외부 커넥션 선택 - 항상 표시 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">외부 커넥션 (선택)</Label>
|
||||
<Label className="text-xs font-medium text-foreground">외부 커넥션 (선택)</Label>
|
||||
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="저장된 커넥션 선택" />
|
||||
|
|
@ -386,22 +387,22 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
apiConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
||||
{conn.connection_name}
|
||||
{conn.description && <span className="ml-1.5 text-[10px] text-gray-500">({conn.description})</span>}
|
||||
{conn.description && <span className="ml-1.5 text-[10px] text-muted-foreground">({conn.description})</span>}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-connections" disabled className="text-xs text-gray-500">
|
||||
<SelectItem value="no-connections" disabled className="text-xs text-muted-foreground">
|
||||
등록된 커넥션이 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-gray-500">저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||
<p className="text-[11px] text-muted-foreground">저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||
</div>
|
||||
|
||||
{/* API URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">API URL *</Label>
|
||||
<Label className="text-xs font-medium text-foreground">API URL *</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://api.example.com/data 또는 /api/typ01/url/wrn_now_data.php"
|
||||
|
|
@ -409,7 +410,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
onChange={(e) => onChange({ endpoint: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
전체 URL 또는 base_url 이후 경로를 입력하세요 (외부 커넥션 선택 시 base_url 자동 입력)
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -417,7 +418,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
{/* 쿼리 파라미터 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-gray-700">URL 쿼리 파라미터</Label>
|
||||
<Label className="text-xs font-medium text-foreground">URL 쿼리 파라미터</Label>
|
||||
<Button variant="outline" size="sm" onClick={addQueryParam} className="h-6 text-[11px]">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
|
|
@ -444,7 +445,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
/>
|
||||
<button
|
||||
onClick={() => removeQueryParam(param.id)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded hover:bg-gray-100"
|
||||
className="flex h-7 w-7 items-center justify-center rounded hover:bg-muted"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -452,17 +453,17 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-[11px] text-gray-500">추가된 파라미터가 없습니다</p>
|
||||
<p className="py-2 text-center text-[11px] text-muted-foreground">추가된 파라미터가 없습니다</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
<p className="text-[11px] text-gray-500">예: category=electronics, limit=10</p>
|
||||
<p className="text-[11px] text-muted-foreground">예: category=electronics, limit=10</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-gray-700">요청 헤더</Label>
|
||||
<Label className="text-xs font-medium text-foreground">요청 헤더</Label>
|
||||
<Button variant="outline" size="sm" onClick={addHeader}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
|
|
@ -523,20 +524,20 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
||||
<p className="py-2 text-center text-sm text-muted-foreground">추가된 헤더가 없습니다</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* JSON Path */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">JSON Path (선택)</Label>
|
||||
<Label className="text-xs font-medium text-foreground">JSON Path (선택)</Label>
|
||||
<Input
|
||||
placeholder="data.results"
|
||||
value={dataSource.jsonPath || ""}
|
||||
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
JSON 응답에서 데이터 배열의 경로 (예: data.results, items, response.data)
|
||||
<br />
|
||||
비워두면 전체 응답을 사용합니다
|
||||
|
|
@ -562,12 +563,12 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
|
||||
{/* 테스트 오류 */}
|
||||
{testError && (
|
||||
<div className="rounded bg-red-50 px-2 py-2">
|
||||
<div className="rounded bg-destructive/10 px-2 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600" />
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-destructive" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-red-800">API 호출 실패</div>
|
||||
<div className="mt-1 text-sm text-red-700">{testError}</div>
|
||||
<div className="text-sm font-medium text-destructive">API 호출 실패</div>
|
||||
<div className="mt-1 text-sm text-destructive">{testError}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -575,9 +576,9 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
|
||||
{/* 테스트 결과 */}
|
||||
{testResult && (
|
||||
<div className="rounded bg-green-50 px-2 py-2">
|
||||
<div className="mb-2 text-sm font-medium text-green-800">API 호출 성공</div>
|
||||
<div className="space-y-1 text-xs text-green-700">
|
||||
<div className="rounded bg-success/10 px-2 py-2">
|
||||
<div className="mb-2 text-sm font-medium text-success">API 호출 성공</div>
|
||||
<div className="space-y-1 text-xs text-success">
|
||||
<div>총 {testResult.rows.length}개의 데이터를 불러왔습니다</div>
|
||||
<div>컬럼: {testResult.columns.join(", ")}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ export function DataSourceSelector({ dataSource, onTypeChange }: DataSourceSelec
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">1단계: 데이터 소스 선택</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">차트에 표시할 데이터를 어디서 가져올지 선택하세요</p>
|
||||
<h3 className="text-lg font-semibold text-foreground">1단계: 데이터 소스 선택</h3>
|
||||
<p className="mt-1 text-sm text-foreground">차트에 표시할 데이터를 어디서 가져올지 선택하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
|
@ -28,20 +28,20 @@ export function DataSourceSelector({ dataSource, onTypeChange }: DataSourceSelec
|
|||
<Card
|
||||
className={`cursor-pointer p-6 transition-all ${
|
||||
dataSource.type === "database"
|
||||
? "border-2 border-blue-500 bg-blue-50"
|
||||
: "border-2 border-gray-200 hover:border-gray-300"
|
||||
? "border-2 border-primary bg-primary/10"
|
||||
: "border-2 border-border hover:border-border"
|
||||
}`}
|
||||
onClick={() => onTypeChange("database")}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-3 text-center">
|
||||
<div className={`rounded-full p-4 ${dataSource.type === "database" ? "bg-blue-100" : "bg-gray-100"}`}>
|
||||
<Database className={`h-8 w-8 ${dataSource.type === "database" ? "text-blue-600" : "text-gray-600"}`} />
|
||||
<div className={`rounded-full p-4 ${dataSource.type === "database" ? "bg-primary/10" : "bg-muted"}`}>
|
||||
<Database className={`h-8 w-8 ${dataSource.type === "database" ? "text-primary" : "text-foreground"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">데이터베이스</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">SQL 쿼리로 데이터 조회</p>
|
||||
<h4 className="font-semibold text-foreground">데이터베이스</h4>
|
||||
<p className="mt-1 text-sm text-foreground">SQL 쿼리로 데이터 조회</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div>✓ 현재 DB 또는 외부 DB</div>
|
||||
<div>✓ SELECT 쿼리 지원</div>
|
||||
<div>✓ 실시간 데이터 조회</div>
|
||||
|
|
@ -53,20 +53,20 @@ export function DataSourceSelector({ dataSource, onTypeChange }: DataSourceSelec
|
|||
<Card
|
||||
className={`cursor-pointer p-6 transition-all ${
|
||||
dataSource.type === "api"
|
||||
? "border-2 border-green-500 bg-green-50"
|
||||
: "border-2 border-gray-200 hover:border-gray-300"
|
||||
? "border-2 border-success bg-success/10"
|
||||
: "border-2 border-border hover:border-border"
|
||||
}`}
|
||||
onClick={() => onTypeChange("api")}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-3 text-center">
|
||||
<div className={`rounded-full p-4 ${dataSource.type === "api" ? "bg-green-100" : "bg-gray-100"}`}>
|
||||
<Globe className={`h-8 w-8 ${dataSource.type === "api" ? "text-green-600" : "text-gray-600"}`} />
|
||||
<div className={`rounded-full p-4 ${dataSource.type === "api" ? "bg-success/10" : "bg-muted"}`}>
|
||||
<Globe className={`h-8 w-8 ${dataSource.type === "api" ? "text-success" : "text-foreground"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">REST API</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터 가져오기</p>
|
||||
<h4 className="font-semibold text-foreground">REST API</h4>
|
||||
<p className="mt-1 text-sm text-foreground">외부 API에서 데이터 가져오기</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div>✓ GET 요청 지원</div>
|
||||
<div>✓ JSON 응답 파싱</div>
|
||||
<div>✓ 커스텀 헤더 설정</div>
|
||||
|
|
@ -77,10 +77,10 @@ export function DataSourceSelector({ dataSource, onTypeChange }: DataSourceSelec
|
|||
|
||||
{/* 선택된 타입 표시 */}
|
||||
{dataSource.type && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="rounded-lg border border-border bg-muted p-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-gray-700">선택됨:</span>
|
||||
<span className="text-gray-900">{dataSource.type === "database" ? "🗄️ 데이터베이스" : "🌐 REST API"}</span>
|
||||
<span className="font-medium text-foreground">선택됨:</span>
|
||||
<span className="text-foreground">{dataSource.type === "database" ? "🗄️ 데이터베이스" : "🌐 REST API"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
<div className="space-y-3">
|
||||
{/* 현재 DB vs 외부 DB 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-gray-700">데이터베이스 선택</Label>
|
||||
<Label className="mb-2 block text-xs font-medium text-foreground">데이터베이스 선택</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -61,7 +61,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
className={`flex flex-1 items-center gap-1.5 rounded border px-2 py-1.5 text-xs transition-colors ${
|
||||
dataSource.connectionType === "current"
|
||||
? "bg-primary border-primary text-white"
|
||||
: "border-gray-200 bg-white hover:bg-gray-50"
|
||||
: "border-border bg-background hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Database className="h-3 w-3" />
|
||||
|
|
@ -75,7 +75,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
className={`flex flex-1 items-center gap-1.5 rounded border px-2 py-1.5 text-xs transition-colors ${
|
||||
dataSource.connectionType === "external"
|
||||
? "bg-primary border-primary text-white"
|
||||
: "border-gray-200 bg-white hover:bg-gray-50"
|
||||
: "border-border bg-background hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Server className="h-3 w-3" />
|
||||
|
|
@ -88,12 +88,12 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
{dataSource.connectionType === "external" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-gray-700">외부 커넥션</Label>
|
||||
<Label className="text-xs font-medium text-foreground">외부 커넥션</Label>
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/admin/external-connections");
|
||||
}}
|
||||
className="flex items-center gap-1 text-[11px] text-blue-600 transition-colors hover:text-blue-700"
|
||||
className="flex items-center gap-1 text-[11px] text-primary transition-colors hover:text-primary"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
커넥션 관리
|
||||
|
|
@ -102,17 +102,17 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
|
||||
<span className="ml-2 text-xs text-gray-600">로딩 중...</span>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-blue-600" />
|
||||
<span className="ml-2 text-xs text-foreground">로딩 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded bg-red-50 px-2 py-1.5">
|
||||
<div className="text-xs text-red-800">{error}</div>
|
||||
<div className="rounded bg-destructive/10 px-2 py-1.5">
|
||||
<div className="text-xs text-destructive">{error}</div>
|
||||
<button
|
||||
onClick={loadExternalConnections}
|
||||
className="mt-1 text-[11px] text-red-600 underline hover:no-underline"
|
||||
className="mt-1 text-[11px] text-destructive underline hover:no-underline"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -120,13 +120,13 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
)}
|
||||
|
||||
{!loading && !error && connections.length === 0 && (
|
||||
<div className="rounded bg-yellow-50 px-2 py-2 text-center">
|
||||
<div className="mb-1 text-xs text-yellow-800">등록된 커넥션이 없습니다</div>
|
||||
<div className="rounded bg-warning/10 px-2 py-2 text-center">
|
||||
<div className="mb-1 text-xs text-warning">등록된 커넥션이 없습니다</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/admin/external-connections");
|
||||
}}
|
||||
className="text-[11px] text-yellow-700 underline hover:no-underline"
|
||||
className="text-[11px] text-warning underline hover:no-underline"
|
||||
>
|
||||
커넥션 등록하기
|
||||
</button>
|
||||
|
|
@ -149,7 +149,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-[10px] text-gray-500">({conn.db_type.toUpperCase()})</span>
|
||||
<span className="text-[10px] text-muted-foreground">({conn.db_type.toUpperCase()})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -157,7 +157,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
</Select>
|
||||
|
||||
{selectedConnection && (
|
||||
<div className="space-y-0.5 rounded bg-gray-50 px-2 py-1.5 text-[11px] text-gray-600">
|
||||
<div className="space-y-0.5 rounded bg-muted px-2 py-1.5 text-[11px] text-foreground">
|
||||
<div>
|
||||
<span className="font-medium">커넥션:</span> {selectedConnection.connection_name}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
||||
interface MultiApiConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
|
|
@ -220,7 +221,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
}
|
||||
});
|
||||
|
||||
const response = await fetch("/api/dashboards/fetch-external-api", {
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
|
|
@ -629,8 +630,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
<div
|
||||
className={`flex items-center gap-2 rounded-md p-2 text-xs ${
|
||||
testResult.success
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-red-50 text-red-700"
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
|
|
@ -709,12 +710,12 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
}[type];
|
||||
|
||||
const typeColor = {
|
||||
number: "text-blue-600 bg-blue-50",
|
||||
string: "text-gray-600 bg-gray-50",
|
||||
date: "text-purple-600 bg-purple-50",
|
||||
boolean: "text-green-600 bg-green-50",
|
||||
object: "text-orange-600 bg-orange-50",
|
||||
unknown: "text-gray-400 bg-gray-50"
|
||||
number: "text-primary bg-primary/10",
|
||||
string: "text-muted-foreground bg-muted",
|
||||
date: "text-purple-500 bg-purple-500/10",
|
||||
boolean: "text-success bg-success/10",
|
||||
object: "text-warning bg-warning/10",
|
||||
unknown: "text-muted-foreground/50 bg-muted"
|
||||
}[type];
|
||||
|
||||
return (
|
||||
|
|
@ -745,7 +746,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
h-4 w-4 rounded border-2 flex items-center justify-center transition-colors
|
||||
${isSelected
|
||||
? "border-primary bg-primary"
|
||||
: "border-gray-300 bg-background"
|
||||
: "border-border bg-background"
|
||||
}
|
||||
`}>
|
||||
{isSelected && (
|
||||
|
|
|
|||
|
|
@ -324,10 +324,10 @@ export default function MultiDataSourceConfig({
|
|||
{(item.status || item.level) && (
|
||||
<div className={`rounded px-2 py-0.5 text-[10px] font-medium ${
|
||||
(item.status || item.level)?.includes('경보') || (item.status || item.level)?.includes('위험')
|
||||
? 'bg-red-100 text-red-700'
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
: (item.status || item.level)?.includes('주의')
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
? 'bg-warning/10 text-warning'
|
||||
: 'bg-primary/10 text-primary'
|
||||
}`}>
|
||||
{item.status || item.level}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -406,8 +406,8 @@ ORDER BY 하위부서수 DESC`,
|
|||
<div
|
||||
className={`flex items-center gap-2 rounded-md p-2 text-xs ${
|
||||
testResult.success
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-red-50 text-red-700"
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
|
|
@ -491,12 +491,12 @@ ORDER BY 하위부서수 DESC`,
|
|||
}[type];
|
||||
|
||||
const typeColor = {
|
||||
number: "text-blue-600 bg-blue-50",
|
||||
string: "text-gray-600 bg-gray-50",
|
||||
date: "text-purple-600 bg-purple-50",
|
||||
boolean: "text-green-600 bg-green-50",
|
||||
object: "text-orange-600 bg-orange-50",
|
||||
unknown: "text-gray-400 bg-gray-50"
|
||||
number: "text-primary bg-primary/10",
|
||||
string: "text-foreground bg-muted",
|
||||
date: "text-purple-500 bg-purple-500/10",
|
||||
boolean: "text-success bg-success/10",
|
||||
object: "text-warning bg-warning/10",
|
||||
unknown: "text-muted-foreground bg-muted"
|
||||
}[type];
|
||||
|
||||
return (
|
||||
|
|
@ -527,7 +527,7 @@ ORDER BY 하위부서수 DESC`,
|
|||
h-4 w-4 rounded border-2 flex items-center justify-center transition-colors
|
||||
${isSelected
|
||||
? "border-primary bg-primary"
|
||||
: "border-gray-300 bg-background"
|
||||
: "border-border bg-background"
|
||||
}
|
||||
`}>
|
||||
{isSelected && (
|
||||
|
|
|
|||
|
|
@ -2,12 +2,10 @@
|
|||
* 대시보드 관리 컴포넌트 인덱스
|
||||
*/
|
||||
|
||||
export { default as DashboardDesigner } from './DashboardDesigner';
|
||||
export { DashboardCanvas } from './DashboardCanvas';
|
||||
export { DashboardSidebar } from './DashboardSidebar';
|
||||
export { DashboardToolbar } from './DashboardToolbar';
|
||||
export { CanvasElement } from './CanvasElement';
|
||||
export { QueryEditor } from './QueryEditor';
|
||||
export { ChartConfigPanel } from './ChartConfigPanel';
|
||||
export { ElementConfigModal } from './ElementConfigModal';
|
||||
export * from './types';
|
||||
export { default as DashboardDesigner } from "./DashboardDesigner";
|
||||
export { DashboardCanvas } from "./DashboardCanvas";
|
||||
export { DashboardSidebar } from "./DashboardSidebar";
|
||||
export { CanvasElement } from "./CanvasElement";
|
||||
export { QueryEditor } from "./QueryEditor";
|
||||
export { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
export * from "./types";
|
||||
|
|
|
|||
|
|
@ -91,19 +91,19 @@ export function CalendarSettings({ config, onSave, onClose }: CalendarSettingsPr
|
|||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
gradient: "bg-gradient-to-br from-white to-gray-100",
|
||||
text: "text-gray-900",
|
||||
gradient: "bg-gradient-to-br from-background to-muted",
|
||||
text: "text-foreground",
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
gradient: "bg-gradient-to-br from-gray-800 to-gray-900",
|
||||
gradient: "bg-gradient-to-br from-foreground to-foreground",
|
||||
text: "text-white",
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "사용자",
|
||||
gradient: "bg-gradient-to-br from-blue-400 to-purple-600",
|
||||
gradient: "bg-gradient-to-br from-primary to-purple-500",
|
||||
text: "text-white",
|
||||
},
|
||||
].map((theme) => (
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps)
|
|||
return (
|
||||
<div className="relative flex h-full w-full flex-col">
|
||||
{/* 헤더 - 네비게이션 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-2">
|
||||
<div className="flex items-center justify-between border-b border-border p-2">
|
||||
{/* 이전 월 버튼 */}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePrevMonth}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
|
|
@ -123,7 +123,7 @@ export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps)
|
|||
<div className="absolute bottom-2 right-2">
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 bg-background/80 hover:bg-background">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
|
|||
|
|
@ -97,19 +97,19 @@ export function ClockSettings({ config, onSave, onClose }: ClockSettingsProps) {
|
|||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
gradient: "bg-gradient-to-br from-white to-gray-100",
|
||||
text: "text-gray-900",
|
||||
gradient: "bg-gradient-to-br from-background to-muted",
|
||||
text: "text-foreground",
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
gradient: "bg-gradient-to-br from-gray-800 to-gray-900",
|
||||
gradient: "bg-gradient-to-br from-foreground to-foreground",
|
||||
text: "text-white",
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "사용자",
|
||||
gradient: "bg-gradient-to-br from-blue-400 to-purple-600",
|
||||
gradient: "bg-gradient-to-br from-primary to-purple-500",
|
||||
text: "text-white",
|
||||
},
|
||||
].map((theme) => (
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export function ClockWidget({ element, onConfigUpdate }: ClockWidgetProps) {
|
|||
<div className="absolute top-2 right-2">
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 bg-background/80 hover:bg-background">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
|
|||
|
|
@ -112,22 +112,22 @@ function getThemeClasses(theme: string, customColor?: string) {
|
|||
|
||||
const themes = {
|
||||
light: {
|
||||
container: "bg-white text-gray-900",
|
||||
date: "text-gray-600",
|
||||
time: "text-gray-900",
|
||||
timezone: "text-gray-500",
|
||||
container: "bg-background text-foreground",
|
||||
date: "text-foreground",
|
||||
time: "text-foreground",
|
||||
timezone: "text-muted-foreground",
|
||||
},
|
||||
dark: {
|
||||
container: "bg-gray-900 text-white",
|
||||
date: "text-gray-300",
|
||||
date: "text-muted-foreground",
|
||||
time: "text-white",
|
||||
timezone: "text-gray-400",
|
||||
timezone: "text-muted-foreground",
|
||||
},
|
||||
custom: {
|
||||
container: "bg-gradient-to-br from-blue-400 to-purple-600 text-white",
|
||||
date: "text-blue-100",
|
||||
container: "bg-gradient-to-br from-primary to-purple-500 text-white",
|
||||
date: "text-primary/70",
|
||||
time: "text-white",
|
||||
timezone: "text-blue-200",
|
||||
timezone: "text-primary/80",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -25,25 +25,25 @@ export function DriverListView({ drivers, config, isCompact = false }: DriverLis
|
|||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-3 p-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">{drivers.length}</div>
|
||||
<div className="text-sm text-gray-600">전체 기사</div>
|
||||
<div className="text-3xl font-bold text-foreground">{drivers.length}</div>
|
||||
<div className="text-sm text-foreground">전체 기사</div>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-2 gap-2 text-center text-xs">
|
||||
<div className="rounded-lg bg-green-100 p-2">
|
||||
<div className="font-semibold text-green-800">{stats.driving}</div>
|
||||
<div className="text-green-600">운행중</div>
|
||||
<div className="rounded-lg bg-success/10 p-2">
|
||||
<div className="font-semibold text-success">{stats.driving}</div>
|
||||
<div className="text-success">운행중</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-100 p-2">
|
||||
<div className="font-semibold text-gray-800">{stats.standby}</div>
|
||||
<div className="text-gray-600">대기중</div>
|
||||
<div className="rounded-lg bg-muted p-2">
|
||||
<div className="font-semibold text-foreground">{stats.standby}</div>
|
||||
<div className="text-foreground">대기중</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-orange-100 p-2">
|
||||
<div className="font-semibold text-orange-800">{stats.resting}</div>
|
||||
<div className="text-orange-600">휴식중</div>
|
||||
<div className="rounded-lg bg-warning/10 p-2">
|
||||
<div className="font-semibold text-warning">{stats.resting}</div>
|
||||
<div className="text-warning">휴식중</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-red-100 p-2">
|
||||
<div className="font-semibold text-red-800">{stats.maintenance}</div>
|
||||
<div className="text-red-600">점검중</div>
|
||||
<div className="rounded-lg bg-destructive/10 p-2">
|
||||
<div className="font-semibold text-destructive">{stats.maintenance}</div>
|
||||
<div className="text-destructive">점검중</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -53,54 +53,54 @@ export function DriverListView({ drivers, config, isCompact = false }: DriverLis
|
|||
// 빈 데이터 처리
|
||||
if (drivers.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-500">조회된 기사 정보가 없습니다</div>
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">조회된 기사 정보가 없습니다</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="sticky top-0 z-10 bg-gray-50">
|
||||
<thead className="sticky top-0 z-10 bg-muted">
|
||||
<tr>
|
||||
{visibleColumns.includes("status") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.status}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground">{COLUMN_LABELS.status}</th>
|
||||
)}
|
||||
{visibleColumns.includes("name") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.name}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground">{COLUMN_LABELS.name}</th>
|
||||
)}
|
||||
{visibleColumns.includes("vehicleNumber") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.vehicleNumber}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground">{COLUMN_LABELS.vehicleNumber}</th>
|
||||
)}
|
||||
{visibleColumns.includes("vehicleType") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.vehicleType}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground">{COLUMN_LABELS.vehicleType}</th>
|
||||
)}
|
||||
{visibleColumns.includes("departure") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.departure}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground">{COLUMN_LABELS.departure}</th>
|
||||
)}
|
||||
{visibleColumns.includes("destination") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.destination}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground">{COLUMN_LABELS.destination}</th>
|
||||
)}
|
||||
{visibleColumns.includes("departureTime") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.departureTime}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground">{COLUMN_LABELS.departureTime}</th>
|
||||
)}
|
||||
{visibleColumns.includes("estimatedArrival") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground">
|
||||
{COLUMN_LABELS.estimatedArrival}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.includes("phone") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.phone}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground">{COLUMN_LABELS.phone}</th>
|
||||
)}
|
||||
{visibleColumns.includes("progress") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.progress}</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-foreground">{COLUMN_LABELS.progress}</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
<tbody className="divide-y divide-gray-200 bg-background">
|
||||
{drivers.map((driver) => {
|
||||
const statusColors = getStatusColor(driver.status);
|
||||
return (
|
||||
<tr key={driver.id} className="transition-colors hover:bg-gray-50">
|
||||
<tr key={driver.id} className="transition-colors hover:bg-muted">
|
||||
{visibleColumns.includes("status") && (
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
|
|
@ -111,42 +111,42 @@ export function DriverListView({ drivers, config, isCompact = false }: DriverLis
|
|||
</td>
|
||||
)}
|
||||
{visibleColumns.includes("name") && (
|
||||
<td className="px-3 py-2 text-sm font-medium text-gray-900">{driver.name}</td>
|
||||
<td className="px-3 py-2 text-sm font-medium text-foreground">{driver.name}</td>
|
||||
)}
|
||||
{visibleColumns.includes("vehicleNumber") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-700">{driver.vehicleNumber}</td>
|
||||
<td className="px-3 py-2 text-sm text-foreground">{driver.vehicleNumber}</td>
|
||||
)}
|
||||
{visibleColumns.includes("vehicleType") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-600">{driver.vehicleType}</td>
|
||||
<td className="px-3 py-2 text-sm text-foreground">{driver.vehicleType}</td>
|
||||
)}
|
||||
{visibleColumns.includes("departure") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-700">
|
||||
{driver.departure || <span className="text-gray-400">-</span>}
|
||||
<td className="px-3 py-2 text-sm text-foreground">
|
||||
{driver.departure || <span className="text-muted-foreground">-</span>}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.includes("destination") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-700">
|
||||
{driver.destination || <span className="text-gray-400">-</span>}
|
||||
<td className="px-3 py-2 text-sm text-foreground">
|
||||
{driver.destination || <span className="text-muted-foreground">-</span>}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.includes("departureTime") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-600">{formatTime(driver.departureTime)}</td>
|
||||
<td className="px-3 py-2 text-sm text-foreground">{formatTime(driver.departureTime)}</td>
|
||||
)}
|
||||
{visibleColumns.includes("estimatedArrival") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-600">{formatTime(driver.estimatedArrival)}</td>
|
||||
<td className="px-3 py-2 text-sm text-foreground">{formatTime(driver.estimatedArrival)}</td>
|
||||
)}
|
||||
{visibleColumns.includes("phone") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-600">{driver.phone}</td>
|
||||
<td className="px-3 py-2 text-sm text-foreground">{driver.phone}</td>
|
||||
)}
|
||||
{visibleColumns.includes("progress") && (
|
||||
<td className="px-3 py-2">
|
||||
{driver.progress !== undefined ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress value={driver.progress} className="h-2 w-16" />
|
||||
<span className="text-xs text-gray-600">{driver.progress}%</span>
|
||||
<span className="text-xs text-foreground">{driver.progress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export function DriverManagementSettings({ config, onSave, onClose }: DriverMana
|
|||
<Card
|
||||
key={key}
|
||||
className={`cursor-pointer border p-3 transition-colors ${
|
||||
localConfig.visibleColumns.includes(key) ? "border-primary bg-primary/5" : "hover:bg-gray-50"
|
||||
localConfig.visibleColumns.includes(key) ? "border-primary bg-primary/5" : "hover:bg-muted"
|
||||
}`}
|
||||
onClick={() => toggleColumn(key)}
|
||||
>
|
||||
|
|
@ -128,7 +128,7 @@ export function DriverManagementSettings({ config, onSave, onClose }: DriverMana
|
|||
</div>
|
||||
|
||||
{/* 푸터 - 고정 */}
|
||||
<div className="flex flex-shrink-0 justify-end gap-3 border-t border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex flex-shrink-0 justify-end gap-3 border-t border-border bg-muted p-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -70,14 +70,14 @@ export function DriverManagementWidget({ element, onConfigUpdate }: DriverManage
|
|||
const isCompact = element.size.width < 400 || element.size.height < 300;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col bg-white">
|
||||
<div className="relative flex h-full w-full flex-col bg-background">
|
||||
{/* 헤더 - 컴팩트 모드가 아닐 때만 표시 */}
|
||||
{!isCompact && (
|
||||
<div className="flex-shrink-0 border-b border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<div className="flex-shrink-0 border-b border-border bg-muted px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* 검색 */}
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="기사명, 차량번호 검색"
|
||||
|
|
@ -132,20 +132,20 @@ export function DriverManagementWidget({ element, onConfigUpdate }: DriverManage
|
|||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-600">
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-foreground">
|
||||
<span>
|
||||
전체 <span className="font-semibold text-gray-900">{filteredDrivers.length}</span>명
|
||||
전체 <span className="font-semibold text-foreground">{filteredDrivers.length}</span>명
|
||||
</span>
|
||||
<span className="text-gray-400">|</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span>
|
||||
운행중{" "}
|
||||
<span className="font-semibold text-green-600">
|
||||
<span className="font-semibold text-success">
|
||||
{filteredDrivers.filter((d) => d.status === "driving").length}
|
||||
</span>
|
||||
명
|
||||
</span>
|
||||
<span className="text-gray-400">|</span>
|
||||
<span className="text-xs text-gray-500">최근 업데이트: {lastRefresh.toLocaleTimeString("ko-KR")}</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-xs text-muted-foreground">최근 업데이트: {lastRefresh.toLocaleTimeString("ko-KR")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
||||
interface ListWidgetProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -58,7 +59,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
});
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -167,8 +168,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<div className="text-sm text-gray-600">데이터 로딩 중...</div>
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<div className="text-sm text-foreground">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -180,8 +181,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-red-600">오류 발생</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{error}</div>
|
||||
<div className="text-sm font-medium text-destructive">오류 발생</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -193,8 +194,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📋</div>
|
||||
<div className="text-sm font-medium text-gray-700">리스트를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
||||
<div className="text-sm font-medium text-foreground">리스트를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -221,7 +222,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
<div className="flex h-full w-full flex-col p-4">
|
||||
{/* 제목 - 항상 표시 */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">{element.customTitle || element.title}</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">{element.customTitle || element.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* 테이블 뷰 */}
|
||||
|
|
@ -250,7 +251,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={displayColumns.filter((col) => col.visible).length}
|
||||
className="text-center text-gray-500"
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
데이터가 없습니다
|
||||
</TableCell>
|
||||
|
|
@ -280,7 +281,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
{config.viewMode === "card" && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{paginatedRows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">데이터가 없습니다</div>
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">데이터가 없습니다</div>
|
||||
) : (
|
||||
<div
|
||||
className={`grid gap-4 ${config.compactMode ? "text-xs" : "text-sm"}`}
|
||||
|
|
@ -295,9 +296,9 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<div key={col.id}>
|
||||
<div className="text-xs font-medium text-gray-500">{col.label || col.name}</div>
|
||||
<div className="text-xs font-medium text-muted-foreground">{col.label || col.name}</div>
|
||||
<div
|
||||
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
||||
className={`font-medium text-foreground ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
||||
>
|
||||
{String(row[col.dataKey || col.field] ?? "")}
|
||||
</div>
|
||||
|
|
@ -314,7 +315,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
{/* 페이지네이션 */}
|
||||
{config.enablePagination && totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-600">
|
||||
<div className="text-foreground">
|
||||
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -327,9 +328,9 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
이전
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<span className="text-gray-700">{currentPage}</span>
|
||||
<span className="text-gray-400">/</span>
|
||||
<span className="text-gray-500">{totalPages}</span>
|
||||
<span className="text-foreground">{currentPage}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-muted-foreground">{totalPages}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -1,326 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig, ListColumn } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ChevronLeft, ChevronRight, Save, X } from "lucide-react";
|
||||
import { DataSourceSelector } from "../data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "../data-sources/ApiConfig";
|
||||
import { QueryEditor } from "../QueryEditor";
|
||||
import { ColumnSelector } from "./list-widget/ColumnSelector";
|
||||
import { ManualColumnEditor } from "./list-widget/ManualColumnEditor";
|
||||
import { ListTableOptions } from "./list-widget/ListTableOptions";
|
||||
|
||||
interface ListWidgetConfigModalProps {
|
||||
isOpen: boolean;
|
||||
element: DashboardElement;
|
||||
onClose: () => void;
|
||||
onSave: (updates: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 위젯 설정 모달
|
||||
* - 3단계 설정: 데이터 소스 → 데이터 가져오기 → 컬럼 설정
|
||||
*/
|
||||
export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: ListWidgetConfigModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
||||
const [title, setTitle] = useState(element.title || "📋 리스트");
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
|
||||
element.listConfig || {
|
||||
columnMode: "auto",
|
||||
viewMode: "table",
|
||||
columns: [],
|
||||
pageSize: 10,
|
||||
enablePagination: true,
|
||||
showHeader: true,
|
||||
stripedRows: true,
|
||||
compactMode: false,
|
||||
cardColumns: 3,
|
||||
},
|
||||
);
|
||||
|
||||
// 모달 열릴 때 element에서 설정 로드 (한 번만)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// element가 변경되었을 때만 설정을 다시 로드
|
||||
setTitle(element.title || "📋 리스트");
|
||||
|
||||
// 기존 dataSource가 있으면 그대로 사용, 없으면 기본값
|
||||
if (element.dataSource) {
|
||||
setDataSource(element.dataSource);
|
||||
}
|
||||
|
||||
// 기존 listConfig가 있으면 그대로 사용, 없으면 기본값
|
||||
if (element.listConfig) {
|
||||
setListConfig(element.listConfig);
|
||||
}
|
||||
|
||||
// 현재 스텝은 1로 초기화
|
||||
setCurrentStep(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, element.id]); // element.id가 변경될 때만 재실행
|
||||
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource((prev) => ({
|
||||
...prev,
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
}));
|
||||
} else {
|
||||
setDataSource((prev) => ({
|
||||
...prev,
|
||||
type: "api",
|
||||
method: "GET",
|
||||
}));
|
||||
}
|
||||
|
||||
// 데이터 소스 타입 변경 시에는 쿼리 결과만 초기화 (컬럼 설정은 유지)
|
||||
setQueryResult(null);
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 쿼리 실행 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
|
||||
// 쿼리 실행할 때마다 컬럼 초기화 후 자동 생성
|
||||
if (result.columns.length > 0) {
|
||||
const autoColumns: ListColumn[] = result.columns.map((col, idx) => ({
|
||||
id: `col_${idx}`,
|
||||
label: col,
|
||||
field: col,
|
||||
align: "left",
|
||||
visible: true,
|
||||
}));
|
||||
setListConfig((prev) => ({ ...prev, columns: autoColumns }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 다음 단계
|
||||
const handleNext = () => {
|
||||
if (currentStep < 3) {
|
||||
setCurrentStep((prev) => (prev + 1) as 1 | 2 | 3);
|
||||
}
|
||||
};
|
||||
|
||||
// 이전 단계
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2 | 3);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
customTitle: title,
|
||||
dataSource,
|
||||
listConfig,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 저장 가능 여부
|
||||
const canSave = queryResult && queryResult.rows.length > 0 && listConfig.columns.length > 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-[90vw] max-w-6xl flex-col rounded-xl border bg-white shadow-2xl">
|
||||
{/* 헤더 */}
|
||||
<div className="space-y-4 border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">📋 리스트 위젯 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">데이터 소스와 컬럼을 설정하세요</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-gray-100">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* 제목 입력 */}
|
||||
<div>
|
||||
<Label htmlFor="list-title" className="text-sm font-medium">
|
||||
리스트 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="list-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="예: 사용자 목록"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 참고: 리스트 위젯은 제목이 항상 표시됩니다 */}
|
||||
<div className="rounded bg-blue-50 p-2 text-xs text-blue-700">💡 리스트 위젯은 제목이 항상 표시됩니다</div>
|
||||
</div>
|
||||
|
||||
{/* 진행 상태 표시 */}
|
||||
<div className="border-b bg-gray-50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 1 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 1 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="text-sm font-medium">데이터 소스</span>
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-gray-300" />
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 2 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 2 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="text-sm font-medium">데이터 가져오기</span>
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-gray-300" />
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 3 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 3 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span className="text-sm font-medium">컬럼 설정</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{currentStep === 1 && (
|
||||
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 왼쪽: 데이터 소스 설정 */}
|
||||
<div>
|
||||
{dataSource.type === "database" ? (
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
) : (
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
)}
|
||||
|
||||
{dataSource.type === "database" && (
|
||||
<div className="mt-4">
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 데이터 미리보기 */}
|
||||
<div>
|
||||
{queryResult && queryResult.rows.length > 0 ? (
|
||||
<div className="rounded-lg border bg-gray-50 p-4">
|
||||
<h3 className="mb-3 font-semibold text-gray-800">📋 데이터 미리보기</h3>
|
||||
<div className="overflow-x-auto rounded bg-white p-3">
|
||||
<Badge variant="secondary" className="mb-2">
|
||||
{queryResult.totalRows}개 데이터
|
||||
</Badge>
|
||||
<pre className="text-xs text-gray-700">
|
||||
{JSON.stringify(queryResult.rows.slice(0, 3), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 미리보기가 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && queryResult && (
|
||||
<div className="space-y-6">
|
||||
{listConfig.columnMode === "auto" ? (
|
||||
<ColumnSelector
|
||||
availableColumns={queryResult.columns}
|
||||
selectedColumns={listConfig.columns}
|
||||
sampleData={queryResult.rows[0]}
|
||||
onChange={(columns) => setListConfig((prev) => ({ ...prev, columns }))}
|
||||
/>
|
||||
) : (
|
||||
<ManualColumnEditor
|
||||
availableFields={queryResult.columns}
|
||||
columns={listConfig.columns}
|
||||
onChange={(columns) => setListConfig((prev) => ({ ...prev, columns }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ListTableOptions
|
||||
config={listConfig}
|
||||
onChange={(updates) => setListConfig((prev) => ({ ...prev, ...updates }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
||||
<div>
|
||||
{queryResult && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
📊 {queryResult.rows.length}개 데이터 로드됨
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
{currentStep < 3 ? (
|
||||
<Button onClick={handleNext} disabled={currentStep === 2 && !queryResult}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -132,31 +132,31 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center justify-between bg-background px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
||||
<span className="text-primary text-xs font-bold">📋</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-900">리스트 위젯 설정</span>
|
||||
<span className="text-xs font-semibold text-foreground">리스트 위젯 설정</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문: 스크롤 가능 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{/* 기본 설정 */}
|
||||
<div className="mb-3 rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">기본 설정</div>
|
||||
<div className="mb-3 rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">기본 설정</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<input
|
||||
|
|
@ -165,31 +165,31 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
|
|||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="리스트 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white focus:ring-1 focus:outline-none"
|
||||
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-border bg-muted px-2 text-xs placeholder:text-muted-foreground focus:bg-background focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 소스 */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스</div>
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">데이터 소스</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue={dataSource.type}
|
||||
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid h-7 w-full grid-cols-2 bg-gray-100 p-0.5">
|
||||
<TabsList className="grid h-7 w-full grid-cols-2 bg-muted p-0.5">
|
||||
<TabsTrigger
|
||||
value="database"
|
||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
||||
className="h-6 rounded text-[11px] data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
||||
>
|
||||
데이터베이스
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="api"
|
||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
||||
className="h-6 rounded text-[11px] data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
||||
>
|
||||
REST API
|
||||
</TabsTrigger>
|
||||
|
|
@ -211,17 +211,17 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
|
|||
|
||||
{/* 데이터 로드 상태 */}
|
||||
{queryResult && (
|
||||
<div className="mt-2 flex items-center gap-1.5 rounded bg-green-50 px-2 py-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span className="text-[10px] font-medium text-green-700">{queryResult.rows.length}개 데이터 로드됨</span>
|
||||
<div className="mt-2 flex items-center gap-1.5 rounded bg-success/10 px-2 py-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
<span className="text-[10px] font-medium text-success">{queryResult.rows.length}개 데이터 로드됨</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
||||
{queryResult && (
|
||||
<div className="mt-3 rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">컬럼 설정</div>
|
||||
<div className="mt-3 rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">컬럼 설정</div>
|
||||
<UnifiedColumnEditor
|
||||
queryResult={queryResult}
|
||||
config={listConfig}
|
||||
|
|
@ -232,18 +232,18 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
|
|||
|
||||
{/* 테이블 옵션 - 컬럼이 있을 때만 표시 */}
|
||||
{listConfig.columns.length > 0 && (
|
||||
<div className="mt-3 rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">테이블 옵션</div>
|
||||
<div className="mt-3 rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">테이블 옵션</div>
|
||||
<ListTableOptions config={listConfig} onConfigChange={handleListConfigChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터: 적용 버튼 */}
|
||||
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<div className="flex gap-2 bg-background p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
||||
className="flex-1 rounded bg-muted py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -67,11 +67,11 @@ export function MonthView({ days, config, isCompact = false, selectedDate, onDat
|
|||
const sizeClass = isCompact ? "text-xs" : "text-sm";
|
||||
const cursorClass = day.isCurrentMonth ? "cursor-pointer" : "cursor-default";
|
||||
|
||||
let colorClass = "text-gray-700";
|
||||
let colorClass = "text-foreground";
|
||||
|
||||
// 현재 월이 아닌 날짜
|
||||
if (!day.isCurrentMonth) {
|
||||
colorClass = "text-gray-300";
|
||||
colorClass = "text-muted-foreground";
|
||||
}
|
||||
// 선택된 날짜
|
||||
else if (isSelected(day)) {
|
||||
|
|
@ -87,7 +87,7 @@ export function MonthView({ days, config, isCompact = false, selectedDate, onDat
|
|||
}
|
||||
// 주말
|
||||
else if (config.highlightWeekends && day.isWeekend) {
|
||||
colorClass = "text-red-600";
|
||||
colorClass = "text-destructive";
|
||||
}
|
||||
|
||||
let bgClass = "";
|
||||
|
|
@ -96,7 +96,7 @@ export function MonthView({ days, config, isCompact = false, selectedDate, onDat
|
|||
} else if (config.highlightToday && day.isToday) {
|
||||
bgClass = "";
|
||||
} else {
|
||||
bgClass = "hover:bg-gray-100";
|
||||
bgClass = "hover:bg-muted";
|
||||
}
|
||||
|
||||
return `${baseClass} ${sizeClass} ${colorClass} ${bgClass} ${cursorClass}`;
|
||||
|
|
@ -112,7 +112,7 @@ export function MonthView({ days, config, isCompact = false, selectedDate, onDat
|
|||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`text-center text-xs font-semibold ${isWeekend && config.highlightWeekends ? "text-red-600" : "text-gray-600"}`}
|
||||
className={`text-center text-xs font-semibold ${isWeekend && config.highlightWeekends ? "text-destructive" : "text-foreground"}`}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,664 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ChartDataSource, QueryResult } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ChevronLeft, ChevronRight, Save, X } from "lucide-react";
|
||||
import { DataSourceSelector } from "../data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "../data-sources/ApiConfig";
|
||||
import { QueryEditor } from "../QueryEditor";
|
||||
|
||||
interface TodoWidgetConfigModalProps {
|
||||
isOpen: boolean;
|
||||
element: DashboardElement;
|
||||
onClose: () => void;
|
||||
onSave: (updates: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정관리 위젯 설정 모달 (범용)
|
||||
* - 2단계 설정: 데이터 소스 → 쿼리 입력/테스트
|
||||
*/
|
||||
export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: TodoWidgetConfigModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
|
||||
const [title, setTitle] = useState(element.title || "일정관리 위젯");
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
|
||||
// 데이터베이스 연동 설정
|
||||
const [enableDbSync, setEnableDbSync] = useState(element.chartConfig?.enableDbSync || false);
|
||||
const [dbSyncMode, setDbSyncMode] = useState<"simple" | "advanced">(element.chartConfig?.dbSyncMode || "simple");
|
||||
const [tableName, setTableName] = useState(element.chartConfig?.tableName || "");
|
||||
const [columnMapping, setColumnMapping] = useState(element.chartConfig?.columnMapping || {
|
||||
id: "id",
|
||||
title: "title",
|
||||
description: "description",
|
||||
priority: "priority",
|
||||
status: "status",
|
||||
assignedTo: "assigned_to",
|
||||
dueDate: "due_date",
|
||||
isUrgent: "is_urgent",
|
||||
});
|
||||
|
||||
// 모달 열릴 때 element에서 설정 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTitle(element.title || "일정관리 위젯");
|
||||
|
||||
// 데이터 소스 설정 로드 (저장된 설정 우선, 없으면 기본값)
|
||||
const loadedDataSource = element.dataSource || {
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
refreshInterval: 0
|
||||
};
|
||||
setDataSource(loadedDataSource);
|
||||
|
||||
// 저장된 쿼리가 있으면 자동으로 실행 (실제 결과 가져오기)
|
||||
if (loadedDataSource.query) {
|
||||
// 쿼리 자동 실행
|
||||
const executeQuery = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
const userLang = localStorage.getItem("userLang") || "KR";
|
||||
|
||||
const apiUrl = loadedDataSource.connectionType === "external" && loadedDataSource.externalConnectionId
|
||||
? `http://localhost:9771/api/external-db/query?userLang=${userLang}`
|
||||
: `http://localhost:9771/api/dashboards/execute-query?userLang=${userLang}`;
|
||||
|
||||
const requestBody = loadedDataSource.connectionType === "external" && loadedDataSource.externalConnectionId
|
||||
? {
|
||||
connectionId: parseInt(loadedDataSource.externalConnectionId),
|
||||
query: loadedDataSource.query,
|
||||
}
|
||||
: { query: loadedDataSource.query };
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const rows = result.data?.rows || result.data || [];
|
||||
setQueryResult({
|
||||
rows: rows,
|
||||
rowCount: rows.length,
|
||||
executionTime: 0,
|
||||
});
|
||||
} else {
|
||||
// 실패해도 더미 결과로 2단계 진입 가능
|
||||
setQueryResult({
|
||||
rows: [{ _info: "저장된 쿼리가 있습니다. 다시 테스트해주세요." }],
|
||||
rowCount: 1,
|
||||
executionTime: 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 에러 발생해도 2단계 진입 가능
|
||||
setQueryResult({
|
||||
rows: [{ _info: "저장된 쿼리가 있습니다. 다시 테스트해주세요." }],
|
||||
rowCount: 1,
|
||||
executionTime: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
executeQuery();
|
||||
}
|
||||
|
||||
// DB 동기화 설정 로드
|
||||
setEnableDbSync(element.chartConfig?.enableDbSync || false);
|
||||
setDbSyncMode(element.chartConfig?.dbSyncMode || "simple");
|
||||
setTableName(element.chartConfig?.tableName || "");
|
||||
if (element.chartConfig?.columnMapping) {
|
||||
setColumnMapping(element.chartConfig.columnMapping);
|
||||
}
|
||||
setCurrentStep(1);
|
||||
}
|
||||
}, [isOpen, element.id]);
|
||||
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource((prev) => ({
|
||||
...prev,
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
}));
|
||||
} else {
|
||||
setDataSource((prev) => ({
|
||||
...prev,
|
||||
type: "api",
|
||||
method: "GET",
|
||||
}));
|
||||
}
|
||||
setQueryResult(null);
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 쿼리 실행 결과 처리
|
||||
const handleQueryTest = useCallback(
|
||||
(result: QueryResult) => {
|
||||
// console.log("🎯 TodoWidget - handleQueryTest 호출됨!");
|
||||
// console.log("📊 쿼리 결과:", result);
|
||||
// console.log("📝 rows 개수:", result.rows?.length);
|
||||
// console.log("❌ error:", result.error);
|
||||
setQueryResult(result);
|
||||
// console.log("✅ setQueryResult 호출 완료!");
|
||||
|
||||
// 강제 리렌더링 확인
|
||||
// setTimeout(() => {
|
||||
// console.log("🔄 1초 후 queryResult 상태:", result);
|
||||
// }, 1000);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(() => {
|
||||
if (!dataSource.query || !queryResult || queryResult.error) {
|
||||
alert("쿼리를 입력하고 테스트를 먼저 실행해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!queryResult.rows || queryResult.rows.length === 0) {
|
||||
alert("쿼리 결과가 없습니다. 데이터가 반환되는 쿼리를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 간편 모드에서 테이블명 필수 체크
|
||||
if (enableDbSync && dbSyncMode === "simple" && !tableName.trim()) {
|
||||
alert("데이터베이스 연동을 활성화하려면 테이블명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
title,
|
||||
dataSource,
|
||||
chartConfig: {
|
||||
...element.chartConfig,
|
||||
enableDbSync,
|
||||
dbSyncMode,
|
||||
tableName,
|
||||
columnMapping,
|
||||
insertQuery: element.chartConfig?.insertQuery,
|
||||
updateQuery: element.chartConfig?.updateQuery,
|
||||
deleteQuery: element.chartConfig?.deleteQuery,
|
||||
},
|
||||
});
|
||||
|
||||
onClose();
|
||||
}, [title, dataSource, queryResult, enableDbSync, dbSyncMode, tableName, columnMapping, element.chartConfig, onSave, onClose]);
|
||||
|
||||
// 다음 단계로
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 1) {
|
||||
if (dataSource.type === "database") {
|
||||
if (!dataSource.connectionId && dataSource.connectionType === "external") {
|
||||
alert("외부 데이터베이스를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
} else if (dataSource.type === "api") {
|
||||
if (!dataSource.url) {
|
||||
alert("API URL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrentStep(2);
|
||||
}
|
||||
}, [currentStep, dataSource]);
|
||||
|
||||
// 이전 단계로
|
||||
const handlePrev = useCallback(() => {
|
||||
if (currentStep === 2) {
|
||||
setCurrentStep(1);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
|
||||
<div className="relative flex h-[90vh] w-[90vw] max-w-6xl flex-col rounded-lg bg-white shadow-xl">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">일정관리 위젯 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
데이터 소스와 쿼리를 설정하면 자동으로 일정 목록이 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 진행 상태 */}
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-6 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center gap-2 ${currentStep === 1 ? "text-primary" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full font-semibold ${
|
||||
currentStep === 1 ? "bg-primary text-white" : "bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="font-medium">데이터 소스 선택</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<div className={`flex items-center gap-2 ${currentStep === 2 ? "text-primary" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full font-semibold ${
|
||||
currentStep === 2 ? "bg-primary text-white" : "bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="font-medium">쿼리 입력 및 테스트</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Step 1: 데이터 소스 선택 */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-base font-semibold">제목</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 오늘의 일정"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base font-semibold">데이터 소스 타입</Label>
|
||||
<DataSourceSelector
|
||||
dataSource={dataSource}
|
||||
onTypeChange={handleDataSourceTypeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dataSource.type === "database" && (
|
||||
<DatabaseConfig dataSource={dataSource} onUpdate={handleDataSourceUpdate} />
|
||||
)}
|
||||
|
||||
{dataSource.type === "api" && <ApiConfig dataSource={dataSource} onUpdate={handleDataSourceUpdate} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 쿼리 입력 및 테스트 */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-4 rounded-lg bg-blue-50 p-4">
|
||||
<h3 className="mb-2 font-semibold text-blue-900">💡 컬럼명 가이드</h3>
|
||||
<p className="mb-2 text-sm text-blue-700">
|
||||
쿼리 결과에 다음 컬럼명이 있으면 자동으로 일정 항목으로 변환됩니다:
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm text-blue-600">
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">id</code> - 고유 ID (없으면 자동 생성)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">title</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">task</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">name</code> - 제목 (필수)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">description</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">desc</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">content</code> - 상세 설명
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">priority</code> - 우선순위 (urgent, high,
|
||||
normal, low)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">status</code> - 상태 (pending, in_progress,
|
||||
completed)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">assigned_to</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">assignedTo</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">user</code> - 담당자
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">due_date</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">dueDate</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">deadline</code> - 마감일
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">is_urgent</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">isUrgent</code>,{" "}
|
||||
<code className="rounded bg-blue-100 px-1 py-0.5">urgent</code> - 긴급 여부
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 디버그: 항상 표시되는 테스트 메시지 */}
|
||||
<div className="mt-4 rounded-lg bg-yellow-50 border-2 border-yellow-500 p-4">
|
||||
<p className="text-sm font-bold text-yellow-900">
|
||||
🔍 디버그: queryResult 상태 = {queryResult ? "있음" : "없음"}
|
||||
</p>
|
||||
{queryResult && (
|
||||
<p className="text-xs text-yellow-700 mt-1">
|
||||
rows: {queryResult.rows?.length}개, error: {queryResult.error || "없음"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{queryResult && !queryResult.error && queryResult.rows && queryResult.rows.length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-green-50 border-2 border-green-500 p-4">
|
||||
<h3 className="mb-2 font-semibold text-green-900">✅ 쿼리 테스트 성공!</h3>
|
||||
<p className="text-sm text-green-700">
|
||||
총 <strong>{queryResult.rows.length}개</strong>의 일정 항목을 찾았습니다.
|
||||
</p>
|
||||
<div className="mt-3 rounded bg-white p-3">
|
||||
<p className="mb-2 text-xs font-semibold text-gray-600">첫 번째 데이터 미리보기:</p>
|
||||
<pre className="overflow-x-auto text-xs text-gray-700">
|
||||
{JSON.stringify(queryResult.rows[0], null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터베이스 연동 쿼리 (선택사항) */}
|
||||
<div className="mt-6 space-y-4 rounded-lg border-2 border-purple-200 bg-purple-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-purple-900">🔗 데이터베이스 연동 (선택사항)</h3>
|
||||
<p className="text-sm text-purple-700">
|
||||
위젯에서 추가/수정/삭제 시 데이터베이스에 직접 반영
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableDbSync}
|
||||
onChange={(e) => setEnableDbSync(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-purple-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-purple-900">활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{enableDbSync && (
|
||||
<>
|
||||
{/* 모드 선택 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setDbSyncMode("simple")}
|
||||
className={`flex-1 rounded px-4 py-2 text-sm font-medium transition-colors ${
|
||||
dbSyncMode === "simple"
|
||||
? "bg-purple-600 text-white"
|
||||
: "bg-white text-purple-600 hover:bg-purple-100"
|
||||
}`}
|
||||
>
|
||||
간편 모드
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDbSyncMode("advanced")}
|
||||
className={`flex-1 rounded px-4 py-2 text-sm font-medium transition-colors ${
|
||||
dbSyncMode === "advanced"
|
||||
? "bg-purple-600 text-white"
|
||||
: "bg-white text-purple-600 hover:bg-purple-100"
|
||||
}`}
|
||||
>
|
||||
고급 모드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 간편 모드 */}
|
||||
{dbSyncMode === "simple" && (
|
||||
<div className="space-y-4 rounded-lg border border-purple-300 bg-white p-4">
|
||||
<p className="text-sm text-purple-700">
|
||||
테이블명과 컬럼 매핑만 입력하면 자동으로 INSERT/UPDATE/DELETE 쿼리가 생성됩니다.
|
||||
</p>
|
||||
|
||||
{/* 테이블명 */}
|
||||
<div>
|
||||
<Label className="text-sm font-semibold text-purple-900">테이블명 *</Label>
|
||||
<Input
|
||||
value={tableName}
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
placeholder="예: tasks"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 매핑 */}
|
||||
<div>
|
||||
<Label className="text-sm font-semibold text-purple-900">컬럼 매핑</Label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">ID 컬럼</label>
|
||||
<Input
|
||||
value={columnMapping.id}
|
||||
onChange={(e) => setColumnMapping({ ...columnMapping, id: e.target.value })}
|
||||
placeholder="id"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">제목 컬럼</label>
|
||||
<Input
|
||||
value={columnMapping.title}
|
||||
onChange={(e) => setColumnMapping({ ...columnMapping, title: e.target.value })}
|
||||
placeholder="title"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">설명 컬럼</label>
|
||||
<Input
|
||||
value={columnMapping.description}
|
||||
onChange={(e) => setColumnMapping({ ...columnMapping, description: e.target.value })}
|
||||
placeholder="description"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">우선순위 컬럼</label>
|
||||
<Input
|
||||
value={columnMapping.priority}
|
||||
onChange={(e) => setColumnMapping({ ...columnMapping, priority: e.target.value })}
|
||||
placeholder="priority"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">상태 컬럼</label>
|
||||
<Input
|
||||
value={columnMapping.status}
|
||||
onChange={(e) => setColumnMapping({ ...columnMapping, status: e.target.value })}
|
||||
placeholder="status"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">담당자 컬럼</label>
|
||||
<Input
|
||||
value={columnMapping.assignedTo}
|
||||
onChange={(e) => setColumnMapping({ ...columnMapping, assignedTo: e.target.value })}
|
||||
placeholder="assigned_to"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">마감일 컬럼</label>
|
||||
<Input
|
||||
value={columnMapping.dueDate}
|
||||
onChange={(e) => setColumnMapping({ ...columnMapping, dueDate: e.target.value })}
|
||||
placeholder="due_date"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">긴급 여부 컬럼</label>
|
||||
<Input
|
||||
value={columnMapping.isUrgent}
|
||||
onChange={(e) => setColumnMapping({ ...columnMapping, isUrgent: e.target.value })}
|
||||
placeholder="is_urgent"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 고급 모드 */}
|
||||
{dbSyncMode === "advanced" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-purple-700">
|
||||
복잡한 로직이 필요한 경우 직접 쿼리를 작성하세요.
|
||||
</p>
|
||||
|
||||
{/* INSERT 쿼리 */}
|
||||
<div>
|
||||
<Label className="text-sm font-semibold text-purple-900">INSERT 쿼리 (추가)</Label>
|
||||
<p className="mb-2 text-xs text-purple-600">
|
||||
사용 가능한 변수: ${"{title}"}, ${"{description}"}, ${"{priority}"}, ${"{status}"}, ${"{assignedTo}"}, ${"{dueDate}"}, ${"{isUrgent}"}
|
||||
</p>
|
||||
<textarea
|
||||
value={element.chartConfig?.insertQuery || ""}
|
||||
onChange={(e) => {
|
||||
const updates = {
|
||||
...element,
|
||||
chartConfig: {
|
||||
...element.chartConfig,
|
||||
insertQuery: e.target.value,
|
||||
},
|
||||
};
|
||||
Object.assign(element, updates);
|
||||
}}
|
||||
placeholder="예: INSERT INTO tasks (title, description, status) VALUES ('${title}', '${description}', '${status}')"
|
||||
className="h-20 w-full rounded border border-purple-300 bg-white px-3 py-2 text-sm font-mono focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* UPDATE 쿼리 */}
|
||||
<div>
|
||||
<Label className="text-sm font-semibold text-purple-900">UPDATE 쿼리 (상태 변경)</Label>
|
||||
<p className="mb-2 text-xs text-purple-600">
|
||||
사용 가능한 변수: ${"{id}"}, ${"{status}"}
|
||||
</p>
|
||||
<textarea
|
||||
value={element.chartConfig?.updateQuery || ""}
|
||||
onChange={(e) => {
|
||||
const updates = {
|
||||
...element,
|
||||
chartConfig: {
|
||||
...element.chartConfig,
|
||||
updateQuery: e.target.value,
|
||||
},
|
||||
};
|
||||
Object.assign(element, updates);
|
||||
}}
|
||||
placeholder="예: UPDATE tasks SET status = '${status}' WHERE id = ${id}"
|
||||
className="h-20 w-full rounded border border-purple-300 bg-white px-3 py-2 text-sm font-mono focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DELETE 쿼리 */}
|
||||
<div>
|
||||
<Label className="text-sm font-semibold text-purple-900">DELETE 쿼리 (삭제)</Label>
|
||||
<p className="mb-2 text-xs text-purple-600">
|
||||
사용 가능한 변수: ${"{id}"}
|
||||
</p>
|
||||
<textarea
|
||||
value={element.chartConfig?.deleteQuery || ""}
|
||||
onChange={(e) => {
|
||||
const updates = {
|
||||
...element,
|
||||
chartConfig: {
|
||||
...element.chartConfig,
|
||||
deleteQuery: e.target.value,
|
||||
},
|
||||
};
|
||||
Object.assign(element, updates);
|
||||
}}
|
||||
placeholder="예: DELETE FROM tasks WHERE id = ${id}"
|
||||
className="h-20 w-full rounded border border-purple-300 bg-white px-3 py-2 text-sm font-mono focus:border-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-4">
|
||||
<div>
|
||||
{currentStep > 1 && (
|
||||
<Button onClick={handlePrev} variant="outline">
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onClose} variant="outline">
|
||||
취소
|
||||
</Button>
|
||||
|
||||
{currentStep < 2 ? (
|
||||
<Button onClick={handleNext}>
|
||||
다음
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={(() => {
|
||||
const isDisabled = !queryResult || queryResult.error || !queryResult.rows || queryResult.rows.length === 0;
|
||||
// console.log("💾 저장 버튼 disabled:", isDisabled);
|
||||
// console.log("💾 queryResult:", queryResult);
|
||||
return isDisabled;
|
||||
})()}
|
||||
>
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -137,11 +137,11 @@ export default function YardManagement3DWidget({
|
|||
// 편집 모드: 레이아웃 선택 UI
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div className="widget-interactive-area flex h-full w-full flex-col bg-white">
|
||||
<div className="widget-interactive-area flex h-full w-full flex-col bg-background">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700">야드 레이아웃 선택</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<h3 className="text-sm font-semibold text-foreground">야드 레이아웃 선택</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -153,14 +153,14 @@ export default function YardManagement3DWidget({
|
|||
<div className="flex-1 overflow-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-sm text-gray-500">로딩 중...</div>
|
||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
) : layouts.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">🏗️</div>
|
||||
<div className="text-sm text-gray-600">생성된 야드 레이아웃이 없습니다</div>
|
||||
<div className="mt-1 text-xs text-gray-400">먼저 야드 레이아웃을 생성하세요</div>
|
||||
<div className="text-sm text-foreground">생성된 야드 레이아웃이 없습니다</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">먼저 야드 레이아웃을 생성하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -169,17 +169,17 @@ export default function YardManagement3DWidget({
|
|||
<div
|
||||
key={layout.id}
|
||||
className={`rounded-lg border p-3 transition-all ${
|
||||
config?.layoutId === layout.id ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"
|
||||
config?.layoutId === layout.id ? "border-primary bg-primary/10" : "border-border bg-background"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button onClick={() => handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{layout.name}</span>
|
||||
{config?.layoutId === layout.id && <Check className="h-4 w-4 text-blue-600" />}
|
||||
<span className="font-medium text-foreground">{layout.name}</span>
|
||||
{config?.layoutId === layout.id && <Check className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
{layout.description && <p className="mt-1 text-xs text-gray-500">{layout.description}</p>}
|
||||
<div className="mt-2 text-xs text-gray-400">배치된 자재: {layout.placement_count}개</div>
|
||||
{layout.description && <p className="mt-1 text-xs text-muted-foreground">{layout.description}</p>}
|
||||
<div className="mt-2 text-xs text-muted-foreground">배치된 자재: {layout.placement_count}개</div>
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
|
|
@ -195,7 +195,7 @@ export default function YardManagement3DWidget({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteLayoutId(layout.id);
|
||||
|
|
@ -230,18 +230,18 @@ export default function YardManagement3DWidget({
|
|||
<DialogTitle>야드 레이아웃 삭제</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-foreground">
|
||||
이 야드 레이아웃을 삭제하시겠습니까?
|
||||
<br />
|
||||
레이아웃 내의 모든 배치 정보도 함께 삭제됩니다.
|
||||
<br />
|
||||
<span className="font-semibold text-red-600">이 작업은 되돌릴 수 없습니다.</span>
|
||||
<span className="font-semibold text-destructive">이 작업은 되돌릴 수 없습니다.</span>
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setDeleteLayoutId(null)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleDeleteLayout} className="bg-red-600 hover:bg-red-700">
|
||||
<Button onClick={handleDeleteLayout} className="bg-destructive hover:bg-destructive/90">
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -256,12 +256,12 @@ export default function YardManagement3DWidget({
|
|||
if (!config?.layoutId) {
|
||||
console.warn("⚠️ 야드관리 위젯: layoutId가 설정되지 않음", { config, isEditMode });
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">🏗️</div>
|
||||
<div className="text-sm font-medium text-gray-600">야드 레이아웃이 설정되지 않았습니다</div>
|
||||
<div className="mt-1 text-xs text-gray-400">대시보드 편집에서 레이아웃을 선택하세요</div>
|
||||
<div className="mt-2 text-xs text-red-500">
|
||||
<div className="text-sm font-medium text-foreground">야드 레이아웃이 설정되지 않았습니다</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">대시보드 편집에서 레이아웃을 선택하세요</div>
|
||||
<div className="mt-2 text-xs text-destructive">
|
||||
디버그: config={JSON.stringify(config)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { DashboardElement } from "../types";
|
||||
|
||||
interface YardWidgetConfigModalProps {
|
||||
element: DashboardElement;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (updates: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
export function YardWidgetConfigModal({ element, isOpen, onClose, onSave }: YardWidgetConfigModalProps) {
|
||||
const [customTitle, setCustomTitle] = useState(element.customTitle || "");
|
||||
const [showHeader, setShowHeader] = useState(element.showHeader !== false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCustomTitle(element.customTitle || "");
|
||||
setShowHeader(element.showHeader !== false);
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
customTitle,
|
||||
showHeader,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent onPointerDown={(e) => e.stopPropagation()} className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>야드 관리 위젯 설정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 위젯 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customTitle">위젯 제목</Label>
|
||||
<Input
|
||||
id="customTitle"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">기본 제목: 야드 관리 3D</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 여부 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showHeader"
|
||||
checked={showHeader}
|
||||
onCheckedChange={(checked) => setShowHeader(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="cursor-pointer text-sm font-normal">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>저장</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,23 +38,23 @@ export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: Y
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center justify-between bg-background px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
||||
<span className="text-primary text-xs font-bold">🏗️</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-900">야드 관리 위젯 설정</span>
|
||||
<span className="text-xs font-semibold text-foreground">야드 관리 위젯 설정</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -62,8 +62,8 @@ export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: Y
|
|||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
{/* 위젯 제목 */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">위젯 제목</div>
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">위젯 제목</div>
|
||||
<Input
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
|
|
@ -71,12 +71,12 @@ export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: Y
|
|||
className="h-8 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-500">기본 제목: 야드 관리 3D</p>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">기본 제목: 야드 관리 3D</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">헤더 표시</div>
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">헤더 표시</div>
|
||||
<RadioGroup
|
||||
value={showHeader ? "show" : "hide"}
|
||||
onValueChange={(value) => setShowHeader(value === "show")}
|
||||
|
|
@ -100,10 +100,10 @@ export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: Y
|
|||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<div className="flex gap-2 bg-background p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
||||
className="flex-1 rounded bg-muted py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -175,23 +175,23 @@ export default function CustomMetricConfigSidebar({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-muted transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center justify-between bg-background px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
||||
<span className="text-primary text-xs font-bold">📊</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-900">커스텀 카드 설정</span>
|
||||
<span className="text-xs font-semibold text-foreground">커스텀 카드 설정</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -199,12 +199,12 @@ export default function CustomMetricConfigSidebar({
|
|||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
{/* 헤더 설정 */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">헤더 설정</div>
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">헤더 설정</div>
|
||||
<div className="space-y-2">
|
||||
{/* 제목 입력 */}
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">제목</label>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">제목</label>
|
||||
<Input
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
|
|
@ -216,15 +216,15 @@ export default function CustomMetricConfigSidebar({
|
|||
|
||||
{/* 헤더 표시 여부 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[9px] font-medium text-gray-500">헤더 표시</label>
|
||||
<label className="text-[9px] font-medium text-muted-foreground">헤더 표시</label>
|
||||
<button
|
||||
onClick={() => setShowHeader(!showHeader)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
showHeader ? "bg-primary" : "bg-gray-300"
|
||||
showHeader ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
showHeader ? "translate-x-5" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
|
|
@ -234,15 +234,15 @@ export default function CustomMetricConfigSidebar({
|
|||
</div>
|
||||
|
||||
{/* 데이터 소스 타입 선택 */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스 타입</div>
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">데이터 소스 타입</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => handleDataSourceTypeChange("database")}
|
||||
className={`flex h-16 items-center justify-center rounded border transition-all ${
|
||||
dataSourceType === "database"
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-gray-200 bg-gray-50 text-gray-600 hover:border-gray-300"
|
||||
: "border-border bg-muted text-foreground hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">데이터베이스</span>
|
||||
|
|
@ -252,7 +252,7 @@ export default function CustomMetricConfigSidebar({
|
|||
className={`flex h-16 items-center justify-center rounded border transition-all ${
|
||||
dataSourceType === "api"
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-gray-200 bg-gray-50 text-gray-600 hover:border-gray-300"
|
||||
: "border-border bg-muted text-foreground hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">REST API</span>
|
||||
|
|
@ -275,9 +275,9 @@ export default function CustomMetricConfigSidebar({
|
|||
)}
|
||||
|
||||
{/* 일반 지표 설정 (항상 표시) */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-[10px] font-semibold tracking-wide text-gray-500 uppercase">일반 지표</div>
|
||||
<div className="text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">일반 지표</div>
|
||||
{queryColumns.length > 0 && (
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={addMetric}>
|
||||
<Plus className="h-3 w-3" />
|
||||
|
|
@ -287,11 +287,11 @@ export default function CustomMetricConfigSidebar({
|
|||
</div>
|
||||
|
||||
{queryColumns.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">먼저 쿼리를 실행하세요</p>
|
||||
<p className="text-xs text-muted-foreground">먼저 쿼리를 실행하세요</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{metrics.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">추가된 지표가 없습니다</p>
|
||||
<p className="text-xs text-muted-foreground">추가된 지표가 없습니다</p>
|
||||
) : (
|
||||
metrics.map((metric, index) => (
|
||||
<div
|
||||
|
|
@ -299,7 +299,7 @@ export default function CustomMetricConfigSidebar({
|
|||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
className={cn(
|
||||
"rounded-md border bg-white p-2 transition-all",
|
||||
"rounded-md border bg-background p-2 transition-all",
|
||||
draggedIndex === index && "opacity-50",
|
||||
dragOverIndex === index && draggedIndex !== index && "border-primary border-2",
|
||||
)}
|
||||
|
|
@ -312,21 +312,21 @@ export default function CustomMetricConfigSidebar({
|
|||
onDragEnd={handleDragEnd}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 text-gray-400" />
|
||||
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr,auto,auto] items-center gap-2">
|
||||
<span className="truncate text-xs font-medium text-gray-900">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{metric.label || "새 지표"}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500">{metric.aggregation.toUpperCase()}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{metric.aggregation.toUpperCase()}</span>
|
||||
<button
|
||||
onClick={() => setExpandedMetric(expandedMetric === metric.id ? null : metric.id)}
|
||||
className="flex items-center justify-center rounded p-0.5 hover:bg-gray-100"
|
||||
className="flex items-center justify-center rounded p-0.5 hover:bg-muted"
|
||||
>
|
||||
{expandedMetric === metric.id ? (
|
||||
<ChevronUp className="h-3.5 w-3.5 text-gray-500" />
|
||||
<ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-500" />
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -334,12 +334,12 @@ export default function CustomMetricConfigSidebar({
|
|||
|
||||
{/* 설정 영역 */}
|
||||
{expandedMetric === metric.id && (
|
||||
<div className="mt-2 space-y-1.5 border-t border-gray-200 pt-2">
|
||||
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
|
||||
{/* 2열 그리드 레이아웃 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 컬럼 */}
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">컬럼</label>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">컬럼</label>
|
||||
<Select
|
||||
value={metric.field}
|
||||
onValueChange={(value) => updateMetric(metric.id, "field", value)}
|
||||
|
|
@ -359,7 +359,7 @@ export default function CustomMetricConfigSidebar({
|
|||
|
||||
{/* 집계 함수 */}
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">집계</label>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">집계</label>
|
||||
<Select
|
||||
value={metric.aggregation}
|
||||
onValueChange={(value: any) => updateMetric(metric.id, "aggregation", value)}
|
||||
|
|
@ -379,7 +379,7 @@ export default function CustomMetricConfigSidebar({
|
|||
|
||||
{/* 단위 */}
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">단위</label>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">단위</label>
|
||||
<Input
|
||||
value={metric.unit}
|
||||
onChange={(e) => updateMetric(metric.id, "unit", e.target.value)}
|
||||
|
|
@ -390,7 +390,7 @@ export default function CustomMetricConfigSidebar({
|
|||
|
||||
{/* 소수점 */}
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">소수점</label>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">소수점</label>
|
||||
<Select
|
||||
value={String(metric.decimals)}
|
||||
onValueChange={(value) => updateMetric(metric.id, "decimals", parseInt(value))}
|
||||
|
|
@ -411,7 +411,7 @@ export default function CustomMetricConfigSidebar({
|
|||
|
||||
{/* 표시 이름 (전체 너비) */}
|
||||
<div>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-gray-500">표시 이름</label>
|
||||
<label className="mb-0.5 block text-[9px] font-medium text-muted-foreground">표시 이름</label>
|
||||
<Input
|
||||
value={metric.label}
|
||||
onChange={(e) => updateMetric(metric.id, "label", e.target.value)}
|
||||
|
|
@ -421,7 +421,7 @@ export default function CustomMetricConfigSidebar({
|
|||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<div className="border-t border-gray-200 pt-1.5">
|
||||
<div className="border-t border-border pt-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
|
@ -442,13 +442,13 @@ export default function CustomMetricConfigSidebar({
|
|||
</div>
|
||||
|
||||
{/* 그룹별 카드 생성 모드 (항상 표시) */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">표시 모드</div>
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">표시 모드</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-900">그룹별 카드 생성</label>
|
||||
<p className="mt-0.5 text-[9px] text-gray-500">
|
||||
<label className="text-xs font-medium text-foreground">그룹별 카드 생성</label>
|
||||
<p className="mt-0.5 text-[9px] text-muted-foreground">
|
||||
쿼리 결과의 각 행을 개별 카드로 표시
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -461,18 +461,18 @@ export default function CustomMetricConfigSidebar({
|
|||
}
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
groupByMode ? "bg-primary" : "bg-gray-300"
|
||||
groupByMode ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
groupByMode ? "translate-x-5" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{groupByMode && (
|
||||
<div className="rounded-md bg-blue-50 p-2 text-[9px] text-blue-700">
|
||||
<div className="rounded-md bg-primary/10 p-2 text-[9px] text-primary">
|
||||
<p className="font-medium">💡 사용 방법</p>
|
||||
<ul className="mt-1 space-y-0.5 pl-3 text-[8px]">
|
||||
<li>• 첫 번째 컬럼: 카드 제목</li>
|
||||
|
|
@ -487,8 +487,8 @@ export default function CustomMetricConfigSidebar({
|
|||
|
||||
{/* 그룹별 카드 전용 쿼리 (활성화 시에만 표시) */}
|
||||
{groupByMode && groupByDataSource && (
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
그룹별 카드 쿼리
|
||||
</div>
|
||||
<DatabaseConfig dataSource={groupByDataSource} onChange={handleGroupByDataSourceUpdate} />
|
||||
|
|
@ -503,7 +503,7 @@ export default function CustomMetricConfigSidebar({
|
|||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex gap-2 border-t bg-white p-3 shadow-sm">
|
||||
<div className="flex gap-2 border-t bg-background p-3 shadow-sm">
|
||||
<Button variant="outline" className="h-8 flex-1 text-xs" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -7,38 +7,38 @@ export function getStatusColor(status: DriverInfo["status"]) {
|
|||
switch (status) {
|
||||
case "driving":
|
||||
return {
|
||||
bg: "bg-green-100",
|
||||
text: "text-green-800",
|
||||
border: "border-green-300",
|
||||
badge: "bg-green-500",
|
||||
bg: "bg-success/10",
|
||||
text: "text-success",
|
||||
border: "border-success",
|
||||
badge: "bg-success",
|
||||
};
|
||||
case "standby":
|
||||
return {
|
||||
bg: "bg-gray-100",
|
||||
text: "text-gray-800",
|
||||
border: "border-gray-300",
|
||||
badge: "bg-gray-500",
|
||||
bg: "bg-muted",
|
||||
text: "text-foreground",
|
||||
border: "border-border",
|
||||
badge: "bg-muted0",
|
||||
};
|
||||
case "resting":
|
||||
return {
|
||||
bg: "bg-orange-100",
|
||||
text: "text-orange-800",
|
||||
border: "border-orange-300",
|
||||
badge: "bg-orange-500",
|
||||
bg: "bg-warning/10",
|
||||
text: "text-warning",
|
||||
border: "border-warning",
|
||||
badge: "bg-warning",
|
||||
};
|
||||
case "maintenance":
|
||||
return {
|
||||
bg: "bg-red-100",
|
||||
text: "text-red-800",
|
||||
border: "border-red-300",
|
||||
badge: "bg-red-500",
|
||||
bg: "bg-destructive/10",
|
||||
text: "text-destructive",
|
||||
border: "border-destructive",
|
||||
badge: "bg-destructive",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: "bg-gray-100",
|
||||
text: "text-gray-800",
|
||||
border: "border-gray-300",
|
||||
badge: "bg-gray-500",
|
||||
bg: "bg-muted",
|
||||
text: "text-foreground",
|
||||
border: "border-border",
|
||||
badge: "bg-muted0",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
|||
className={`group relative rounded-md border transition-all ${
|
||||
isSelected
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
||||
: "border-border bg-background hover:border-border hover:shadow-sm"
|
||||
} ${isDraggable ? "cursor-grab active:cursor-grabbing" : ""} ${
|
||||
draggedIndex === columnIndex ? "scale-95 opacity-50" : ""
|
||||
}`}
|
||||
|
|
@ -137,20 +137,20 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
|||
/>
|
||||
<GripVertical
|
||||
className={`h-3.5 w-3.5 shrink-0 transition-colors ${
|
||||
isDraggable ? "group-hover:text-primary text-gray-400" : "text-gray-300"
|
||||
isDraggable ? "group-hover:text-primary text-muted-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-gray-900">{field}</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
||||
<span className="truncate text-[11px] font-medium text-foreground">{field}</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-muted-foreground">예: {previewText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 영역 */}
|
||||
{isSelected && selectedCol && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
||||
<div className="border-t border-border bg-muted/50 px-2.5 py-1.5">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 표시 이름 */}
|
||||
<div className="min-w-0">
|
||||
|
|
@ -158,7 +158,7 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
|||
value={selectedCol.label}
|
||||
onChange={(e) => handleLabelChange(field, e.target.value)}
|
||||
placeholder="표시 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-border bg-background px-1.5 text-[10px] placeholder:text-muted-foreground focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -170,7 +170,7 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
|||
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-border bg-background px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
||||
>
|
||||
<SelectValue />
|
||||
|
|
@ -210,7 +210,7 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
|||
return (
|
||||
<div
|
||||
key={field}
|
||||
className="group rounded-md border border-gray-200 bg-white transition-all hover:border-gray-300 hover:shadow-sm"
|
||||
className="group rounded-md border border-border bg-background transition-all hover:border-border hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Checkbox
|
||||
|
|
@ -218,11 +218,11 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
|||
onCheckedChange={() => handleToggle(field)}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<GripVertical className="h-3.5 w-3.5 shrink-0 text-gray-300" />
|
||||
<GripVertical className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-gray-600">{field}</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
||||
<span className="truncate text-[11px] font-medium text-foreground">{field}</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-muted-foreground">예: {previewText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -232,9 +232,9 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
|||
</div>
|
||||
|
||||
{selectedColumns.length === 0 && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
||||
<span className="text-amber-600">⚠️</span>
|
||||
<span className="text-[10px] text-amber-700">최소 1개 이상의 컬럼을 선택해주세요</span>
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-warning bg-warning/10 px-3 py-2">
|
||||
<span className="text-warning">⚠️</span>
|
||||
<span className="text-[10px] text-warning">최소 1개 이상의 컬럼을 선택해주세요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function ListTableOptions({ config, onConfigChange }: ListTableOptionsPro
|
|||
<div className="space-y-3">
|
||||
{/* 뷰 모드 */}
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">뷰 모드</Label>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-foreground">뷰 모드</Label>
|
||||
<RadioGroup
|
||||
value={config.viewMode}
|
||||
onValueChange={(value: "table" | "card") => onConfigChange({ viewMode: value })}
|
||||
|
|
@ -46,7 +46,7 @@ export function ListTableOptions({ config, onConfigChange }: ListTableOptionsPro
|
|||
{/* 카드 뷰 컬럼 수 */}
|
||||
{config.viewMode === "card" && (
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">카드 컬럼 수</Label>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-foreground">카드 컬럼 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
|
|
@ -55,13 +55,13 @@ export function ListTableOptions({ config, onConfigChange }: ListTableOptionsPro
|
|||
onChange={(e) => onConfigChange({ cardColumns: parseInt(e.target.value) || 3 })}
|
||||
className="h-6 w-full px-1.5 text-[11px]"
|
||||
/>
|
||||
<p className="mt-0.5 text-[9px] text-gray-500">한 줄에 표시할 카드 개수 (1-6)</p>
|
||||
<p className="mt-0.5 text-[9px] text-muted-foreground">한 줄에 표시할 카드 개수 (1-6)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지 크기 */}
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">페이지당 행 수</Label>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-foreground">페이지당 행 수</Label>
|
||||
<Select
|
||||
value={String(config.pageSize)}
|
||||
onValueChange={(value) => onConfigChange({ pageSize: parseInt(value) })}
|
||||
|
|
@ -91,7 +91,7 @@ export function ListTableOptions({ config, onConfigChange }: ListTableOptionsPro
|
|||
|
||||
{/* 기능 활성화 */}
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">페이지네이션</Label>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-foreground">페이지네이션</Label>
|
||||
<RadioGroup
|
||||
value={config.enablePagination ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => onConfigChange({ enablePagination: value === "enabled" })}
|
||||
|
|
@ -115,7 +115,7 @@ export function ListTableOptions({ config, onConfigChange }: ListTableOptionsPro
|
|||
{/* 헤더 표시 */}
|
||||
{config.viewMode === "table" && (
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">헤더 표시</Label>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-foreground">헤더 표시</Label>
|
||||
<RadioGroup
|
||||
value={config.showHeader ? "show" : "hide"}
|
||||
onValueChange={(value) => onConfigChange({ showHeader: value === "show" })}
|
||||
|
|
@ -140,7 +140,7 @@ export function ListTableOptions({ config, onConfigChange }: ListTableOptionsPro
|
|||
{/* 줄무늬 행 */}
|
||||
{config.viewMode === "table" && (
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">줄무늬 행</Label>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-foreground">줄무늬 행</Label>
|
||||
<RadioGroup
|
||||
value={config.stripedRows ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => onConfigChange({ stripedRows: value === "enabled" })}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEdito
|
|||
<div>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-gray-500">직접 컬럼을 추가하고 데이터 필드를 매핑하세요</p>
|
||||
<p className="text-[10px] text-muted-foreground">직접 컬럼을 추가하고 데이터 필드를 매핑하세요</p>
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="bg-primary hover:bg-primary/90 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
||||
|
|
@ -102,24 +102,24 @@ export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEdito
|
|||
handleDragEnd();
|
||||
e.currentTarget.style.cursor = "grab";
|
||||
}}
|
||||
className={`group relative rounded-md border border-gray-200 bg-white shadow-sm transition-all hover:border-gray-300 hover:shadow-sm ${
|
||||
className={`group relative rounded-md border border-border bg-background shadow-sm transition-all hover:border-border hover:shadow-sm ${
|
||||
draggedIndex === index ? "scale-95 opacity-50" : ""
|
||||
} cursor-grab active:cursor-grabbing`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
|
||||
<span className="text-[11px] font-medium text-gray-900">컬럼 {index + 1}</span>
|
||||
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors" />
|
||||
<span className="text-[11px] font-medium text-foreground">컬럼 {index + 1}</span>
|
||||
<button
|
||||
onClick={() => handleRemove(col.id)}
|
||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 설정 영역 */}
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
||||
<div className="border-t border-border bg-muted/50 px-2.5 py-1.5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* 표시 이름 */}
|
||||
<div className="min-w-0">
|
||||
|
|
@ -127,7 +127,7 @@ export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEdito
|
|||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="표시 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-border bg-background px-1.5 text-[10px] placeholder:text-muted-foreground focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -138,7 +138,7 @@ export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEdito
|
|||
value={col.field}
|
||||
onChange={(e) => handleUpdate(col.id, { field: e.target.value })}
|
||||
placeholder="데이터 필드"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-border bg-background px-1.5 text-[10px] placeholder:text-muted-foreground focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -150,7 +150,7 @@ export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEdito
|
|||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-border bg-background px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
||||
>
|
||||
<SelectValue />
|
||||
|
|
@ -175,9 +175,9 @@ export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEdito
|
|||
</div>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
||||
<span className="text-amber-600">⚠️</span>
|
||||
<span className="text-[10px] text-amber-700">컬럼을 추가하여 시작하세요</span>
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-warning bg-warning/10 px-3 py-2">
|
||||
<span className="text-warning">⚠️</span>
|
||||
<span className="text-[10px] text-warning">컬럼을 추가하여 시작하세요</span>
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="bg-primary hover:bg-primary/90 ml-auto flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
|
|||
<div>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-gray-500">컬럼을 선택하고 편집하세요</p>
|
||||
<p className="text-[10px] text-muted-foreground">컬럼을 선택하고 편집하세요</p>
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="bg-primary hover:bg-primary/90 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
||||
|
|
@ -124,7 +124,7 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
|
|||
className={`group relative rounded-md border transition-all ${
|
||||
col.visible
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
||||
: "border-border bg-background hover:border-border hover:shadow-sm"
|
||||
} ${draggedIndex === index ? "scale-95 opacity-50" : ""}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
|
|
@ -146,19 +146,19 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
|
|||
}}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
|
||||
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-gray-900">
|
||||
<span className="truncate text-[11px] font-medium text-foreground">
|
||||
{col.field || "(필드명 없음)"}
|
||||
</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
||||
{previewText && <span className="shrink-0 text-[9px] text-muted-foreground">예: {previewText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(col.id)}
|
||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -166,7 +166,7 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
|
|||
|
||||
{/* 설정 영역 */}
|
||||
{col.visible && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
||||
<div className="border-t border-border bg-muted/50 px-2.5 py-1.5">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 표시 이름 */}
|
||||
<div className="min-w-0">
|
||||
|
|
@ -174,7 +174,7 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
|
|||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="표시 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-border bg-background px-1.5 text-[10px] placeholder:text-muted-foreground focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -186,7 +186,7 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
|
|||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-border bg-background px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
||||
>
|
||||
<SelectValue />
|
||||
|
|
@ -213,9 +213,9 @@ export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: Uni
|
|||
</div>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
||||
<span className="text-amber-600">⚠️</span>
|
||||
<span className="text-[10px] text-amber-700">쿼리를 실행하거나 컬럼을 추가하세요</span>
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-warning bg-warning/10 px-3 py-2">
|
||||
<span className="text-warning">⚠️</span>
|
||||
<span className="text-[10px] text-warning">쿼리를 실행하거나 컬럼을 추가하세요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -94,13 +94,13 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
|
||||
<div className="space-y-4">
|
||||
{/* 자재 정보 */}
|
||||
<div className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-gray-600">선택한 자재</div>
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<div className="mb-2 text-sm font-medium text-foreground">선택한 자재</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded border" style={{ backgroundColor: material.default_color }} />
|
||||
<div>
|
||||
<div className="font-medium">{material.material_name}</div>
|
||||
<div className="text-sm text-gray-600">{material.material_code}</div>
|
||||
<div className="text-sm text-foreground">{material.material_code}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -117,7 +117,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
min="1"
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{material.unit}</span>
|
||||
<span className="text-sm text-foreground">{material.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
<Label>3D 위치</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="posX" className="text-xs text-gray-600">
|
||||
<Label htmlFor="posX" className="text-xs text-foreground">
|
||||
X (좌우)
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -138,7 +138,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="posY" className="text-xs text-gray-600">
|
||||
<Label htmlFor="posY" className="text-xs text-foreground">
|
||||
Y (높이)
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -150,7 +150,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="posZ" className="text-xs text-gray-600">
|
||||
<Label htmlFor="posZ" className="text-xs text-foreground">
|
||||
Z (앞뒤)
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -169,7 +169,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
<Label>3D 크기</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="sizeX" className="text-xs text-gray-600">
|
||||
<Label htmlFor="sizeX" className="text-xs text-foreground">
|
||||
너비
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -182,7 +182,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sizeY" className="text-xs text-gray-600">
|
||||
<Label htmlFor="sizeY" className="text-xs text-foreground">
|
||||
높이
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -195,7 +195,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sizeZ" className="text-xs text-gray-600">
|
||||
<Label htmlFor="sizeZ" className="text-xs text-foreground">
|
||||
깊이
|
||||
</Label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemo
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 border-l bg-white p-4">
|
||||
<div className="w-80 border-l bg-background p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">자재 정보</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
|
|
@ -82,18 +82,18 @@ export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemo
|
|||
|
||||
<div className="space-y-4">
|
||||
{/* 읽기 전용 정보 */}
|
||||
<div className="space-y-3 rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-xs font-medium text-gray-500">자재 정보 (읽기 전용)</div>
|
||||
<div className="space-y-3 rounded-lg bg-muted p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">자재 정보 (읽기 전용)</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">자재 코드</div>
|
||||
<div className="text-xs text-foreground">자재 코드</div>
|
||||
<div className="mt-1 text-sm font-medium">{placement.material_code}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">자재 이름</div>
|
||||
<div className="text-xs text-foreground">자재 이름</div>
|
||||
<div className="mt-1 text-sm font-medium">{placement.material_name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">수량</div>
|
||||
<div className="text-xs text-foreground">수량</div>
|
||||
<div className="mt-1 text-sm font-medium">
|
||||
{placement.quantity} {placement.unit}
|
||||
</div>
|
||||
|
|
@ -102,14 +102,14 @@ export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemo
|
|||
|
||||
{/* 배치 정보 (편집 가능) */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-gray-500">배치 정보 (편집 가능)</div>
|
||||
<div className="text-xs font-medium text-muted-foreground">배치 정보 (편집 가능)</div>
|
||||
|
||||
{/* 3D 크기 */}
|
||||
<div>
|
||||
<Label className="text-xs">크기</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label htmlFor="edit-sizeX" className="text-xs text-gray-600">
|
||||
<Label htmlFor="edit-sizeX" className="text-xs text-foreground">
|
||||
너비
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -123,7 +123,7 @@ export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemo
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-sizeY" className="text-xs text-gray-600">
|
||||
<Label htmlFor="edit-sizeY" className="text-xs text-foreground">
|
||||
높이
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -137,7 +137,7 @@ export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemo
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-sizeZ" className="text-xs text-gray-600">
|
||||
<Label htmlFor="edit-sizeZ" className="text-xs text-foreground">
|
||||
깊이
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -217,7 +217,7 @@ export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemo
|
|||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRemove} className="bg-red-600 hover:bg-red-700">
|
||||
<AlertDialogAction onClick={handleRemove} className="bg-destructive hover:bg-destructive/90">
|
||||
해제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export default function MaterialLibrary({ isOpen, onClose, onSelect }: MaterialL
|
|||
{/* 검색 및 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="자재 코드 또는 이름 검색..."
|
||||
value={searchText}
|
||||
|
|
@ -105,7 +105,7 @@ export default function MaterialLibrary({ isOpen, onClose, onSelect }: MaterialL
|
|||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
className="rounded-md border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">전체 카테고리</option>
|
||||
{categories.map((category) => (
|
||||
|
|
@ -119,10 +119,10 @@ export default function MaterialLibrary({ isOpen, onClose, onSelect }: MaterialL
|
|||
{/* 자재 목록 */}
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
{searchText || selectedCategory ? "검색 결과가 없습니다" : "등록된 자재가 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -142,7 +142,7 @@ export default function MaterialLibrary({ isOpen, onClose, onSelect }: MaterialL
|
|||
<TableRow
|
||||
key={material.id}
|
||||
className={`cursor-pointer ${
|
||||
selectedMaterial?.id === material.id ? "bg-blue-50" : "hover:bg-gray-50"
|
||||
selectedMaterial?.id === material.id ? "bg-primary/10" : "hover:bg-muted"
|
||||
}`}
|
||||
onClick={() => setSelectedMaterial(material)}
|
||||
>
|
||||
|
|
@ -162,17 +162,17 @@ export default function MaterialLibrary({ isOpen, onClose, onSelect }: MaterialL
|
|||
|
||||
{/* 선택된 자재 정보 */}
|
||||
{selectedMaterial && (
|
||||
<div className="rounded-lg bg-blue-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-blue-900">선택된 자재</div>
|
||||
<div className="rounded-lg bg-primary/10 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-primary">선택된 자재</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded border" style={{ backgroundColor: selectedMaterial.default_color }} />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{selectedMaterial.material_name}</div>
|
||||
<div className="text-sm text-gray-600">{selectedMaterial.material_code}</div>
|
||||
<div className="text-sm text-foreground">{selectedMaterial.material_code}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedMaterial.description && (
|
||||
<div className="mt-2 text-sm text-gray-600">{selectedMaterial.description}</div>
|
||||
<div className="mt-2 text-sm text-foreground">{selectedMaterial.description}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -647,6 +647,7 @@ export default function Yard3DCanvas({
|
|||
fov: 50,
|
||||
}}
|
||||
shadows
|
||||
gl={{ preserveDrawingBuffer: true }}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Scene
|
||||
|
|
|
|||
|
|
@ -90,10 +90,10 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
|
||||
<div className="mt-2 text-sm text-gray-600">3D 장면 로딩 중...</div>
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
|
||||
<div className="mt-2 text-sm text-foreground">3D 장면 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -101,10 +101,10 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||||
<div className="text-sm font-medium text-foreground">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -112,10 +112,10 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
|
|||
|
||||
if (placements.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📦</div>
|
||||
<div className="text-sm font-medium text-gray-600">배치된 자재가 없습니다</div>
|
||||
<div className="text-sm font-medium text-foreground">배치된 자재가 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -132,23 +132,23 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
|
|||
|
||||
{/* 야드 이름 (좌측 상단) */}
|
||||
{layoutName && (
|
||||
<div className="absolute top-4 left-4 z-49 rounded-lg border border-gray-300 bg-white px-4 py-2 shadow-lg">
|
||||
<h2 className="text-base font-bold text-gray-900">{layoutName}</h2>
|
||||
<div className="absolute top-4 left-4 z-49 rounded-lg border border-border bg-background px-4 py-2 shadow-lg">
|
||||
<h2 className="text-base font-bold text-foreground">{layoutName}</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택된 자재 정보 패널 (우측 상단) */}
|
||||
{selectedPlacement && (
|
||||
<div className="absolute top-4 right-4 z-50 w-64 rounded-lg border border-gray-300 bg-white p-4 shadow-xl">
|
||||
<div className="absolute top-4 right-4 z-50 w-64 rounded-lg border border-border bg-background p-4 shadow-xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{selectedPlacement.material_name ? "자재 정보" : "미설정 요소"}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPlacement(null);
|
||||
}}
|
||||
className="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
@ -157,23 +157,23 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
|
|||
{selectedPlacement.material_name && selectedPlacement.quantity && selectedPlacement.unit ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">자재명</label>
|
||||
<div className="mt-1 text-sm font-semibold text-gray-900">{selectedPlacement.material_name}</div>
|
||||
<label className="text-xs font-medium text-muted-foreground">자재명</label>
|
||||
<div className="mt-1 text-sm font-semibold text-foreground">{selectedPlacement.material_name}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">수량</label>
|
||||
<div className="mt-1 text-sm font-semibold text-gray-900">
|
||||
<label className="text-xs font-medium text-muted-foreground">수량</label>
|
||||
<div className="mt-1 text-sm font-semibold text-foreground">
|
||||
{selectedPlacement.quantity} {selectedPlacement.unit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-orange-50 p-3 text-center">
|
||||
<div className="rounded-lg bg-warning/10 p-3 text-center">
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-orange-700">데이터 바인딩이</div>
|
||||
<div className="text-sm font-medium text-orange-700">설정되지 않았습니다</div>
|
||||
<div className="mt-2 text-xs text-orange-600">편집 모드에서 설정해주세요</div>
|
||||
<div className="text-sm font-medium text-warning">데이터 바인딩이</div>
|
||||
<div className="text-sm font-medium text-warning">설정되지 않았습니다</div>
|
||||
<div className="mt-2 text-xs text-warning">편집 모드에서 설정해주세요</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
|||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center bg-gray-900">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
|
@ -465,7 +465,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
{/* 상단 툴바 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -476,16 +476,16 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{layout.name}</h2>
|
||||
{layout.description && <p className="text-sm text-gray-500">{layout.description}</p>}
|
||||
{layout.description && <p className="text-sm text-muted-foreground">{layout.description}</p>}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleEditLayout} className="h-8 w-8 p-0">
|
||||
<Edit2 className="h-4 w-4 text-gray-500" />
|
||||
<Edit2 className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{hasUnsavedChanges && <span className="text-sm font-medium text-orange-600">미저장 변경사항 있음</span>}
|
||||
{hasUnsavedChanges && <span className="text-sm font-medium text-warning">미저장 변경사항 있음</span>}
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
|
|
@ -515,8 +515,8 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
{/* 좌측: 3D 캔버스 */}
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
<div className="flex h-full items-center justify-center bg-muted">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Yard3DCanvas
|
||||
|
|
@ -537,7 +537,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
</div>
|
||||
|
||||
{/* 우측: 요소 목록 또는 설정 패널 */}
|
||||
<div className="w-80 border-l bg-white">
|
||||
<div className="w-80 border-l bg-background">
|
||||
{showConfigPanel && selectedPlacement ? (
|
||||
// 설정 패널
|
||||
<YardElementConfigPanel
|
||||
|
|
@ -556,12 +556,12 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
요소 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">총 {placements.length}개</p>
|
||||
<p className="text-xs text-muted-foreground">총 {placements.length}개</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{placements.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
|
||||
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-muted-foreground">
|
||||
요소가 없습니다.
|
||||
<br />
|
||||
{'위의 "요소 추가" 버튼을 클릭하세요.'}
|
||||
|
|
@ -577,10 +577,10 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
key={placement.id}
|
||||
className={`rounded-lg border p-3 transition-all ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50"
|
||||
? "border-primary bg-primary/10"
|
||||
: configured
|
||||
? "border-gray-200 bg-white hover:border-gray-300"
|
||||
: "border-orange-200 bg-orange-50"
|
||||
? "border-border bg-background hover:border-border"
|
||||
: "border-warning bg-warning/10"
|
||||
}`}
|
||||
onClick={() => handleSelectPlacement(placement)}
|
||||
>
|
||||
|
|
@ -588,15 +588,15 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
<div className="flex-1">
|
||||
{configured ? (
|
||||
<>
|
||||
<div className="text-sm font-medium text-gray-900">{placement.material_name}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="text-sm font-medium text-foreground">{placement.material_name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
수량: {placement.quantity} {placement.unit}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-orange-700">요소 #{placement.id}</div>
|
||||
<div className="mt-1 text-xs text-orange-600">데이터 바인딩 설정 필요</div>
|
||||
<div className="text-sm font-medium text-warning">요소 #{placement.id}</div>
|
||||
<div className="mt-1 text-xs text-warning">데이터 바인딩 설정 필요</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -618,7 +618,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeletePlacement(placement.id);
|
||||
|
|
@ -645,12 +645,12 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
<DialogTitle className="flex items-center gap-2">
|
||||
{saveResultDialog.success ? (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
저장 완료
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
저장 실패
|
||||
</>
|
||||
)}
|
||||
|
|
@ -671,20 +671,20 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
<DialogContent onPointerDown={(e) => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-orange-600" />
|
||||
<AlertCircle className="h-5 w-5 text-warning" />
|
||||
요소 삭제 확인
|
||||
</DialogTitle>
|
||||
<DialogDescription className="pt-2">
|
||||
이 요소를 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="font-semibold text-orange-600">저장 버튼을 눌러야 최종적으로 삭제됩니다.</span>
|
||||
<span className="font-semibold text-warning">저장 버튼을 눌러야 최종적으로 삭제됩니다.</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirmDialog({ open: false, placementId: null })}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={confirmDeletePlacement} className="bg-red-600 hover:bg-red-700">
|
||||
<Button onClick={confirmDeletePlacement} className="bg-destructive hover:bg-destructive/90">
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -699,7 +699,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
|||
<DialogContent onPointerDown={(e) => e.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Edit2 className="h-5 w-5 text-blue-600" />
|
||||
<Edit2 className="h-5 w-5 text-primary" />
|
||||
야드 레이아웃 정보 수정
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }:
|
|||
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">({conn.db_type.toUpperCase()})</span>
|
||||
<span className="text-xs text-muted-foreground">({conn.db_type.toUpperCase()})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -367,7 +367,7 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }:
|
|||
placeholder="data.items"
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">예: data.items (응답에서 배열이 있는 경로)</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">예: data.items (응답에서 배열이 있는 경로)</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={executeRestApi} disabled={isExecuting} size="sm" className="w-full">
|
||||
|
|
@ -409,7 +409,7 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }:
|
|||
{queryResult.rows.slice(0, 10).map((row, idx) => (
|
||||
<TableRow
|
||||
key={idx}
|
||||
className={selectedRowIndex === idx ? "bg-blue-50" : ""}
|
||||
className={selectedRowIndex === idx ? "bg-primary/10" : ""}
|
||||
onClick={() => setSelectedRowIndex(idx)}
|
||||
>
|
||||
<TableCell>
|
||||
|
|
@ -428,7 +428,7 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }:
|
|||
</Table>
|
||||
</div>
|
||||
{queryResult.rows.length > 10 && (
|
||||
<p className="mt-2 text-xs text-gray-500">... 및 {queryResult.rows.length - 10}개 더</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">... 및 {queryResult.rows.length - 10}개 더</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -468,7 +468,7 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }:
|
|||
<div>
|
||||
<Label className="text-xs">단위 입력</Label>
|
||||
<Input value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="EA" className="mt-1" />
|
||||
<p className="mt-1 text-xs text-gray-500">예: EA, BOX, KG, M, L 등</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">예: EA, BOX, KG, M, L 등</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="yard-name">
|
||||
야드 이름 <span className="text-red-500">*</span>
|
||||
야드 이름 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="yard-name"
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete,
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -136,7 +136,7 @@ export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete,
|
|||
{/* 검색 및 정렬 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="야드 이름 또는 설명 검색..."
|
||||
value={searchText}
|
||||
|
|
@ -147,7 +147,7 @@ export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete,
|
|||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value as "recent" | "name")}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||
className="rounded-md border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="recent">최근 수정순</option>
|
||||
<option value="name">이름순</option>
|
||||
|
|
@ -157,7 +157,7 @@ export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete,
|
|||
{/* 테이블 */}
|
||||
{sortedLayouts.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="text-center text-muted-foreground">
|
||||
{searchText ? "검색 결과가 없습니다" : "등록된 야드가 없습니다"}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -175,11 +175,11 @@ export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete,
|
|||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedLayouts.map((layout) => (
|
||||
<TableRow key={layout.id} className="cursor-pointer hover:bg-gray-50" onClick={() => onSelect(layout)}>
|
||||
<TableRow key={layout.id} className="cursor-pointer hover:bg-muted" onClick={() => onSelect(layout)}>
|
||||
<TableCell className="font-medium">{layout.name}</TableCell>
|
||||
<TableCell className="text-gray-600">{layout.description || "-"}</TableCell>
|
||||
<TableCell className="text-foreground">{layout.description || "-"}</TableCell>
|
||||
<TableCell className="text-center">{layout.placement_count}개</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{formatDate(layout.updated_at)}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{formatDate(layout.updated_at)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
|
|
@ -190,7 +190,7 @@ export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete,
|
|||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onSelect(layout)}>편집</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDuplicateClick(layout)}>복제</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteTarget(layout)} className="text-red-600">
|
||||
<DropdownMenuItem onClick={() => setDeleteTarget(layout)} className="text-destructive">
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -204,7 +204,7 @@ export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete,
|
|||
)}
|
||||
|
||||
{/* 총 개수 */}
|
||||
<div className="text-sm text-gray-500">총 {sortedLayouts.length}개</div>
|
||||
<div className="text-sm text-muted-foreground">총 {sortedLayouts.length}개</div>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
|
|
@ -222,7 +222,7 @@ export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete,
|
|||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ import { DashboardElement, QueryResult } from "@/components/admin/dashboard/type
|
|||
import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer";
|
||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||
import { RESOLUTIONS, Resolution } from "@/components/admin/dashboard/ResolutionSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// 위젯 동적 import - 모든 위젯
|
||||
|
|
@ -163,7 +171,7 @@ function renderWidget(element: DashboardElement) {
|
|||
// === 기본 fallback ===
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-400 to-gray-600 p-4 text-white">
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-muted-foreground p-4 text-white">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-3xl">❓</div>
|
||||
<div className="text-sm">알 수 없는 위젯 타입: {element.subtype}</div>
|
||||
|
|
@ -179,6 +187,7 @@ interface DashboardViewerProps {
|
|||
refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms)
|
||||
backgroundColor?: string; // 배경색
|
||||
resolution?: string; // 대시보드 해상도
|
||||
dashboardTitle?: string; // 대시보드 제목 (다운로드 파일명용)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -192,10 +201,217 @@ export function DashboardViewer({
|
|||
refreshInterval,
|
||||
backgroundColor = "#f9fafb",
|
||||
resolution = "fhd",
|
||||
dashboardTitle,
|
||||
}: DashboardViewerProps) {
|
||||
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
|
||||
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
|
||||
|
||||
// 대시보드 다운로드
|
||||
// 헬퍼 함수: dataUrl로 다운로드 처리
|
||||
const handleDownloadWithDataUrl = async (
|
||||
dataUrl: string,
|
||||
format: "png" | "pdf",
|
||||
canvasWidth: number,
|
||||
canvasHeight: number
|
||||
) => {
|
||||
if (format === "png") {
|
||||
console.log("💾 PNG 다운로드 시작...");
|
||||
const link = document.createElement("a");
|
||||
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`;
|
||||
link.download = filename;
|
||||
link.href = dataUrl;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
console.log("✅ PNG 다운로드 완료:", filename);
|
||||
} else {
|
||||
console.log("📄 PDF 생성 중...");
|
||||
const jsPDF = (await import("jspdf")).default;
|
||||
|
||||
// dataUrl에서 이미지 크기 계산
|
||||
const img = new Image();
|
||||
img.src = dataUrl;
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
|
||||
console.log("📐 이미지 실제 크기:", { width: img.width, height: img.height });
|
||||
console.log("📐 캔버스 계산 크기:", { width: canvasWidth, height: canvasHeight });
|
||||
|
||||
// PDF 크기 계산 (A4 기준)
|
||||
const imgWidth = 210; // A4 width in mm
|
||||
const actualHeight = canvasHeight;
|
||||
const actualWidth = canvasWidth;
|
||||
const imgHeight = (actualHeight * imgWidth) / actualWidth;
|
||||
|
||||
console.log("📄 PDF 크기:", { width: imgWidth, height: imgHeight });
|
||||
|
||||
const pdf = new jsPDF({
|
||||
orientation: imgHeight > imgWidth ? "portrait" : "landscape",
|
||||
unit: "mm",
|
||||
format: [imgWidth, imgHeight],
|
||||
});
|
||||
|
||||
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
|
||||
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`;
|
||||
pdf.save(filename);
|
||||
console.log("✅ PDF 다운로드 완료:", filename);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (format: "png" | "pdf") => {
|
||||
try {
|
||||
console.log("🔍 다운로드 시작:", format);
|
||||
|
||||
const canvas = document.querySelector(".dashboard-viewer-canvas") as HTMLElement;
|
||||
console.log("🔍 캔버스 찾기:", canvas);
|
||||
|
||||
if (!canvas) {
|
||||
alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📸 html-to-image 로딩 중...");
|
||||
// html-to-image 동적 import
|
||||
const { toPng } = await import("html-to-image");
|
||||
|
||||
console.log("📸 캔버스 캡처 중...");
|
||||
|
||||
// 3D/WebGL 렌더링 완료 대기
|
||||
console.log("⏳ 3D 렌더링 완료 대기 중...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존)
|
||||
console.log("🎨 WebGL 캔버스 처리 중...");
|
||||
const webglCanvases = canvas.querySelectorAll("canvas");
|
||||
const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = [];
|
||||
|
||||
webglCanvases.forEach((webglCanvas) => {
|
||||
try {
|
||||
const rect = webglCanvas.getBoundingClientRect();
|
||||
const dataUrl = webglCanvas.toDataURL("image/png");
|
||||
webglImages.push({ canvas: webglCanvas, dataUrl, rect });
|
||||
console.log("✅ WebGL 캔버스 캡처:", {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("⚠️ WebGL 캔버스 캡처 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// 캔버스의 실제 크기와 위치 가져오기
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasWidth = canvas.scrollWidth;
|
||||
|
||||
// 실제 콘텐츠의 최하단 위치 계산
|
||||
// 뷰어 모드에서는 모든 자식 요소를 확인
|
||||
const children = canvas.querySelectorAll("*");
|
||||
let maxBottom = 0;
|
||||
children.forEach((child) => {
|
||||
// canvas 자신이나 너무 작은 요소는 제외
|
||||
if (child === canvas || child.clientHeight < 10) {
|
||||
return;
|
||||
}
|
||||
const childRect = child.getBoundingClientRect();
|
||||
const relativeBottom = childRect.bottom - rect.top;
|
||||
if (relativeBottom > maxBottom) {
|
||||
maxBottom = relativeBottom;
|
||||
}
|
||||
});
|
||||
|
||||
// 실제 콘텐츠 높이 + 여유 공간 (50px)
|
||||
// maxBottom이 0이면 기본 캔버스 높이 사용
|
||||
const canvasHeight = maxBottom > 50 ? maxBottom + 50 : Math.max(canvas.scrollHeight, rect.height);
|
||||
|
||||
console.log("📐 캔버스 정보:", {
|
||||
rect: { x: rect.x, y: rect.y, left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||
scroll: { width: canvasWidth, height: canvas.scrollHeight },
|
||||
calculated: { width: canvasWidth, height: canvasHeight },
|
||||
maxBottom: maxBottom,
|
||||
webglCount: webglImages.length
|
||||
});
|
||||
|
||||
// html-to-image로 캔버스 캡처 (WebGL 제외)
|
||||
const dataUrl = await toPng(canvas, {
|
||||
backgroundColor: backgroundColor || "#ffffff",
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
pixelRatio: 2, // 고해상도
|
||||
cacheBust: true,
|
||||
skipFonts: false,
|
||||
preferredFontFormat: 'woff2',
|
||||
filter: (node) => {
|
||||
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
|
||||
if (node instanceof HTMLCanvasElement) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// WebGL 캔버스를 이미지 위에 합성
|
||||
if (webglImages.length > 0) {
|
||||
console.log("🖼️ WebGL 이미지 합성 중...");
|
||||
const img = new Image();
|
||||
img.src = dataUrl;
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
|
||||
// 새 캔버스에 합성
|
||||
const compositeCanvas = document.createElement("canvas");
|
||||
compositeCanvas.width = img.width;
|
||||
compositeCanvas.height = img.height;
|
||||
const ctx = compositeCanvas.getContext("2d");
|
||||
|
||||
if (ctx) {
|
||||
// 기본 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// WebGL 이미지들을 위치에 맞게 그리기
|
||||
for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) {
|
||||
const webglImg = new Image();
|
||||
webglImg.src = webglDataUrl;
|
||||
await new Promise((resolve) => {
|
||||
webglImg.onload = resolve;
|
||||
});
|
||||
|
||||
// 상대 위치 계산 (pixelRatio 2 고려)
|
||||
const relativeX = (webglRect.left - rect.left) * 2;
|
||||
const relativeY = (webglRect.top - rect.top) * 2;
|
||||
const width = webglRect.width * 2;
|
||||
const height = webglRect.height * 2;
|
||||
|
||||
ctx.drawImage(webglImg, relativeX, relativeY, width, height);
|
||||
console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height });
|
||||
}
|
||||
|
||||
// 합성된 이미지를 dataUrl로 변환
|
||||
const compositeDataUrl = compositeCanvas.toDataURL("image/png");
|
||||
console.log("✅ 최종 합성 완료");
|
||||
|
||||
// 합성된 이미지로 다운로드
|
||||
return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ 캡처 완료 (WebGL 없음)");
|
||||
|
||||
// WebGL이 없는 경우 기본 다운로드
|
||||
await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight);
|
||||
} catch (error) {
|
||||
console.error("❌ 다운로드 실패:", error);
|
||||
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
},
|
||||
[backgroundColor, dashboardTitle],
|
||||
);
|
||||
|
||||
// 캔버스 설정 계산
|
||||
const canvasConfig = useMemo(() => {
|
||||
return RESOLUTIONS[resolution as Resolution] || RESOLUTIONS.fhd;
|
||||
|
|
@ -312,11 +528,11 @@ export function DashboardViewer({
|
|||
// 요소가 없는 경우
|
||||
if (elements.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 text-6xl">📊</div>
|
||||
<div className="mb-2 text-xl font-medium text-gray-700">표시할 요소가 없습니다</div>
|
||||
<div className="text-sm text-gray-500">대시보드 편집기에서 차트나 위젯을 추가해보세요</div>
|
||||
<div className="mb-2 text-xl font-medium text-foreground">표시할 요소가 없습니다</div>
|
||||
<div className="text-sm text-muted-foreground">대시보드 편집기에서 차트나 위젯을 추가해보세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -325,10 +541,26 @@ export function DashboardViewer({
|
|||
return (
|
||||
<DashboardProvider>
|
||||
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
||||
<div className="hidden min-h-screen bg-gray-100 py-8 lg:block" style={{ backgroundColor }}>
|
||||
<div className="hidden min-h-screen bg-muted py-8 lg:block" style={{ backgroundColor }}>
|
||||
<div className="mx-auto px-4" style={{ maxWidth: `${canvasConfig.width}px` }}>
|
||||
{/* 다운로드 버튼 */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleDownload("png")}>PNG 이미지로 저장</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDownload("pdf")}>PDF 문서로 저장</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative rounded-lg"
|
||||
className="dashboard-viewer-canvas relative rounded-lg"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
|
|
@ -352,18 +584,36 @@ export function DashboardViewer({
|
|||
</div>
|
||||
|
||||
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
||||
<div className="block min-h-screen bg-gray-100 p-4 lg:hidden" style={{ backgroundColor }}>
|
||||
<div className="block min-h-screen bg-muted p-4 lg:hidden" style={{ backgroundColor }}>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={true}
|
||||
/>
|
||||
))}
|
||||
{/* 다운로드 버튼 */}
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
다운로드
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleDownload("png")}>PNG 이미지로 저장</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDownload("pdf")}>PDF 문서로 저장</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-viewer-canvas">
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardProvider>
|
||||
|
|
@ -396,16 +646,16 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
// 태블릿 이하: 세로 스택 카드 스타일
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
className="relative overflow-hidden rounded-lg border border-border bg-background shadow-sm"
|
||||
style={{ minHeight: "300px" }}
|
||||
>
|
||||
{element.showHeader !== false && (
|
||||
<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>
|
||||
<h3 className="text-xs font-semibold text-foreground">{element.customTitle || element.title}</h3>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||
title="새로고침"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -427,7 +677,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
<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" />
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : element.type === "chart" ? (
|
||||
<ChartRenderer element={element} data={data} width={undefined} height={250} />
|
||||
|
|
@ -436,10 +686,10 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
)}
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white">
|
||||
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<div className="text-sm text-gray-600">업데이트 중...</div>
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<div className="text-sm text-foreground">업데이트 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -453,7 +703,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
|
||||
return (
|
||||
<div
|
||||
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
className="absolute overflow-hidden rounded-lg border border-border bg-background shadow-sm"
|
||||
style={{
|
||||
left: `${(element.position.x / canvasWidth) * 100}%`,
|
||||
top: element.position.y,
|
||||
|
|
@ -463,11 +713,11 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
>
|
||||
{element.showHeader !== false && (
|
||||
<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>
|
||||
<h3 className="text-xs font-semibold text-foreground">{element.customTitle || element.title}</h3>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||
title="새로고침"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -489,7 +739,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
<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" />
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : element.type === "chart" ? (
|
||||
<ChartRenderer
|
||||
|
|
@ -503,10 +753,10 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
|
|||
)}
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white">
|
||||
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<div className="text-sm text-gray-600">업데이트 중...</div>
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<div className="text-sm text-foreground">업데이트 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -129,10 +129,10 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
|
|||
const diff = scheduled.getTime() - now.getTime();
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
|
||||
if (hours < 0) return { text: "⏰ 시간 초과", color: "text-red-600" };
|
||||
if (hours < 2) return { text: `⏱️ ${hours}시간 후`, color: "text-red-600" };
|
||||
if (hours < 4) return { text: `⏱️ ${hours}시간 후`, color: "text-orange-600" };
|
||||
return { text: `📅 ${hours}시간 후`, color: "text-gray-600" };
|
||||
if (hours < 0) return { text: "⏰ 시간 초과", color: "text-destructive" };
|
||||
if (hours < 2) return { text: `⏱️ ${hours}시간 후`, color: "text-destructive" };
|
||||
if (hours < 4) return { text: `⏱️ ${hours}시간 후`, color: "text-warning" };
|
||||
return { text: `📅 ${hours}시간 후`, color: "text-foreground" };
|
||||
};
|
||||
|
||||
const isNew = (createdAt: string) => {
|
||||
|
|
@ -143,34 +143,34 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-rose-50">
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-background to-destructive/10">
|
||||
{/* 신규 알림 배너 */}
|
||||
{showNotification && newCount > 0 && (
|
||||
<div className="animate-pulse border-b border-rose-300 bg-rose-100 px-4 py-2 text-center">
|
||||
<span className="font-bold text-rose-700">🔔 새로운 예약 {newCount}건이 도착했습니다!</span>
|
||||
<div className="animate-pulse border-b border-destructive bg-destructive/10 px-4 py-2 text-center">
|
||||
<span className="font-bold text-destructive">🔔 새로운 예약 {newCount}건이 도착했습니다!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||
<div className="border-b border-border bg-background px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "예약 요청 알림"}</h3>
|
||||
<h3 className="text-lg font-bold text-foreground">{element?.customTitle || "예약 요청 알림"}</h3>
|
||||
{newCount > 0 && (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-destructive text-xs font-bold text-primary-foreground">
|
||||
{newCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchBookings}
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
🔄 새로고침
|
||||
</button>
|
||||
|
|
@ -183,7 +183,7 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
|
|||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
filter === f ? "bg-primary text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
filter === f ? "bg-primary text-primary-foreground" : "bg-muted text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{f === "pending" ? "대기중" : f === "accepted" ? "수락됨" : "전체"}
|
||||
|
|
@ -195,7 +195,7 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
|
|||
{/* 예약 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{bookings.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📭</div>
|
||||
<div>예약 요청이 없습니다</div>
|
||||
|
|
@ -206,14 +206,14 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
|
|||
{bookings.map((booking) => (
|
||||
<div
|
||||
key={booking.id}
|
||||
className={`group relative rounded-lg border-2 bg-white p-4 shadow-sm transition-all hover:shadow-md ${
|
||||
booking.priority === "urgent" ? "border-red-400" : "border-gray-200"
|
||||
className={`group relative rounded-lg border-2 bg-background p-4 shadow-sm transition-all hover:shadow-md ${
|
||||
booking.priority === "urgent" ? "border-destructive" : "border-border"
|
||||
} ${booking.status !== "pending" ? "opacity-60" : ""}`}
|
||||
>
|
||||
{/* NEW 뱃지 */}
|
||||
{isNew(booking.createdAt) && booking.status === "pending" && (
|
||||
<div className="absolute -right-2 -top-2 animate-bounce">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white shadow-lg">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive text-xs font-bold text-primary-foreground shadow-lg">
|
||||
🆕
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -221,7 +221,7 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
|
|||
|
||||
{/* 우선순위 표시 */}
|
||||
{booking.priority === "urgent" && (
|
||||
<div className="mb-2 flex items-center gap-1 text-sm font-bold text-red-600">
|
||||
<div className="mb-2 flex items-center gap-1 text-sm font-bold text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
긴급 예약
|
||||
</div>
|
||||
|
|
@ -233,8 +233,8 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
|
|||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-2xl">{getVehicleIcon(booking.vehicleType)}</span>
|
||||
<div>
|
||||
<div className="font-bold text-gray-800">{booking.customerName}</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-600">
|
||||
<div className="font-bold text-foreground">{booking.customerName}</div>
|
||||
<div className="flex items-center gap-1 text-xs text-foreground">
|
||||
<Phone className="h-3 w-3" />
|
||||
{booking.customerPhone}
|
||||
</div>
|
||||
|
|
@ -245,14 +245,14 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
|
|||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleAccept(booking.id)}
|
||||
className="flex items-center gap-1 rounded bg-green-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-600"
|
||||
className="flex items-center gap-1 rounded bg-success px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-success/90"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
수락
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReject(booking.id)}
|
||||
className="flex items-center gap-1 rounded bg-red-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-600"
|
||||
className="flex items-center gap-1 rounded bg-destructive px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
거절
|
||||
|
|
@ -260,26 +260,26 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
|
|||
</div>
|
||||
)}
|
||||
{booking.status === "accepted" && (
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
|
||||
<span className="rounded bg-success/10 px-2 py-1 text-xs font-medium text-success">
|
||||
✓ 수락됨
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 경로 정보 */}
|
||||
<div className="mb-3 space-y-2 border-t border-gray-100 pt-3">
|
||||
<div className="mb-3 space-y-2 border-t border-border pt-3">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
|
||||
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-700">출발지</div>
|
||||
<div className="text-gray-600">{booking.pickupLocation}</div>
|
||||
<div className="font-medium text-foreground">출발지</div>
|
||||
<div className="text-foreground">{booking.pickupLocation}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-rose-600" />
|
||||
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-700">도착지</div>
|
||||
<div className="text-gray-600">{booking.dropoffLocation}</div>
|
||||
<div className="font-medium text-foreground">도착지</div>
|
||||
<div className="text-foreground">{booking.dropoffLocation}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -287,8 +287,8 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
|
|||
{/* 상세 정보 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Package className="h-3 w-3 text-gray-500" />
|
||||
<span className="text-gray-600">
|
||||
<Package className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">
|
||||
{booking.cargoType} ({booking.weight}kg)
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -169,55 +169,55 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
|
|||
}, [display, previousValue, operation, waitingForOperand]);
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full p-3 bg-gradient-to-br from-slate-50 to-gray-100 ${className}`}>
|
||||
<div className="h-full flex flex-col gap-2">
|
||||
<div className={`h-full w-full p-2 sm:p-3 bg-background ${className}`}>
|
||||
<div className="h-full flex flex-col gap-1.5 sm:gap-2">
|
||||
{/* 제목 */}
|
||||
<h3 className="text-base font-semibold text-gray-900 text-center">{element?.customTitle || "계산기"}</h3>
|
||||
<h3 className="text-sm sm:text-base font-semibold text-foreground text-center">{element?.customTitle || "계산기"}</h3>
|
||||
|
||||
{/* 디스플레이 */}
|
||||
<div className="bg-white border-2 border-gray-200 rounded-lg p-4 shadow-inner min-h-[80px]">
|
||||
<div className="bg-background border-2 border-border rounded-lg p-2 sm:p-4 shadow-inner min-h-[60px] sm:min-h-[80px]">
|
||||
<div className="text-right h-full flex flex-col justify-center">
|
||||
<div className="h-4 mb-1">
|
||||
<div className="h-3 sm:h-4 mb-0.5 sm:mb-1">
|
||||
{operation && previousValue !== null && (
|
||||
<div className="text-xs text-gray-400">
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
{previousValue} {operation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 truncate">
|
||||
<div className="text-lg sm:text-2xl font-bold text-foreground truncate">
|
||||
{display}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 그리드 */}
|
||||
<div className="flex-1 grid grid-cols-4 gap-2">
|
||||
<div className="flex-1 grid grid-cols-4 gap-1 sm:gap-2">
|
||||
{/* 첫 번째 줄 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
className="h-full text-red-600 hover:bg-red-50 hover:text-red-700 font-semibold select-none"
|
||||
className="h-full text-xs sm:text-base text-destructive hover:bg-destructive/10 hover:text-destructive font-semibold select-none"
|
||||
>
|
||||
AC
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSign}
|
||||
className="h-full text-gray-600 hover:bg-gray-100 font-semibold select-none"
|
||||
className="h-full text-xs sm:text-base text-foreground hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
+/-
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePercent}
|
||||
className="h-full text-gray-600 hover:bg-gray-100 font-semibold select-none"
|
||||
className="h-full text-xs sm:text-base text-foreground hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
%
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleOperation('÷')}
|
||||
className="h-full bg-blue-500 hover:bg-blue-600 text-white font-semibold select-none"
|
||||
className="h-full text-xs sm:text-base bg-primary hover:bg-primary/90 text-primary-foreground font-semibold select-none"
|
||||
>
|
||||
÷
|
||||
</Button>
|
||||
|
|
@ -226,28 +226,28 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
|
|||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('7')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
7
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('8')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
8
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('9')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
9
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleOperation('×')}
|
||||
className="h-full bg-blue-500 hover:bg-blue-600 text-white font-semibold select-none"
|
||||
className="h-full text-xs sm:text-base bg-primary hover:bg-primary/90 text-primary-foreground font-semibold select-none"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
|
|
@ -256,28 +256,28 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
|
|||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('4')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
4
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('5')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
5
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('6')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
6
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleOperation('-')}
|
||||
className="h-full bg-blue-500 hover:bg-blue-600 text-white font-semibold select-none"
|
||||
className="h-full text-xs sm:text-base bg-primary hover:bg-primary/90 text-primary-foreground font-semibold select-none"
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
|
|
@ -286,28 +286,28 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
|
|||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('1')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
1
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('2')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
2
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('3')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
3
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleOperation('+')}
|
||||
className="h-full bg-blue-500 hover:bg-blue-600 text-white font-semibold select-none"
|
||||
className="h-full text-xs sm:text-base bg-primary hover:bg-primary/90 text-primary-foreground font-semibold select-none"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
|
|
@ -316,21 +316,21 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
|
|||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('0')}
|
||||
className="h-full col-span-2 hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full col-span-2 text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
0
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDecimal}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
className="h-full text-sm sm:text-lg hover:bg-muted font-semibold select-none"
|
||||
>
|
||||
.
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleEquals}
|
||||
className="h-full bg-green-500 hover:bg-green-600 text-white font-semibold select-none"
|
||||
className="h-full text-xs sm:text-base bg-success hover:bg-success/90 text-primary-foreground font-semibold select-none"
|
||||
>
|
||||
=
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -83,11 +83,11 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
|||
if (statusLower.includes("배송중") || statusLower.includes("delivering")) {
|
||||
return "bg-primary text-primary-foreground";
|
||||
} else if (statusLower.includes("완료") || statusLower.includes("delivered")) {
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100";
|
||||
return "bg-success/10 text-success dark:bg-success/20 dark:text-success";
|
||||
} else if (statusLower.includes("지연") || statusLower.includes("delayed")) {
|
||||
return "bg-destructive text-destructive-foreground";
|
||||
} else if (statusLower.includes("픽업") || statusLower.includes("pending")) {
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100";
|
||||
return "bg-warning/10 text-warning dark:bg-warning/20 dark:text-warning";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { DashboardElement, ChartDataSource, ChartData } from "@/components/admin
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
import { Chart } from "@/components/admin/dashboard/charts/Chart";
|
||||
|
||||
interface ChartTestWidgetProps {
|
||||
|
|
@ -21,7 +22,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 600, height: 400 });
|
||||
|
||||
console.log("🧪 ChartTestWidget 렌더링 (D3 기반)!", element);
|
||||
// console.log("🧪 ChartTestWidget 렌더링 (D3 기반)!", element);
|
||||
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
|
@ -47,11 +48,11 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
// console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
// console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -59,7 +60,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
const results = await Promise.allSettled(
|
||||
dataSources.map(async (source) => {
|
||||
try {
|
||||
console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
||||
// console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
||||
|
||||
if (source.type === "api") {
|
||||
return await loadRestApiData(source);
|
||||
|
|
@ -86,7 +87,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
}
|
||||
});
|
||||
|
||||
console.log(`✅ 총 ${allData.length}개의 데이터 로딩 완료`);
|
||||
// console.log(`✅ 총 ${allData.length}개의 데이터 로딩 완료`);
|
||||
setData(allData);
|
||||
setLastRefreshTime(new Date());
|
||||
} catch (err: any) {
|
||||
|
|
@ -99,7 +100,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
|
||||
// 수동 새로고침
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
// console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadMultipleDataSources();
|
||||
}, [loadMultipleDataSources]);
|
||||
|
||||
|
|
@ -127,7 +128,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
});
|
||||
}
|
||||
|
||||
const response = await fetch("/api/dashboards/fetch-external-api", {
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
|
|
@ -211,15 +212,15 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
if (intervals.length === 0) return;
|
||||
|
||||
const minInterval = Math.min(...intervals);
|
||||
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
// console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log("🔄 자동 새로고침 실행");
|
||||
// console.log("🔄 자동 새로고침 실행");
|
||||
loadMultipleDataSources();
|
||||
}, minInterval * 1000);
|
||||
|
||||
return () => {
|
||||
console.log("⏹️ 자동 새로고침 정리");
|
||||
// console.log("⏹️ 자동 새로고침 정리");
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
|
@ -240,14 +241,20 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
|
||||
// 병합 모드: 여러 데이터 소스를 하나로 합침
|
||||
if (mergeMode && dataSourceConfigs.length > 1) {
|
||||
// console.log("🔀 병합 모드 활성화");
|
||||
|
||||
// 모든 데이터 소스의 X축 필드 수집 (첫 번째 데이터 소스의 X축 사용)
|
||||
const baseConfig = dataSourceConfigs[0];
|
||||
const xAxisField = baseConfig.xAxis;
|
||||
const yAxisField = baseConfig.yAxis[0];
|
||||
|
||||
// console.log("📊 X축 필드:", xAxisField);
|
||||
|
||||
// X축 값 수집
|
||||
dataSourceConfigs.forEach((dsConfig) => {
|
||||
// X축 값 수집 (모든 데이터 소스에서)
|
||||
dataSourceConfigs.forEach((dsConfig, idx) => {
|
||||
const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
// console.log(` 소스 ${idx + 1} (${sourceName}): ${sourceData.length}개 행`);
|
||||
|
||||
sourceData.forEach((item) => {
|
||||
if (item[xAxisField] !== undefined) {
|
||||
labels.add(String(item[xAxisField]));
|
||||
|
|
@ -255,26 +262,36 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
});
|
||||
});
|
||||
|
||||
// 데이터 병합
|
||||
const mergedData: number[] = [];
|
||||
labels.forEach((label) => {
|
||||
let totalValue = 0;
|
||||
dataSourceConfigs.forEach((dsConfig) => {
|
||||
const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
const matchingItem = sourceData.find((item) => String(item[xAxisField]) === label);
|
||||
if (matchingItem && yAxisField) {
|
||||
totalValue += parseFloat(matchingItem[yAxisField]) || 0;
|
||||
}
|
||||
// console.log("📍 수집된 X축 라벨:", Array.from(labels));
|
||||
|
||||
// 각 데이터 소스별로 데이터셋 생성 (병합하지 않고 각각 표시)
|
||||
dataSourceConfigs.forEach((dsConfig, idx) => {
|
||||
const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${idx + 1}`;
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
|
||||
// 각 Y축 필드마다 데이터셋 생성
|
||||
(dsConfig.yAxis || []).forEach((yAxisField, yIdx) => {
|
||||
const datasetData: number[] = [];
|
||||
|
||||
labels.forEach((label) => {
|
||||
const matchingItem = sourceData.find((item) => String(item[xAxisField]) === label);
|
||||
const value = matchingItem && yAxisField ? parseFloat(matchingItem[yAxisField]) || 0 : 0;
|
||||
datasetData.push(value);
|
||||
});
|
||||
|
||||
// console.log(` 📈 ${sourceName} - ${yAxisField}: [${datasetData.join(", ")}]`);
|
||||
|
||||
datasets.push({
|
||||
label: `${sourceName} - ${yAxisField}`,
|
||||
data: datasetData,
|
||||
backgroundColor: COLORS[(idx * 2 + yIdx) % COLORS.length],
|
||||
borderColor: COLORS[(idx * 2 + yIdx) % COLORS.length],
|
||||
type: dsConfig.chartType || chartType, // 데이터 소스별 차트 타입
|
||||
});
|
||||
});
|
||||
mergedData.push(totalValue);
|
||||
});
|
||||
|
||||
datasets.push({
|
||||
label: yAxisField,
|
||||
data: mergedData,
|
||||
color: COLORS[0],
|
||||
});
|
||||
// console.log("✅ 병합 모드 데이터셋 생성 완료:", datasets.length, "개");
|
||||
} else {
|
||||
// 일반 모드: 각 데이터 소스를 별도로 표시
|
||||
dataSourceConfigs.forEach((dsConfig, index) => {
|
||||
|
|
@ -313,7 +330,7 @@ export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
|||
}, [data, dataSourceConfigs, mergeMode, dataSources]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-white">
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
{/* 차트 영역 - 전체 공간 사용 */}
|
||||
<div ref={containerRef} className="flex-1 overflow-hidden p-2">
|
||||
{error ? (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
|
|
@ -37,14 +38,14 @@ const calculateMetric = (rows: any[], field: string, aggregation: string): numbe
|
|||
}
|
||||
};
|
||||
|
||||
// 색상 스타일 매핑
|
||||
// 색상 스타일 매핑 (차분한 색상)
|
||||
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" },
|
||||
indigo: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
green: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
blue: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
purple: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
orange: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
gray: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -63,7 +64,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
const [selectedMetric, setSelectedMetric] = useState<any | null>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
|
||||
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
|
||||
// console.log("🧪 CustomMetricTestWidget 렌더링!", element);
|
||||
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
|
@ -100,10 +101,10 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(groupByDS.query);
|
||||
|
||||
if (result.success && result.data?.rows) {
|
||||
const rows = result.data.rows;
|
||||
if (result && result.rows) {
|
||||
const rows = result.rows;
|
||||
if (rows.length > 0) {
|
||||
const columns = result.data.columns || Object.keys(rows[0]);
|
||||
const columns = result.columns || Object.keys(rows[0]);
|
||||
const labelColumn = columns[0];
|
||||
const valueColumn = columns[1];
|
||||
|
||||
|
|
@ -120,12 +121,17 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
else if (dataSourceType === "api") {
|
||||
if (!groupByDS.endpoint) return;
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.fetchExternalApi({
|
||||
method: "GET",
|
||||
url: groupByDS.endpoint,
|
||||
headers: (groupByDS as any).headers || {},
|
||||
// REST API 호출 (백엔드 프록시 사용)
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: groupByDS.endpoint,
|
||||
method: "GET",
|
||||
headers: (groupByDS as any).headers || {},
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
let rows: any[] = [];
|
||||
|
|
@ -162,11 +168,11 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
// console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
// console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -175,7 +181,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
const results = await Promise.allSettled(
|
||||
dataSources.map(async (source, sourceIndex) => {
|
||||
try {
|
||||
console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`);
|
||||
// console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`);
|
||||
|
||||
let rows: any[] = [];
|
||||
if (source.type === "api") {
|
||||
|
|
@ -184,7 +190,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
rows = await loadDatabaseData(source);
|
||||
}
|
||||
|
||||
console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`);
|
||||
// console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`);
|
||||
|
||||
return {
|
||||
sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
|
||||
|
|
@ -202,7 +208,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
}),
|
||||
);
|
||||
|
||||
console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
|
||||
// console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
|
||||
|
||||
// 각 데이터 소스별로 메트릭 생성
|
||||
const allMetrics: any[] = [];
|
||||
|
|
@ -215,10 +221,8 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
|
||||
const { sourceName, rows } = result.value;
|
||||
|
||||
// 집계된 데이터인지 확인 (행이 적고 숫자 컬럼이 있으면)
|
||||
const hasAggregatedData = rows.length > 0 && rows.length <= 100;
|
||||
|
||||
if (hasAggregatedData && rows.length > 0) {
|
||||
// 🎯 간단한 쿼리도 잘 작동하도록 개선된 로직
|
||||
if (rows.length > 0) {
|
||||
const firstRow = rows[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
|
|
@ -229,28 +233,44 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
});
|
||||
|
||||
// 문자열 컬럼 찾기
|
||||
const stringColumns = columns.filter((col) => {
|
||||
const value = firstRow[col];
|
||||
return typeof value === "string" || !numericColumns.includes(col);
|
||||
});
|
||||
const stringColumns = columns.filter((col) => !numericColumns.includes(col));
|
||||
|
||||
console.log(`📊 [${sourceName}] 컬럼 분석:`, {
|
||||
전체: columns,
|
||||
숫자: numericColumns,
|
||||
문자열: stringColumns,
|
||||
});
|
||||
|
||||
// 🆕 자동 집계 로직: 집계 컬럼 이름으로 판단 (count, 개수, sum, avg 등)
|
||||
const isAggregated = numericColumns.some((col) =>
|
||||
/count|개수|sum|합계|avg|평균|min|최소|max|최대|total|전체/i.test(col)
|
||||
);
|
||||
|
||||
if (isAggregated && numericColumns.length > 0) {
|
||||
// 집계 컬럼이 있으면 이미 집계된 데이터로 판단 (GROUP BY 결과)
|
||||
console.log(`✅ [${sourceName}] 집계된 데이터로 판단 (집계 컬럼 발견: ${numericColumns.join(", ")})`);
|
||||
// 🎯 케이스 0: 1행인데 숫자 컬럼이 여러 개 → 각 컬럼을 별도 카드로
|
||||
if (rows.length === 1 && numericColumns.length > 1) {
|
||||
// 예: SELECT COUNT(*) AS 전체, SUM(...) AS 배송중, ...
|
||||
numericColumns.forEach((col) => {
|
||||
allMetrics.push({
|
||||
label: col, // 컬럼명이 라벨
|
||||
value: Number(firstRow[col]) || 0,
|
||||
field: col,
|
||||
aggregation: "custom",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: [firstRow],
|
||||
});
|
||||
});
|
||||
}
|
||||
// 🎯 케이스 1: 컬럼이 2개 (라벨 + 값) → 가장 간단한 형태
|
||||
else if (columns.length === 2) {
|
||||
const labelCol = columns[0];
|
||||
const valueCol = columns[1];
|
||||
|
||||
rows.forEach((row) => {
|
||||
allMetrics.push({
|
||||
label: String(row[labelCol] || ""),
|
||||
value: Number(row[valueCol]) || 0,
|
||||
field: valueCol,
|
||||
aggregation: "custom",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: [row],
|
||||
});
|
||||
});
|
||||
}
|
||||
// 🎯 케이스 2: 숫자 컬럼이 1개 이상 있음 → 집계된 데이터
|
||||
else if (numericColumns.length > 0) {
|
||||
rows.forEach((row, index) => {
|
||||
// 라벨: 첫 번째 문자열 컬럼
|
||||
// 라벨: 첫 번째 문자열 컬럼 (없으면 첫 번째 컬럼)
|
||||
const labelField = stringColumns[0] || columns[0];
|
||||
const label = String(row[labelField] || `항목 ${index + 1}`);
|
||||
|
||||
|
|
@ -258,8 +278,6 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
const valueField = numericColumns[0];
|
||||
const value = Number(row[valueField]) || 0;
|
||||
|
||||
console.log(` [${sourceName}] 메트릭: ${label} = ${value}`);
|
||||
|
||||
allMetrics.push({
|
||||
label: label,
|
||||
value: value,
|
||||
|
|
@ -270,25 +288,18 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
rawData: [row],
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 숫자 컬럼이 없으면 자동 집계 (마지막 컬럼 기준)
|
||||
console.log(`✅ [${sourceName}] 자동 집계 모드 (숫자 컬럼 없음)`);
|
||||
|
||||
// 마지막 컬럼을 집계 기준으로 사용
|
||||
}
|
||||
// 🎯 케이스 3: 숫자 컬럼이 없음 → 마지막 컬럼 기준으로 카운트
|
||||
else {
|
||||
const aggregateField = columns[columns.length - 1];
|
||||
console.log(` [${sourceName}] 집계 기준 컬럼: ${aggregateField}`);
|
||||
|
||||
// 해당 컬럼의 값별로 카운트
|
||||
const countMap = new Map<string, number>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const value = String(row[aggregateField] || "기타");
|
||||
countMap.set(value, (countMap.get(value) || 0) + 1);
|
||||
});
|
||||
|
||||
// 카운트 결과를 메트릭으로 변환
|
||||
countMap.forEach((count, label) => {
|
||||
console.log(` [${sourceName}] 자동 집계: ${label} = ${count}개`);
|
||||
|
||||
allMetrics.push({
|
||||
label: label,
|
||||
value: count,
|
||||
|
|
@ -312,74 +323,25 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
});
|
||||
}
|
||||
|
||||
// 🆕 숫자 컬럼이 없을 때의 기존 로직은 주석 처리
|
||||
if (false) {
|
||||
// 숫자 컬럼이 없으면 각 컬럼별 고유값 개수 표시
|
||||
console.log(`📊 [${sourceName}] 문자열 데이터, 각 컬럼별 고유값 개수 표시`);
|
||||
|
||||
// 데이터 소스에서 선택된 컬럼 가져오기
|
||||
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
||||
(ds) => ds.name === sourceName || ds.id === result.value.sourceIndex.toString(),
|
||||
);
|
||||
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
||||
|
||||
// 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
|
||||
const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
|
||||
|
||||
console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
|
||||
|
||||
columnsToShow.forEach((col) => {
|
||||
// 해당 컬럼이 실제로 존재하는지 확인
|
||||
if (!columns.includes(col)) {
|
||||
console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 해당 컬럼의 고유값 개수 계산
|
||||
const uniqueValues = new Set(rows.map((row) => row[col]));
|
||||
const uniqueCount = uniqueValues.size;
|
||||
|
||||
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||
|
||||
allMetrics.push({
|
||||
label: `${sourceName} - ${col} (고유값)`,
|
||||
value: uniqueCount,
|
||||
field: col,
|
||||
aggregation: "distinct",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
});
|
||||
|
||||
// 총 행 개수도 추가
|
||||
allMetrics.push({
|
||||
label: `${sourceName} - 총 개수`,
|
||||
value: rows.length,
|
||||
field: "count",
|
||||
aggregation: "count",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 행이 많으면 각 컬럼별 고유값 개수 + 총 개수 표시
|
||||
console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`);
|
||||
}
|
||||
// 🎯 행이 많을 때도 간단하게 처리
|
||||
else if (rows.length > 100) {
|
||||
// 행이 많으면 총 개수만 표시
|
||||
// console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`);
|
||||
|
||||
const firstRow = rows[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
// 데이터 소스에서 선택된 컬럼 가져오기
|
||||
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
||||
(ds) => ds.name === sourceName || ds.id === result.value.sourceIndex.toString(),
|
||||
(ds) => ds.name === sourceName || (result.status === "fulfilled" && ds.id === result.value?.sourceIndex.toString()),
|
||||
);
|
||||
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
||||
|
||||
// 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
|
||||
const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
|
||||
|
||||
console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
|
||||
// console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
|
||||
|
||||
// 각 컬럼별 고유값 개수
|
||||
columnsToShow.forEach((col) => {
|
||||
|
|
@ -392,7 +354,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
const uniqueValues = new Set(rows.map((row) => row[col]));
|
||||
const uniqueCount = uniqueValues.size;
|
||||
|
||||
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||
// console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||
|
||||
allMetrics.push({
|
||||
label: `${sourceName} - ${col} (고유값)`,
|
||||
|
|
@ -418,7 +380,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
}
|
||||
});
|
||||
|
||||
console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`);
|
||||
// console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`);
|
||||
setMetrics(allMetrics);
|
||||
setLastRefreshTime(new Date());
|
||||
} catch (err) {
|
||||
|
|
@ -459,13 +421,13 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
|
||||
// 수동 새로고침 핸들러
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
// console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// XML 데이터 파싱
|
||||
const parseXmlData = (xmlText: string): any[] => {
|
||||
console.log("🔍 XML 파싱 시작");
|
||||
// console.log("🔍 XML 파싱 시작");
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||
|
|
@ -485,7 +447,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
result.push(obj);
|
||||
}
|
||||
|
||||
console.log(`✅ XML 파싱 완료: ${result.length}개 레코드`);
|
||||
// console.log(`✅ XML 파싱 완료: ${result.length}개 레코드`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ XML 파싱 실패:", error);
|
||||
|
|
@ -495,16 +457,16 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
|
||||
// 텍스트/CSV 데이터 파싱
|
||||
const parseTextData = (text: string): any[] => {
|
||||
console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
|
||||
// console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
|
||||
|
||||
// XML 감지
|
||||
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||||
console.log("📄 XML 형식 감지");
|
||||
// console.log("📄 XML 형식 감지");
|
||||
return parseXmlData(text);
|
||||
}
|
||||
|
||||
// CSV 파싱
|
||||
console.log("📄 CSV 형식으로 파싱 시도");
|
||||
// console.log("📄 CSV 형식으로 파싱 시도");
|
||||
const lines = text.trim().split("\n");
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
|
|
@ -522,7 +484,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
result.push(obj);
|
||||
}
|
||||
|
||||
console.log(`✅ CSV 파싱 완료: ${result.length}개 행`);
|
||||
// console.log(`✅ CSV 파싱 완료: ${result.length}개 행`);
|
||||
return result;
|
||||
};
|
||||
|
||||
|
|
@ -551,9 +513,9 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
}
|
||||
}
|
||||
|
||||
console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params));
|
||||
// console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params));
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -577,7 +539,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log("✅ API 응답:", result);
|
||||
// console.log("✅ API 응답:", result);
|
||||
|
||||
if (!result.success) {
|
||||
console.error("❌ API 실패:", result);
|
||||
|
|
@ -588,10 +550,10 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
|
||||
// 텍스트/XML 데이터 처리
|
||||
if (typeof processedData === "string") {
|
||||
console.log("📄 텍스트 형식 데이터 감지");
|
||||
// console.log("📄 텍스트 형식 데이터 감지");
|
||||
processedData = parseTextData(processedData);
|
||||
} else if (processedData && typeof processedData === "object" && processedData.text) {
|
||||
console.log("📄 래핑된 텍스트 데이터 감지");
|
||||
// console.log("📄 래핑된 텍스트 데이터 감지");
|
||||
processedData = parseTextData(processedData.text);
|
||||
}
|
||||
|
||||
|
|
@ -607,12 +569,12 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
}
|
||||
} else if (!Array.isArray(processedData) && typeof processedData === "object") {
|
||||
// JSON Path 없으면 자동으로 배열 찾기
|
||||
console.log("🔍 JSON Path 없음, 자동으로 배열 찾기 시도");
|
||||
// console.log("🔍 JSON Path 없음, 자동으로 배열 찾기 시도");
|
||||
const arrayKeys = ["data", "items", "result", "records", "rows", "list"];
|
||||
|
||||
for (const key of arrayKeys) {
|
||||
if (Array.isArray(processedData[key])) {
|
||||
console.log(`✅ 배열 발견: ${key}`);
|
||||
// console.log(`✅ 배열 발견: ${key}`);
|
||||
processedData = processedData[key];
|
||||
break;
|
||||
}
|
||||
|
|
@ -680,15 +642,15 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
if (intervals.length === 0) return;
|
||||
|
||||
const minInterval = Math.min(...intervals);
|
||||
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
// console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log("🔄 자동 새로고침 실행");
|
||||
// console.log("🔄 자동 새로고침 실행");
|
||||
loadAllData();
|
||||
}, minInterval * 1000);
|
||||
|
||||
return () => {
|
||||
console.log("⏹️ 자동 새로고침 정리");
|
||||
// console.log("⏹️ 자동 새로고침 정리");
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [dataSources, loadAllData]);
|
||||
|
|
@ -698,10 +660,10 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
// 로딩 상태 (원본 스타일)
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-background p-2">
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -710,12 +672,12 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
// 에러 상태 (원본 스타일)
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-background p-2">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-red-600">⚠️ {error}</p>
|
||||
<p className="text-sm text-destructive">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={handleManualRefresh}
|
||||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -727,8 +689,8 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
// 데이터 소스 없음 (원본 스타일)
|
||||
if (!(element?.dataSources || element?.chartConfig?.dataSources) && !isGroupByMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<p className="text-sm text-gray-500">데이터 소스를 연결해주세요</p>
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-background p-2">
|
||||
<p className="text-sm text-muted-foreground">데이터 소스를 연결해주세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -736,17 +698,20 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
// 메트릭 설정 없음 (원본 스타일)
|
||||
if (metricConfig.length === 0 && !isGroupByMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<p className="text-sm text-gray-500">메트릭을 설정해주세요</p>
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-background p-2">
|
||||
<p className="text-sm text-muted-foreground">메트릭을 설정해주세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 메인 렌더링 (원본 스타일 - 심플하게)
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-white p-2">
|
||||
<div className="flex h-full w-full flex-col bg-background p-2">
|
||||
{/* 콘텐츠 영역 - 스크롤 가능하도록 개선 */}
|
||||
<div className="grid w-full gap-2 overflow-y-auto" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}>
|
||||
<div
|
||||
className="grid w-full gap-2 overflow-y-auto"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}
|
||||
>
|
||||
{/* 그룹별 카드 (활성화 시) */}
|
||||
{isGroupByMode &&
|
||||
groupedCards.map((card, index) => {
|
||||
|
|
@ -760,7 +725,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
key={`group-${index}`}
|
||||
className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
|
||||
>
|
||||
<div className="text-[10px] text-gray-600">{card.label}</div>
|
||||
<div className="text-[10px] text-foreground">{card.label}</div>
|
||||
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -783,7 +748,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
}}
|
||||
className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2 transition-all hover:shadow-md`}
|
||||
>
|
||||
<div className="text-[10px] text-gray-600">{metric.label}</div>
|
||||
<div className="text-[10px] text-foreground">{metric.label}</div>
|
||||
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>
|
||||
{formattedValue}
|
||||
{metric.unit && <span className="ml-0.5 text-sm">{metric.unit}</span>}
|
||||
|
|
|
|||
|
|
@ -35,12 +35,12 @@ const calculateMetric = (rows: any[], field: string, aggregation: string): numbe
|
|||
|
||||
// 색상 스타일 매핑
|
||||
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" },
|
||||
indigo: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
green: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
blue: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
purple: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
orange: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
gray: { bg: "bg-muted", text: "text-foreground", border: "border-border" },
|
||||
};
|
||||
|
||||
export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
|
||||
|
|
@ -298,10 +298,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white">
|
||||
<div className="flex h-full items-center justify-center bg-background">
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -309,12 +309,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white p-4">
|
||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-red-600">⚠️ {error}</p>
|
||||
<p className="text-sm text-destructive">⚠️ {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"
|
||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -344,10 +344,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
|
||||
if (shouldShowEmpty) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white p-4">
|
||||
<div className="flex h-full items-center justify-center bg-background 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">
|
||||
<h3 className="text-sm font-bold text-foreground">사용자 커스텀 카드</h3>
|
||||
<div className="space-y-1.5 text-xs text-foreground">
|
||||
<p className="font-medium">📊 맞춤형 지표 위젯</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• SQL 쿼리로 데이터를 불러옵니다</li>
|
||||
|
|
@ -359,7 +359,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
||||
<p className="font-medium">⚙️ 설정 방법</p>
|
||||
<p className="mb-1">
|
||||
{isGroupByMode
|
||||
|
|
@ -379,7 +379,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full w-full overflow-hidden bg-white p-0.5 ${
|
||||
className={`flex h-full w-full overflow-hidden bg-background p-0.5 ${
|
||||
isHorizontalLayout ? "flex-row gap-0.5" : "flex-col gap-0.5"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -396,7 +396,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
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="text-[8px] leading-tight text-foreground">{card.label}</div>
|
||||
<div className={`mt-0 text-xs leading-none font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -412,7 +412,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
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="text-[8px] leading-tight text-foreground">{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>
|
||||
|
|
|
|||
|
|
@ -600,26 +600,26 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
// 색상 매핑
|
||||
const getColorClasses = (color: string) => {
|
||||
const colorMap: { [key: string]: { bg: string; text: string } } = {
|
||||
indigo: { bg: "bg-indigo-50", text: "text-indigo-600" },
|
||||
green: { bg: "bg-green-50", text: "text-green-600" },
|
||||
blue: { bg: "bg-blue-50", text: "text-blue-600" },
|
||||
purple: { bg: "bg-purple-50", text: "text-purple-600" },
|
||||
orange: { bg: "bg-orange-50", text: "text-orange-600" },
|
||||
yellow: { bg: "bg-yellow-50", text: "text-yellow-600" },
|
||||
cyan: { bg: "bg-cyan-50", text: "text-cyan-600" },
|
||||
pink: { bg: "bg-pink-50", text: "text-pink-600" },
|
||||
teal: { bg: "bg-teal-50", text: "text-teal-600" },
|
||||
gray: { bg: "bg-gray-50", text: "text-gray-600" },
|
||||
indigo: { bg: "bg-primary/10", text: "text-primary" },
|
||||
green: { bg: "bg-success/10", text: "text-success" },
|
||||
blue: { bg: "bg-primary/10", text: "text-primary" },
|
||||
purple: { bg: "bg-primary/10", text: "text-primary" },
|
||||
orange: { bg: "bg-warning/10", text: "text-warning" },
|
||||
yellow: { bg: "bg-warning/10", text: "text-warning" },
|
||||
cyan: { bg: "bg-primary/10", text: "text-primary" },
|
||||
pink: { bg: "bg-muted", text: "text-foreground" },
|
||||
teal: { bg: "bg-primary/10", text: "text-primary" },
|
||||
gray: { bg: "bg-muted", text: "text-foreground" },
|
||||
};
|
||||
return colorMap[color] || colorMap.gray;
|
||||
};
|
||||
|
||||
if (isLoading && stats.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
<div className="mt-2 text-sm text-gray-600">로딩 중...</div>
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<div className="mt-2 text-sm text-foreground">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -627,14 +627,14 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||||
<div className="flex h-full items-center justify-center bg-muted p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||||
{!element?.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
||||
<div className="text-sm font-medium text-foreground">{error}</div>
|
||||
{!element?.dataSource?.query && <div className="mt-2 text-xs text-muted-foreground">쿼리를 설정하세요</div>}
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
className="mt-3 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -645,11 +645,11 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
|
||||
if (stats.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||||
<div className="flex h-full items-center justify-center bg-muted p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📊</div>
|
||||
<div className="text-sm font-medium text-gray-600">데이터 없음</div>
|
||||
<div className="mt-2 text-xs text-gray-500">쿼리를 실행하여 통계를 확인하세요</div>
|
||||
<div className="text-sm font-medium text-foreground">데이터 없음</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">쿼리를 실행하여 통계를 확인하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -685,13 +685,13 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
// console.log("🎨 렌더링 - allStats:", allStats.map(s => s.label));
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col bg-white">
|
||||
<div className="relative flex h-full flex-col bg-background">
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex items-center justify-between border-b bg-gray-50 px-4 py-2">
|
||||
<div className="flex items-center justify-between border-b bg-muted px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📊</span>
|
||||
<span className="text-sm font-medium text-gray-700">커스텀 통계</span>
|
||||
<span className="text-xs text-gray-500">({stats.length}개 표시 중)</span>
|
||||
<span className="text-sm font-medium text-foreground">커스텀 통계</span>
|
||||
<span className="text-xs text-muted-foreground">({stats.length}개 표시 중)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -701,7 +701,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
setSelectedStats(currentLabels);
|
||||
setShowSettings(true);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
|
||||
className="flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm text-foreground hover:bg-muted"
|
||||
title="표시할 통계 선택"
|
||||
>
|
||||
<span>⚙️</span>
|
||||
|
|
@ -716,7 +716,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
const colors = getColorClasses(stat.color);
|
||||
return (
|
||||
<div key={index} className={`rounded-lg border ${colors.bg} p-4 text-center`}>
|
||||
<div className="text-sm text-gray-600">{stat.label}</div>
|
||||
<div className="text-sm text-foreground">{stat.label}</div>
|
||||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
|
||||
{stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()}
|
||||
<span className="ml-1 text-lg">{stat.unit}</span>
|
||||
|
|
@ -730,15 +730,15 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
{/* 설정 모달 */}
|
||||
{showSettings && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50">
|
||||
<div className="max-h-[80%] w-[90%] max-w-md overflow-auto rounded-lg bg-white p-6 shadow-xl">
|
||||
<div className="max-h-[80%] w-[90%] max-w-md overflow-auto rounded-lg bg-background p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold">표시할 통계 선택</h3>
|
||||
<button onClick={() => setShowSettings(false)} className="text-2xl text-gray-500 hover:text-gray-700">
|
||||
<button onClick={() => setShowSettings(false)} className="text-2xl text-muted-foreground hover:text-foreground">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600">표시하고 싶은 통계를 선택하세요 (최대 제한 없음)</div>
|
||||
<div className="mb-4 text-sm text-foreground">표시하고 싶은 통계를 선택하세요 (최대 제한 없음)</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{allStats.map((stat, index) => {
|
||||
|
|
@ -747,7 +747,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
<label
|
||||
key={index}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors ${
|
||||
isChecked ? "border-blue-500 bg-blue-50" : "hover:bg-gray-50"
|
||||
isChecked ? "border-primary bg-primary/10" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
|
|
@ -761,9 +761,9 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{stat.label}</div>
|
||||
<div className="text-sm text-gray-500">단위: {stat.unit}</div>
|
||||
<div className="text-sm text-muted-foreground">단위: {stat.unit}</div>
|
||||
</div>
|
||||
{isChecked && <span className="text-blue-600">✓</span>}
|
||||
{isChecked && <span className="text-primary">✓</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
|
@ -772,7 +772,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
<div className="mt-6 flex gap-2">
|
||||
<button
|
||||
onClick={handleApplySettings}
|
||||
className="flex-1 rounded-lg bg-blue-500 py-2 text-white hover:bg-blue-600"
|
||||
className="flex-1 rounded-lg bg-primary py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
적용 ({selectedStats.length}개 선택)
|
||||
</button>
|
||||
|
|
@ -781,7 +781,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
// console.log("❌ 설정 취소");
|
||||
setShowSettings(false);
|
||||
}}
|
||||
className="rounded-lg border px-4 py-2 hover:bg-gray-50"
|
||||
className="rounded-lg border px-4 py-2 hover:bg-muted"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -85,9 +85,9 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
if (priorityLower.includes("긴급") || priorityLower.includes("high") || priorityLower.includes("urgent")) {
|
||||
return "bg-destructive text-destructive-foreground";
|
||||
} else if (priorityLower.includes("보통") || priorityLower.includes("medium") || priorityLower.includes("normal")) {
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100";
|
||||
return "bg-warning/10 text-warning dark:bg-warning/20 dark:text-warning";
|
||||
} else if (priorityLower.includes("낮음") || priorityLower.includes("low")) {
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100";
|
||||
return "bg-success/10 text-success dark:bg-success/20 dark:text-success";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
};
|
||||
|
|
@ -98,7 +98,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
if (statusLower.includes("처리중") || statusLower.includes("processing") || statusLower.includes("pending")) {
|
||||
return "bg-primary text-primary-foreground";
|
||||
} else if (statusLower.includes("완료") || statusLower.includes("resolved") || statusLower.includes("closed")) {
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100";
|
||||
return "bg-success/10 text-success dark:bg-success/20 dark:text-success";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
};
|
||||
|
|
@ -188,7 +188,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
onClick={() => setFilterPriority("보통")}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
filterPriority === "보통"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
? "bg-warning/10 text-warning"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -198,7 +198,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
onClick={() => setFilterPriority("낮음")}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
filterPriority === "낮음"
|
||||
? "bg-green-100 text-green-800"
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -89,45 +89,45 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
const getBorderColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "배송중":
|
||||
return "border-blue-500";
|
||||
return "border-primary";
|
||||
case "완료":
|
||||
return "border-green-500";
|
||||
return "border-success";
|
||||
case "지연":
|
||||
return "border-red-500";
|
||||
return "border-destructive";
|
||||
case "픽업 대기":
|
||||
return "border-yellow-500";
|
||||
return "border-warning";
|
||||
default:
|
||||
return "border-gray-500";
|
||||
return "border-border";
|
||||
}
|
||||
};
|
||||
|
||||
const getDotColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "배송중":
|
||||
return "bg-blue-500";
|
||||
return "bg-primary";
|
||||
case "완료":
|
||||
return "bg-green-500";
|
||||
return "bg-success";
|
||||
case "지연":
|
||||
return "bg-red-500";
|
||||
return "bg-destructive";
|
||||
case "픽업 대기":
|
||||
return "bg-yellow-500";
|
||||
return "bg-warning/100";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
return "bg-muted0";
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "배송중":
|
||||
return "text-blue-600";
|
||||
return "text-primary";
|
||||
case "완료":
|
||||
return "text-green-600";
|
||||
return "text-success";
|
||||
case "지연":
|
||||
return "text-red-600";
|
||||
return "text-destructive";
|
||||
case "픽업 대기":
|
||||
return "text-yellow-600";
|
||||
return "text-warning";
|
||||
default:
|
||||
return "text-gray-600";
|
||||
return "text-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -136,7 +136,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
<div className="flex h-full items-center justify-center">
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -145,11 +145,11 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="text-sm">⚠️ {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"
|
||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -161,7 +161,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
if (!element?.dataSource?.query) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -171,20 +171,20 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
const totalCount = statusData.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 p-2">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-background to-primary/10 p-2">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">📊 배송 상태 요약</h3>
|
||||
<h3 className="text-sm font-bold text-foreground">📊 배송 상태 요약</h3>
|
||||
{totalCount > 0 ? (
|
||||
<p className="text-xs text-gray-500">총 {totalCount.toLocaleString()}건</p>
|
||||
<p className="text-xs text-muted-foreground">총 {totalCount.toLocaleString()}건</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 데이터 연결 필요</p>
|
||||
<p className="text-xs text-warning">⚙️ 데이터 연결 필요</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-background p-0 text-xs disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
|
|
@ -198,11 +198,11 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
{statusData.map((item) => (
|
||||
<div
|
||||
key={item.status}
|
||||
className={`rounded border-l-2 bg-white p-1.5 shadow-sm ${getBorderColor(item.status)}`}
|
||||
className={`rounded border-l-2 bg-background p-1.5 shadow-sm ${getBorderColor(item.status)}`}
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${getDotColor(item.status)}`}></div>
|
||||
<div className="text-xs font-medium text-gray-600">{item.status}</div>
|
||||
<div className="text-xs font-medium text-foreground">{item.status}</div>
|
||||
</div>
|
||||
<div className={`text-lg font-bold ${getTextColor(item.status)}`}>{item.count.toLocaleString()}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -176,15 +176,15 @@ export default function DeliveryStatusWidget({ element, refreshInterval = 60000
|
|||
const getStatusColor = (status: DeliveryItem["status"]) => {
|
||||
switch (status) {
|
||||
case "in_transit":
|
||||
return "bg-blue-100 text-blue-700 border-blue-300";
|
||||
return "bg-primary/10 text-primary border-primary";
|
||||
case "delivered":
|
||||
return "bg-green-100 text-green-700 border-green-300";
|
||||
return "bg-success/10 text-success border-success";
|
||||
case "delayed":
|
||||
return "bg-red-100 text-red-700 border-red-300";
|
||||
return "bg-destructive/10 text-destructive border-destructive";
|
||||
case "pickup_waiting":
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-300";
|
||||
return "bg-warning/10 text-warning border-warning";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 border-gray-300";
|
||||
return "bg-muted text-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -236,13 +236,13 @@ export default function DeliveryStatusWidget({ element, refreshInterval = 60000
|
|||
const getIssueStatusColor = (status: CustomerIssue["status"]) => {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return "bg-red-100 text-red-700 border-red-300";
|
||||
return "bg-destructive/10 text-destructive border-destructive";
|
||||
case "in_progress":
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-300";
|
||||
return "bg-warning/10 text-warning border-warning";
|
||||
case "resolved":
|
||||
return "bg-green-100 text-green-700 border-green-300";
|
||||
return "bg-success/10 text-success border-success";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 border-gray-300";
|
||||
return "bg-muted text-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -293,12 +293,12 @@ export default function DeliveryStatusWidget({ element, refreshInterval = 60000
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-gradient-to-br from-slate-50 to-blue-50 p-4">
|
||||
<div className="h-full w-full overflow-auto bg-gradient-to-br from-background to-primary/10 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">📦 배송 / 화물 처리 현황</h3>
|
||||
<p className="text-xs text-gray-500">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
<h3 className="text-lg font-bold text-foreground">📦 배송 / 화물 처리 현황</h3>
|
||||
<p className="text-xs text-muted-foreground">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadData} disabled={isLoading} className="h-8 w-8 p-0">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
|
|
@ -307,96 +307,96 @@ export default function DeliveryStatusWidget({ element, refreshInterval = 60000
|
|||
|
||||
{/* 배송 상태 요약 */}
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">배송 상태 요약 (클릭하여 필터링)</h4>
|
||||
<h4 className="mb-2 text-sm font-semibold text-foreground">배송 상태 요약 (클릭하여 필터링)</h4>
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
|
||||
<button
|
||||
onClick={() => setSelectedStatus("all")}
|
||||
className={`rounded-lg border-l-4 p-1.5 shadow-sm transition-all ${
|
||||
selectedStatus === "all"
|
||||
? "border-gray-900 bg-gray-100 ring-2 ring-gray-900"
|
||||
: "border-gray-500 bg-white hover:bg-gray-50"
|
||||
? "border-foreground bg-muted ring-2 ring-foreground"
|
||||
: "border-border bg-background hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-0.5 text-xs text-gray-600">전체</div>
|
||||
<div className="text-lg font-bold text-gray-900">{deliveries.length}</div>
|
||||
<div className="mb-0.5 text-xs text-foreground">전체</div>
|
||||
<div className="text-lg font-bold text-foreground">{deliveries.length}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("in_transit")}
|
||||
className={`rounded-lg border-l-4 p-1.5 shadow-sm transition-all ${
|
||||
selectedStatus === "in_transit"
|
||||
? "border-blue-900 bg-blue-100 ring-2 ring-blue-900"
|
||||
: "border-blue-500 bg-white hover:bg-blue-50"
|
||||
? "border-primary bg-primary/10 ring-2 ring-primary"
|
||||
: "border-primary bg-background hover:bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-0.5 text-xs text-gray-600">배송중</div>
|
||||
<div className="text-lg font-bold text-blue-600">{statusStats.in_transit}</div>
|
||||
<div className="mb-0.5 text-xs text-foreground">배송중</div>
|
||||
<div className="text-lg font-bold text-primary">{statusStats.in_transit}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("delivered")}
|
||||
className={`rounded-lg border-l-4 p-1.5 shadow-sm transition-all ${
|
||||
selectedStatus === "delivered"
|
||||
? "border-green-900 bg-green-100 ring-2 ring-green-900"
|
||||
: "border-green-500 bg-white hover:bg-green-50"
|
||||
? "border-success bg-success/10 ring-2 ring-success"
|
||||
: "border-success bg-background hover:bg-success/10"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-0.5 text-xs text-gray-600">완료</div>
|
||||
<div className="text-lg font-bold text-green-600">{statusStats.delivered}</div>
|
||||
<div className="mb-0.5 text-xs text-foreground">완료</div>
|
||||
<div className="text-lg font-bold text-success">{statusStats.delivered}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("delayed")}
|
||||
className={`rounded-lg border-l-4 p-1.5 shadow-sm transition-all ${
|
||||
selectedStatus === "delayed"
|
||||
? "border-red-900 bg-red-100 ring-2 ring-red-900"
|
||||
: "border-red-500 bg-white hover:bg-red-50"
|
||||
? "border-destructive bg-destructive/10 ring-2 ring-destructive"
|
||||
: "border-destructive bg-background hover:bg-destructive/10"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-0.5 text-xs text-gray-600">지연</div>
|
||||
<div className="text-lg font-bold text-red-600">{statusStats.delayed}</div>
|
||||
<div className="mb-0.5 text-xs text-foreground">지연</div>
|
||||
<div className="text-lg font-bold text-destructive">{statusStats.delayed}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("pickup_waiting")}
|
||||
className={`rounded-lg border-l-4 p-1.5 shadow-sm transition-all ${
|
||||
selectedStatus === "pickup_waiting"
|
||||
? "border-yellow-900 bg-yellow-100 ring-2 ring-yellow-900"
|
||||
: "border-yellow-500 bg-white hover:bg-yellow-50"
|
||||
? "border-warning bg-warning/10 ring-2 ring-warning"
|
||||
: "border-warning bg-background hover:bg-warning/10"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-0.5 text-xs text-gray-600">픽업 대기</div>
|
||||
<div className="text-lg font-bold text-yellow-600">{statusStats.pickup_waiting}</div>
|
||||
<div className="mb-0.5 text-xs text-foreground">픽업 대기</div>
|
||||
<div className="text-lg font-bold text-warning">{statusStats.pickup_waiting}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오늘 발송/도착 건수 */}
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">오늘 처리 현황</h4>
|
||||
<h4 className="mb-2 text-sm font-semibold text-foreground">오늘 처리 현황</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg border-l-4 border-gray-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 text-xs text-gray-600">발송 건수</div>
|
||||
<div className="text-lg font-bold text-gray-900">{todayStats.shipped}</div>
|
||||
<div className="text-xs text-gray-500">건</div>
|
||||
<div className="rounded-lg border-l-4 border-border bg-background p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 text-xs text-foreground">발송 건수</div>
|
||||
<div className="text-lg font-bold text-foreground">{todayStats.shipped}</div>
|
||||
<div className="text-xs text-muted-foreground">건</div>
|
||||
</div>
|
||||
<div className="rounded-lg border-l-4 border-gray-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 text-xs text-gray-600">도착 건수</div>
|
||||
<div className="text-lg font-bold text-gray-900">{todayStats.delivered}</div>
|
||||
<div className="text-xs text-gray-500">건</div>
|
||||
<div className="rounded-lg border-l-4 border-border bg-background p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 text-xs text-foreground">도착 건수</div>
|
||||
<div className="text-lg font-bold text-foreground">{todayStats.delivered}</div>
|
||||
<div className="text-xs text-muted-foreground">건</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터링된 화물 리스트 */}
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<Package className="h-4 w-4 text-gray-600" />
|
||||
<h4 className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Package className="h-4 w-4 text-foreground" />
|
||||
{selectedStatus === "all" && `전체 화물 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "in_transit" && `배송 중인 화물 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "delivered" && `배송 완료 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "delayed" && `지연 중인 화물 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "pickup_waiting" && `픽업 대기 (${filteredDeliveries.length})`}
|
||||
</h4>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-background shadow-sm">
|
||||
{filteredDeliveries.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
{selectedStatus === "all" ? "화물이 없습니다" : "해당 상태의 화물이 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -404,12 +404,12 @@ export default function DeliveryStatusWidget({ element, refreshInterval = 60000
|
|||
{filteredDeliveries.map((delivery) => (
|
||||
<div
|
||||
key={delivery.id}
|
||||
className="border-b border-gray-200 p-3 transition-colors last:border-b-0 hover:bg-gray-50"
|
||||
className="border-b border-border p-3 transition-colors last:border-b-0 hover:bg-muted"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900">{delivery.customer}</div>
|
||||
<div className="text-xs text-gray-600">{delivery.trackingNumber}</div>
|
||||
<div className="text-sm font-semibold text-foreground">{delivery.customer}</div>
|
||||
<div className="text-xs text-foreground">{delivery.trackingNumber}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-md border px-2 py-1 text-xs font-semibold ${getStatusColor(delivery.status)}`}
|
||||
|
|
@ -417,7 +417,7 @@ export default function DeliveryStatusWidget({ element, refreshInterval = 60000
|
|||
{getStatusText(delivery.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="space-y-1 text-xs text-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">경로:</span>
|
||||
<span>
|
||||
|
|
@ -429,7 +429,7 @@ export default function DeliveryStatusWidget({ element, refreshInterval = 60000
|
|||
<span>{delivery.estimatedDelivery}</span>
|
||||
</div>
|
||||
{delivery.delayReason && (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<div className="flex items-center gap-1 text-destructive">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span className="font-medium">사유:</span>
|
||||
<span>{delivery.delayReason}</span>
|
||||
|
|
@ -445,27 +445,27 @@ export default function DeliveryStatusWidget({ element, refreshInterval = 60000
|
|||
|
||||
{/* 고객 클레임/이슈 리포트 */}
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<XCircle className="h-4 w-4 text-orange-600" />
|
||||
<h4 className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<XCircle className="h-4 w-4 text-warning" />
|
||||
고객 클레임/이슈 ({issues.filter((i) => i.status !== "resolved").length})
|
||||
</h4>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-background shadow-sm">
|
||||
{issues.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">이슈가 없습니다</div>
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">이슈가 없습니다</div>
|
||||
) : (
|
||||
<div className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 max-h-[200px] overflow-y-auto">
|
||||
{issues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="border-b border-gray-200 p-3 transition-colors last:border-b-0 hover:bg-gray-50"
|
||||
className="border-b border-border p-3 transition-colors last:border-b-0 hover:bg-muted"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900">{issue.customer}</div>
|
||||
<div className="text-xs text-gray-600">{issue.trackingNumber}</div>
|
||||
<div className="text-sm font-semibold text-foreground">{issue.customer}</div>
|
||||
<div className="text-xs text-foreground">{issue.trackingNumber}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span className="rounded-md border border-gray-300 bg-gray-100 px-2 py-1 text-xs font-semibold text-gray-700">
|
||||
<span className="rounded-md border border-border bg-muted px-2 py-1 text-xs font-semibold text-foreground">
|
||||
{getIssueTypeText(issue.issueType)}
|
||||
</span>
|
||||
<span
|
||||
|
|
@ -475,9 +475,9 @@ export default function DeliveryStatusWidget({ element, refreshInterval = 60000
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="space-y-1 text-xs text-foreground">
|
||||
<div>{issue.description}</div>
|
||||
<div className="text-gray-500">접수: {issue.reportedAt}</div>
|
||||
<div className="text-muted-foreground">접수: {issue.reportedAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
<div className="flex h-full items-center justify-center">
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -104,11 +104,11 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="text-sm">⚠️ {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"
|
||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -120,7 +120,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
if (!element?.dataSource?.query) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -128,11 +128,11 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-white p-4">
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-800">오늘 처리 현황</h3>
|
||||
<button onClick={loadData} className="rounded-full p-1 text-gray-500 hover:bg-gray-100" title="새로고침">
|
||||
<h3 className="text-lg font-semibold text-foreground">오늘 처리 현황</h3>
|
||||
<button onClick={loadData} className="rounded-full p-1 text-muted-foreground hover:bg-muted" title="새로고침">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -140,19 +140,19 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
{/* 통계 카드 */}
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
{/* 오늘 발송 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 p-6">
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-lg bg-gradient-to-br from-primary/10 to-primary/20 p-6">
|
||||
<div className="mb-2 text-4xl">📤</div>
|
||||
<p className="text-sm font-medium text-blue-700">오늘 발송</p>
|
||||
<p className="mt-2 text-4xl font-bold text-blue-800">{todayStats.shipped.toLocaleString()}</p>
|
||||
<p className="mt-1 text-xs text-blue-600">건</p>
|
||||
<p className="text-sm font-medium text-primary">오늘 발송</p>
|
||||
<p className="mt-2 text-4xl font-bold text-primary">{todayStats.shipped.toLocaleString()}</p>
|
||||
<p className="mt-1 text-xs text-primary">건</p>
|
||||
</div>
|
||||
|
||||
{/* 오늘 도착 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-lg bg-gradient-to-br from-green-50 to-green-100 p-6">
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-lg bg-gradient-to-br from-success/10 to-success/20 p-6">
|
||||
<div className="mb-2 text-4xl">📥</div>
|
||||
<p className="text-sm font-medium text-green-700">오늘 도착</p>
|
||||
<p className="mt-2 text-4xl font-bold text-green-800">{todayStats.delivered.toLocaleString()}</p>
|
||||
<p className="mt-1 text-xs text-green-600">건</p>
|
||||
<p className="text-sm font-medium text-success">오늘 도착</p>
|
||||
<p className="mt-2 text-4xl font-bold text-success">{todayStats.delivered.toLocaleString()}</p>
|
||||
<p className="mt-1 text-xs text-success">건</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -105,13 +105,13 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
|
|||
const getCategoryColor = (category: Document["category"]) => {
|
||||
switch (category) {
|
||||
case "계약서":
|
||||
return "bg-blue-100 text-blue-700";
|
||||
return "bg-primary/10 text-primary";
|
||||
case "보험":
|
||||
return "bg-green-100 text-green-700";
|
||||
return "bg-success/10 text-success";
|
||||
case "세금계산서":
|
||||
return "bg-amber-100 text-amber-700";
|
||||
return "bg-warning/10 text-warning";
|
||||
case "기타":
|
||||
return "bg-gray-100 text-gray-700";
|
||||
return "bg-muted text-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -128,45 +128,45 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||
<div className="border-b border-border bg-background px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "문서 관리"}</h3>
|
||||
<button className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90">
|
||||
<h3 className="text-lg font-bold text-foreground">{element?.customTitle || "문서 관리"}</h3>
|
||||
<button className="rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground transition-colors hover:bg-primary/90">
|
||||
+ 업로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="mb-3 grid grid-cols-4 gap-2 text-xs">
|
||||
<div className="rounded bg-gray-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-gray-700">{stats.total}</div>
|
||||
<div className="text-gray-600">전체</div>
|
||||
<div className="rounded bg-muted px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-foreground">{stats.total}</div>
|
||||
<div className="text-foreground">전체</div>
|
||||
</div>
|
||||
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-blue-700">{stats.contract}</div>
|
||||
<div className="text-blue-600">계약서</div>
|
||||
<div className="rounded bg-primary/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-primary">{stats.contract}</div>
|
||||
<div className="text-primary">계약서</div>
|
||||
</div>
|
||||
<div className="rounded bg-green-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-green-700">{stats.insurance}</div>
|
||||
<div className="text-green-600">보험</div>
|
||||
<div className="rounded bg-success/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-success">{stats.insurance}</div>
|
||||
<div className="text-success">보험</div>
|
||||
</div>
|
||||
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-amber-700">{stats.tax}</div>
|
||||
<div className="text-amber-600">계산서</div>
|
||||
<div className="rounded bg-warning/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-warning">{stats.tax}</div>
|
||||
<div className="text-warning">계산서</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="mb-3 relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="문서명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-primary focus:outline-none"
|
||||
className="w-full rounded border border-border py-2 pl-10 pr-3 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -177,7 +177,7 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
|
|||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
filter === f ? "bg-primary text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
filter === f ? "bg-primary text-primary-foreground" : "bg-muted text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "전체" : f}
|
||||
|
|
@ -189,7 +189,7 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
|
|||
{/* 문서 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📭</div>
|
||||
<div>문서가 없습니다</div>
|
||||
|
|
@ -200,10 +200,10 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
|
|||
{filteredDocuments.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-primary hover:shadow-md"
|
||||
className="group flex items-center gap-3 rounded-lg border border-border bg-background p-3 shadow-sm transition-all hover:border-primary hover:shadow-md"
|
||||
>
|
||||
{/* 아이콘 */}
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-gray-50 text-2xl">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-2xl">
|
||||
{getCategoryIcon(doc.category)}
|
||||
</div>
|
||||
|
||||
|
|
@ -211,11 +211,11 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate font-medium text-gray-800">{doc.name}</div>
|
||||
<div className="truncate font-medium text-foreground">{doc.name}</div>
|
||||
{doc.description && (
|
||||
<div className="mt-0.5 truncate text-xs text-gray-600">{doc.description}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-foreground">{doc.description}</div>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-gray-500">
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className={`rounded px-2 py-0.5 ${getCategoryColor(doc.category)}`}>
|
||||
{doc.category}
|
||||
</span>
|
||||
|
|
@ -232,7 +232,7 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
|
|||
{/* 다운로드 버튼 */}
|
||||
<button
|
||||
onClick={() => handleDownload(doc)}
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary text-white transition-colors hover:bg-primary/90"
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -122,10 +122,10 @@ export default function ExchangeWidget({
|
|||
// 로딩 상태
|
||||
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 h-full items-center justify-center bg-background 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>
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-success" />
|
||||
<p className="text-sm text-foreground">환율 정보 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -135,12 +135,12 @@ export default function ExchangeWidget({
|
|||
const hasError = error || !exchangeRate;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-4 @container">
|
||||
<div className="h-full bg-background rounded-lg border p-4 @container">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-1">{element?.customTitle || "환율"}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
<h3 className="text-base font-semibold text-foreground mb-1">{element?.customTitle || "환율"}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{lastUpdated
|
||||
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
|
|
@ -163,7 +163,7 @@ export default function ExchangeWidget({
|
|||
{/* 통화 선택 - 반응형 (좁을 때 세로 배치) */}
|
||||
<div className="flex @[300px]:flex-row flex-col items-center gap-2 mb-3">
|
||||
<Select value={base} onValueChange={setBase}>
|
||||
<SelectTrigger className="w-full @[300px]:flex-1 bg-white h-8 text-xs">
|
||||
<SelectTrigger className="w-full @[300px]:flex-1 bg-background h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -179,13 +179,13 @@ export default function ExchangeWidget({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSwap}
|
||||
className="h-8 w-8 p-0 rounded-full hover:bg-white @[300px]:rotate-0 rotate-90"
|
||||
className="h-8 w-8 p-0 rounded-full hover:bg-background @[300px]:rotate-0 rotate-90"
|
||||
>
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="w-full @[300px]:flex-1 bg-white h-8 text-xs">
|
||||
<SelectTrigger className="w-full @[300px]:flex-1 bg-background h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -200,11 +200,11 @@ export default function ExchangeWidget({
|
|||
|
||||
{/* 에러 메시지 */}
|
||||
{hasError && (
|
||||
<div className="mb-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-xs text-red-600 text-center">{error || '환율 정보를 불러올 수 없습니다.'}</p>
|
||||
<div className="mb-3 p-3 bg-destructive/10 border border-destructive rounded-lg">
|
||||
<p className="text-xs text-destructive text-center">{error || '환율 정보를 불러올 수 없습니다.'}</p>
|
||||
<button
|
||||
onClick={fetchExchangeRate}
|
||||
className="mt-2 w-full text-xs text-red-600 hover:text-red-700 underline"
|
||||
className="mt-2 w-full text-xs text-destructive hover:text-destructive underline"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -213,12 +213,12 @@ export default function ExchangeWidget({
|
|||
|
||||
{/* 환율 표시 */}
|
||||
{!hasError && (
|
||||
<div className="mb-2 bg-white rounded-lg border p-2">
|
||||
<div className="mb-2 bg-background rounded-lg border p-2">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-400 mb-0.5">
|
||||
<div className="text-xs text-muted-foreground mb-0.5">
|
||||
{exchangeRate.base === 'KRW' ? '1,000' : '1'} {getCurrencySymbol(exchangeRate.base)} =
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
<div className="text-lg font-bold text-foreground">
|
||||
{exchangeRate.base === 'KRW'
|
||||
? (exchangeRate.rate * 1000).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
|
|
@ -229,13 +229,13 @@ export default function ExchangeWidget({
|
|||
maximumFractionDigits: 4,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{getCurrencySymbol(exchangeRate.target)}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{getCurrencySymbol(exchangeRate.target)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 계산기 입력 */}
|
||||
<div className="bg-white rounded-lg border p-2">
|
||||
<div className="bg-background rounded-lg border p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
|
|
@ -247,30 +247,30 @@ export default function ExchangeWidget({
|
|||
autoComplete="off"
|
||||
className="flex-1 text-center text-sm font-semibold"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 w-12">{base}</span>
|
||||
<span className="text-xs text-muted-foreground w-12">{base}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent" />
|
||||
<span className="text-xs text-gray-400">▼</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent" />
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-border to-transparent" />
|
||||
<span className="text-xs text-muted-foreground">▼</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-border to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 text-center text-lg font-bold text-green-600 bg-green-50 border border-green-200 rounded px-2 py-1.5">
|
||||
<div className="flex-1 text-center text-lg font-bold text-success bg-success/10 border border-success rounded px-2 py-1.5">
|
||||
{calculateResult().toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 w-12">{target}</span>
|
||||
<span className="text-xs text-muted-foreground w-12">{target}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 출처 */}
|
||||
<div className="mt-3 pt-2 border-t text-center">
|
||||
<p className="text-xs text-gray-400">출처: {exchangeRate.source}</p>
|
||||
<p className="text-xs text-muted-foreground">출처: {exchangeRate.source}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
<div className="flex h-full items-center justify-center">
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -212,11 +212,11 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="text-sm">⚠️ {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"
|
||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -230,8 +230,8 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
<div className="flex h-full items-center justify-center p-3">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<div className="text-3xl">📋</div>
|
||||
<h3 className="text-sm font-bold text-gray-900">데이터 목록</h3>
|
||||
<div className="space-y-1.5 text-xs text-gray-600">
|
||||
<h3 className="text-sm font-bold text-foreground">데이터 목록</h3>
|
||||
<div className="space-y-1.5 text-xs text-foreground">
|
||||
<p className="font-medium">📋 테이블 형식 데이터 표시 위젯</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• SQL 쿼리로 데이터를 불러옵니다</li>
|
||||
|
|
@ -240,7 +240,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
<li>• 실시간 데이터 모니터링 가능</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
||||
<p>SQL 쿼리를 입력하고 저장하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -249,16 +249,16 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 p-2">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-background to-primary/10 p-2">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">📋 {displayTitle}</h3>
|
||||
<p className="text-xs text-gray-500">총 {filteredData.length.toLocaleString()}건</p>
|
||||
<h3 className="text-sm font-bold text-foreground">📋 {displayTitle}</h3>
|
||||
<p className="text-xs text-muted-foreground">총 {filteredData.length.toLocaleString()}건</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-background p-0 text-xs disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
|
|
@ -273,7 +273,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
placeholder="검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="focus:border-primary focus:ring-primary w-full rounded border border-gray-300 px-2 py-1 text-xs focus:ring-1 focus:outline-none"
|
||||
className="focus:border-primary focus:ring-primary w-full rounded border border-border px-2 py-1 text-xs focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -282,20 +282,20 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
<div className="flex-1 overflow-auto">
|
||||
{filteredData.length > 0 ? (
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 bg-gray-100">
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className="border border-gray-300 px-2 py-1 text-left font-semibold text-gray-700">
|
||||
<th key={col.key} className="border border-border px-2 py-1 text-left font-semibold text-foreground">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white">
|
||||
<tbody className="bg-background">
|
||||
{filteredData.map((row, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<tr key={idx} className="hover:bg-muted">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="border border-gray-300 px-2 py-1 text-gray-800">
|
||||
<td key={col.key} className="border border-border px-2 py-1 text-foreground">
|
||||
{String(row[col.key] || "")}
|
||||
</td>
|
||||
))}
|
||||
|
|
@ -304,7 +304,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">검색 결과가 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||
import { Card } from "@/components/ui/card";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
||||
interface ListTestWidgetProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -33,13 +34,13 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
|
||||
// console.log("🧪 ListTestWidget 렌더링!", element);
|
||||
// // console.log("🧪 ListTestWidget 렌더링!", element);
|
||||
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||
|
||||
// console.log("📊 dataSources 확인:", {
|
||||
// // console.log("📊 dataSources 확인:", {
|
||||
// hasDataSources: !!dataSources,
|
||||
// dataSourcesLength: dataSources?.length || 0,
|
||||
// dataSources: dataSources,
|
||||
|
|
@ -60,11 +61,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
// console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
// console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -73,7 +74,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
const results = await Promise.allSettled(
|
||||
dataSources.map(async (source) => {
|
||||
try {
|
||||
console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
||||
// console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
||||
|
||||
if (source.type === "api") {
|
||||
return await loadRestApiData(source);
|
||||
|
|
@ -126,7 +127,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
});
|
||||
setLastRefreshTime(new Date());
|
||||
|
||||
console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
|
||||
// console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
|
|
@ -136,7 +137,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
|
||||
// 수동 새로고침 핸들러
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
// console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadMultipleDataSources();
|
||||
}, [loadMultipleDataSources]);
|
||||
|
||||
|
|
@ -155,11 +156,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
});
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
url: source.endpoint,
|
||||
method: "GET",
|
||||
|
|
@ -179,7 +181,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log("✅ API 응답:", result);
|
||||
// console.log("✅ API 응답:", result);
|
||||
|
||||
if (!result.success) {
|
||||
console.error("❌ API 실패:", result);
|
||||
|
|
@ -246,7 +248,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(source.query);
|
||||
|
||||
// console.log("💾 내부 DB 쿼리 결과:", {
|
||||
// // console.log("💾 내부 DB 쿼리 결과:", {
|
||||
// hasRows: !!result.rows,
|
||||
// rowCount: result.rows?.length || 0,
|
||||
// hasColumns: !!result.columns,
|
||||
|
|
@ -259,7 +261,7 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
const mappedRows = applyColumnMapping(result.rows, source.columnMapping);
|
||||
const columns = mappedRows.length > 0 ? Object.keys(mappedRows[0]) : result.columns;
|
||||
|
||||
// console.log("✅ 매핑 후:", {
|
||||
// // console.log("✅ 매핑 후:", {
|
||||
// columns,
|
||||
// rowCount: mappedRows.length,
|
||||
// firstMappedRow: mappedRows[0],
|
||||
|
|
@ -290,15 +292,15 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
|
|||
if (intervals.length === 0) return;
|
||||
|
||||
const minInterval = Math.min(...intervals);
|
||||
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
// console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log("🔄 자동 새로고침 실행");
|
||||
// console.log("🔄 자동 새로고침 실행");
|
||||
loadMultipleDataSources();
|
||||
}, minInterval * 1000);
|
||||
|
||||
return () => {
|
||||
console.log("⏹️ 자동 새로고침 정리");
|
||||
// console.log("⏹️ 자동 새로고침 정리");
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
|
|
|||
|
|
@ -70,13 +70,13 @@ export default function MaintenanceWidget() {
|
|||
const getStatusBadge = (status: MaintenanceSchedule["status"]) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return <span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">예정</span>;
|
||||
return <span className="rounded bg-primary/10 px-2 py-1 text-xs font-medium text-primary">예정</span>;
|
||||
case "in_progress":
|
||||
return <span className="rounded bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">진행중</span>;
|
||||
return <span className="rounded bg-warning/10 px-2 py-1 text-xs font-medium text-warning">진행중</span>;
|
||||
case "completed":
|
||||
return <span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-700">완료</span>;
|
||||
return <span className="rounded bg-success/10 px-2 py-1 text-xs font-medium text-success">완료</span>;
|
||||
case "overdue":
|
||||
return <span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-700">지연</span>;
|
||||
return <span className="rounded bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">지연</span>;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -115,33 +115,33 @@ export default function MaintenanceWidget() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-teal-50">
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-background to-primary/10">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||
<div className="border-b border-border bg-background px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-gray-800">🔧 정비 일정 관리</h3>
|
||||
<button className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90">
|
||||
<h3 className="text-lg font-bold text-foreground">🔧 정비 일정 관리</h3>
|
||||
<button className="rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground transition-colors hover:bg-primary/90">
|
||||
+ 일정 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="mb-3 grid grid-cols-4 gap-2 text-xs">
|
||||
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-blue-700">{stats.scheduled}</div>
|
||||
<div className="text-blue-600">예정</div>
|
||||
<div className="rounded bg-primary/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-primary">{stats.scheduled}</div>
|
||||
<div className="text-primary">예정</div>
|
||||
</div>
|
||||
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-amber-700">{stats.inProgress}</div>
|
||||
<div className="text-amber-600">진행중</div>
|
||||
<div className="rounded bg-warning/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-warning">{stats.inProgress}</div>
|
||||
<div className="text-warning">진행중</div>
|
||||
</div>
|
||||
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-red-700">{stats.overdue}</div>
|
||||
<div className="text-red-600">지연</div>
|
||||
<div className="rounded bg-destructive/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-destructive">{stats.overdue}</div>
|
||||
<div className="text-destructive">지연</div>
|
||||
</div>
|
||||
<div className="rounded bg-gray-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-gray-700">{stats.total}</div>
|
||||
<div className="text-gray-600">전체</div>
|
||||
<div className="rounded bg-muted px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-foreground">{stats.total}</div>
|
||||
<div className="text-foreground">전체</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ export default function MaintenanceWidget() {
|
|||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
filter === f ? "bg-primary text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
filter === f ? "bg-primary text-primary-foreground" : "bg-muted text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "전체" : f === "scheduled" ? "예정" : f === "in_progress" ? "진행중" : "지연"}
|
||||
|
|
@ -164,7 +164,7 @@ export default function MaintenanceWidget() {
|
|||
{/* 일정 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{filteredSchedules.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📅</div>
|
||||
<div>정비 일정이 없습니다</div>
|
||||
|
|
@ -175,34 +175,34 @@ export default function MaintenanceWidget() {
|
|||
{filteredSchedules.map((schedule) => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className={`group rounded-lg border-2 bg-white p-4 shadow-sm transition-all hover:shadow-md ${
|
||||
schedule.status === "overdue" ? "border-red-300" : "border-gray-200"
|
||||
className={`group rounded-lg border-2 bg-background p-4 shadow-sm transition-all hover:shadow-md ${
|
||||
schedule.status === "overdue" ? "border-destructive" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{getMaintenanceIcon(schedule.maintenanceType)}</span>
|
||||
<div>
|
||||
<div className="font-bold text-gray-800">{schedule.vehicleNumber}</div>
|
||||
<div className="text-xs text-gray-600">{schedule.vehicleType}</div>
|
||||
<div className="font-bold text-foreground">{schedule.vehicleNumber}</div>
|
||||
<div className="text-xs text-foreground">{schedule.vehicleType}</div>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(schedule.status)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 rounded bg-gray-50 p-2">
|
||||
<div className="text-sm font-medium text-gray-700">{schedule.maintenanceType}</div>
|
||||
{schedule.notes && <div className="mt-1 text-xs text-gray-600">{schedule.notes}</div>}
|
||||
<div className="mb-3 rounded bg-muted p-2">
|
||||
<div className="text-sm font-medium text-foreground">{schedule.maintenanceType}</div>
|
||||
{schedule.notes && <div className="mt-1 text-xs text-foreground">{schedule.notes}</div>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1 text-gray-600">
|
||||
<div className="flex items-center gap-1 text-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(schedule.scheduledDate).toLocaleDateString()}
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-1 font-medium ${
|
||||
schedule.status === "overdue" ? "text-red-600" : "text-blue-600"
|
||||
schedule.status === "overdue" ? "text-destructive" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
|
|
@ -218,17 +218,17 @@ export default function MaintenanceWidget() {
|
|||
{/* 액션 버튼 */}
|
||||
{schedule.status === "scheduled" && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="flex-1 rounded bg-blue-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600">
|
||||
<button className="flex-1 rounded bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90">
|
||||
시작
|
||||
</button>
|
||||
<button className="flex-1 rounded bg-gray-200 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-300">
|
||||
<button className="flex-1 rounded bg-muted px-3 py-1.5 text-xs font-medium text-foreground hover:bg-muted/90">
|
||||
일정 변경
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{schedule.status === "in_progress" && (
|
||||
<div className="mt-3">
|
||||
<button className="w-full rounded bg-green-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-600">
|
||||
<button className="w-full rounded bg-success px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-success/90">
|
||||
<Check className="mr-1 inline h-3 w-3" />
|
||||
완료 처리
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
|
|
@ -105,7 +106,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
setTableName(extractedTableName);
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -154,20 +155,20 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 위치` : "위치 지도");
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 p-2">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-background to-primary/10 p-2">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">{displayTitle}</h3>
|
||||
<h3 className="text-sm font-bold text-foreground">{displayTitle}</h3>
|
||||
{element?.dataSource?.query ? (
|
||||
<p className="text-xs text-gray-500">총 {markers.length.toLocaleString()}개 마커</p>
|
||||
<p className="text-xs text-muted-foreground">총 {markers.length.toLocaleString()}개 마커</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 톱니바퀴 버튼을 눌러 데이터를 연결하세요</p>
|
||||
<p className="text-xs text-warning">⚙️ 톱니바퀴 버튼을 눌러 데이터를 연결하세요</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadMapData}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-background p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={loading || !element?.dataSource?.query}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
|
|
@ -176,13 +177,13 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
|
||||
{/* 에러 메시지 (지도 위에 오버레이) */}
|
||||
{error && (
|
||||
<div className="mb-2 rounded border border-red-300 bg-red-50 p-2 text-center text-xs text-red-600">
|
||||
<div className="mb-2 rounded border border-destructive bg-destructive/10 p-2 text-center text-xs text-destructive">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지도 (항상 표시) */}
|
||||
<div className="relative flex-1 rounded border border-gray-300 bg-white overflow-hidden z-0">
|
||||
<div className="relative flex-1 rounded border border-border bg-background overflow-hidden z-0">
|
||||
<MapContainer
|
||||
key={`map-${element.id}`}
|
||||
center={[36.5, 127.5]}
|
||||
|
|
|
|||
|
|
@ -247,15 +247,15 @@ const findNearestCity = (lat: number, lng: number): string => {
|
|||
const getWeatherIcon = (weatherMain: string) => {
|
||||
switch (weatherMain.toLowerCase()) {
|
||||
case "clear":
|
||||
return <Sun className="h-4 w-4 text-yellow-500" />;
|
||||
return <Sun className="h-4 w-4 text-warning" />;
|
||||
case "rain":
|
||||
return <CloudRain className="h-4 w-4 text-blue-500" />;
|
||||
return <CloudRain className="h-4 w-4 text-primary" />;
|
||||
case "snow":
|
||||
return <CloudSnow className="h-4 w-4 text-blue-300" />;
|
||||
return <CloudSnow className="h-4 w-4 text-primary/70" />;
|
||||
case "clouds":
|
||||
return <Cloud className="h-4 w-4 text-gray-400" />;
|
||||
return <Cloud className="h-4 w-4 text-muted-foreground" />;
|
||||
default:
|
||||
return <Wind className="h-4 w-4 text-gray-500" />;
|
||||
return <Wind className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -867,22 +867,22 @@ function MapTestWidget({ element }: MapTestWidgetProps) {
|
|||
const dataSource = element?.dataSource;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 p-2">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-background to-primary/10 p-2">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">{displayTitle}</h3>
|
||||
<h3 className="text-sm font-bold text-foreground">{displayTitle}</h3>
|
||||
{dataSource ? (
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dataSource.type === "api" ? "🌐 REST API" : "💾 Database"} · 총 {markers.length.toLocaleString()}개 마커
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">데이터를 연결하세요</p>
|
||||
<p className="text-xs text-warning">데이터를 연결하세요</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadMapData}
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-background p-0 text-xs disabled:opacity-50"
|
||||
disabled={loading || !element?.dataSource}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
|
|
@ -891,19 +891,19 @@ function MapTestWidget({ element }: MapTestWidgetProps) {
|
|||
|
||||
{/* 에러 메시지 (지도 위에 오버레이) */}
|
||||
{error && (
|
||||
<div className="mb-2 rounded border border-red-300 bg-red-50 p-2 text-center text-xs text-red-600">
|
||||
<div className="mb-2 rounded border border-destructive bg-destructive/10 p-2 text-center text-xs text-destructive">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지도 또는 빈 상태 */}
|
||||
<div className="relative z-0 flex-1 overflow-hidden rounded border border-gray-300 bg-white">
|
||||
<div className="relative z-0 flex-1 overflow-hidden rounded border border-border bg-background">
|
||||
{!element?.chartConfig?.tileMapUrl && !element?.dataSource ? (
|
||||
// 타일맵 URL도 없고 데이터 소스도 없을 때: 빈 상태 표시
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-gray-600">🗺️ 지도를 설정하세요</p>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
<p className="text-sm font-medium text-foreground">🗺️ 지도를 설정하세요</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
차트 설정에서 타일맵 URL을 입력하거나
|
||||
<br />
|
||||
데이터 소스에서 마커 데이터를 연결하세요
|
||||
|
|
@ -979,10 +979,10 @@ function MapTestWidget({ element }: MapTestWidgetProps) {
|
|||
<div style="font-weight: 600; font-size: 12px; color: ${getAlertColor(alert.severity)};">
|
||||
${alert.title}
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #6b7280; margin-top: 4px;">
|
||||
<div style="font-size: 11px; color: hsl(var(--muted-foreground)); margin-top: 4px;">
|
||||
${alert.description}
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #9ca3af; margin-top: 4px;">
|
||||
<div style="font-size: 10px; color: hsl(var(--muted-foreground) / 0.7); margin-top: 4px;">
|
||||
${new Date(alert.timestamp).toLocaleString("ko-KR")}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1064,10 +1064,10 @@ function MapTestWidget({ element }: MapTestWidgetProps) {
|
|||
}}
|
||||
>
|
||||
<div style={{ fontWeight: "600", fontSize: "11px", color: alertColor }}>{alert.title}</div>
|
||||
<div style={{ fontSize: "10px", color: "#6b7280", marginTop: "3px" }}>
|
||||
<div className="text-[10px] text-muted-foreground mt-[3px]">
|
||||
{alert.description}
|
||||
</div>
|
||||
<div style={{ fontSize: "9px", color: "#9ca3af", marginTop: "3px" }}>
|
||||
<div className="text-[9px] text-muted-foreground/70 mt-[3px]">
|
||||
{new Date(alert.timestamp).toLocaleString("ko-KR")}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1113,7 +1113,7 @@ function MapTestWidget({ element }: MapTestWidgetProps) {
|
|||
<div className="mb-2 border-b pb-2">
|
||||
<div className="mb-1 text-sm font-bold">{marker.name}</div>
|
||||
{marker.description && (
|
||||
<div className="text-xs text-gray-600 whitespace-pre-line">{marker.description}</div>
|
||||
<div className="text-xs text-foreground whitespace-pre-line">{marker.description}</div>
|
||||
)}
|
||||
{marker.info && Object.entries(marker.info)
|
||||
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
|
||||
|
|
@ -1131,22 +1131,22 @@ function MapTestWidget({ element }: MapTestWidgetProps) {
|
|||
{getWeatherIcon(marker.weather.weatherMain)}
|
||||
<span className="text-xs font-semibold">현재 날씨</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{marker.weather.weatherDescription}</div>
|
||||
<div className="text-xs text-foreground">{marker.weather.weatherDescription}</div>
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">온도</span>
|
||||
<span className="text-muted-foreground">온도</span>
|
||||
<span className="font-medium">{marker.weather.temperature}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">체감온도</span>
|
||||
<span className="text-muted-foreground">체감온도</span>
|
||||
<span className="font-medium">{marker.weather.feelsLike}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">습도</span>
|
||||
<span className="text-muted-foreground">습도</span>
|
||||
<span className="font-medium">{marker.weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">풍속</span>
|
||||
<span className="text-muted-foreground">풍속</span>
|
||||
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1162,7 +1162,7 @@ function MapTestWidget({ element }: MapTestWidgetProps) {
|
|||
|
||||
{/* 범례 (특보가 있을 때만 표시) */}
|
||||
{weatherAlerts && weatherAlerts.length > 0 && (
|
||||
<div className="absolute right-4 bottom-4 z-10 rounded-lg border bg-white p-3 shadow-lg">
|
||||
<div className="absolute right-4 bottom-4 z-10 rounded-lg border bg-background p-3 shadow-lg">
|
||||
<div className="mb-2 flex items-center gap-1 text-xs font-semibold">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
기상특보
|
||||
|
|
@ -1181,7 +1181,7 @@ function MapTestWidget({ element }: MapTestWidgetProps) {
|
|||
<span>약한 주의보</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 border-t pt-2 text-[10px] text-gray-500">총 {weatherAlerts.length}건 발효 중</div>
|
||||
<div className="mt-2 border-t pt-2 text-[10px] text-muted-foreground">총 {weatherAlerts.length}건 발효 중</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
|
|
@ -66,8 +67,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
|
||||
console.log("🧪 MapTestWidgetV2 렌더링!", element);
|
||||
console.log("📍 마커:", markers.length, "🔷 폴리곤:", polygons.length);
|
||||
// // console.log("🧪 MapTestWidgetV2 렌더링!", element);
|
||||
// // console.log("📍 마커:", markers.length, "🔷 폴리곤:", polygons.length);
|
||||
|
||||
// dataSources를 useMemo로 추출 (circular reference 방지)
|
||||
const dataSources = useMemo(() => {
|
||||
|
|
@ -79,11 +80,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const dataSourcesList = dataSources;
|
||||
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
// // console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
// // console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -92,7 +93,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const results = await Promise.allSettled(
|
||||
dataSources.map(async (source) => {
|
||||
try {
|
||||
console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
||||
// // console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
||||
|
||||
if (source.type === "api") {
|
||||
return await loadRestApiData(source);
|
||||
|
|
@ -113,21 +114,21 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const allPolygons: PolygonData[] = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
console.log(`🔍 결과 ${index}:`, result);
|
||||
// // console.log(`🔍 결과 ${index}:`, result);
|
||||
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] };
|
||||
console.log(`✅ 데이터 소스 ${index} 성공:`, value);
|
||||
// // console.log(`✅ 데이터 소스 ${index} 성공:`, value);
|
||||
|
||||
// 마커 병합
|
||||
if (value.markers && Array.isArray(value.markers)) {
|
||||
console.log(` → 마커 ${value.markers.length}개 추가`);
|
||||
// // console.log(` → 마커 ${value.markers.length}개 추가`);
|
||||
allMarkers.push(...value.markers);
|
||||
}
|
||||
|
||||
// 폴리곤 병합
|
||||
if (value.polygons && Array.isArray(value.polygons)) {
|
||||
console.log(` → 폴리곤 ${value.polygons.length}개 추가`);
|
||||
// // console.log(` → 폴리곤 ${value.polygons.length}개 추가`);
|
||||
allPolygons.push(...value.polygons);
|
||||
}
|
||||
} else if (result.status === "rejected") {
|
||||
|
|
@ -135,9 +136,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
}
|
||||
});
|
||||
|
||||
console.log(`✅ 총 ${allMarkers.length}개의 마커, ${allPolygons.length}개의 폴리곤 로딩 완료`);
|
||||
console.log("📍 최종 마커 데이터:", allMarkers);
|
||||
console.log("🔷 최종 폴리곤 데이터:", allPolygons);
|
||||
// // console.log(`✅ 총 ${allMarkers.length}개의 마커, ${allPolygons.length}개의 폴리곤 로딩 완료`);
|
||||
// // console.log("📍 최종 마커 데이터:", allMarkers);
|
||||
// // console.log("🔷 최종 폴리곤 데이터:", allPolygons);
|
||||
|
||||
setMarkers(allMarkers);
|
||||
setPolygons(allPolygons);
|
||||
|
|
@ -152,13 +153,13 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
// 수동 새로고침 핸들러
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
// // console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadMultipleDataSources();
|
||||
}, [loadMultipleDataSources]);
|
||||
|
||||
// REST API 데이터 로딩
|
||||
const loadRestApiData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||||
console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
|
||||
// // console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
|
||||
|
||||
if (!source.endpoint) {
|
||||
throw new Error("API endpoint가 없습니다.");
|
||||
|
|
@ -185,7 +186,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
}
|
||||
|
||||
// 백엔드 프록시를 통해 API 호출
|
||||
const response = await fetch("/api/dashboards/fetch-external-api", {
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -214,10 +215,10 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
// 텍스트 형식 데이터 체크 (기상청 API 등)
|
||||
if (data && typeof data === 'object' && data.text && typeof data.text === 'string') {
|
||||
console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
// // console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
const parsedData = parseTextData(data.text);
|
||||
if (parsedData.length > 0) {
|
||||
console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
||||
// // console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
||||
// 컬럼 매핑 적용
|
||||
const mappedData = applyColumnMapping(parsedData, source.columnMapping);
|
||||
return convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source);
|
||||
|
|
@ -243,7 +244,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
// Database 데이터 로딩
|
||||
const loadDatabaseData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||||
console.log(`💾 Database 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
|
||||
// // console.log(`💾 Database 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
|
||||
|
||||
if (!source.query) {
|
||||
throw new Error("SQL 쿼리가 없습니다.");
|
||||
|
|
@ -286,7 +287,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// XML 데이터 파싱 (UTIC API 등)
|
||||
const parseXmlData = (xmlText: string): any[] => {
|
||||
try {
|
||||
console.log(" 📄 XML 파싱 시작");
|
||||
// // console.log(" 📄 XML 파싱 시작");
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||
|
||||
|
|
@ -305,7 +306,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
results.push(obj);
|
||||
}
|
||||
|
||||
console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`);
|
||||
// // console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(" ❌ XML 파싱 실패:", error);
|
||||
|
|
@ -316,11 +317,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
|
||||
const parseTextData = (text: string): any[] => {
|
||||
try {
|
||||
console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500));
|
||||
// // console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500));
|
||||
|
||||
// XML 형식 감지
|
||||
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||||
console.log(" 📄 XML 형식 데이터 감지");
|
||||
// // console.log(" 📄 XML 형식 데이터 감지");
|
||||
return parseXmlData(text);
|
||||
}
|
||||
|
||||
|
|
@ -332,7 +333,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
!trimmed.startsWith('---');
|
||||
});
|
||||
|
||||
console.log(` 📝 유효한 라인: ${lines.length}개`);
|
||||
// // console.log(` 📝 유효한 라인: ${lines.length}개`);
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
|
|
@ -343,7 +344,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const line = lines[i];
|
||||
const values = line.split(',').map(v => v.trim().replace(/,=$/g, ''));
|
||||
|
||||
console.log(` 라인 ${i}:`, values);
|
||||
// // console.log(` 라인 ${i}:`, values);
|
||||
|
||||
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
|
||||
if (values.length >= 4) {
|
||||
|
|
@ -363,11 +364,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
obj.name = obj.subRegion || obj.region || obj.code;
|
||||
|
||||
result.push(obj);
|
||||
console.log(` ✅ 파싱 성공:`, obj);
|
||||
// console.log(` ✅ 파싱 성공:`, obj);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(" 📊 최종 파싱 결과:", result.length, "개");
|
||||
// // console.log(" 📊 최종 파싱 결과:", result.length, "개");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(" ❌ 텍스트 파싱 오류:", error);
|
||||
|
|
@ -382,9 +383,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
mapDisplayType?: "auto" | "marker" | "polygon",
|
||||
dataSource?: ChartDataSource
|
||||
): { markers: MarkerData[]; polygons: PolygonData[] } => {
|
||||
console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행");
|
||||
console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`);
|
||||
console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor);
|
||||
// // console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행");
|
||||
// // console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`);
|
||||
// // console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor);
|
||||
|
||||
if (rows.length === 0) return { markers: [], polygons: [] };
|
||||
|
||||
|
|
@ -392,13 +393,13 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const polygons: PolygonData[] = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
console.log(` 행 ${index}:`, row);
|
||||
// // console.log(` 행 ${index}:`, row);
|
||||
|
||||
// 텍스트 데이터 체크 (기상청 API 등)
|
||||
if (row && typeof row === 'object' && row.text && typeof row.text === 'string') {
|
||||
console.log(" 📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
// // console.log(" 📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
const parsedData = parseTextData(row.text);
|
||||
console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`);
|
||||
// // console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`);
|
||||
|
||||
// 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달)
|
||||
const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource);
|
||||
|
|
@ -409,11 +410,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
// 폴리곤 데이터 체크 (coordinates 필드가 배열인 경우 또는 강제 polygon 모드)
|
||||
if (row.coordinates && Array.isArray(row.coordinates) && row.coordinates.length > 0) {
|
||||
console.log(` → coordinates 발견:`, row.coordinates.length, "개");
|
||||
// // console.log(` → coordinates 발견:`, row.coordinates.length, "개");
|
||||
// coordinates가 [lat, lng] 배열의 배열인지 확인
|
||||
const firstCoord = row.coordinates[0];
|
||||
if (Array.isArray(firstCoord) && firstCoord.length === 2) {
|
||||
console.log(` → 폴리곤으로 처리:`, row.name);
|
||||
// console.log(` → 폴리곤으로 처리:`, row.name);
|
||||
polygons.push({
|
||||
id: row.id || row.code || `polygon-${index}`,
|
||||
name: row.name || row.title || `영역 ${index + 1}`,
|
||||
|
|
@ -430,7 +431,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 지역명으로 해상 구역 확인 (auto 또는 polygon 모드일 때만)
|
||||
const regionName = row.name || row.area || row.region || row.location || row.subRegion;
|
||||
if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") {
|
||||
console.log(` → 해상 구역 발견: ${regionName}, 폴리곤으로 처리`);
|
||||
// // console.log(` → 해상 구역 발견: ${regionName}, 폴리곤으로 처리`);
|
||||
polygons.push({
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||
name: regionName,
|
||||
|
|
@ -450,24 +451,24 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 위도/경도가 없으면 지역 코드/지역명으로 변환 시도
|
||||
if ((lat === undefined || lng === undefined) && (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)) {
|
||||
const regionCode = row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId;
|
||||
console.log(` → 지역 코드 발견: ${regionCode}, 위도/경도 변환 시도`);
|
||||
// // console.log(` → 지역 코드 발견: ${regionCode}, 위도/경도 변환 시도`);
|
||||
const coords = getCoordinatesByRegionCode(regionCode);
|
||||
if (coords) {
|
||||
lat = coords.lat;
|
||||
lng = coords.lng;
|
||||
console.log(` → 변환 성공: (${lat}, ${lng})`);
|
||||
// console.log(` → 변환 성공: (${lat}, ${lng})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 지역명으로도 시도
|
||||
if ((lat === undefined || lng === undefined) && (row.name || row.area || row.region || row.location)) {
|
||||
const regionName = row.name || row.area || row.region || row.location;
|
||||
console.log(` → 지역명 발견: ${regionName}, 위도/경도 변환 시도`);
|
||||
// // console.log(` → 지역명 발견: ${regionName}, 위도/경도 변환 시도`);
|
||||
const coords = getCoordinatesByRegionName(regionName);
|
||||
if (coords) {
|
||||
lat = coords.lat;
|
||||
lng = coords.lng;
|
||||
console.log(` → 변환 성공: (${lat}, ${lng})`);
|
||||
// console.log(` → 변환 성공: (${lat}, ${lng})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -475,7 +476,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
if (mapDisplayType === "polygon") {
|
||||
const regionName = row.name || row.subRegion || row.region || row.area;
|
||||
if (regionName) {
|
||||
console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
|
||||
// console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
|
||||
polygons.push({
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`,
|
||||
name: regionName,
|
||||
|
|
@ -486,14 +487,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||
});
|
||||
} else {
|
||||
console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`);
|
||||
// console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`);
|
||||
}
|
||||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||||
}
|
||||
|
||||
// 위도/경도가 있고 marker 모드가 아니면 마커로 처리
|
||||
if (lat !== undefined && lng !== undefined && mapDisplayType !== "polygon") {
|
||||
console.log(` → 마커로 처리: (${lat}, ${lng})`);
|
||||
// // console.log(` → 마커로 처리: (${lat}, ${lng})`);
|
||||
markers.push({
|
||||
id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||
lat: Number(lat),
|
||||
|
|
@ -510,7 +511,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
||||
const regionName = row.name || row.subRegion || row.region || row.area;
|
||||
if (regionName) {
|
||||
console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
|
||||
// console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
|
||||
polygons.push({
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`,
|
||||
name: regionName,
|
||||
|
|
@ -521,13 +522,13 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||
});
|
||||
} else {
|
||||
console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`);
|
||||
console.log(` 데이터:`, row);
|
||||
// console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`);
|
||||
// console.log(` 데이터:`, row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ ${sourceName}: 마커 ${markers.length}개, 폴리곤 ${polygons.length}개 변환 완료`);
|
||||
// // console.log(`✅ ${sourceName}: 마커 ${markers.length}개, 폴리곤 ${polygons.length}개 변환 완료`);
|
||||
return { markers, polygons };
|
||||
};
|
||||
|
||||
|
|
@ -756,7 +757,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
try {
|
||||
const response = await fetch("/geojson/korea-municipalities.json");
|
||||
const data = await response.json();
|
||||
console.log("🗺️ GeoJSON 로드 완료:", data.features?.length, "개 시/군/구");
|
||||
// // console.log("🗺️ GeoJSON 로드 완료:", data.features?.length, "개 시/군/구");
|
||||
setGeoJsonData(data);
|
||||
} catch (err) {
|
||||
console.error("❌ GeoJSON 로드 실패:", err);
|
||||
|
|
@ -768,11 +769,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 초기 로드
|
||||
useEffect(() => {
|
||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||
console.log("🔄 useEffect 트리거! dataSources:", dataSources);
|
||||
// // console.log("🔄 useEffect 트리거! dataSources:", dataSources);
|
||||
if (dataSources && dataSources.length > 0) {
|
||||
loadMultipleDataSources();
|
||||
} else {
|
||||
console.log("⚠️ dataSources가 없거나 비어있음");
|
||||
// // console.log("⚠️ dataSources가 없거나 비어있음");
|
||||
setMarkers([]);
|
||||
setPolygons([]);
|
||||
}
|
||||
|
|
@ -790,15 +791,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
if (intervals.length === 0) return;
|
||||
|
||||
const minInterval = Math.min(...intervals);
|
||||
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
// // console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log("🔄 자동 새로고침 실행");
|
||||
// // console.log("🔄 자동 새로고침 실행");
|
||||
loadMultipleDataSources();
|
||||
}, minInterval * 1000);
|
||||
|
||||
return () => {
|
||||
console.log("⏹️ 자동 새로고침 정리");
|
||||
// // console.log("⏹️ 자동 새로고침 정리");
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
|
@ -875,11 +876,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
{/* 폴리곤 렌더링 */}
|
||||
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
|
||||
{(() => {
|
||||
console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, {
|
||||
geoJsonData: !!geoJsonData,
|
||||
polygonsLength: polygons.length,
|
||||
polygonNames: polygons.map(p => p.name),
|
||||
});
|
||||
// console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, {
|
||||
// geoJsonData: !!geoJsonData,
|
||||
// polygonsLength: polygons.length,
|
||||
// polygonNames: polygons.map(p => p.name),
|
||||
// });
|
||||
return null;
|
||||
})()}
|
||||
{geoJsonData && polygons.length > 0 ? (
|
||||
|
|
@ -896,31 +897,31 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
// 정확한 매칭
|
||||
if (p.name === sigName) {
|
||||
console.log(`✅ 정확 매칭: ${p.name} === ${sigName}`);
|
||||
// console.log(`✅ 정확 매칭: ${p.name} === ${sigName}`);
|
||||
return true;
|
||||
}
|
||||
if (p.name === ctpName) {
|
||||
console.log(`✅ 정확 매칭: ${p.name} === ${ctpName}`);
|
||||
// console.log(`✅ 정확 매칭: ${p.name} === ${ctpName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지)
|
||||
if (sigName && sigName.includes(p.name)) {
|
||||
console.log(`✅ 부분 매칭: ${sigName} includes ${p.name}`);
|
||||
// console.log(`✅ 부분 매칭: ${sigName} includes ${p.name}`);
|
||||
return true;
|
||||
}
|
||||
if (ctpName && ctpName.includes(p.name)) {
|
||||
console.log(`✅ 부분 매칭: ${ctpName} includes ${p.name}`);
|
||||
// console.log(`✅ 부분 매칭: ${ctpName} includes ${p.name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지)
|
||||
if (sigName && p.name.includes(sigName)) {
|
||||
console.log(`✅ 역방향 매칭: ${p.name} includes ${sigName}`);
|
||||
// console.log(`✅ 역방향 매칭: ${p.name} includes ${sigName}`);
|
||||
return true;
|
||||
}
|
||||
if (ctpName && p.name.includes(ctpName)) {
|
||||
console.log(`✅ 역방향 매칭: ${p.name} includes ${ctpName}`);
|
||||
// console.log(`✅ 역방향 매칭: ${p.name} includes ${ctpName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -968,7 +969,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
}}
|
||||
/>
|
||||
) : (
|
||||
<>{console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`)}</>
|
||||
<>
|
||||
{/* console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`) */}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 폴리곤 렌더링 (해상 구역만) */}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { RefreshCw, AlertTriangle, Cloud, Construction, Database as DatabaseIcon } from "lucide-react";
|
||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
||||
type AlertType = "accident" | "weather" | "construction" | "system" | "security" | "other";
|
||||
|
||||
|
|
@ -38,12 +39,12 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
const parseTextData = (text: string): any[] => {
|
||||
// XML 형식 감지
|
||||
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||||
console.log("📄 XML 형식 데이터 감지");
|
||||
// console.log("📄 XML 형식 데이터 감지");
|
||||
return parseXmlData(text);
|
||||
}
|
||||
|
||||
// CSV 형식 (기상청 특보)
|
||||
console.log("📄 CSV 형식 데이터 감지");
|
||||
// console.log("📄 CSV 형식 데이터 감지");
|
||||
const lines = text.split("\n").filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed && !trimmed.startsWith("#") && trimmed !== "=";
|
||||
|
|
@ -97,7 +98,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
results.push(obj);
|
||||
}
|
||||
|
||||
console.log(`✅ XML 파싱 완료: ${results.length}개 레코드`);
|
||||
// console.log(`✅ XML 파싱 완료: ${results.length}개 레코드`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("❌ XML 파싱 실패:", error);
|
||||
|
|
@ -106,14 +107,78 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
};
|
||||
|
||||
const loadRestApiData = useCallback(async (source: ChartDataSource) => {
|
||||
if (!source.endpoint) {
|
||||
// 🆕 외부 연결 ID가 있으면 먼저 외부 연결 정보를 가져옴
|
||||
let actualEndpoint = source.endpoint;
|
||||
let actualQueryParams = source.queryParams;
|
||||
let actualHeaders = source.headers;
|
||||
|
||||
if (source.externalConnectionId) {
|
||||
// console.log("🔗 외부 연결 ID 감지:", source.externalConnectionId);
|
||||
try {
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const connection = await ExternalDbConnectionAPI.getApiConnectionById(Number(source.externalConnectionId));
|
||||
|
||||
if (connection) {
|
||||
// console.log("✅ 외부 연결 정보 가져옴:", connection);
|
||||
|
||||
// 전체 엔드포인트 URL 생성
|
||||
actualEndpoint = connection.endpoint_path
|
||||
? `${connection.base_url}${connection.endpoint_path}`
|
||||
: connection.base_url;
|
||||
|
||||
// console.log("📍 실제 엔드포인트:", actualEndpoint);
|
||||
|
||||
// 기본 헤더 적용
|
||||
const headers: any[] = [];
|
||||
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
|
||||
Object.entries(connection.default_headers).forEach(([key, value]) => {
|
||||
headers.push({ key, value });
|
||||
});
|
||||
}
|
||||
|
||||
// 인증 정보 적용
|
||||
const queryParams: any[] = [];
|
||||
if (connection.auth_type && connection.auth_type !== "none" && connection.auth_config) {
|
||||
const authConfig = connection.auth_config;
|
||||
|
||||
if (connection.auth_type === "api-key") {
|
||||
if (authConfig.keyLocation === "header" && authConfig.keyName && authConfig.keyValue) {
|
||||
headers.push({ key: authConfig.keyName, value: authConfig.keyValue });
|
||||
// console.log("🔑 API Key 헤더 추가:", authConfig.keyName);
|
||||
} else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) {
|
||||
const actualKeyName = authConfig.keyName === "apiKey" ? "key" : authConfig.keyName;
|
||||
queryParams.push({ key: actualKeyName, value: authConfig.keyValue });
|
||||
// console.log("🔑 API Key 쿼리 파라미터 추가:", actualKeyName);
|
||||
}
|
||||
} else if (connection.auth_type === "bearer" && authConfig.token) {
|
||||
headers.push({ key: "Authorization", value: `Bearer ${authConfig.token}` });
|
||||
// console.log("🔑 Bearer Token 헤더 추가");
|
||||
} else if (connection.auth_type === "basic" && authConfig.username && authConfig.password) {
|
||||
const credentials = btoa(`${authConfig.username}:${authConfig.password}`);
|
||||
headers.push({ key: "Authorization", value: `Basic ${credentials}` });
|
||||
// console.log("🔑 Basic Auth 헤더 추가");
|
||||
}
|
||||
}
|
||||
|
||||
actualHeaders = headers;
|
||||
actualQueryParams = queryParams;
|
||||
|
||||
// console.log("✅ 최종 헤더:", actualHeaders);
|
||||
// console.log("✅ 최종 쿼리 파라미터:", actualQueryParams);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ 외부 연결 정보 가져오기 실패:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!actualEndpoint) {
|
||||
throw new Error("API endpoint가 없습니다.");
|
||||
}
|
||||
|
||||
// 쿼리 파라미터 처리
|
||||
const queryParamsObj: Record<string, string> = {};
|
||||
if (source.queryParams && Array.isArray(source.queryParams)) {
|
||||
source.queryParams.forEach((param) => {
|
||||
if (actualQueryParams && Array.isArray(actualQueryParams)) {
|
||||
actualQueryParams.forEach((param) => {
|
||||
if (param.key && param.value) {
|
||||
queryParamsObj[param.key] = param.value;
|
||||
}
|
||||
|
|
@ -122,34 +187,33 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
|
||||
// 헤더 처리
|
||||
const headersObj: Record<string, string> = {};
|
||||
if (source.headers && Array.isArray(source.headers)) {
|
||||
source.headers.forEach((header) => {
|
||||
if (actualHeaders && Array.isArray(actualHeaders)) {
|
||||
actualHeaders.forEach((header) => {
|
||||
if (header.key && header.value) {
|
||||
headersObj[header.key] = header.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🌐 API 호출 준비:", {
|
||||
endpoint: source.endpoint,
|
||||
queryParams: queryParamsObj,
|
||||
headers: headersObj,
|
||||
});
|
||||
console.log("🔍 원본 source.queryParams:", source.queryParams);
|
||||
console.log("🔍 원본 source.headers:", source.headers);
|
||||
// console.log("🌐 API 호출 준비:", {
|
||||
// endpoint: actualEndpoint,
|
||||
// queryParams: queryParamsObj,
|
||||
// headers: headersObj,
|
||||
// });
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
url: source.endpoint,
|
||||
url: actualEndpoint,
|
||||
method: "GET",
|
||||
headers: headersObj,
|
||||
queryParams: queryParamsObj,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("🌐 API 응답 상태:", response.status);
|
||||
// console.log("🌐 API 응답 상태:", response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
|
|
@ -162,22 +226,22 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
|
||||
let apiData = result.data;
|
||||
|
||||
console.log("🔍 API 응답 데이터 타입:", typeof apiData);
|
||||
console.log("🔍 API 응답 데이터 (처음 500자):", typeof apiData === "string" ? apiData.substring(0, 500) : JSON.stringify(apiData).substring(0, 500));
|
||||
// console.log("🔍 API 응답 데이터 타입:", typeof apiData);
|
||||
// console.log("🔍 API 응답 데이터 (처음 500자):", typeof apiData === "string" ? apiData.substring(0, 500) : JSON.stringify(apiData).substring(0, 500));
|
||||
|
||||
// 백엔드가 {text: "XML..."} 형태로 감싼 경우 처리
|
||||
if (apiData && typeof apiData === "object" && apiData.text && typeof apiData.text === "string") {
|
||||
console.log("📦 백엔드가 text 필드로 감싼 데이터 감지");
|
||||
// console.log("📦 백엔드가 text 필드로 감싼 데이터 감지");
|
||||
apiData = parseTextData(apiData.text);
|
||||
console.log("✅ 파싱 성공:", apiData.length, "개 행");
|
||||
// console.log("✅ 파싱 성공:", apiData.length, "개 행");
|
||||
} else if (typeof apiData === "string") {
|
||||
console.log("📄 텍스트 형식 데이터 감지, 파싱 시도");
|
||||
// console.log("📄 텍스트 형식 데이터 감지, 파싱 시도");
|
||||
apiData = parseTextData(apiData);
|
||||
console.log("✅ 파싱 성공:", apiData.length, "개 행");
|
||||
// console.log("✅ 파싱 성공:", apiData.length, "개 행");
|
||||
} else if (Array.isArray(apiData)) {
|
||||
console.log("✅ 이미 배열 형태의 데이터입니다.");
|
||||
// console.log("✅ 이미 배열 형태의 데이터입니다.");
|
||||
} else {
|
||||
console.log("⚠️ 예상치 못한 데이터 형식입니다. 배열로 변환 시도.");
|
||||
// console.log("⚠️ 예상치 못한 데이터 형식입니다. 배열로 변환 시도.");
|
||||
apiData = [apiData];
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +283,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
}, []);
|
||||
|
||||
const convertToAlerts = useCallback((rows: any[], sourceName: string): Alert[] => {
|
||||
console.log("🔄 convertToAlerts 호출:", rows.length, "개 행");
|
||||
// console.log("🔄 convertToAlerts 호출:", rows.length, "개 행");
|
||||
|
||||
return rows.map((row: any, index: number) => {
|
||||
// 타입 결정 (UTIC XML 기준)
|
||||
|
|
@ -324,7 +388,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
source: sourceName,
|
||||
};
|
||||
|
||||
console.log(` ✅ Alert ${index}:`, alert);
|
||||
// console.log(` ✅ Alert ${index}:`, alert);
|
||||
return alert;
|
||||
});
|
||||
}, []);
|
||||
|
|
@ -337,19 +401,19 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log("🔄 RiskAlertTestWidget 데이터 로딩 시작:", dataSources.length, "개 소스");
|
||||
// console.log("🔄 RiskAlertTestWidget 데이터 로딩 시작:", dataSources.length, "개 소스");
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
dataSources.map(async (source, index) => {
|
||||
console.log(`📡 데이터 소스 ${index + 1} 로딩 중:`, source.name, source.type);
|
||||
// console.log(`📡 데이터 소스 ${index + 1} 로딩 중:`, source.name, source.type);
|
||||
if (source.type === "api") {
|
||||
const alerts = await loadRestApiData(source);
|
||||
console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
|
||||
// console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
|
||||
return alerts;
|
||||
} else {
|
||||
const alerts = await loadDatabaseData(source);
|
||||
console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
|
||||
// console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
|
||||
return alerts;
|
||||
}
|
||||
})
|
||||
|
|
@ -358,14 +422,14 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
const allAlerts: Alert[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
console.log(`✅ 결과 ${index + 1} 병합:`, result.value.length, "개 알림");
|
||||
// console.log(`✅ 결과 ${index + 1} 병합:`, result.value.length, "개 알림");
|
||||
allAlerts.push(...result.value);
|
||||
} else {
|
||||
console.error(`❌ 결과 ${index + 1} 실패:`, result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("✅ 총", allAlerts.length, "개 알림 로딩 완료");
|
||||
// console.log("✅ 총", allAlerts.length, "개 알림 로딩 완료");
|
||||
allAlerts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
setAlerts(allAlerts);
|
||||
setLastRefreshTime(new Date());
|
||||
|
|
@ -379,7 +443,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
|
||||
// 수동 새로고침 핸들러
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
// console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadMultipleDataSources();
|
||||
}, [loadMultipleDataSources]);
|
||||
|
||||
|
|
@ -402,15 +466,15 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
if (intervals.length === 0) return;
|
||||
|
||||
const minInterval = Math.min(...intervals);
|
||||
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
// console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log("🔄 자동 새로고침 실행");
|
||||
// console.log("🔄 자동 새로고침 실행");
|
||||
loadMultipleDataSources();
|
||||
}, minInterval * 1000);
|
||||
|
||||
return () => {
|
||||
console.log("⏹️ 자동 새로고침 정리");
|
||||
// console.log("⏹️ 자동 새로고침 정리");
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
|
@ -426,9 +490,9 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
|
||||
const getSeverityColor = (severity: "high" | "medium" | "low") => {
|
||||
switch (severity) {
|
||||
case "high": return "bg-red-500";
|
||||
case "medium": return "bg-yellow-500";
|
||||
case "low": return "bg-blue-500";
|
||||
case "high": return "bg-destructive";
|
||||
case "medium": return "bg-warning/100";
|
||||
case "low": return "bg-primary";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -439,7 +503,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
<div className="flex h-full items-center justify-center">
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -448,11 +512,11 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadMultipleDataSources}
|
||||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -466,8 +530,8 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
<div className="flex h-full items-center justify-center p-3">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<div className="text-3xl">🚨</div>
|
||||
<h3 className="text-sm font-bold text-gray-900">리스크/알림</h3>
|
||||
<div className="space-y-1.5 text-xs text-gray-600">
|
||||
<h3 className="text-sm font-bold text-foreground">리스크/알림</h3>
|
||||
<div className="space-y-1.5 text-xs text-foreground">
|
||||
<p className="font-medium">다중 데이터 소스 지원</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• 여러 REST API 동시 연결</li>
|
||||
|
|
@ -476,7 +540,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
<li>• 알림 타입별 필터링</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
||||
<p className="font-medium">⚙️ 설정 방법</p>
|
||||
<p>데이터 소스를 추가하고 저장하세요</p>
|
||||
</div>
|
||||
|
|
@ -486,9 +550,9 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-red-50 to-orange-50">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-background">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b bg-white/80 p-3">
|
||||
<div className="flex items-center justify-between border-b bg-background/80 p-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">
|
||||
{element?.customTitle || "리스크/알림"}
|
||||
|
|
@ -515,7 +579,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-hidden p-2">
|
||||
<div className="flex flex-1 flex-col overflow-hidden p-2">
|
||||
<div className="mb-2 flex gap-1 overflow-x-auto">
|
||||
<Button
|
||||
variant={filter === "all" ? "default" : "outline"}
|
||||
|
|
@ -544,16 +608,16 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1.5 overflow-y-auto">
|
||||
<div className="flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">알림이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredAlerts.map((alert) => (
|
||||
<Card key={alert.id} className="border-l-4 p-2" style={{ borderLeftColor: alert.severity === "high" ? "#ef4444" : alert.severity === "medium" ? "#f59e0b" : "#3b82f6" }}>
|
||||
<Card key={alert.id} className="p-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={`mt-0.5 rounded-full p-1 ${alert.severity === "high" ? "bg-red-100 text-red-600" : alert.severity === "medium" ? "bg-yellow-100 text-yellow-600" : "bg-blue-100 text-blue-600"}`}>
|
||||
<div className={`mt-0.5 rounded-full p-1 ${alert.severity === "high" ? "bg-destructive/10 text-destructive" : alert.severity === "medium" ? "bg-warning/10 text-warning" : "bg-primary/10 text-primary"}`}>
|
||||
{getTypeIcon(alert.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -566,10 +630,10 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
</Badge>
|
||||
</div>
|
||||
{alert.location && (
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">📍 {alert.location}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">📍 {alert.location}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-600 mt-0.5 line-clamp-2">{alert.description}</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-[9px] text-gray-400">
|
||||
<p className="text-[10px] text-foreground mt-0.5 line-clamp-2">{alert.description}</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-[9px] text-muted-foreground">
|
||||
<span>{new Date(alert.timestamp).toLocaleString("ko-KR")}</span>
|
||||
{alert.source && <span>· {alert.source}</span>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -135,11 +135,11 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
|
|||
const getAlertIcon = (type: AlertType) => {
|
||||
switch (type) {
|
||||
case "accident":
|
||||
return <AlertTriangle className="h-5 w-5 text-red-600" />;
|
||||
return <AlertTriangle className="h-5 w-5 text-destructive" />;
|
||||
case "weather":
|
||||
return <Cloud className="h-5 w-5 text-blue-600" />;
|
||||
return <Cloud className="h-5 w-5 text-primary" />;
|
||||
case "construction":
|
||||
return <Construction className="h-5 w-5 text-yellow-600" />;
|
||||
return <Construction className="h-5 w-5 text-warning" />;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -208,30 +208,30 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
|
|||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card
|
||||
className={`cursor-pointer p-3 transition-all hover:shadow-md ${
|
||||
filter === "accident" ? "bg-red-50" : ""
|
||||
filter === "accident" ? "bg-destructive/10" : ""
|
||||
}`}
|
||||
onClick={() => setFilter(filter === "accident" ? "all" : "accident")}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground">교통사고</div>
|
||||
<div className="text-2xl font-bold text-red-600">{stats.accident}건</div>
|
||||
<div className="text-2xl font-bold text-destructive">{stats.accident}건</div>
|
||||
</Card>
|
||||
<Card
|
||||
className={`cursor-pointer p-3 transition-all hover:shadow-md ${
|
||||
filter === "weather" ? "bg-blue-50" : ""
|
||||
filter === "weather" ? "bg-primary/10" : ""
|
||||
}`}
|
||||
onClick={() => setFilter(filter === "weather" ? "all" : "weather")}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground">날씨특보</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.weather}건</div>
|
||||
<div className="text-2xl font-bold text-primary">{stats.weather}건</div>
|
||||
</Card>
|
||||
<Card
|
||||
className={`cursor-pointer p-3 transition-all hover:shadow-md ${
|
||||
filter === "construction" ? "bg-yellow-50" : ""
|
||||
filter === "construction" ? "bg-warning/10" : ""
|
||||
}`}
|
||||
onClick={() => setFilter(filter === "construction" ? "all" : "construction")}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground">도로공사</div>
|
||||
<div className="text-2xl font-bold text-yellow-600">{stats.construction}건</div>
|
||||
<div className="text-2xl font-bold text-warning">{stats.construction}건</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export default function StatusSummaryWidget({
|
|||
element,
|
||||
title = "상태 요약",
|
||||
icon = "📊",
|
||||
bgGradient = "from-slate-50 to-blue-50",
|
||||
bgGradient = "from-background to-primary/10",
|
||||
statusConfig,
|
||||
}: StatusSummaryWidgetProps) {
|
||||
const [statusData, setStatusData] = useState<StatusData[]>([]);
|
||||
|
|
@ -265,13 +265,13 @@ export default function StatusSummaryWidget({
|
|||
}
|
||||
|
||||
const colorMap = {
|
||||
blue: { border: "border-blue-500", dot: "bg-blue-500", text: "text-blue-600" },
|
||||
green: { border: "border-green-500", dot: "bg-green-500", text: "text-green-600" },
|
||||
red: { border: "border-red-500", dot: "bg-red-500", text: "text-red-600" },
|
||||
yellow: { border: "border-yellow-500", dot: "bg-yellow-500", text: "text-yellow-600" },
|
||||
orange: { border: "border-orange-500", dot: "bg-orange-500", text: "text-orange-600" },
|
||||
purple: { border: "border-purple-500", dot: "bg-purple-500", text: "text-purple-600" },
|
||||
gray: { border: "border-gray-500", dot: "bg-gray-500", text: "text-gray-600" },
|
||||
blue: { border: "border-primary", dot: "bg-primary", text: "text-primary" },
|
||||
green: { border: "border-success", dot: "bg-success", text: "text-success" },
|
||||
red: { border: "border-destructive", dot: "bg-destructive", text: "text-destructive" },
|
||||
yellow: { border: "border-warning", dot: "bg-warning", text: "text-warning" },
|
||||
orange: { border: "border-warning", dot: "bg-warning", text: "text-warning" },
|
||||
purple: { border: "border-primary", dot: "bg-primary", text: "text-primary" },
|
||||
gray: { border: "border-border", dot: "bg-muted-foreground", text: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
return colorMap[color as keyof typeof colorMap] || colorMap.gray;
|
||||
|
|
@ -282,7 +282,7 @@ export default function StatusSummaryWidget({
|
|||
<div className="flex h-full items-center justify-center">
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -291,11 +291,11 @@ export default function StatusSummaryWidget({
|
|||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="text-sm">⚠️ {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"
|
||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -309,8 +309,8 @@ export default function StatusSummaryWidget({
|
|||
<div className="flex h-full items-center justify-center p-3">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<div className="text-3xl">{icon}</div>
|
||||
<h3 className="text-sm font-bold text-gray-900">{title}</h3>
|
||||
<div className="space-y-1.5 text-xs text-gray-600">
|
||||
<h3 className="text-sm font-bold text-foreground">{title}</h3>
|
||||
<div className="space-y-1.5 text-xs text-foreground">
|
||||
<p className="font-medium">📊 상태별 데이터 집계 위젯</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• SQL 쿼리로 데이터를 불러옵니다</li>
|
||||
|
|
@ -319,7 +319,7 @@ export default function StatusSummaryWidget({
|
|||
<li>• 색상과 라벨 커스터마이징 지원</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
||||
<p className="font-medium">⚙️ 설정 방법</p>
|
||||
<p>SQL 쿼리를 입력하고 저장하세요</p>
|
||||
</div>
|
||||
|
|
@ -357,18 +357,18 @@ export default function StatusSummaryWidget({
|
|||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">
|
||||
<h3 className="text-sm font-bold text-foreground">
|
||||
{icon} {displayTitle}
|
||||
</h3>
|
||||
{totalCount > 0 ? (
|
||||
<p className="text-xs text-gray-500">총 {totalCount.toLocaleString()}건</p>
|
||||
<p className="text-xs text-muted-foreground">총 {totalCount.toLocaleString()}건</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 데이터 연결 필요</p>
|
||||
<p className="text-xs text-warning">⚙️ 데이터 연결 필요</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-background p-0 text-xs disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
|
|
@ -382,10 +382,10 @@ export default function StatusSummaryWidget({
|
|||
{statusData.map((item) => {
|
||||
const colors = getColorClasses(item.status);
|
||||
return (
|
||||
<div key={item.status} className="rounded border border-gray-200 bg-white p-1.5 shadow-sm">
|
||||
<div key={item.status} className="rounded border border-border bg-background p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${colors.dot}`}></div>
|
||||
<div className="text-xs font-medium text-gray-600">{item.status}</div>
|
||||
<div className="text-xs font-medium text-foreground">{item.status}</div>
|
||||
</div>
|
||||
<div className={`text-lg font-bold ${colors.text}`}>{item.count.toLocaleString()}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -481,10 +481,10 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
|
||||
const getPriorityColor = (priority: TaskItem["priority"]) => {
|
||||
switch (priority) {
|
||||
case "urgent": return "bg-red-100 text-red-700 border-red-300";
|
||||
case "high": return "bg-orange-100 text-orange-700 border-orange-300";
|
||||
case "normal": return "bg-blue-100 text-blue-700 border-blue-300";
|
||||
case "low": return "bg-gray-100 text-gray-700 border-gray-300";
|
||||
case "urgent": return "bg-destructive/10 text-destructive border-destructive";
|
||||
case "high": return "bg-warning/10 text-warning border-warning";
|
||||
case "normal": return "bg-primary/10 text-primary border-primary";
|
||||
case "low": return "bg-muted text-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -549,22 +549,22 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
{/* 제목 */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-2">
|
||||
<div className="border-b border-border bg-background px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-800">
|
||||
<h3 className="text-lg font-bold text-foreground">
|
||||
{element?.customTitle || "일정관리 위젯"}
|
||||
</h3>
|
||||
{selectedDate && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-green-600">
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-success">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
<span className="font-semibold">{formatSelectedDate()} 일정</span>
|
||||
</div>
|
||||
|
|
@ -572,7 +572,7 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
추가
|
||||
|
|
@ -582,24 +582,24 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
|
||||
{/* 헤더 (통계, 필터) */}
|
||||
{element?.showHeader !== false && (
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||
<div className="border-b border-border bg-background px-4 py-3">
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-2 text-xs mb-3">
|
||||
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-blue-700">{stats.pending}</div>
|
||||
<div className="text-blue-600">대기</div>
|
||||
<div className="rounded bg-primary/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-primary">{stats.pending}</div>
|
||||
<div className="text-primary">대기</div>
|
||||
</div>
|
||||
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-amber-700">{stats.inProgress}</div>
|
||||
<div className="text-amber-600">진행중</div>
|
||||
<div className="rounded bg-warning/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-warning">{stats.inProgress}</div>
|
||||
<div className="text-warning">진행중</div>
|
||||
</div>
|
||||
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-red-700">{stats.urgent}</div>
|
||||
<div className="text-red-600">긴급</div>
|
||||
<div className="rounded bg-destructive/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-destructive">{stats.urgent}</div>
|
||||
<div className="text-destructive">긴급</div>
|
||||
</div>
|
||||
<div className="rounded bg-rose-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-rose-700">{stats.overdue}</div>
|
||||
<div className="text-rose-600">지연</div>
|
||||
<div className="rounded bg-destructive/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-destructive">{stats.overdue}</div>
|
||||
<div className="text-destructive">지연</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -610,7 +610,7 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
filter === f ? "bg-primary text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
filter === f ? "bg-primary text-primary-foreground" : "bg-muted text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
|
||||
|
|
@ -622,7 +622,7 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
|
||||
{/* 추가 폼 */}
|
||||
{showAddForm && (
|
||||
<div className="max-h-[400px] overflow-y-auto border-b border-gray-200 bg-white p-4">
|
||||
<div className="max-h-[400px] overflow-y-auto border-b border-border bg-background p-4">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -630,21 +630,21 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
value={newTask.title}
|
||||
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
className="w-full rounded border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="상세 설명 (선택)"
|
||||
value={newTask.description}
|
||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
className="w-full rounded border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={newTask.priority}
|
||||
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value as TaskItem["priority"] })}
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
className="rounded border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="low">🟢 낮음</option>
|
||||
<option value="normal">🟡 보통</option>
|
||||
|
|
@ -655,7 +655,7 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
type="datetime-local"
|
||||
value={newTask.dueDate}
|
||||
onChange={(e) => setNewTask({ ...newTask, dueDate: e.target.value })}
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
className="rounded border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -664,21 +664,21 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
type="checkbox"
|
||||
checked={newTask.isUrgent}
|
||||
onChange={(e) => setNewTask({ ...newTask, isUrgent: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-red-600 font-medium">긴급</span>
|
||||
<span className="text-destructive font-medium">긴급</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAddTask}
|
||||
className="flex-1 rounded bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90"
|
||||
className="flex-1 rounded bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-300"
|
||||
className="rounded bg-muted px-4 py-2 text-sm text-foreground hover:bg-muted/90"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
|
|
@ -690,7 +690,7 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
{/* Task 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📝</div>
|
||||
<div>{selectedDate ? `${formatSelectedDate()} 일정이 없습니다` : `일정이 없습니다`}</div>
|
||||
|
|
@ -701,8 +701,8 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
{filteredTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`group relative rounded-lg border-2 bg-white p-3 shadow-sm transition-all hover:shadow-md ${
|
||||
task.isUrgent || task.status === "overdue" ? "border-red-400" : "border-gray-200"
|
||||
className={`group relative rounded-lg border-2 bg-background p-3 shadow-sm transition-all hover:shadow-md ${
|
||||
task.isUrgent || task.status === "overdue" ? "border-destructive" : "border-border"
|
||||
} ${task.status === "completed" ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -716,26 +716,26 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${task.status === "completed" ? "line-through" : ""}`}>
|
||||
{task.isUrgent && <span className="mr-1 text-red-600">⚡</span>}
|
||||
{task.isUrgent && <span className="mr-1 text-destructive">⚡</span>}
|
||||
{task.vehicleNumber ? (
|
||||
<>
|
||||
<span className="font-bold">{task.vehicleNumber}</span>
|
||||
{task.vehicleType && <span className="ml-2 text-xs text-gray-600">({task.vehicleType})</span>}
|
||||
{task.vehicleType && <span className="ml-2 text-xs text-foreground">({task.vehicleType})</span>}
|
||||
</>
|
||||
) : (
|
||||
task.title
|
||||
)}
|
||||
</div>
|
||||
{task.maintenanceType && (
|
||||
<div className="mt-1 rounded bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700">
|
||||
<div className="mt-1 rounded bg-muted px-2 py-1 text-xs font-medium text-foreground">
|
||||
{task.maintenanceType}
|
||||
</div>
|
||||
)}
|
||||
{task.description && (
|
||||
<div className="mt-1 text-xs text-gray-600">{task.description}</div>
|
||||
<div className="mt-1 text-xs text-foreground">{task.description}</div>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<div className="mt-1 text-xs text-gray-500">{getTimeRemaining(task.dueDate)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{getTimeRemaining(task.dueDate)}</div>
|
||||
)}
|
||||
{task.estimatedCost && (
|
||||
<div className="mt-1 text-xs font-bold text-primary">
|
||||
|
|
@ -749,7 +749,7 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
{task.status !== "completed" && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(task.id, "completed")}
|
||||
className="rounded p-1 text-green-600 hover:bg-green-50"
|
||||
className="rounded p-1 text-success hover:bg-success/10"
|
||||
title="완료"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
|
|
@ -757,7 +757,7 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(task.id)}
|
||||
className="rounded p-1 text-red-600 hover:bg-red-50"
|
||||
className="rounded p-1 text-destructive hover:bg-destructive/10"
|
||||
title="삭제"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -772,8 +772,8 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
onClick={() => handleUpdateStatus(task.id, "pending")}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
task.status === "pending"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
대기
|
||||
|
|
@ -782,8 +782,8 @@ export default function TaskWidget({ element }: TaskWidgetProps) {
|
|||
onClick={() => handleUpdateStatus(task.id, "in_progress")}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
task.status === "in_progress"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
? "bg-warning/10 text-warning"
|
||||
: "bg-muted text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
진행중
|
||||
|
|
|
|||
|
|
@ -267,13 +267,13 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
const getPriorityColor = (priority: TodoItem["priority"]) => {
|
||||
switch (priority) {
|
||||
case "urgent":
|
||||
return "bg-red-100 text-red-700 border-red-300";
|
||||
return "bg-destructive/10 text-destructive border-destructive";
|
||||
case "high":
|
||||
return "bg-orange-100 text-orange-700 border-orange-300";
|
||||
return "bg-warning/10 text-warning border-warning";
|
||||
case "normal":
|
||||
return "bg-blue-100 text-blue-700 border-blue-300";
|
||||
return "bg-primary/10 text-primary border-primary";
|
||||
case "low":
|
||||
return "bg-gray-100 text-gray-700 border-gray-300";
|
||||
return "bg-muted text-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -327,20 +327,20 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
{/* 제목 - 항상 표시 */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-2">
|
||||
<div className="border-b border-border bg-background px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "To-Do / 긴급 지시"}</h3>
|
||||
<h3 className="text-lg font-bold text-foreground">{element?.customTitle || "To-Do / 긴급 지시"}</h3>
|
||||
{selectedDate && (
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-green-600">
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-success">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
<span className="font-semibold">{formatSelectedDate()} 할일</span>
|
||||
</div>
|
||||
|
|
@ -349,7 +349,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
{/* 추가 버튼 - 항상 표시 */}
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
title="할 일 추가"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
|
|
@ -360,25 +360,25 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
|
||||
{/* 헤더 (통계, 필터) - showHeader가 false일 때만 숨김 */}
|
||||
{element?.showHeader !== false && (
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||
<div className="border-b border-border bg-background px-4 py-3">
|
||||
{/* 통계 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-2 text-xs mb-3">
|
||||
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-blue-700">{stats.pending}</div>
|
||||
<div className="text-blue-600">대기</div>
|
||||
<div className="rounded bg-primary/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-primary">{stats.pending}</div>
|
||||
<div className="text-primary">대기</div>
|
||||
</div>
|
||||
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-amber-700">{stats.inProgress}</div>
|
||||
<div className="text-amber-600">진행중</div>
|
||||
<div className="rounded bg-warning/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-warning">{stats.inProgress}</div>
|
||||
<div className="text-warning">진행중</div>
|
||||
</div>
|
||||
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-red-700">{stats.urgent}</div>
|
||||
<div className="text-red-600">긴급</div>
|
||||
<div className="rounded bg-destructive/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-destructive">{stats.urgent}</div>
|
||||
<div className="text-destructive">긴급</div>
|
||||
</div>
|
||||
<div className="rounded bg-rose-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-rose-700">{stats.overdue}</div>
|
||||
<div className="text-rose-600">지연</div>
|
||||
<div className="rounded bg-destructive/10 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-destructive">{stats.overdue}</div>
|
||||
<div className="text-destructive">지연</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -391,8 +391,8 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
onClick={() => setFilter(f)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
filter === f
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
|
||||
|
|
@ -404,7 +404,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
|
||||
{/* 추가 폼 */}
|
||||
{showAddForm && (
|
||||
<div className="max-h-[400px] overflow-y-auto border-b border-gray-200 bg-white p-4">
|
||||
<div className="max-h-[400px] overflow-y-auto border-b border-border bg-background p-4">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -412,21 +412,21 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
value={newTodo.title}
|
||||
onChange={(e) => setNewTodo({ ...newTodo, title: e.target.value })}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
className="w-full rounded border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="상세 설명 (선택)"
|
||||
value={newTodo.description}
|
||||
onChange={(e) => setNewTodo({ ...newTodo, description: e.target.value })}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
className="w-full rounded border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={newTodo.priority}
|
||||
onChange={(e) => setNewTodo({ ...newTodo, priority: e.target.value as TodoItem["priority"] })}
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
className="rounded border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="low">🟢 낮음</option>
|
||||
<option value="normal">🟡 보통</option>
|
||||
|
|
@ -437,7 +437,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
type="datetime-local"
|
||||
value={newTodo.dueDate}
|
||||
onChange={(e) => setNewTodo({ ...newTodo, dueDate: e.target.value })}
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
className="rounded border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -446,21 +446,21 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
type="checkbox"
|
||||
checked={newTodo.isUrgent}
|
||||
onChange={(e) => setNewTodo({ ...newTodo, isUrgent: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<span className="text-red-600 font-medium">긴급 지시</span>
|
||||
<span className="text-destructive font-medium">긴급 지시</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAddTodo}
|
||||
className="flex-1 rounded bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90"
|
||||
className="flex-1 rounded bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-300"
|
||||
className="rounded bg-muted px-4 py-2 text-sm text-foreground hover:bg-muted/90"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
|
|
@ -472,7 +472,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
{/* To-Do 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
||||
{filteredTodos.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📝</div>
|
||||
<div>{selectedDate ? `${formatSelectedDate()} 할 일이 없습니다` : "할 일이 없습니다"}</div>
|
||||
|
|
@ -483,8 +483,8 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
{filteredTodos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`group relative rounded-lg border-2 bg-white p-3 shadow-sm transition-all hover:shadow-md ${
|
||||
todo.isUrgent ? "border-red-400" : "border-gray-200"
|
||||
className={`group relative rounded-lg border-2 bg-background p-3 shadow-sm transition-all hover:shadow-md ${
|
||||
todo.isUrgent ? "border-destructive" : "border-border"
|
||||
} ${todo.status === "completed" ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -496,14 +496,14 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${todo.status === "completed" ? "line-through" : ""}`}>
|
||||
{todo.isUrgent && <span className="mr-1 text-red-600">⚡</span>}
|
||||
{todo.isUrgent && <span className="mr-1 text-destructive">⚡</span>}
|
||||
{todo.title}
|
||||
</div>
|
||||
{todo.description && (
|
||||
<div className="mt-1 text-xs text-gray-600">{todo.description}</div>
|
||||
<div className="mt-1 text-xs text-foreground">{todo.description}</div>
|
||||
)}
|
||||
{todo.dueDate && (
|
||||
<div className="mt-1 text-xs text-gray-500">{getTimeRemaining(todo.dueDate)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{getTimeRemaining(todo.dueDate)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -512,7 +512,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
{todo.status !== "completed" && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(todo.id, "completed")}
|
||||
className="rounded p-1 text-green-600 hover:bg-green-50"
|
||||
className="rounded p-1 text-success hover:bg-success/10"
|
||||
title="완료"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
|
|
@ -520,7 +520,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(todo.id)}
|
||||
className="rounded p-1 text-red-600 hover:bg-red-50"
|
||||
className="rounded p-1 text-destructive hover:bg-destructive/10"
|
||||
title="삭제"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -535,8 +535,8 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
onClick={() => handleUpdateStatus(todo.id, "pending")}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
todo.status === "pending"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
대기
|
||||
|
|
@ -545,8 +545,8 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
onClick={() => handleUpdateStatus(todo.id, "in_progress")}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
todo.status === "in_progress"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
? "bg-warning/10 text-warning"
|
||||
: "bg-muted text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
진행중
|
||||
|
|
|
|||
|
|
@ -247,10 +247,10 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
|||
|
||||
if (isLoading && !stats) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
<div className="mt-2 text-sm text-gray-600">로딩 중...</div>
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<div className="mt-2 text-sm text-foreground">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -258,14 +258,14 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
|||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||||
<div className="flex h-full items-center justify-center bg-muted p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error || "데이터 없음"}</div>
|
||||
{!element?.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
||||
<div className="text-sm font-medium text-foreground">{error || "데이터 없음"}</div>
|
||||
{!element?.dataSource?.query && <div className="mt-2 text-xs text-muted-foreground">쿼리를 설정하세요</div>}
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
className="mt-3 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -275,48 +275,48 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white p-6">
|
||||
<div className="flex h-full items-center justify-center bg-background p-6">
|
||||
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
|
||||
{/* 총 건수 */}
|
||||
<div className="rounded-lg border bg-indigo-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">총 건수</div>
|
||||
<div className="mt-2 text-3xl font-bold text-indigo-600">
|
||||
<div className="rounded-lg border bg-primary/10 p-4 text-center">
|
||||
<div className="text-sm text-foreground">총 건수</div>
|
||||
<div className="mt-2 text-3xl font-bold text-primary">
|
||||
{stats.total_count.toLocaleString()}
|
||||
<span className="ml-1 text-lg">건</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 운송량 */}
|
||||
<div className="rounded-lg border bg-green-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">총 운송량</div>
|
||||
<div className="mt-2 text-3xl font-bold text-green-600">
|
||||
<div className="rounded-lg border bg-success/10 p-4 text-center">
|
||||
<div className="text-sm text-foreground">총 운송량</div>
|
||||
<div className="mt-2 text-3xl font-bold text-success">
|
||||
{stats.total_weight.toFixed(1)}
|
||||
<span className="ml-1 text-lg">톤</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 누적 거리 */}
|
||||
<div className="rounded-lg border bg-blue-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">누적 거리</div>
|
||||
<div className="mt-2 text-3xl font-bold text-blue-600">
|
||||
<div className="rounded-lg border bg-primary/10 p-4 text-center">
|
||||
<div className="text-sm text-foreground">누적 거리</div>
|
||||
<div className="mt-2 text-3xl font-bold text-primary">
|
||||
{stats.total_distance.toFixed(1)}
|
||||
<span className="ml-1 text-lg">km</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정시 도착률 */}
|
||||
<div className="rounded-lg border bg-purple-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">정시 도착률</div>
|
||||
<div className="mt-2 text-3xl font-bold text-purple-600">
|
||||
<div className="rounded-lg border bg-primary/10 p-4 text-center">
|
||||
<div className="text-sm text-foreground">정시 도착률</div>
|
||||
<div className="mt-2 text-3xl font-bold text-primary">
|
||||
{stats.on_time_rate.toFixed(1)}
|
||||
<span className="ml-1 text-lg">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 평균 배송시간 */}
|
||||
<div className="rounded-lg border bg-orange-50 p-4 text-center">
|
||||
<div className="text-sm text-gray-600">평균 배송시간</div>
|
||||
<div className="mt-2 text-3xl font-bold text-orange-600">
|
||||
<div className="rounded-lg border bg-warning/10 p-4 text-center">
|
||||
<div className="text-sm text-foreground">평균 배송시간</div>
|
||||
<div className="mt-2 text-3xl font-bold text-warning">
|
||||
{stats.avg_delivery_time.toFixed(1)}
|
||||
<span className="ml-1 text-lg">분</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -74,11 +74,11 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
|
|||
|
||||
const getStatusColor = (status: string) => {
|
||||
const s = status?.toLowerCase() || "";
|
||||
if (s === "active" || s === "running") return "bg-green-500";
|
||||
if (s === "inactive" || s === "idle") return "bg-yellow-500";
|
||||
if (s === "maintenance") return "bg-orange-500";
|
||||
if (s === "warning" || s === "breakdown") return "bg-red-500";
|
||||
return "bg-gray-500";
|
||||
if (s === "active" || s === "running") return "bg-success";
|
||||
if (s === "inactive" || s === "idle") return "bg-warning/100";
|
||||
if (s === "maintenance") return "bg-warning";
|
||||
if (s === "warning" || s === "breakdown") return "bg-destructive";
|
||||
return "bg-muted0";
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
|
|
@ -94,12 +94,12 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
|
|||
selectedStatus === "all" ? vehicles : vehicles.filter((v) => v.status?.toLowerCase() === selectedStatus);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-gradient-to-br from-slate-50 to-blue-50 p-4">
|
||||
<div className="flex h-full w-full flex-col bg-gradient-to-br from-background to-primary/10 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">차량 목록</h3>
|
||||
<p className="text-xs text-gray-500">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
<h3 className="text-lg font-bold text-foreground">차량 목록</h3>
|
||||
<p className="text-xs text-muted-foreground">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadVehicles} disabled={isLoading} className="h-8 w-8 p-0">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
|
|
@ -111,7 +111,7 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
|
|||
<button
|
||||
onClick={() => setSelectedStatus("all")}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
selectedStatus === "all" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
selectedStatus === "all" ? "bg-primary text-primary-foreground" : "bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
전체 ({vehicles.length})
|
||||
|
|
@ -119,7 +119,7 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
|
|||
<button
|
||||
onClick={() => setSelectedStatus("active")}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
selectedStatus === "active" ? "bg-green-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
selectedStatus === "active" ? "bg-success text-primary-foreground" : "bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
운행 중 ({vehicles.filter((v) => v.status?.toLowerCase() === "active").length})
|
||||
|
|
@ -127,7 +127,7 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
|
|||
<button
|
||||
onClick={() => setSelectedStatus("inactive")}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
selectedStatus === "inactive" ? "bg-yellow-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
selectedStatus === "inactive" ? "bg-warning text-primary-foreground" : "bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
대기 ({vehicles.filter((v) => v.status?.toLowerCase() === "inactive").length})
|
||||
|
|
@ -135,7 +135,7 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
|
|||
<button
|
||||
onClick={() => setSelectedStatus("maintenance")}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
selectedStatus === "maintenance" ? "bg-orange-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
selectedStatus === "maintenance" ? "bg-warning text-primary-foreground" : "bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
정비 ({vehicles.filter((v) => v.status?.toLowerCase() === "maintenance").length})
|
||||
|
|
@ -143,7 +143,7 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
|
|||
<button
|
||||
onClick={() => setSelectedStatus("warning")}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
selectedStatus === "warning" ? "bg-red-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
selectedStatus === "warning" ? "bg-destructive text-primary-foreground" : "bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
고장 ({vehicles.filter((v) => v.status?.toLowerCase() === "warning").length})
|
||||
|
|
@ -153,10 +153,10 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
|
|||
{/* 차량 목록 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredVehicles.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white">
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-border bg-background">
|
||||
<div className="text-center">
|
||||
<Truck className="mx-auto h-12 w-12 text-gray-300" />
|
||||
<p className="mt-2 text-sm text-gray-500">차량이 없습니다</p>
|
||||
<Truck className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">차량이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -164,12 +164,12 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
|
|||
{filteredVehicles.map((vehicle) => (
|
||||
<div
|
||||
key={vehicle.id}
|
||||
className="rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:shadow-md"
|
||||
className="rounded-lg border border-border bg-background p-3 shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Truck className="h-4 w-4 text-gray-600" />
|
||||
<span className="font-semibold text-gray-900">{vehicle.vehicle_name}</span>
|
||||
<Truck className="h-4 w-4 text-foreground" />
|
||||
<span className="font-semibold text-foreground">{vehicle.vehicle_name}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold text-white ${getStatusColor(vehicle.status)}`}
|
||||
|
|
@ -178,22 +178,22 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="space-y-1 text-xs text-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">차량번호</span>
|
||||
<span className="text-muted-foreground">차량번호</span>
|
||||
<span className="font-mono font-medium">{vehicle.vehicle_number}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">기사</span>
|
||||
<span className="text-muted-foreground">기사</span>
|
||||
<span className="font-medium">{vehicle.driver_name || "미배정"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Navigation className="h-3 w-3 text-gray-400" />
|
||||
<span className="flex-1 truncate text-gray-700">{vehicle.destination || "대기 중"}</span>
|
||||
<Navigation className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-foreground">{vehicle.destination || "대기 중"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-gray-700">{vehicle.speed || 0} km/h</span>
|
||||
<Gauge className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">{vehicle.speed || 0} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -164,12 +164,12 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gradient-to-br from-slate-50 to-blue-50 p-4">
|
||||
<div className="h-full w-full bg-gradient-to-br from-background to-primary/10 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">🗺️ 차량 위치 지도</h3>
|
||||
<p className="text-xs text-gray-500">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
<h3 className="text-lg font-bold text-foreground">🗺️ 차량 위치 지도</h3>
|
||||
<p className="text-xs text-muted-foreground">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadVehicles} disabled={isLoading} className="h-8 w-8 p-0">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
|
|
@ -178,7 +178,7 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
|
||||
{/* 지도 영역 - 브이월드 타일맵 */}
|
||||
<div className="h-[calc(100%-60px)]">
|
||||
<div className="relative z-0 h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white">
|
||||
<div className="relative z-0 h-full overflow-hidden rounded-lg border-2 border-border bg-background">
|
||||
<MapContainer
|
||||
key={`vehicle-map-${element.id}`}
|
||||
center={[36.5, 127.5]}
|
||||
|
|
@ -235,19 +235,19 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
</MapContainer>
|
||||
|
||||
{/* 지도 정보 */}
|
||||
<div className="absolute top-2 right-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className="absolute top-2 right-2 z-[1000] rounded-lg bg-background/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
<div className="text-xs text-foreground">
|
||||
<div className="mb-1 font-semibold">🗺️ 브이월드 (VWorld)</div>
|
||||
<div className="text-xs">국토교통부 공식 지도</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차량 수 표시 또는 설정 안내 */}
|
||||
<div className="absolute bottom-2 left-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
<div className="absolute bottom-2 left-2 z-[1000] rounded-lg bg-background/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
{vehicles.length > 0 ? (
|
||||
<div className="text-xs font-semibold text-gray-900">총 {vehicles.length}대 모니터링 중</div>
|
||||
<div className="text-xs font-semibold text-foreground">총 {vehicles.length}대 모니터링 중</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-600">데이터를 연결하세요</div>
|
||||
<div className="text-xs text-foreground">데이터를 연결하세요</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -124,15 +124,15 @@ export default function VehicleStatusWidget({ element, refreshInterval = 30000 }
|
|||
const activeRate = statusData.total > 0 ? ((statusData.active / statusData.total) * 100).toFixed(1) : "0";
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-green-50 p-2">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-background to-success/10 p-2">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">📊 차량 상태 현황</h3>
|
||||
<h3 className="text-sm font-bold text-foreground">📊 차량 상태 현황</h3>
|
||||
{statusData.total > 0 ? (
|
||||
<p className="text-xs text-gray-500">{lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
<p className="text-xs text-muted-foreground">{lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 데이터 연결 필요</p>
|
||||
<p className="text-xs text-warning">⚙️ 데이터 연결 필요</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadStatusData} disabled={isLoading} className="h-7 w-7 p-0">
|
||||
|
|
@ -143,15 +143,15 @@ export default function VehicleStatusWidget({ element, refreshInterval = 30000 }
|
|||
{/* 스크롤 가능한 콘텐츠 영역 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 총 차량 수 */}
|
||||
<div className="mb-1 rounded border border-gray-200 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-1 rounded border border-border bg-background p-1.5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">총 차량</div>
|
||||
<div className="text-base font-bold text-gray-900">{statusData.total}대</div>
|
||||
<div className="text-xs text-foreground">총 차량</div>
|
||||
<div className="text-base font-bold text-foreground">{statusData.total}대</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-gray-600">가동률</div>
|
||||
<div className="flex items-center gap-0.5 text-sm font-bold text-green-600">{activeRate}%</div>
|
||||
<div className="text-xs text-foreground">가동률</div>
|
||||
<div className="flex items-center gap-0.5 text-sm font-bold text-success">{activeRate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -159,39 +159,39 @@ export default function VehicleStatusWidget({ element, refreshInterval = 30000 }
|
|||
{/* 상태별 카드 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 운행 중 */}
|
||||
<div className="rounded border-l-2 border-green-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="rounded border-l-2 border-success bg-background p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">운행</div>
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-success"></div>
|
||||
<div className="text-xs font-medium text-foreground">운행</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-green-600">{statusData.active}</div>
|
||||
<div className="text-lg font-bold text-success">{statusData.active}</div>
|
||||
</div>
|
||||
|
||||
{/* 대기 */}
|
||||
<div className="rounded border-l-2 border-yellow-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="rounded border-l-2 border-warning bg-background p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-yellow-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">대기</div>
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-warning/100"></div>
|
||||
<div className="text-xs font-medium text-foreground">대기</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-yellow-600">{statusData.inactive}</div>
|
||||
<div className="text-lg font-bold text-warning">{statusData.inactive}</div>
|
||||
</div>
|
||||
|
||||
{/* 정비 */}
|
||||
<div className="rounded border-l-2 border-orange-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="rounded border-l-2 border-warning bg-background p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-orange-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">정비</div>
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-warning"></div>
|
||||
<div className="text-xs font-medium text-foreground">정비</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-orange-600">{statusData.maintenance}</div>
|
||||
<div className="text-lg font-bold text-warning">{statusData.maintenance}</div>
|
||||
</div>
|
||||
|
||||
{/* 고장 */}
|
||||
<div className="rounded border-l-2 border-red-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="rounded border-l-2 border-destructive bg-background p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-red-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">고장</div>
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-destructive"></div>
|
||||
<div className="text-xs font-medium text-foreground">고장</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-red-600">{statusData.warning}</div>
|
||||
<div className="text-lg font-bold text-destructive">{statusData.warning}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,15 +38,15 @@ interface WeatherMapWidgetProps {
|
|||
const getWeatherIcon = (weatherMain: string) => {
|
||||
switch (weatherMain.toLowerCase()) {
|
||||
case "clear":
|
||||
return <Sun className="h-6 w-6 text-yellow-500" />;
|
||||
return <Sun className="h-6 w-6 text-warning" />;
|
||||
case "rain":
|
||||
return <CloudRain className="h-6 w-6 text-blue-500" />;
|
||||
return <CloudRain className="h-6 w-6 text-primary" />;
|
||||
case "snow":
|
||||
return <CloudSnow className="h-6 w-6 text-blue-300" />;
|
||||
return <CloudSnow className="h-6 w-6 text-primary/70" />;
|
||||
case "clouds":
|
||||
return <Cloud className="h-6 w-6 text-gray-400" />;
|
||||
return <Cloud className="h-6 w-6 text-muted-foreground" />;
|
||||
default:
|
||||
return <Wind className="h-6 w-6 text-gray-500" />;
|
||||
return <Wind className="h-6 w-6 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -263,28 +263,28 @@ export default function WeatherWidget({
|
|||
const getWeatherIcon = (weatherMain: string) => {
|
||||
switch (weatherMain.toLowerCase()) {
|
||||
case 'clear':
|
||||
return <Sun className="h-12 w-12 text-yellow-500" />;
|
||||
return <Sun className="h-12 w-12 text-warning" />;
|
||||
case 'clouds':
|
||||
return <Cloud className="h-12 w-12 text-gray-400" />;
|
||||
return <Cloud className="h-12 w-12 text-muted-foreground" />;
|
||||
case 'rain':
|
||||
case 'drizzle':
|
||||
return <CloudRain className="h-12 w-12 text-blue-500" />;
|
||||
return <CloudRain className="h-12 w-12 text-primary" />;
|
||||
case 'snow':
|
||||
return <CloudSnow className="h-12 w-12 text-blue-300" />;
|
||||
return <CloudSnow className="h-12 w-12 text-primary/70" />;
|
||||
default:
|
||||
return <Cloud className="h-12 w-12 text-gray-400" />;
|
||||
return <Cloud className="h-12 w-12 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
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 h-full items-center justify-center bg-background rounded-lg border p-6">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold text-gray-800 mb-1">실제 기상청 API 연결 중...</p>
|
||||
<p className="text-xs text-gray-500">실시간 관측 데이터를 가져오고 있습니다</p>
|
||||
<p className="text-sm font-semibold text-foreground mb-1">실제 기상청 API 연결 중...</p>
|
||||
<p className="text-xs text-muted-foreground">실시간 관측 데이터를 가져오고 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -295,21 +295,17 @@ export default function WeatherWidget({
|
|||
if (error || !weather) {
|
||||
const isTestMode = error?.includes('API 키가 설정되지 않았습니다');
|
||||
return (
|
||||
<div className={`flex h-full flex-col items-center justify-center rounded-lg border p-6 ${
|
||||
isTestMode
|
||||
? 'bg-gradient-to-br from-yellow-50 to-orange-50'
|
||||
: 'bg-gradient-to-br from-red-50 to-orange-50'
|
||||
}`}>
|
||||
<Cloud className="h-12 w-12 text-gray-400 mb-2" />
|
||||
<div className="flex h-full flex-col items-center justify-center rounded-lg border p-6 bg-background">
|
||||
<Cloud className="h-12 w-12 text-muted-foreground mb-2" />
|
||||
<div className="text-center mb-3">
|
||||
<p className="text-sm font-semibold text-gray-800 mb-1">
|
||||
<p className="text-sm font-semibold text-foreground mb-1">
|
||||
{isTestMode ? '⚠️ 테스트 모드' : '❌ 연결 실패'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
<p className="text-xs text-foreground">
|
||||
{error || '날씨 정보를 불러올 수 없습니다.'}
|
||||
</p>
|
||||
{isTestMode && (
|
||||
<p className="text-xs text-yellow-700 mt-2">
|
||||
<p className="text-xs text-warning mt-2">
|
||||
임시 데이터가 표시됩니다
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -328,11 +324,11 @@ export default function WeatherWidget({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-4">
|
||||
<div className="h-full bg-background rounded-lg border p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">🌤️ {element?.customTitle || "날씨"}</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-1">🌤️ {element?.customTitle || "날씨"}</h3>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -340,7 +336,7 @@ export default function WeatherWidget({
|
|||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between text-sm text-gray-600 hover:bg-white/50 h-auto py-0.5 px-2"
|
||||
className="justify-between text-sm text-foreground hover:bg-muted/80 h-auto py-0.5 px-2"
|
||||
>
|
||||
{cities.find((city) => city.value === selectedCity)?.label || '도시 선택'}
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
|
|
@ -376,7 +372,7 @@ export default function WeatherWidget({
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 pl-2">
|
||||
<p className="text-xs text-muted-foreground pl-2">
|
||||
{lastUpdated
|
||||
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
|
|
@ -397,7 +393,7 @@ export default function WeatherWidget({
|
|||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-3" align="end">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">표시 항목</h4>
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">표시 항목</h4>
|
||||
{weatherItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
|
|
@ -407,8 +403,8 @@ export default function WeatherWidget({
|
|||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors',
|
||||
selectedItems.includes(item.id)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
|
|
@ -439,31 +435,31 @@ export default function WeatherWidget({
|
|||
{/* 반응형 그리드 레이아웃 - 자동 조정 */}
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
|
||||
{/* 날씨 아이콘 및 온도 */}
|
||||
<div className="bg-white/50 rounded-lg p-3">
|
||||
<div className="bg-muted/80 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-shrink-0">
|
||||
{(() => {
|
||||
const iconClass = "h-5 w-5";
|
||||
switch (weather.weatherMain.toLowerCase()) {
|
||||
case 'clear':
|
||||
return <Sun className={`${iconClass} text-yellow-500`} />;
|
||||
return <Sun className={`${iconClass} text-warning`} />;
|
||||
case 'clouds':
|
||||
return <Cloud className={`${iconClass} text-gray-400`} />;
|
||||
return <Cloud className={`${iconClass} text-muted-foreground`} />;
|
||||
case 'rain':
|
||||
case 'drizzle':
|
||||
return <CloudRain className={`${iconClass} text-blue-500`} />;
|
||||
return <CloudRain className={`${iconClass} text-primary`} />;
|
||||
case 'snow':
|
||||
return <CloudSnow className={`${iconClass} text-blue-300`} />;
|
||||
return <CloudSnow className={`${iconClass} text-primary/70`} />;
|
||||
default:
|
||||
return <Cloud className={`${iconClass} text-gray-400`} />;
|
||||
return <Cloud className={`${iconClass} text-muted-foreground`} />;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-bold text-gray-900 leading-tight truncate">
|
||||
<div className="text-sm font-bold text-foreground leading-tight truncate">
|
||||
{weather.temperature}°C
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 capitalize leading-tight truncate">
|
||||
<p className="text-xs text-muted-foreground capitalize leading-tight truncate">
|
||||
{weather.weatherDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -472,11 +468,11 @@ export default function WeatherWidget({
|
|||
|
||||
{/* 기온 - 선택 가능 */}
|
||||
{selectedItems.includes('temperature') && (
|
||||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||||
<Sun className="h-3.5 w-3.5 text-orange-500 flex-shrink-0" />
|
||||
<div className="flex items-center gap-1.5 bg-muted/80 rounded-lg p-3">
|
||||
<Sun className="h-3.5 w-3.5 text-warning flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-400 leading-tight truncate">기온</p>
|
||||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||||
<p className="text-xs text-muted-foreground leading-tight truncate">기온</p>
|
||||
<p className="text-sm font-semibold text-foreground leading-tight truncate">
|
||||
{weather.temperature}°C
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -485,11 +481,11 @@ export default function WeatherWidget({
|
|||
|
||||
{/* 체감 온도 */}
|
||||
{selectedItems.includes('feelsLike') && (
|
||||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||||
<Wind className="h-3.5 w-3.5 text-blue-500 flex-shrink-0" />
|
||||
<div className="flex items-center gap-1.5 bg-muted/80 rounded-lg p-3">
|
||||
<Wind className="h-3.5 w-3.5 text-primary flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-400 leading-tight truncate">체감온도</p>
|
||||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||||
<p className="text-xs text-muted-foreground leading-tight truncate">체감온도</p>
|
||||
<p className="text-sm font-semibold text-foreground leading-tight truncate">
|
||||
{weather.feelsLike}°C
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -498,11 +494,11 @@ export default function WeatherWidget({
|
|||
|
||||
{/* 습도 */}
|
||||
{selectedItems.includes('humidity') && (
|
||||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||||
<Droplets className="h-3.5 w-3.5 text-blue-500 flex-shrink-0" />
|
||||
<div className="flex items-center gap-1.5 bg-muted/80 rounded-lg p-3">
|
||||
<Droplets className="h-3.5 w-3.5 text-primary flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-400 leading-tight truncate">습도</p>
|
||||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||||
<p className="text-xs text-muted-foreground leading-tight truncate">습도</p>
|
||||
<p className="text-sm font-semibold text-foreground leading-tight truncate">
|
||||
{weather.humidity}%
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -511,11 +507,11 @@ export default function WeatherWidget({
|
|||
|
||||
{/* 풍속 */}
|
||||
{selectedItems.includes('windSpeed') && (
|
||||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||||
<Wind className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
<div className="flex items-center gap-1.5 bg-muted/80 rounded-lg p-3">
|
||||
<Wind className="h-3.5 w-3.5 text-success flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-400 leading-tight truncate">풍속</p>
|
||||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||||
<p className="text-xs text-muted-foreground leading-tight truncate">풍속</p>
|
||||
<p className="text-sm font-semibold text-foreground leading-tight truncate">
|
||||
{weather.windSpeed} m/s
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -524,11 +520,11 @@ export default function WeatherWidget({
|
|||
|
||||
{/* 기압 */}
|
||||
{selectedItems.includes('pressure') && (
|
||||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 bg-muted/80 rounded-lg p-3">
|
||||
<Gauge className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-400 leading-tight truncate">기압</p>
|
||||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||||
<p className="text-xs text-muted-foreground leading-tight truncate">기압</p>
|
||||
<p className="text-sm font-semibold text-foreground leading-tight truncate">
|
||||
{weather.pressure} hPa
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -77,10 +77,10 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
|
||||
if (isLoading && data.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
<div className="mt-2 text-sm text-gray-600">로딩 중...</div>
|
||||
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<div className="mt-2 text-sm text-foreground">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -88,14 +88,14 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50 p-4">
|
||||
<div className="flex h-full items-center justify-center bg-muted p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||||
{!element.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
||||
<div className="text-sm font-medium text-foreground">{error}</div>
|
||||
{!element.dataSource?.query && <div className="mt-2 text-xs text-muted-foreground">쿼리를 설정하세요</div>}
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
className="mt-3 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -105,7 +105,7 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
{/* 필터 */}
|
||||
<div className="flex gap-2 border-b p-3">
|
||||
<select
|
||||
|
|
@ -134,7 +134,7 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="ml-auto rounded bg-blue-500 px-3 py-1 text-sm text-white hover:bg-blue-600"
|
||||
className="ml-auto rounded bg-primary px-3 py-1 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
🔄 새로고침
|
||||
</button>
|
||||
|
|
@ -143,7 +143,7 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-gray-50 text-left">
|
||||
<thead className="sticky top-0 bg-muted text-left">
|
||||
<tr>
|
||||
<th className="border-b px-3 py-2 font-medium">작업번호</th>
|
||||
<th className="border-b px-3 py-2 font-medium">일시</th>
|
||||
|
|
@ -158,7 +158,7 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-gray-500">
|
||||
<td colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||
작업 이력이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -167,7 +167,7 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
.filter((item) => selectedType === "all" || item.work_type === selectedType)
|
||||
.filter((item) => selectedStatus === "all" || item.status === selectedStatus)
|
||||
.map((item, index) => (
|
||||
<tr key={item.id || index} className="border-b hover:bg-gray-50">
|
||||
<tr key={item.id || index} className="border-b hover:bg-muted">
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.work_number}</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.work_date
|
||||
|
|
@ -180,7 +180,7 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
: "-"}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
<span className="rounded bg-primary/10 px-2 py-1 text-xs font-medium text-primary">
|
||||
{WORK_TYPE_LABELS[item.work_type as WorkType] || item.work_type}
|
||||
</span>
|
||||
</td>
|
||||
|
|
@ -194,7 +194,7 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${WORK_STATUS_COLORS[item.status as WorkStatus] || "bg-gray-100 text-gray-800"}`}
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${WORK_STATUS_COLORS[item.status as WorkStatus] || "bg-muted text-foreground"}`}
|
||||
>
|
||||
{WORK_STATUS_LABELS[item.status as WorkStatus] || item.status}
|
||||
</span>
|
||||
|
|
@ -207,7 +207,7 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t bg-gray-50 px-3 py-2 text-xs text-gray-600">전체 {data.length}건</div>
|
||||
<div className="border-t bg-muted px-3 py-2 text-xs text-foreground">전체 {data.length}건</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,10 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"docx-preview": "^0.3.6",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"isomorphic-dompurify": "^2.28.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
|
|
@ -5585,6 +5588,19 @@
|
|||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
|
||||
|
|
@ -6638,6 +6654,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
|
|
@ -6851,6 +6876,26 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
|
|
@ -7077,6 +7122,18 @@
|
|||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.46.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
|
||||
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
|
|
@ -7127,6 +7184,15 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||
|
|
@ -8785,6 +8851,23 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-png/node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
|
|
@ -9306,6 +9389,25 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
|
|
@ -9447,6 +9549,12 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
|
|
@ -10028,6 +10136,23 @@
|
|||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
|
||||
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jsts": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/jsts/-/jsts-2.7.1.tgz",
|
||||
|
|
@ -11052,6 +11177,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -11403,6 +11535,16 @@
|
|||
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
|
||||
|
|
@ -11823,6 +11965,13 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
|
|
@ -11911,6 +12060,16 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
|
|
@ -12333,6 +12492,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/stats-gl": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
|
||||
|
|
@ -12576,6 +12745,16 @@
|
|||
"react": ">=17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sweepline-intersections": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz",
|
||||
|
|
@ -12655,6 +12834,15 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
|
|
@ -13171,6 +13359,15 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -60,7 +60,10 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"docx-preview": "^0.3.6",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"isomorphic-dompurify": "^2.28.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
|
|
|
|||
Loading…
Reference in New Issue