대시보드 기타 수정 #167
|
|
@ -0,0 +1,332 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { Dashboard } from "@/lib/api/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
||||
|
||||
interface DashboardListClientProps {
|
||||
initialDashboards: Dashboard[];
|
||||
initialPagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 목록 클라이언트 컴포넌트
|
||||
* - 대시보드 목록 조회
|
||||
* - 대시보드 생성/수정/삭제/복사
|
||||
*/
|
||||
export default function DashboardListClient({ initialDashboards, initialPagination }: DashboardListClientProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>(initialDashboards);
|
||||
const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 페이지네이션 상태
|
||||
const [currentPage, setCurrentPage] = useState(initialPagination.page);
|
||||
const [pageSize, setPageSize] = useState(initialPagination.limit);
|
||||
const [totalCount, setTotalCount] = useState(initialPagination.total);
|
||||
|
||||
// 모달 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
||||
|
||||
// 대시보드 목록 로드
|
||||
const loadDashboards = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await dashboardApi.getMyDashboards({
|
||||
search: searchTerm,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
});
|
||||
setDashboards(result.dashboards);
|
||||
setTotalCount(result.pagination.total);
|
||||
} catch (err) {
|
||||
console.error("Failed to load dashboards:", err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "대시보드 목록을 불러오는데 실패했습니다. 네트워크 연결을 확인하거나 잠시 후 다시 시도해주세요.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로드 여부 추적
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 초기 로드는 건너뛰기 (서버에서 이미 데이터를 가져왔음)
|
||||
if (isInitialLoad) {
|
||||
setIsInitialLoad(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 이후 검색어/페이지 변경 시에만 fetch
|
||||
loadDashboards();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchTerm, currentPage, pageSize]);
|
||||
|
||||
// 페이지네이션 정보 계산
|
||||
const paginationInfo: PaginationInfo = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
totalItems: totalCount,
|
||||
itemsPerPage: pageSize,
|
||||
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||||
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||
};
|
||||
|
||||
// 페이지 변경 핸들러
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// 페이지 크기 변경 핸들러
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||
};
|
||||
|
||||
// 대시보드 삭제 확인 모달 열기
|
||||
const handleDeleteClick = (id: string, title: string) => {
|
||||
setDeleteTarget({ id, title });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 대시보드 삭제 실행
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
try {
|
||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTarget(null);
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "대시보드가 삭제되었습니다.",
|
||||
});
|
||||
loadDashboards();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete dashboard:", err);
|
||||
setDeleteDialogOpen(false);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 삭제에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 대시보드 복사
|
||||
const handleCopy = async (dashboard: Dashboard) => {
|
||||
try {
|
||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||
|
||||
await dashboardApi.createDashboard({
|
||||
title: `${fullDashboard.title} (복사본)`,
|
||||
description: fullDashboard.description,
|
||||
elements: fullDashboard.elements || [],
|
||||
isPublic: false,
|
||||
tags: fullDashboard.tags,
|
||||
category: fullDashboard.category,
|
||||
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||
});
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "대시보드가 복사되었습니다.",
|
||||
});
|
||||
loadDashboards();
|
||||
} catch (err) {
|
||||
console.error("Failed to copy dashboard:", err);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 포맷팅 헬퍼
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 검색 및 액션 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="대시보드 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 대시보드 목록 */}
|
||||
{loading ? (
|
||||
<div className="bg-card flex h-64 items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">로딩 중...</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">대시보드 목록을 불러오고 있습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<AlertCircle className="text-destructive h-8 w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-destructive mb-2 text-lg font-semibold">데이터를 불러올 수 없습니다</h3>
|
||||
<p className="text-destructive/80 max-w-md text-sm">{error}</p>
|
||||
</div>
|
||||
<Button onClick={loadDashboards} variant="outline" className="mt-2 gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : dashboards.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboards.map((dashboard) => (
|
||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||
className="gap-2 text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && dashboards.length > 0 && (
|
||||
<Pagination
|
||||
paginationInfo={paginationInfo}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
showPageSizeSelector={true}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">대시보드 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,307 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { Dashboard } from "@/lib/api/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical } from "lucide-react";
|
||||
import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
/**
|
||||
* 대시보드 관리 페이지
|
||||
* - 대시보드 목록 조회
|
||||
* - 대시보드 생성/수정/삭제/복사
|
||||
* 서버에서 초기 대시보드 목록 fetch
|
||||
*/
|
||||
export default function DashboardListPage() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
async function getInitialDashboards() {
|
||||
try {
|
||||
// 서버 사이드 전용: 백엔드 API 직접 호출
|
||||
// 도커 네트워크 내부에서는 서비스 이름 사용, 로컬에서는 127.0.0.1
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://backend:8080";
|
||||
|
||||
// 페이지네이션 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
// 쿠키에서 authToken 추출
|
||||
const cookieStore = await cookies();
|
||||
const authToken = cookieStore.get("authToken")?.value;
|
||||
|
||||
// 모달 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
||||
|
||||
// 대시보드 목록 로드
|
||||
const loadDashboards = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await dashboardApi.getMyDashboards({
|
||||
search: searchTerm,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
});
|
||||
setDashboards(result.dashboards);
|
||||
setTotalCount(result.pagination.total);
|
||||
} catch (err) {
|
||||
console.error("Failed to load dashboards:", err);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 목록을 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!authToken) {
|
||||
// 토큰이 없으면 빈 데이터 반환 (클라이언트에서 로드)
|
||||
return {
|
||||
dashboards: [],
|
||||
pagination: { total: 0, page: 1, limit: 10 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboards();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchTerm, currentPage, pageSize]);
|
||||
|
||||
// 페이지네이션 정보 계산
|
||||
const paginationInfo: PaginationInfo = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
totalItems: totalCount,
|
||||
itemsPerPage: pageSize,
|
||||
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||||
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||
};
|
||||
|
||||
// 페이지 변경 핸들러
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// 페이지 크기 변경 핸들러
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||
};
|
||||
|
||||
// 대시보드 삭제 확인 모달 열기
|
||||
const handleDeleteClick = (id: string, title: string) => {
|
||||
setDeleteTarget({ id, title });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 대시보드 삭제 실행
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
try {
|
||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTarget(null);
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "대시보드가 삭제되었습니다.",
|
||||
});
|
||||
loadDashboards();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete dashboard:", err);
|
||||
setDeleteDialogOpen(false);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 삭제에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 대시보드 복사
|
||||
const handleCopy = async (dashboard: Dashboard) => {
|
||||
try {
|
||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||
|
||||
await dashboardApi.createDashboard({
|
||||
title: `${fullDashboard.title} (복사본)`,
|
||||
description: fullDashboard.description,
|
||||
elements: fullDashboard.elements || [],
|
||||
isPublic: false,
|
||||
tags: fullDashboard.tags,
|
||||
category: fullDashboard.category,
|
||||
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||
});
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "대시보드가 복사되었습니다.",
|
||||
});
|
||||
loadDashboards();
|
||||
} catch (err) {
|
||||
console.error("Failed to copy dashboard:", err);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "대시보드 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 포맷팅 헬퍼
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
const response = await fetch(`${backendUrl}/api/dashboards/my?page=1&limit=10`, {
|
||||
cache: "no-store", // 항상 최신 데이터
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`, // Authorization 헤더로 전달
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-card flex h-full items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium">로딩 중...</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">대시보드 목록을 불러오고 있습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch dashboards: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
dashboards: data.data || [],
|
||||
pagination: data.pagination || { total: 0, page: 1, limit: 10 },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Server-side fetch error:", error);
|
||||
// 에러 발생 시 빈 데이터 반환 (클라이언트에서 재시도 가능)
|
||||
return {
|
||||
dashboards: [],
|
||||
pagination: { total: 0, page: 1, limit: 10 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 관리 페이지 (서버 컴포넌트)
|
||||
* - 페이지 헤더 + 초기 데이터를 서버에서 렌더링
|
||||
* - 클라이언트 컴포넌트로 초기 데이터 전달
|
||||
*/
|
||||
export default async function DashboardListPage() {
|
||||
const initialData = await getInitialDashboards();
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
{/* 페이지 헤더 (서버에서 렌더링) */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
||||
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 액션 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="대시보드 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 대시보드 목록 */}
|
||||
{dashboards.length === 0 ? (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card rounded-lg border shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboards.map((dashboard) => (
|
||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||
{formatDate(dashboard.updatedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||
className="gap-2 text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && dashboards.length > 0 && (
|
||||
<Pagination
|
||||
paginationInfo={paginationInfo}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
showPageSizeSelector={true}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
{/* 나머지 컨텐츠 (클라이언트 컴포넌트 + 서버 데이터) */}
|
||||
<DashboardListClient initialDashboards={initialData.dashboards} initialPagination={initialData.pagination} />
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">대시보드 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-background p-3 rounded-lg shadow-lg z-50 flex gap-3">
|
||||
<button
|
||||
onClick={onClearCanvas}
|
||||
className="
|
||||
px-4 py-2 border border-border bg-background rounded-md
|
||||
text-sm font-medium text-foreground
|
||||
hover:bg-muted hover:border-border/80
|
||||
transition-colors duration-200
|
||||
"
|
||||
>
|
||||
🗑️ 전체 삭제
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSaveLayout}
|
||||
className="
|
||||
px-4 py-2 border border-border bg-background rounded-md
|
||||
text-sm font-medium text-foreground
|
||||
hover:bg-muted hover:border-border/80
|
||||
transition-colors duration-200
|
||||
"
|
||||
>
|
||||
💾 레이아웃 저장
|
||||
</button>
|
||||
|
||||
{/* 캔버스 배경색 변경 버튼 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
className="
|
||||
px-4 py-2 border border-border bg-background rounded-md
|
||||
text-sm font-medium text-foreground
|
||||
hover:bg-muted hover:border-border/80
|
||||
transition-colors duration-200
|
||||
flex items-center gap-2
|
||||
"
|
||||
>
|
||||
🎨 캔버스 색상
|
||||
<div
|
||||
className="w-4 h-4 rounded border border-border"
|
||||
style={{ backgroundColor: canvasBackgroundColor }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 색상 선택 패널 */}
|
||||
{showColorPicker && (
|
||||
<div className="absolute top-full left-0 mt-2 bg-background p-4 rounded-lg shadow-xl z-50 border border-border 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-border 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-border 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-primary ring-2 ring-primary/20' : 'border-border'}`}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowColorPicker(false)}
|
||||
className="w-full px-3 py-1.5 text-sm text-foreground border border-border rounded hover:bg-muted"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,427 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
|
||||
import { QueryEditor } from "./QueryEditor";
|
||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||
import { MapTestConfigPanel } from "./MapTestConfigPanel";
|
||||
import { DataSourceSelector } from "./data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { X, ChevronLeft, ChevronRight, Save } from "lucide-react";
|
||||
|
||||
interface ElementConfigModalProps {
|
||||
element: DashboardElement;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (element: DashboardElement) => void;
|
||||
onPreview?: (element: DashboardElement) => void; // 실시간 미리보기용 (저장 전)
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 설정 모달 컴포넌트 (리팩토링)
|
||||
* - 2단계 플로우: 데이터 소스 선택 → 데이터 설정 및 차트 설정
|
||||
* - 새로운 데이터 소스 컴포넌트 통합
|
||||
*/
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave, onPreview }: ElementConfigModalProps) {
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>(element.chartConfig || {});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
|
||||
const [customTitle, setCustomTitle] = useState<string>(element.customTitle || "");
|
||||
const [showHeader, setShowHeader] = useState<boolean>(element.showHeader !== false);
|
||||
|
||||
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
||||
const isSimpleWidget =
|
||||
element.subtype === "todo" || // To-Do 위젯
|
||||
element.subtype === "booking-alert" || // 예약 알림 위젯
|
||||
element.subtype === "maintenance" || // 정비 일정 위젯
|
||||
element.subtype === "document" || // 문서 위젯
|
||||
element.subtype === "risk-alert" || // 리스크 알림 위젯
|
||||
element.subtype === "vehicle-status" ||
|
||||
element.subtype === "vehicle-list" ||
|
||||
element.subtype === "status-summary" || // 커스텀 상태 카드
|
||||
// element.subtype === "list-summary" || // 커스텀 목록 카드 (다른 분 작업 중 - 임시 주석)
|
||||
element.subtype === "delivery-status" ||
|
||||
element.subtype === "delivery-status-summary" ||
|
||||
element.subtype === "delivery-today-stats" ||
|
||||
element.subtype === "cargo-list" ||
|
||||
element.subtype === "customer-issues" ||
|
||||
element.subtype === "driver-management" ||
|
||||
element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요)
|
||||
element.subtype === "transport-stats"; // 커스텀 통계 카드 위젯 (쿼리 필요)
|
||||
|
||||
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
||||
const isSelfContainedWidget =
|
||||
element.subtype === "weather" || // 날씨 위젯 (외부 API)
|
||||
element.subtype === "exchange" || // 환율 위젯 (외부 API)
|
||||
element.subtype === "calculator"; // 계산기 위젯 (자체 기능)
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test";
|
||||
|
||||
// 주석
|
||||
// 모달이 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const dataSourceToSet = element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 };
|
||||
setDataSource(dataSourceToSet);
|
||||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setCurrentStep(1);
|
||||
setCustomTitle(element.customTitle || "");
|
||||
setShowHeader(element.showHeader !== false); // showHeader 초기화
|
||||
|
||||
// 쿼리가 이미 있으면 자동 실행
|
||||
if (dataSourceToSet.type === "database" && dataSourceToSet.query) {
|
||||
console.log("🔄 기존 쿼리 자동 실행:", dataSourceToSet.query);
|
||||
executeQueryAutomatically(dataSourceToSet);
|
||||
}
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
// 쿼리 자동 실행 함수
|
||||
const executeQueryAutomatically = async (dataSourceToExecute: ChartDataSource) => {
|
||||
if (dataSourceToExecute.type !== "database" || !dataSourceToExecute.query) return;
|
||||
|
||||
try {
|
||||
const { queryApi } = await import("@/lib/api/query");
|
||||
const result = await queryApi.executeQuery({
|
||||
query: dataSourceToExecute.query,
|
||||
connectionType: dataSourceToExecute.connectionType || "current",
|
||||
externalConnectionId: dataSourceToExecute.externalConnectionId,
|
||||
});
|
||||
|
||||
console.log("✅ 쿼리 자동 실행 완료:", result);
|
||||
setQueryResult(result);
|
||||
} catch (error) {
|
||||
console.error("❌ 쿼리 자동 실행 실패:", error);
|
||||
// 실패해도 모달은 열리도록 (사용자가 다시 실행 가능)
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource({
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
} else {
|
||||
setDataSource({
|
||||
type: "api",
|
||||
method: "GET",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 데이터 소스 변경 시 쿼리 결과와 차트 설정 초기화
|
||||
setQueryResult(null);
|
||||
setChartConfig({});
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 차트 설정 변경 처리
|
||||
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
|
||||
// 🎯 실시간 미리보기: chartConfig 변경 시 즉시 부모에게 전달
|
||||
if (onPreview) {
|
||||
onPreview({
|
||||
...element,
|
||||
chartConfig: newConfig,
|
||||
dataSource: dataSource,
|
||||
customTitle: customTitle,
|
||||
showHeader: showHeader,
|
||||
});
|
||||
}
|
||||
}, [element, dataSource, customTitle, showHeader, onPreview]);
|
||||
|
||||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
|
||||
// 쿼리가 변경되었으므로 차트 설정 초기화 (X/Y축 리셋)
|
||||
// console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화");
|
||||
setChartConfig({});
|
||||
}, []);
|
||||
|
||||
// 다음 단계로 이동
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 1) {
|
||||
setCurrentStep(2);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// 이전 단계로 이동
|
||||
const handlePrev = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(() => {
|
||||
const updatedElement: DashboardElement = {
|
||||
...element,
|
||||
dataSource,
|
||||
chartConfig,
|
||||
customTitle: customTitle.trim() || undefined, // 빈 문자열이면 undefined
|
||||
showHeader, // 헤더 표시 여부
|
||||
};
|
||||
|
||||
// console.log(" 저장할 element:", updatedElement);
|
||||
|
||||
onSave(updatedElement);
|
||||
onClose();
|
||||
}, [element, dataSource, chartConfig, customTitle, showHeader, onSave, onClose]);
|
||||
|
||||
// 모달이 열려있지 않으면 렌더링하지 않음
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 시계, 달력, 날씨, 환율, 계산기 위젯은 헤더 설정만 가능
|
||||
const isHeaderOnlyWidget =
|
||||
element.type === "widget" &&
|
||||
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
|
||||
|
||||
// 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
|
||||
if (element.type === "widget" && element.subtype === "driver-management") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 저장 가능 여부 확인
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = dataSource.type === "api";
|
||||
|
||||
// Y축 검증 헬퍼
|
||||
const hasYAxis =
|
||||
chartConfig.yAxis &&
|
||||
(typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
||||
|
||||
// customTitle이 변경되었는지 확인
|
||||
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
|
||||
|
||||
// showHeader가 변경되었는지 확인
|
||||
const isHeaderChanged = showHeader !== (element.showHeader !== false);
|
||||
|
||||
const canSave =
|
||||
isTitleChanged || // 제목만 변경해도 저장 가능
|
||||
isHeaderChanged || // 헤더 표시 여부만 변경해도 저장 가능
|
||||
(isSimpleWidget
|
||||
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 (차트 설정 불필요)
|
||||
currentStep === 2 && queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? // 지도 위젯: 타일맵 URL 또는 위도/경도 매핑 필요
|
||||
element.subtype === "map-test"
|
||||
? // 🧪 지도 테스트 위젯: 타일맵 URL만 있으면 저장 가능
|
||||
currentStep === 2 && chartConfig.tileMapUrl
|
||||
: // 기존 지도 위젯: 쿼리 결과 + 위도/경도 필수
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.latitudeColumn &&
|
||||
chartConfig.longitudeColumn
|
||||
: // 차트: 기존 로직 (2단계에서 차트 설정 필요)
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource
|
||||
? // 파이/도넛 차트 또는 REST API
|
||||
chartConfig.aggregation === "count"
|
||||
? true // count는 Y축 없어도 됨
|
||||
: hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수
|
||||
: // 일반 차트 (DB): Y축 필수
|
||||
hasYAxis));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className={`flex flex-col rounded-xl border bg-background 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-foreground">{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-foreground">위젯 제목 (선택사항)</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-border px-3 py-2 text-sm focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
비워두면 테이블명으로 자동 생성됩니다 (예: "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-border"
|
||||
/>
|
||||
<label htmlFor="showHeader" className="text-sm font-medium text-foreground">
|
||||
위젯 헤더 표시 (제목 + 새로고침 버튼)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행 상황 표시 - 간단한 위젯과 헤더 전용 위젯은 표시 안 함 */}
|
||||
{!isSimpleWidget && !isHeaderOnlyWidget && (
|
||||
<div className="border-b bg-muted px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
단계 {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-border bg-muted p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">데이터를 가져온 후 지도 설정이 표시됩니다</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-border bg-muted p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex items-center justify-between border-t bg-muted p-6">
|
||||
<div>{queryResult && <Badge variant="default">{queryResult.rows.length}개 데이터 로드됨</Badge>}</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
{isHeaderOnlyWidget ? (
|
||||
// 헤더 전용 위젯: 바로 저장
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
) : currentStep === 1 ? (
|
||||
// 1단계: 다음 버튼 (차트 위젯, 간단한 위젯 모두)
|
||||
<Button onClick={handleNext}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
// 2단계: 저장 버튼
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,10 @@ import { X } from "lucide-react";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import CustomMetricConfigSidebar from "./widgets/custom-metric/CustomMetricConfigSidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface ElementConfigSidebarProps {
|
||||
element: DashboardElement | null;
|
||||
|
|
@ -50,16 +54,11 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
// 사이드바가 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen && element) {
|
||||
console.log("🔄 ElementConfigSidebar 초기화 - element.id:", element.id);
|
||||
console.log("🔄 element.dataSources:", element.dataSources);
|
||||
console.log("🔄 element.chartConfig?.dataSources:", element.chartConfig?.dataSources);
|
||||
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
|
||||
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
||||
// ⚠️ 중요: 없으면 반드시 빈 배열로 초기화
|
||||
const initialDataSources = element.dataSources || element.chartConfig?.dataSources || [];
|
||||
console.log("🔄 초기화된 dataSources:", initialDataSources);
|
||||
setDataSources(initialDataSources);
|
||||
|
||||
setChartConfig(element.chartConfig || {});
|
||||
|
|
@ -69,7 +68,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
setShowHeader(element.showHeader !== false);
|
||||
} else if (!isOpen) {
|
||||
// 사이드바가 닫힐 때 모든 상태 초기화
|
||||
console.log("🧹 ElementConfigSidebar 닫힘 - 상태 초기화");
|
||||
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
setDataSources([]);
|
||||
setChartConfig({});
|
||||
|
|
@ -124,8 +122,8 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
(newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
|
||||
// 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-test 위젯용)
|
||||
if (element && element.subtype === "map-test" && newConfig.tileMapUrl) {
|
||||
// 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-summary-v2 위젯용)
|
||||
if (element && element.subtype === "map-summary-v2" && newConfig.tileMapUrl) {
|
||||
onApply({
|
||||
...element,
|
||||
chartConfig: newConfig,
|
||||
|
|
@ -148,10 +146,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
const handleApply = useCallback(() => {
|
||||
if (!element) return;
|
||||
|
||||
console.log("🔧 적용 버튼 클릭 - dataSource:", dataSource);
|
||||
console.log("🔧 적용 버튼 클릭 - dataSources:", dataSources);
|
||||
console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig);
|
||||
|
||||
// 다중 데이터 소스 위젯 체크
|
||||
const isMultiDS =
|
||||
element.subtype === "map-summary-v2" ||
|
||||
|
|
@ -170,7 +164,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
showHeader,
|
||||
};
|
||||
|
||||
console.log("🔧 적용할 요소:", updatedElement);
|
||||
onApply(updatedElement);
|
||||
// 사이드바는 열린 채로 유지 (연속 수정 가능)
|
||||
}, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]);
|
||||
|
|
@ -179,7 +172,7 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
if (!element) return null;
|
||||
|
||||
// 리스트 위젯은 별도 사이드바로 처리
|
||||
if (element.subtype === "list") {
|
||||
if (element.subtype === "list-v2") {
|
||||
return (
|
||||
<ListWidgetConfigSidebar
|
||||
element={element}
|
||||
|
|
@ -207,7 +200,7 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
}
|
||||
|
||||
// 사용자 커스텀 카드 위젯은 사이드바로 처리
|
||||
if (element.subtype === "custom-metric") {
|
||||
if (element.subtype === "custom-metric-v2") {
|
||||
return (
|
||||
<CustomMetricConfigSidebar
|
||||
element={element}
|
||||
|
|
@ -226,7 +219,6 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
element.subtype === "booking-alert" ||
|
||||
element.subtype === "maintenance" ||
|
||||
element.subtype === "document" ||
|
||||
element.subtype === "risk-alert" ||
|
||||
element.subtype === "vehicle-status" ||
|
||||
element.subtype === "vehicle-list" ||
|
||||
element.subtype === "status-summary" ||
|
||||
|
|
@ -244,8 +236,7 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget =
|
||||
element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test";
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary-v2";
|
||||
|
||||
// 헤더 전용 위젯
|
||||
const isHeaderOnlyWidget =
|
||||
|
|
@ -254,12 +245,11 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
|
||||
// 다중 데이터 소스 위젯
|
||||
const isMultiDataSourceWidget =
|
||||
element.subtype === "map-summary-v2" ||
|
||||
element.subtype === "chart" ||
|
||||
element.subtype === "list-v2" ||
|
||||
element.subtype === "custom-metric-v2" ||
|
||||
element.subtype === "status-summary-test" ||
|
||||
element.subtype === "risk-alert-v2";
|
||||
(element.subtype as string) === "map-summary-v2" ||
|
||||
(element.subtype as string) === "chart" ||
|
||||
(element.subtype as string) === "list-v2" ||
|
||||
(element.subtype as string) === "custom-metric-v2" ||
|
||||
(element.subtype as string) === "risk-alert-v2";
|
||||
|
||||
// 저장 가능 여부 확인
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
|
|
@ -280,8 +270,8 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
: isSimpleWidget
|
||||
? queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? element.subtype === "map-test"
|
||||
? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 🧪 지도 테스트 위젯: 타일맵 URL 또는 API 데이터
|
||||
? element.subtype === "map-summary-v2"
|
||||
? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 지도 위젯: 타일맵 URL 또는 API 데이터
|
||||
: queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
|
||||
: queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
|
|
@ -291,62 +281,58 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-72 flex-col bg-muted 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-background 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-foreground">{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-muted"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</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-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground 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-border bg-muted px-2 text-xs placeholder:text-muted-foreground focus:bg-background 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-border bg-muted px-2 py-1.5 transition-colors hover:border-border">
|
||||
<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-border"
|
||||
onCheckedChange={(checked) => setShowHeader(checked === true)}
|
||||
/>
|
||||
<span className="text-xs text-foreground">헤더 표시</span>
|
||||
</label>
|
||||
<Label htmlFor="showHeader" className="cursor-pointer text-xs font-normal">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다중 데이터 소스 위젯 */}
|
||||
{isMultiDataSourceWidget && (
|
||||
<>
|
||||
<div className="rounded-lg bg-background 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-background 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-muted">
|
||||
<summary className="hover:bg-muted flex cursor-pointer items-center justify-between p-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-wide text-muted-foreground 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-background 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-muted">
|
||||
<summary className="hover:bg-muted flex cursor-pointer items-center justify-between p-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-wide text-muted-foreground 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-background p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-muted-foreground 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-muted 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-background 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-background 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-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 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-background p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded bg-muted py-2 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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-background 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-foreground">데이터 소스와 컬럼을 설정하세요</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-muted">
|
||||
<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-primary/10 p-2 text-xs text-primary">💡 리스트 위젯은 제목이 항상 표시됩니다</div>
|
||||
</div>
|
||||
|
||||
{/* 진행 상태 표시 */}
|
||||
<div className="border-b bg-muted 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-primary" : "text-muted-foreground"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 1 ? "bg-primary text-white" : "bg-muted"}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="text-sm font-medium">데이터 소스</span>
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-muted" />
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 2 ? "text-primary" : "text-muted-foreground"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 2 ? "bg-primary text-white" : "bg-muted"}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="text-sm font-medium">데이터 가져오기</span>
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-muted" />
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 3 ? "text-primary" : "text-muted-foreground"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 3 ? "bg-primary text-white" : "bg-muted"}`}
|
||||
>
|
||||
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-muted p-4">
|
||||
<h3 className="mb-3 font-semibold text-foreground">📋 데이터 미리보기</h3>
|
||||
<div className="overflow-x-auto rounded bg-background p-3">
|
||||
<Badge variant="secondary" className="mb-2">
|
||||
{queryResult.totalRows}개 데이터
|
||||
</Badge>
|
||||
<pre className="text-xs text-foreground">
|
||||
{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-border bg-muted p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">데이터를 가져온 후 미리보기가 표시됩니다</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-muted p-6">
|
||||
<div>
|
||||
{queryResult && (
|
||||
<Badge variant="default" className="bg-success">
|
||||
📊 {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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-background shadow-xl">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground">일정관리 위젯 설정</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
데이터 소스와 쿼리를 설정하면 자동으로 일정 목록이 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 진행 상태 */}
|
||||
<div className="border-b border-border bg-muted px-6 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center gap-2 ${currentStep === 1 ? "text-primary" : "text-muted-foreground"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full font-semibold ${
|
||||
currentStep === 1 ? "bg-primary text-white" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="font-medium">데이터 소스 선택</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
<div className={`flex items-center gap-2 ${currentStep === 2 ? "text-primary" : "text-muted-foreground"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full font-semibold ${
|
||||
currentStep === 2 ? "bg-primary text-white" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
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-primary/10 p-4">
|
||||
<h3 className="mb-2 font-semibold text-primary">💡 컬럼명 가이드</h3>
|
||||
<p className="mb-2 text-sm text-primary">
|
||||
쿼리 결과에 다음 컬럼명이 있으면 자동으로 일정 항목으로 변환됩니다:
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm text-primary">
|
||||
<li>
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">id</code> - 고유 ID (없으면 자동 생성)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">title</code>,{" "}
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">task</code>,{" "}
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">name</code> - 제목 (필수)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">description</code>,{" "}
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">desc</code>,{" "}
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">content</code> - 상세 설명
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">priority</code> - 우선순위 (urgent, high,
|
||||
normal, low)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">status</code> - 상태 (pending, in_progress,
|
||||
completed)
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">assigned_to</code>,{" "}
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">assignedTo</code>,{" "}
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">user</code> - 담당자
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">due_date</code>,{" "}
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">dueDate</code>,{" "}
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">deadline</code> - 마감일
|
||||
</li>
|
||||
<li>
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">is_urgent</code>,{" "}
|
||||
<code className="rounded bg-primary/10 px-1 py-0.5">isUrgent</code>,{" "}
|
||||
<code className="rounded bg-primary/10 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-warning/10 border-2 border-warning p-4">
|
||||
<p className="text-sm font-bold text-warning">
|
||||
🔍 디버그: queryResult 상태 = {queryResult ? "있음" : "없음"}
|
||||
</p>
|
||||
{queryResult && (
|
||||
<p className="text-xs text-warning 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-success/10 border-2 border-success p-4">
|
||||
<h3 className="mb-2 font-semibold text-success">✅ 쿼리 테스트 성공!</h3>
|
||||
<p className="text-sm text-success">
|
||||
총 <strong>{queryResult.rows.length}개</strong>의 일정 항목을 찾았습니다.
|
||||
</p>
|
||||
<div className="mt-3 rounded bg-background p-3">
|
||||
<p className="mb-2 text-xs font-semibold text-foreground">첫 번째 데이터 미리보기:</p>
|
||||
<pre className="overflow-x-auto text-xs text-foreground">
|
||||
{JSON.stringify(queryResult.rows[0], null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터베이스 연동 쿼리 (선택사항) */}
|
||||
<div className="mt-6 space-y-4 rounded-lg border-2 border-purple-500 bg-purple-500/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-purple-700">🔗 데이터베이스 연동 (선택사항)</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-500/50"
|
||||
/>
|
||||
<span className="text-sm font-medium text-purple-700">활성화</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-500 text-white"
|
||||
: "bg-background text-purple-500 hover:bg-purple-500/10"
|
||||
}`}
|
||||
>
|
||||
간편 모드
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDbSyncMode("advanced")}
|
||||
className={`flex-1 rounded px-4 py-2 text-sm font-medium transition-colors ${
|
||||
dbSyncMode === "advanced"
|
||||
? "bg-purple-500 text-white"
|
||||
: "bg-background text-purple-500 hover:bg-purple-500/10"
|
||||
}`}
|
||||
>
|
||||
고급 모드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 간편 모드 */}
|
||||
{dbSyncMode === "simple" && (
|
||||
<div className="space-y-4 rounded-lg border border-purple-500/50 bg-background p-4">
|
||||
<p className="text-sm text-purple-700">
|
||||
테이블명과 컬럼 매핑만 입력하면 자동으로 INSERT/UPDATE/DELETE 쿼리가 생성됩니다.
|
||||
</p>
|
||||
|
||||
{/* 테이블명 */}
|
||||
<div>
|
||||
<Label className="text-sm font-semibold text-purple-700">테이블명 *</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-700">컬럼 매핑</Label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-foreground">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-foreground">제목 컬럼</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-foreground">설명 컬럼</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-foreground">우선순위 컬럼</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-foreground">상태 컬럼</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-foreground">담당자 컬럼</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-foreground">마감일 컬럼</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-foreground">긴급 여부 컬럼</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-700">INSERT 쿼리 (추가)</Label>
|
||||
<p className="mb-2 text-xs text-purple-500">
|
||||
사용 가능한 변수: ${"{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-500/50 bg-background 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-700">UPDATE 쿼리 (상태 변경)</Label>
|
||||
<p className="mb-2 text-xs text-purple-500">
|
||||
사용 가능한 변수: ${"{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-500/50 bg-background 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-700">DELETE 쿼리 (삭제)</Label>
|
||||
<p className="mb-2 text-xs text-purple-500">
|
||||
사용 가능한 변수: ${"{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-500/50 bg-background 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-border 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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-muted-foreground">기본 제목: 야드 관리 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue