메일 관리 작업 저장용 커밋

This commit is contained in:
leeheejin
2025-10-01 16:15:53 +09:00
parent 2a8841c6dc
commit 0209be8fd6
65 changed files with 8636 additions and 2145 deletions

View File

@@ -1,343 +1,212 @@
"use client";
import React, { useState, useEffect } from "react";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { ArrowLeft, ArrowRight, Table, Search, Loader2 } from "lucide-react";
import { toast } from "sonner";
// API import
import { getTablesFromConnection, getBatchTablesWithColumns } from "@/lib/api/multiConnection";
// 타입 import
import { Connection, TableInfo } from "@/lib/types/multiConnection";
import React, { useState } from "react";
import { Table, ArrowLeft, ArrowRight, CheckCircle, Database } from "lucide-react";
import { Connection, TableInfo } from "../types/redesigned";
interface TableStepProps {
fromConnection?: Connection;
toConnection?: Connection;
fromTable?: TableInfo;
toTable?: TableInfo;
onSelectTable: (type: "from" | "to", table: TableInfo) => void;
onFromTableChange: (table: TableInfo) => void;
onToTableChange: (table: TableInfo) => void;
onNext: () => void;
onBack: () => void;
}
/**
* 📋 2단계: 테이블 선택
* - FROM/TO 테이블 선택
* - 테이블 검색 기능
* - 컬럼 수 정보 표시
*/
const TableStep: React.FC<TableStepProps> = ({
// 임시 테이블 데이터
const mockTables: TableInfo[] = [
{
name: "users",
schema: "public",
columns: [
{ name: "id", type: "integer", nullable: false, primaryKey: true },
{ name: "name", type: "varchar", nullable: false, primaryKey: false },
{ name: "email", type: "varchar", nullable: true, primaryKey: false },
{ name: "created_at", type: "timestamp", nullable: false, primaryKey: false }
],
rowCount: 1250
},
{
name: "orders",
schema: "public",
columns: [
{ name: "id", type: "integer", nullable: false, primaryKey: true },
{ name: "user_id", type: "integer", nullable: false, primaryKey: false, foreignKey: true },
{ name: "product_name", type: "varchar", nullable: false, primaryKey: false },
{ name: "amount", type: "decimal", nullable: false, primaryKey: false },
{ name: "order_date", type: "timestamp", nullable: false, primaryKey: false }
],
rowCount: 3420
},
{
name: "products",
schema: "public",
columns: [
{ name: "id", type: "integer", nullable: false, primaryKey: true },
{ name: "name", type: "varchar", nullable: false, primaryKey: false },
{ name: "price", type: "decimal", nullable: false, primaryKey: false },
{ name: "category", type: "varchar", nullable: true, primaryKey: false }
],
rowCount: 156
}
];
export const TableStep: React.FC<TableStepProps> = ({
fromConnection,
toConnection,
fromTable,
toTable,
onSelectTable,
onFromTableChange,
onToTableChange,
onNext,
onBack,
}) => {
const [fromTables, setFromTables] = useState<TableInfo[]>([]);
const [toTables, setToTables] = useState<TableInfo[]>([]);
const [fromSearch, setFromSearch] = useState("");
const [toSearch, setToSearch] = useState("");
const [isLoadingFrom, setIsLoadingFrom] = useState(false);
const [isLoadingTo, setIsLoadingTo] = useState(false);
const [tableColumnCounts, setTableColumnCounts] = useState<Record<string, number>>({});
const [selectedFromTable, setSelectedFromTable] = useState<string>(fromTable?.name || "");
const [selectedToTable, setSelectedToTable] = useState<string>(toTable?.name || "");
// FROM 테이블 목록 로드 (배치 조회)
useEffect(() => {
if (fromConnection) {
const loadFromTables = async () => {
try {
setIsLoadingFrom(true);
console.log("🚀 FROM 테이블 배치 조회 시작");
// 배치 조회로 테이블 정보와 컬럼 수를 한번에 가져오기
const batchResult = await getBatchTablesWithColumns(fromConnection.id);
console.log("✅ FROM 테이블 배치 조회 완료:", batchResult);
// TableInfo 형식으로 변환
const tables: TableInfo[] = batchResult.map((item) => ({
tableName: item.tableName,
displayName: item.displayName || item.tableName,
}));
setFromTables(tables);
// 컬럼 수 정보를 state에 저장
const columnCounts: Record<string, number> = {};
batchResult.forEach((item) => {
columnCounts[`from_${item.tableName}`] = item.columnCount;
});
setTableColumnCounts((prev) => ({
...prev,
...columnCounts,
}));
console.log(`📊 FROM 테이블 ${tables.length}개 로드 완료, 컬럼 수:`, columnCounts);
} catch (error) {
console.error("FROM 테이블 목록 로드 실패:", error);
toast.error("소스 테이블 목록을 불러오는데 실패했습니다.");
} finally {
setIsLoadingFrom(false);
}
};
loadFromTables();
}
}, [fromConnection]);
// TO 테이블 목록 로드 (배치 조회)
useEffect(() => {
if (toConnection) {
const loadToTables = async () => {
try {
setIsLoadingTo(true);
console.log("🚀 TO 테이블 배치 조회 시작");
// 배치 조회로 테이블 정보와 컬럼 수를 한번에 가져오기
const batchResult = await getBatchTablesWithColumns(toConnection.id);
console.log("✅ TO 테이블 배치 조회 완료:", batchResult);
// TableInfo 형식으로 변환
const tables: TableInfo[] = batchResult.map((item) => ({
tableName: item.tableName,
displayName: item.displayName || item.tableName,
}));
setToTables(tables);
// 컬럼 수 정보를 state에 저장
const columnCounts: Record<string, number> = {};
batchResult.forEach((item) => {
columnCounts[`to_${item.tableName}`] = item.columnCount;
});
setTableColumnCounts((prev) => ({
...prev,
...columnCounts,
}));
console.log(`📊 TO 테이블 ${tables.length}개 로드 완료, 컬럼 수:`, columnCounts);
} catch (error) {
console.error("TO 테이블 목록 로드 실패:", error);
toast.error("대상 테이블 목록을 불러오는데 실패했습니다.");
} finally {
setIsLoadingTo(false);
}
};
loadToTables();
}
}, [toConnection]);
// 테이블 필터링
const filteredFromTables = fromTables.filter((table) =>
(table.displayName || table.tableName).toLowerCase().includes(fromSearch.toLowerCase()),
);
const filteredToTables = toTables.filter((table) =>
(table.displayName || table.tableName).toLowerCase().includes(toSearch.toLowerCase()),
);
const handleTableSelect = (type: "from" | "to", tableName: string) => {
const tables = type === "from" ? fromTables : toTables;
const table = tables.find((t) => t.tableName === tableName);
const handleFromTableSelect = (tableName: string) => {
const table = mockTables.find(t => t.name === tableName);
if (table) {
onSelectTable(type, table);
setSelectedFromTable(tableName);
onFromTableChange(table);
}
};
const canProceed = fromTable && toTable;
const renderTableItem = (table: TableInfo, type: "from" | "to") => {
const displayName =
table.displayName && table.displayName !== table.tableName ? table.displayName : table.tableName;
const columnCount = tableColumnCounts[`${type}_${table.tableName}`];
return (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Table className="h-4 w-4" />
<span>{displayName}</span>
</div>
<Badge variant="outline" className="text-xs">
{columnCount !== undefined ? columnCount : table.columnCount || 0}
</Badge>
</div>
);
const handleToTableSelect = (tableName: string) => {
const table = mockTables.find(t => t.name === tableName);
if (table) {
setSelectedToTable(tableName);
onToTableChange(table);
}
};
const canProceed = selectedFromTable && selectedToTable;
return (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Table className="h-5 w-5" />
2단계: 테이블
</CardTitle>
<p className="text-muted-foreground text-sm"> .</p>
</CardHeader>
<div className="space-y-8">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
</h2>
<p className="text-gray-600">
</p>
</div>
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
{/* FROM 테이블 선택 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium">FROM ()</h3>
<Badge variant="outline" className="text-xs">
{fromConnection?.name}
</Badge>
{/* 연결 정보 표시 */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Database className="w-5 h-5 text-blue-600" />
<span className="font-medium text-gray-900">{fromConnection?.name}</span>
<span className="text-sm text-gray-500"></span>
<Database className="w-5 h-5 text-green-600" />
<span className="font-medium text-gray-900">{toConnection?.name}</span>
</div>
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="테이블 검색..."
value={fromSearch}
onChange={(e) => setFromSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* 테이블 선택 */}
{isLoadingFrom ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm"> ...</span>
</div>
) : (
<Select value={fromTable?.tableName || ""} onValueChange={(value) => handleTableSelect("from", value)}>
<SelectTrigger>
<SelectValue placeholder="소스 테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{filteredFromTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{renderTableItem(table, "from")}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{fromTable && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">{fromTable.displayName || fromTable.tableName}</span>
<Badge variant="secondary">
📊 {tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}
</Badge>
</div>
{fromTable.description && <p className="text-muted-foreground text-xs">{fromTable.description}</p>}
</div>
)}
</div>
</div>
{/* TO 테이블 선택 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium">TO ()</h3>
<Badge variant="outline" className="text-xs">
{toConnection?.name}
</Badge>
</div>
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="테이블 검색..."
value={toSearch}
onChange={(e) => setToSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* 테이블 선택 */}
{isLoadingTo ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm"> ...</span>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* FROM 테이블 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-bold">1</span>
</div>
) : (
<Select value={toTable?.tableName || ""} onValueChange={(value) => handleTableSelect("to", value)}>
<SelectTrigger>
<SelectValue placeholder="대상 테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{filteredToTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{renderTableItem(table, "to")}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{toTable && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">{toTable.displayName || toTable.tableName}</span>
<Badge variant="secondary">
📊 {tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}
</Badge>
</div>
{toTable.description && <p className="text-muted-foreground text-xs">{toTable.description}</p>}
</div>
)}
</div>
{/* 테이블 매핑 표시 */}
{fromTable && toTable && (
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
<div className="flex items-center justify-center gap-4">
<div className="text-center">
<div className="font-medium">{fromTable.displayName || fromTable.tableName}</div>
<div className="text-muted-foreground text-xs">
{tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}
<h3 className="text-lg font-semibold text-gray-900"> </h3>
<span className="text-sm text-gray-500">(FROM)</span>
</div>
<div className="space-y-2">
{mockTables.map((table) => (
<div
key={table.name}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
selectedFromTable === table.name
? "border-blue-500 bg-blue-50 shadow-md"
: "border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-25"
}`}
onClick={() => handleFromTableSelect(table.name)}
>
<div className="flex items-center gap-3">
<Table className="w-6 h-6 text-blue-600" />
<div className="flex-1">
<h4 className="font-medium text-gray-900">{table.name}</h4>
<p className="text-sm text-gray-600">{table.columns.length} </p>
<p className="text-xs text-gray-500">{table.rowCount?.toLocaleString()} </p>
</div>
{selectedFromTable === table.name && (
<CheckCircle className="w-5 h-5 text-blue-600" />
)}
</div>
</div>
))}
</div>
</div>
<ArrowRight className="text-primary h-5 w-5" />
<div className="text-center">
<div className="font-medium">{toTable.displayName || toTable.tableName}</div>
<div className="text-muted-foreground text-xs">
{tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}
{/* TO 테이블 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<span className="text-green-600 font-bold">2</span>
</div>
<h3 className="text-lg font-semibold text-gray-900"> </h3>
<span className="text-sm text-gray-500">(TO)</span>
</div>
<div className="space-y-2">
{mockTables.map((table) => (
<div
key={table.name}
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
selectedToTable === table.name
? "border-green-500 bg-green-50 shadow-md"
: "border-gray-200 bg-white hover:border-green-300 hover:bg-green-25"
}`}
onClick={() => handleToTableSelect(table.name)}
>
<div className="flex items-center gap-3">
<Table className="w-6 h-6 text-green-600" />
<div className="flex-1">
<h4 className="font-medium text-gray-900">{table.name}</h4>
<p className="text-sm text-gray-600">{table.columns.length} </p>
<p className="text-xs text-gray-500">{table.rowCount?.toLocaleString()} </p>
</div>
{selectedToTable === table.name && (
<CheckCircle className="w-5 h-5 text-green-600" />
)}
</div>
</div>
</div>
<div className="mt-3 text-center">
<Badge variant="outline" className="text-primary">
💡 : {fromTable.displayName || fromTable.tableName} {" "}
{toTable.displayName || toTable.tableName}
</Badge>
</div>
))}
</div>
)}
{/* 네비게이션 버튼 */}
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />
이전: 연결
</Button>
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
다음: 컬럼
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
</>
</div>
{/* 버튼들 */}
<div className="flex justify-between">
<button
onClick={onBack}
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 transition-all duration-200"
>
<ArrowLeft className="w-4 h-4" />
</button>
<button
onClick={onNext}
disabled={!canProceed}
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-all duration-200 ${
canProceed
? "bg-orange-500 text-white hover:bg-orange-600 shadow-md hover:shadow-lg"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
>
단계: 필드
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
);
};
export default TableStep;
};