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

This commit is contained in:
kjs 2025-10-30 12:10:43 +09:00
commit dea88dd42b
99 changed files with 2942 additions and 3732 deletions

View File

@ -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

View File

@ -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 # 외부에서 생성된 네트워크 사용

View File

@ -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">
&quot;{deleteTarget?.title}&quot; ?
<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>
</>
);
}

View File

@ -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">
&quot;{deleteTarget?.title}&quot; ?
<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>
);
}

View File

@ -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}
/>

View File

@ -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>
);
}
}

View File

@ -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);

View File

@ -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)}
/>
);

View File

@ -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) => (

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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>

View File

@ -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}>

View File

@ -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} / .

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);

View File

@ -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}

View File

@ -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>
)}

View File

@ -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>
)}

View File

@ -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>

View File

@ -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>
)}

View File

@ -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>

View File

@ -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 && (

View File

@ -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>

View File

@ -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 && (

View File

@ -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";

View File

@ -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) => (

View File

@ -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>

View File

@ -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) => (

View File

@ -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>

View File

@ -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",
},
};

View File

@ -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>
)}

View File

@ -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>

View File

@ -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>
)}

View File

@ -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"

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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",
};
}
}

View File

@ -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>

View File

@ -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" })}

View File

@ -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"

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>
)}

View File

@ -647,6 +647,7 @@ export default function Yard3DCanvas({
fov: 50,
}}
shadows
gl={{ preserveDrawingBuffer: true }}
>
<Suspense fallback={null}>
<Scene

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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 ? (
<>

View File

@ -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>
)}

View File

@ -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>

View File

@ -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>

View File

@ -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";
};

View File

@ -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 ? (

View File

@ -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>}

View File

@ -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>

View File

@ -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>

View File

@ -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"
}`}
>

View File

@ -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>

View File

@ -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>
))}

View File

@ -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>

View File

@ -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" />

View File

@ -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>
);

View File

@ -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>
)}

View File

@ -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]);

View File

@ -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>

View File

@ -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]}

View File

@ -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>

View File

@ -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}`) */}
</>
)}
{/* 폴리곤 렌더링 (해상 구역만) */}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"
}`}
>

View File

@ -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"
}`}
>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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" />;
}
};

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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",

View File

@ -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",