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

This commit is contained in:
kjs 2025-09-18 18:48:45 +09:00
commit 87f3959036
15 changed files with 1094 additions and 979 deletions

View File

@ -10,9 +10,22 @@ const prisma = new PrismaClient();
// 업로드 디렉토리 설정 (회사별로 분리)
const baseUploadDir = path.join(process.cwd(), "uploads");
if (!fs.existsSync(baseUploadDir)) {
// 디렉토리 생성 함수 (에러 핸들링 포함)
const ensureUploadDir = () => {
try {
if (!fs.existsSync(baseUploadDir)) {
fs.mkdirSync(baseUploadDir, { recursive: true });
}
}
} catch (error) {
console.warn(
`업로드 디렉토리 생성 실패: ${error}. 기존 디렉토리를 사용합니다.`
);
}
};
// 초기화 시 디렉토리 확인
ensureUploadDir();
// 회사별 + 날짜별 디렉토리 생성 함수
const getCompanyUploadDir = (companyCode: string, dateFolder?: string) => {

View File

@ -43,8 +43,8 @@ COPY --from=build /app/dist ./dist
# Copy package files
COPY package*.json ./
# Create logs directory and set permissions
RUN mkdir -p logs && chown -R appuser:appgroup logs && chmod -R 755 logs
# Create logs and uploads directories and set permissions
RUN mkdir -p logs uploads && chown -R appuser:appgroup logs uploads && chmod -R 755 logs uploads
EXPOSE 8080
USER appuser

View File

@ -1,361 +0,0 @@
"use client";
import React, { useState, useMemo } from "react";
import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { AlertModal } from "@/components/common/AlertModal";
import {
useComponents,
useComponentCategories,
useComponentStatistics,
useDeleteComponent,
useCreateComponent,
useUpdateComponent,
} from "@/hooks/admin/useComponents";
import { ComponentFormModal } from "@/components/admin/ComponentFormModal";
// 컴포넌트 카테고리 정의
const COMPONENT_CATEGORIES = [
{ id: "input", name: "입력", color: "blue" },
{ id: "action", name: "액션", color: "green" },
{ id: "display", name: "표시", color: "purple" },
{ id: "layout", name: "레이아웃", color: "orange" },
{ id: "other", name: "기타", color: "gray" },
];
export default function ComponentManagementPage() {
const [searchTerm, setSearchTerm] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [sortBy, setSortBy] = useState<string>("sort_order");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedComponent, setSelectedComponent] = useState<any>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showNewComponentModal, setShowNewComponentModal] = useState(false);
const [showEditComponentModal, setShowEditComponentModal] = useState(false);
// 컴포넌트 데이터 가져오기
const {
data: componentsData,
isLoading: loading,
error,
refetch,
} = useComponents({
category: selectedCategory === "all" ? undefined : selectedCategory,
active: "Y",
search: searchTerm,
sort: sortBy,
order: sortOrder,
});
// 카테고리와 통계 데이터
const { data: categories } = useComponentCategories();
const { data: statistics } = useComponentStatistics();
// 뮤테이션
const deleteComponentMutation = useDeleteComponent();
const createComponentMutation = useCreateComponent();
const updateComponentMutation = useUpdateComponent();
// 컴포넌트 목록 (이미 필터링과 정렬이 적용된 상태)
const components = componentsData?.components || [];
// 카테고리별 통계 (백엔드에서 가져온 데이터 사용)
const categoryStats = useMemo(() => {
if (!statistics?.byCategory) return {};
const stats: Record<string, number> = {};
statistics.byCategory.forEach(({ category, count }) => {
stats[category] = count;
});
return stats;
}, [statistics]);
// 카테고리 이름 및 색상 가져오기
const getCategoryInfo = (categoryId: string) => {
const category = COMPONENT_CATEGORIES.find((cat) => cat.id === categoryId);
return category || { id: "other", name: "기타", color: "gray" };
};
// 삭제 처리
const handleDelete = async () => {
if (!selectedComponent) return;
try {
await deleteComponentMutation.mutateAsync(selectedComponent.component_code);
setShowDeleteModal(false);
setSelectedComponent(null);
} catch (error) {
console.error("컴포넌트 삭제 실패:", error);
}
};
// 컴포넌트 생성 처리
const handleCreate = async (data: any) => {
await createComponentMutation.mutateAsync(data);
setShowNewComponentModal(false);
};
// 컴포넌트 수정 처리
const handleUpdate = async (data: any) => {
if (!selectedComponent) return;
await updateComponentMutation.mutateAsync({
component_code: selectedComponent.component_code,
data,
});
setShowEditComponentModal(false);
setSelectedComponent(null);
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<RefreshCw className="mx-auto h-8 w-8 animate-spin text-gray-400" />
<p className="mt-2 text-sm text-gray-500"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Package className="mx-auto h-8 w-8 text-red-400" />
<p className="mt-2 text-sm text-red-600"> .</p>
<Button variant="outline" size="sm" onClick={() => refetch()} className="mt-4">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col p-6">
{/* 헤더 */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="text-sm text-gray-500"> </p>
</div>
<div className="flex items-center space-x-2">
<Button size="sm" onClick={() => setShowNewComponentModal(true)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* 카테고리 통계 */}
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
{COMPONENT_CATEGORIES.map((category) => {
const count = categoryStats[category.id] || 0;
return (
<Card
key={category.id}
className="cursor-pointer hover:shadow-md"
onClick={() => setSelectedCategory(category.id)}
>
<CardContent className="p-4 text-center">
<div className={`mb-2 text-2xl font-bold text-${category.color}-600`}>{count}</div>
<div className="text-sm text-gray-600">{category.name}</div>
</CardContent>
</Card>
);
})}
</div>
{/* 검색 및 필터 */}
<Card className="mb-6">
<CardContent className="p-4">
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:space-y-0 md:space-x-4">
{/* 검색 */}
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="컴포넌트 이름, 타입, 설명으로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
{/* 카테고리 필터 */}
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="w-40">
<Filter className="mr-2 h-4 w-4" />
<SelectValue placeholder="카테고리" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{COMPONENT_CATEGORIES.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-32">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sort_order"></SelectItem>
<SelectItem value="type_name"></SelectItem>
<SelectItem value="web_type"></SelectItem>
<SelectItem value="category"></SelectItem>
<SelectItem value="updated_date"></SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}>
{sortOrder === "asc" ? "↑" : "↓"}
</Button>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 컴포넌트 목록 테이블 */}
<Card className="flex-1">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span> ({components.length})</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{components.map((component) => {
const categoryInfo = getCategoryInfo(component.category || "other");
return (
<TableRow key={component.component_code}>
<TableCell>
<div>
<div className="font-medium">{component.component_name}</div>
{component.component_name_eng && (
<div className="text-xs text-gray-500">{component.component_name_eng}</div>
)}
</div>
</TableCell>
<TableCell>
<code className="rounded bg-gray-100 px-2 py-1 text-xs">{component.component_code}</code>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`text-${categoryInfo.color}-600 border-${categoryInfo.color}-200`}
>
{categoryInfo.name}
</Badge>
</TableCell>
<TableCell>
{component.component_config ? (
<code className="text-xs text-blue-600">
{component.component_config.type || component.component_code}
</code>
) : (
<span className="text-xs text-gray-400"></span>
)}
</TableCell>
<TableCell>
<Badge variant={component.is_active === "Y" ? "default" : "secondary"}>
{component.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="text-xs text-gray-500">
{component.updated_date ? new Date(component.updated_date).toLocaleDateString() : "-"}
</TableCell>
<TableCell>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedComponent(component);
setShowEditComponentModal(true);
}}
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedComponent(component);
setShowDeleteModal(true);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 삭제 확인 모달 */}
<AlertModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDelete}
type="warning"
title="컴포넌트 삭제"
message={`정말로 "${selectedComponent?.component_name}" 컴포넌트를 삭제하시겠습니까?`}
confirmText="삭제"
/>
{/* 새 컴포넌트 추가 모달 */}
<ComponentFormModal
isOpen={showNewComponentModal}
onClose={() => setShowNewComponentModal(false)}
onSubmit={handleCreate}
mode="create"
/>
{/* 컴포넌트 편집 모달 */}
<ComponentFormModal
isOpen={showEditComponentModal}
onClose={() => {
setShowEditComponentModal(false);
setSelectedComponent(null);
}}
onSubmit={handleUpdate}
initialData={selectedComponent}
mode="edit"
/>
</div>
);
}

View File

@ -1,565 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, Save, RotateCcw, AlertTriangle, CheckCircle } from "lucide-react";
import { toast } from "sonner";
import { useComponentDuplicateCheck } from "@/hooks/admin/useComponentDuplicateCheck";
import { Alert, AlertDescription } from "@/components/ui/alert";
// 컴포넌트 카테고리 정의
const COMPONENT_CATEGORIES = [
{ id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" },
{ id: "action", name: "액션", description: "사용자 액션을 처리하는 컴포넌트" },
{ id: "display", name: "표시", description: "정보를 표시하는 컴포넌트" },
{ id: "layout", name: "레이아웃", description: "레이아웃을 구성하는 컴포넌트" },
{ id: "other", name: "기타", description: "기타 컴포넌트" },
];
// 컴포넌트 타입 정의
const COMPONENT_TYPES = [
{ id: "widget", name: "위젯", description: "입력 양식 위젯" },
{ id: "button", name: "버튼", description: "액션 버튼" },
{ id: "card", name: "카드", description: "카드 컨테이너" },
{ id: "container", name: "컨테이너", description: "일반 컨테이너" },
{ id: "dashboard", name: "대시보드", description: "대시보드 그리드" },
{ id: "alert", name: "알림", description: "알림 메시지" },
{ id: "badge", name: "배지", description: "상태 배지" },
{ id: "progress", name: "진행률", description: "진행률 표시" },
{ id: "chart", name: "차트", description: "데이터 차트" },
];
// 웹타입 정의 (위젯인 경우만)
const WEB_TYPES = [
"text",
"number",
"decimal",
"date",
"datetime",
"select",
"dropdown",
"textarea",
"boolean",
"checkbox",
"radio",
"code",
"entity",
"file",
"email",
"tel",
"color",
"range",
"time",
"week",
"month",
];
interface ComponentFormData {
component_code: string;
component_name: string;
description: string;
category: string;
component_config: {
type: string;
webType?: string;
config_panel?: string;
};
default_size: {
width: number;
height: number;
};
icon_name: string;
active: string;
sort_order: number;
}
interface ComponentFormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: ComponentFormData) => Promise<void>;
initialData?: any;
mode?: "create" | "edit";
}
export const ComponentFormModal: React.FC<ComponentFormModalProps> = ({
isOpen,
onClose,
onSubmit,
initialData,
mode = "create",
}) => {
const [formData, setFormData] = useState<ComponentFormData>({
component_code: "",
component_name: "",
description: "",
category: "other",
component_config: {
type: "widget",
},
default_size: {
width: 200,
height: 40,
},
icon_name: "",
is_active: "Y",
sort_order: 100,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [shouldCheckDuplicate, setShouldCheckDuplicate] = useState(false);
// 중복 체크 쿼리 (생성 모드에서만 활성화)
const duplicateCheck = useComponentDuplicateCheck(
formData.component_code,
mode === "create" && shouldCheckDuplicate && formData.component_code.length > 0,
);
// 초기 데이터 설정
useEffect(() => {
if (isOpen) {
if (mode === "edit" && initialData) {
setFormData({
component_code: initialData.component_code || "",
component_name: initialData.component_name || "",
description: initialData.description || "",
category: initialData.category || "other",
component_config: initialData.component_config || { type: "widget" },
default_size: initialData.default_size || { width: 200, height: 40 },
icon_name: initialData.icon_name || "",
is_active: initialData.is_active || "Y",
sort_order: initialData.sort_order || 100,
});
} else {
// 새 컴포넌트 생성 시 초기값
setFormData({
component_code: "",
component_name: "",
description: "",
category: "other",
component_config: {
type: "widget",
},
default_size: {
width: 200,
height: 40,
},
icon_name: "",
is_active: "Y",
sort_order: 100,
});
}
}
}, [isOpen, mode, initialData]);
// 컴포넌트 코드 자동 생성
const generateComponentCode = (name: string, type: string) => {
if (!name) return "";
// 한글을 영문으로 매핑
const koreanToEnglish: { [key: string]: string } = {
: "help",
: "tooltip",
: "guide",
: "alert",
: "button",
: "card",
: "dashboard",
: "panel",
: "input",
: "text",
: "select",
: "check",
: "radio",
: "file",
: "image",
: "table",
: "list",
: "form",
};
// 한글을 영문으로 변환
let englishName = name;
Object.entries(koreanToEnglish).forEach(([korean, english]) => {
englishName = englishName.replace(new RegExp(korean, "g"), english);
});
const cleanName = englishName
.toLowerCase()
.replace(/[^a-z0-9\s]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
// 빈 문자열이거나 숫자로 시작하는 경우 기본값 설정
const finalName = cleanName || "component";
const validName = /^[0-9]/.test(finalName) ? `comp-${finalName}` : finalName;
return type === "widget" ? validName : `${validName}-${type}`;
};
// 폼 필드 변경 처리
const handleChange = (field: string, value: any) => {
setFormData((prev) => {
const newData = { ...prev };
if (field.includes(".")) {
const [parent, child] = field.split(".");
newData[parent as keyof ComponentFormData] = {
...(newData[parent as keyof ComponentFormData] as any),
[child]: value,
};
} else {
(newData as any)[field] = value;
}
// 컴포넌트 이름이 변경되면 코드 자동 생성
if (field === "component_name" || field === "component_config.type") {
const name = field === "component_name" ? value : newData.component_name;
const type = field === "component_config.type" ? value : newData.component_config.type;
if (name && mode === "create") {
newData.component_code = generateComponentCode(name, type);
// 자동 생성된 코드에 대해서도 중복 체크 활성화
setShouldCheckDuplicate(true);
}
}
// 컴포넌트 코드가 직접 변경되면 중복 체크 활성화
if (field === "component_code" && mode === "create") {
setShouldCheckDuplicate(true);
}
return newData;
});
};
// 폼 제출
const handleSubmit = async () => {
// 유효성 검사
if (!formData.component_code || !formData.component_name) {
toast.error("컴포넌트 코드와 이름은 필수입니다.");
return;
}
if (!formData.component_config.type) {
toast.error("컴포넌트 타입을 선택해주세요.");
return;
}
// 생성 모드에서 중복 체크
if (mode === "create" && duplicateCheck.data?.isDuplicate) {
toast.error("이미 사용 중인 컴포넌트 코드입니다. 다른 코드를 사용해주세요.");
return;
}
setIsSubmitting(true);
try {
await onSubmit(formData);
toast.success(mode === "create" ? "컴포넌트가 생성되었습니다." : "컴포넌트가 수정되었습니다.");
onClose();
} catch (error) {
toast.error(mode === "create" ? "컴포넌트 생성에 실패했습니다." : "컴포넌트 수정에 실패했습니다.");
} finally {
setIsSubmitting(false);
}
};
// 폼 초기화
const handleReset = () => {
if (mode === "edit" && initialData) {
setFormData({
component_code: initialData.component_code || "",
component_name: initialData.component_name || "",
description: initialData.description || "",
category: initialData.category || "other",
component_config: initialData.component_config || { type: "widget" },
default_size: initialData.default_size || { width: 200, height: 40 },
icon_name: initialData.icon_name || "",
is_active: initialData.is_active || "Y",
sort_order: initialData.sort_order || 100,
});
} else {
setFormData({
component_code: "",
component_name: "",
description: "",
category: "other",
component_config: {
type: "widget",
},
default_size: {
width: 200,
height: 40,
},
icon_name: "",
is_active: "Y",
sort_order: 100,
});
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{mode === "create" ? "새 컴포넌트 추가" : "컴포넌트 편집"}</DialogTitle>
<DialogDescription>
{mode === "create"
? "화면 설계에 사용할 새로운 컴포넌트를 추가합니다."
: "선택한 컴포넌트의 정보를 수정합니다."}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="component_name"> *</Label>
<Input
id="component_name"
value={formData.component_name}
onChange={(e) => handleChange("component_name", e.target.value)}
placeholder="예: 정보 알림"
/>
</div>
<div>
<Label htmlFor="component_code"> *</Label>
<div className="relative">
<Input
id="component_code"
value={formData.component_code}
onChange={(e) => handleChange("component_code", e.target.value)}
placeholder="예: alert-info"
disabled={mode === "edit"}
className={
mode === "create" && duplicateCheck.data?.isDuplicate
? "border-red-500 pr-10"
: mode === "create" && duplicateCheck.data && !duplicateCheck.data.isDuplicate
? "border-green-500 pr-10"
: ""
}
/>
{mode === "create" && formData.component_code && duplicateCheck.data && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
{duplicateCheck.data.isDuplicate ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</div>
)}
</div>
{mode === "create" && formData.component_code && duplicateCheck.data && (
<Alert
className={`mt-2 ${duplicateCheck.data.isDuplicate ? "border-red-200 bg-red-50" : "border-green-200 bg-green-50"}`}
>
<AlertDescription className={duplicateCheck.data.isDuplicate ? "text-red-700" : "text-green-700"}>
{duplicateCheck.data.isDuplicate
? "⚠️ 이미 사용 중인 컴포넌트 코드입니다."
: "✅ 사용 가능한 컴포넌트 코드입니다."}
</AlertDescription>
</Alert>
)}
</div>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
placeholder="컴포넌트에 대한 설명을 입력하세요"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label></Label>
<Select value={formData.category} onValueChange={(value) => handleChange("category", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPONENT_CATEGORIES.map((category) => (
<SelectItem key={category.id} value={category.id}>
<div>
<div className="font-medium">{category.name}</div>
<div className="text-xs text-gray-500">{category.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="icon_name"> </Label>
<Input
id="icon_name"
value={formData.icon_name}
onChange={(e) => handleChange("icon_name", e.target.value)}
placeholder="예: info, alert-triangle"
/>
</div>
</div>
</CardContent>
</Card>
{/* 컴포넌트 설정 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label> </Label>
<Select
value={formData.component_config.type}
onValueChange={(value) => handleChange("component_config.type", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMPONENT_TYPES.map((type) => (
<SelectItem key={type.id} value={type.id}>
<div>
<div className="font-medium">{type.name}</div>
<div className="text-xs text-gray-500">{type.description}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 위젯인 경우 웹타입 선택 */}
{formData.component_config.type === "widget" && (
<div>
<Label></Label>
<Select
value={formData.component_config.webType || ""}
onValueChange={(value) => handleChange("component_config.webType", value)}
>
<SelectTrigger>
<SelectValue placeholder="웹타입을 선택하세요" />
</SelectTrigger>
<SelectContent>
{WEB_TYPES.map((webType) => (
<SelectItem key={webType} value={webType}>
{webType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="config_panel"> </Label>
<Input
id="config_panel"
value={formData.component_config.config_panel || ""}
onChange={(e) => handleChange("component_config.config_panel", e.target.value)}
placeholder="예: AlertConfigPanel"
/>
</div>
</CardContent>
</Card>
{/* 기본 크기 및 기타 설정 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="width"> (px)</Label>
<Input
id="width"
type="number"
value={formData.default_size.width}
onChange={(e) => handleChange("default_size.width", parseInt(e.target.value))}
min="1"
/>
</div>
<div>
<Label htmlFor="height"> (px)</Label>
<Input
id="height"
type="number"
value={formData.default_size.height}
onChange={(e) => handleChange("default_size.height", parseInt(e.target.value))}
min="1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="sort_order"> </Label>
<Input
id="sort_order"
type="number"
value={formData.sort_order}
onChange={(e) => handleChange("sort_order", parseInt(e.target.value))}
min="0"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="active"
checked={formData.is_active === "Y"}
onCheckedChange={(checked) => handleChange("is_active", checked ? "Y" : "N")}
/>
<Label htmlFor="active"></Label>
</div>
</div>
</CardContent>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleReset} disabled={isSubmitting}>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || (mode === "create" && duplicateCheck.data?.isDuplicate)}
>
<Save className="mr-2 h-4 w-4" />
{isSubmitting ? "저장 중..." : mode === "create" ? "생성" : "수정"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -343,6 +343,37 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
break;
case "data-save":
settings = dataSaveSettings;
// INSERT가 아닌 액션 타입에 대한 실행조건 필수 검증
for (const action of dataSaveSettings.actions) {
if (action.actionType !== "insert") {
if (!action.conditions || action.conditions.length === 0) {
toast.error(
`${action.actionType.toUpperCase()} 액션은 실행조건이 필수입니다. '${action.name}' 액션에 실행조건을 추가해주세요.`,
);
return;
}
// 실제 조건이 있는지 확인 (group-start, group-end만 있는 경우 제외)
const hasValidConditions = action.conditions.some((condition) => {
if (condition.type !== "condition") return false;
if (!condition.field || !condition.operator) return false;
// value가 null, undefined, 빈 문자열이면 유효하지 않음
const value = condition.value;
if (value === null || value === undefined || value === "") return false;
return true;
});
if (!hasValidConditions) {
toast.error(
`${action.actionType.toUpperCase()} 액션은 완전한 실행조건이 필요합니다. '${action.name}' 액션에 필드, 연산자, 값을 모두 설정해주세요.`,
);
return;
}
}
}
break;
case "external-call":
// 외부 호출은 plan에 저장
@ -508,9 +539,43 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
case "data-save":
// 데이터 저장: 액션과 필드 매핑이 완성되어야 함
const hasActions = dataSaveSettings.actions.length > 0;
const allActionsHaveMappings = dataSaveSettings.actions.every((action) => action.fieldMappings.length > 0);
const allMappingsComplete = dataSaveSettings.actions.every((action) =>
action.fieldMappings.every((mapping) => {
// DELETE 액션은 필드 매핑이 필요 없음
const allActionsHaveMappings = dataSaveSettings.actions.every((action) => {
if (action.actionType === "delete") {
return true; // DELETE는 필드 매핑 불필요
}
return action.fieldMappings.length > 0;
});
const allMappingsComplete = dataSaveSettings.actions.every((action) => {
if (action.actionType === "delete") {
return true; // DELETE는 필드 매핑 검증 생략
}
// INSERT 액션의 경우 모든 TO 테이블 컬럼이 매핑되거나 기본값이 있어야 함
if (action.actionType === "insert") {
// TO 테이블의 모든 컬럼을 찾기
const toTableName = action.fieldMappings[0]?.targetTable;
if (!toTableName) return false;
const toTableColumns = tableColumnsCache[toTableName] || [];
if (toTableColumns.length === 0) return false;
// 모든 TO 컬럼이 매핑되거나 기본값이 있는지 확인
return toTableColumns.every((column) => {
const mapping = action.fieldMappings.find((m) => m.targetField === column.columnName);
if (!mapping) return false;
// 소스 매핑 또는 기본값 중 하나는 있어야 함
const hasSource = mapping.sourceTable && mapping.sourceField;
const hasDefault = mapping.defaultValue && mapping.defaultValue.trim();
return hasSource || hasDefault;
});
}
return action.fieldMappings.every((mapping) => {
// 타겟은 항상 필요
if (!mapping.targetTable || !mapping.targetField) return false;
@ -525,9 +590,36 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
// FROM 테이블이 있으면 소스 매핑 완성 또는 기본값 필요
return hasSource || hasDefault;
}),
);
return !hasActions || !allActionsHaveMappings || !allMappingsComplete;
});
});
// INSERT가 아닌 액션 타입에 대한 실행조건 필수 검증
const allRequiredConditionsMet = dataSaveSettings.actions.every((action) => {
if (action.actionType === "insert") {
return true; // INSERT는 조건 불필요
}
// INSERT가 아닌 액션은 유효한 조건이 있어야 함
if (!action.conditions || action.conditions.length === 0) {
return false;
}
// 실제 조건이 있는지 확인 (group-start, group-end만 있는 경우 제외)
const hasValidConditions = action.conditions.some((condition) => {
if (condition.type !== "condition") return false;
if (!condition.field || !condition.operator) return false;
// value가 null, undefined, 빈 문자열이면 유효하지 않음
const value = condition.value;
if (value === null || value === undefined || value === "") return false;
return true;
});
return hasValidConditions;
});
return !hasActions || !allActionsHaveMappings || !allMappingsComplete || !allRequiredConditionsMet;
case "external-call":
// 외부 호출: 설정 ID와 메시지가 있어야 함

View File

@ -32,6 +32,22 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
}) => {
const { addActionGroupStart, addActionGroupEnd, getActionCurrentGroupLevel } = useActionConditionHelpers();
// INSERT가 아닌 액션 타입인지 확인
const isConditionRequired = action.actionType !== "insert";
// 유효한 조건이 있는지 확인 (group-start, group-end만 있는 경우 제외)
const hasValidConditions =
action.conditions?.some((condition) => {
if (condition.type !== "condition") return false;
if (!condition.field || !condition.operator) return false;
// value가 null, undefined, 빈 문자열이면 유효하지 않음
const value = condition.value;
if (value === null || value === undefined || value === "") return false;
return true;
}) || false;
const addActionCondition = () => {
const newActions = [...settings.actions];
if (!newActions[actionIndex].conditions) {
@ -65,14 +81,28 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
return (
<div className="mt-3">
<details className="group">
<summary className="flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900">
<summary
className={`flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium hover:bg-gray-50 hover:text-gray-900 ${
isConditionRequired && !hasValidConditions
? "border-red-300 bg-red-50 text-red-700"
: "border-gray-200 text-gray-700"
}`}
>
<div className="flex items-center gap-2">
🔍 ()
🔍
{isConditionRequired ? (
<span className="rounded bg-red-100 px-1 py-0.5 text-xs font-semibold text-red-700"></span>
) : (
<span className="text-gray-500">()</span>
)}
{action.conditions && action.conditions.length > 0 && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
{action.conditions.length}
</span>
)}
{isConditionRequired && !hasValidConditions && (
<span className="rounded bg-red-100 px-1 py-0.5 text-xs text-red-600"> </span>
)}
</div>
{action.conditions && action.conditions.length > 0 && (
<Button
@ -115,6 +145,36 @@ export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = (
</Button>
</div>
</div>
{/* 조건이 없을 때 안내 메시지 */}
{(!action.conditions || action.conditions.length === 0) && (
<div
className={`rounded border p-3 text-xs ${
isConditionRequired
? "border-red-200 bg-red-50 text-red-700"
: "border-gray-200 bg-gray-50 text-gray-600"
}`}
>
{isConditionRequired ? (
<div className="flex items-start gap-2">
<span className="text-red-500"></span>
<div>
<div className="font-medium"> </div>
<div className="mt-1">
{action.actionType.toUpperCase()} .
<br />
<strong>, , </strong> .
<br />
: user_name = '관리자우저' AND status = 'active'
</div>
</div>
</div>
) : (
<div> . INSERT .</div>
)}
</div>
)}
{action.conditions && action.conditions.length > 0 && (
<div className="space-y-2">
{action.conditions.map((condition, condIndex) => (

View File

@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Plus, Trash2 } from "lucide-react";
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
import { DataSaveSettings } from "@/types/connectionTypes";
import { InsertFieldMappingPanel } from "./InsertFieldMappingPanel";
interface ActionFieldMappingsProps {
action: DataSaveSettings["actions"][0];
@ -16,6 +17,10 @@ interface ActionFieldMappingsProps {
onSettingsChange: (settings: DataSaveSettings) => void;
availableTables: TableInfo[];
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
fromTableColumns?: ColumnInfo[];
toTableColumns?: ColumnInfo[];
fromTableName?: string;
toTableName?: string;
}
export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
@ -25,7 +30,26 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
onSettingsChange,
availableTables,
tableColumnsCache,
fromTableColumns = [],
toTableColumns = [],
fromTableName,
toTableName,
}) => {
// INSERT 액션일 때는 새로운 패널 사용
if (action.actionType === "insert" && fromTableColumns.length > 0 && toTableColumns.length > 0) {
return (
<InsertFieldMappingPanel
action={action}
actionIndex={actionIndex}
settings={settings}
onSettingsChange={onSettingsChange}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
fromTableName={fromTableName}
toTableName={toTableName}
/>
);
}
const addFieldMapping = () => {
const newActions = [...settings.actions];
newActions[actionIndex].fieldMappings.push({
@ -54,7 +78,10 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
return (
<div className="mt-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="text-xs font-medium"> </Label>
<span className="text-xs text-red-600">()</span>
</div>
<Button size="sm" variant="outline" onClick={addFieldMapping} className="h-6 text-xs">
<Plus className="mr-1 h-2 w-2" />
@ -208,6 +235,22 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
</div>
</div>
))}
{/* 필드 매핑이 없을 때 안내 메시지 */}
{action.fieldMappings.length === 0 && (
<div className="rounded border border-red-200 bg-red-50 p-3 text-xs text-red-700">
<div className="flex items-start gap-2">
<span className="text-red-500"></span>
<div>
<div className="font-medium"> </div>
<div className="mt-1">
{action.actionType.toUpperCase()}
.
</div>
</div>
</div>
</div>
)}
</div>
</div>
);

View File

@ -0,0 +1,334 @@
"use client";
import React, { useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ColumnInfo } from "@/lib/api/dataflow";
import { getInputTypeForDataType } from "@/utils/connectionUtils";
interface ColumnMapping {
toColumnName: string;
fromColumnName?: string;
defaultValue?: string;
}
interface ColumnTableSectionProps {
type: "from" | "to";
tableName: string;
columns: ColumnInfo[];
selectedColumn: string | null;
onColumnClick: (columnName: string) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
dataTypeFilter: string;
onDataTypeFilterChange: (filter: string) => void;
showMappedOnly: boolean;
onShowMappedOnlyChange: (show: boolean) => void;
showUnmappedOnly: boolean;
onShowUnmappedOnlyChange: (show: boolean) => void;
columnMappings: ColumnMapping[];
onDefaultValueChange?: (columnName: string, value: string) => void;
onRemoveMapping?: (columnName: string) => void;
isColumnClickable: (column: ColumnInfo) => boolean;
oppositeSelectedColumn?: string | null;
oppositeColumns?: ColumnInfo[];
}
export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
type,
tableName,
columns,
selectedColumn,
onColumnClick,
searchTerm,
onSearchChange,
dataTypeFilter,
onDataTypeFilterChange,
showMappedOnly,
onShowMappedOnlyChange,
showUnmappedOnly,
onShowUnmappedOnlyChange,
columnMappings,
onDefaultValueChange,
onRemoveMapping,
isColumnClickable,
oppositeSelectedColumn,
oppositeColumns,
}) => {
const isFromTable = type === "from";
// 데이터 타입 목록 추출
const dataTypes = useMemo(() => {
const types = new Set(columns.map((col) => col.dataType).filter((type): type is string => !!type));
return Array.from(types).sort();
}, [columns]);
// 필터링된 컬럼 목록
const filteredColumns = useMemo(() => {
return columns.filter((column) => {
// 검색어 필터
const matchesSearch = searchTerm === "" || column.columnName.toLowerCase().includes(searchTerm.toLowerCase());
// 데이터 타입 필터
const matchesDataType = dataTypeFilter === "" || column.dataType === dataTypeFilter;
// 매핑 상태 필터
const isMapped = isFromTable
? columnMappings.some((mapping) => mapping.fromColumnName === column.columnName)
: (() => {
const mapping = columnMappings.find((mapping) => mapping.toColumnName === column.columnName);
return !!mapping?.fromColumnName || !!(mapping?.defaultValue && mapping.defaultValue.trim());
})();
const matchesMappingFilter =
(!showMappedOnly && !showUnmappedOnly) || (showMappedOnly && isMapped) || (showUnmappedOnly && !isMapped);
return matchesSearch && matchesDataType && matchesMappingFilter;
});
}, [columns, searchTerm, dataTypeFilter, showMappedOnly, showUnmappedOnly, columnMappings, isFromTable]);
const mappedCount = columns.filter((column) =>
isFromTable
? columnMappings.some((mapping) => mapping.fromColumnName === column.columnName)
: (() => {
const mapping = columnMappings.find((mapping) => mapping.toColumnName === column.columnName);
return !!mapping?.fromColumnName || !!(mapping?.defaultValue && mapping.defaultValue.trim());
})(),
).length;
return (
<div className="flex-1">
{/* 헤더 */}
<div className="rounded-t-lg bg-gray-600 px-4 py-3 text-white">
<h3 className="text-sm font-semibold">
{isFromTable ? "From" : "To"}: {tableName} ({columns.length}/{columns.length})
</h3>
</div>
{/* 검색 및 필터 */}
<div className="border-r border-l border-gray-200 bg-gray-50 p-3">
<div className="flex flex-col gap-3">
<Input
placeholder="컬럼명 검색..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 text-xs"
/>
<div className="flex flex-col gap-2">
<Select
value={dataTypeFilter || "all"}
onValueChange={(value) => onDataTypeFilterChange(value === "all" ? "" : value || "")}
>
<SelectTrigger className="h-7 w-full text-xs" size="sm">
<SelectValue placeholder="모든 타입" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{dataTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex cursor-pointer gap-1">
<button
onClick={() => {
onShowMappedOnlyChange(!showMappedOnly);
if (showUnmappedOnly) onShowUnmappedOnlyChange(false);
}}
className={`rounded px-2 py-1 text-xs transition-colors hover:cursor-pointer ${
showMappedOnly ? "bg-gray-600 text-white" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
</button>
<button
onClick={() => {
onShowUnmappedOnlyChange(!showUnmappedOnly);
if (showMappedOnly) onShowMappedOnlyChange(false);
}}
className={`rounded px-2 py-1 text-xs transition-colors hover:cursor-pointer ${
showUnmappedOnly ? "bg-gray-600 text-white" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
</button>
</div>
</div>
</div>
</div>
{/* 컬럼 리스트 */}
<div className="h-96 overflow-y-auto border-r border-l border-gray-200">
{filteredColumns.map((column) => {
const isSelected = selectedColumn === column.columnName;
const isClickable = isColumnClickable(column);
if (isFromTable) {
// FROM 테이블 렌더링
const isMapped = columnMappings.some((mapping) => mapping.fromColumnName === column.columnName);
const mappedToColumn = columnMappings.find(
(mapping) => mapping.fromColumnName === column.columnName,
)?.toColumnName;
// 선택된 TO 컬럼과 타입 호환성 체크 (이미 매핑된 컬럼은 제외)
const isTypeCompatible =
!oppositeSelectedColumn ||
isMapped ||
oppositeColumns?.find((col) => col.columnName === oppositeSelectedColumn)?.dataType === column.dataType;
return (
<div
key={column.columnName}
onClick={isClickable ? () => onColumnClick(column.columnName) : undefined}
className={`border-b border-gray-200 px-3 py-2 text-xs transition-colors ${
isSelected
? "bg-gray-200 text-gray-800"
: isMapped
? "bg-gray-100 text-gray-700"
: oppositeSelectedColumn && !isTypeCompatible
? "cursor-not-allowed bg-red-50 text-red-400 opacity-60"
: isClickable
? "cursor-pointer hover:bg-gray-50"
: "cursor-not-allowed bg-gray-100 text-gray-400"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{column.columnName}</span>
{isSelected && <span className="flex-shrink-0 text-blue-500"></span>}
{isMapped && <span className="flex-shrink-0 text-green-500"></span>}
{oppositeSelectedColumn && !isTypeCompatible && (
<span className="flex-shrink-0 text-red-500" title="데이터 타입이 호환되지 않음">
</span>
)}
</div>
<div
className={`mt-1 text-xs ${oppositeSelectedColumn && !isTypeCompatible ? "text-red-400" : "text-gray-500"}`}
>
{column.dataType}
{oppositeSelectedColumn && !isTypeCompatible && (
<span className="ml-1 text-red-400">( )</span>
)}
</div>
{isMapped && mappedToColumn && (
<div className="mt-1 truncate text-xs text-green-600"> {mappedToColumn}</div>
)}
</div>
</div>
</div>
);
} else {
// TO 테이블 렌더링
const mapping = columnMappings.find((m) => m.toColumnName === column.columnName);
const isMapped = !!mapping?.fromColumnName;
const hasDefaultValue = !!(mapping?.defaultValue && mapping.defaultValue.trim());
// 선택된 FROM 컬럼과 타입 호환성 체크 (이미 매핑된 컬럼은 제외)
const isTypeCompatible =
!oppositeSelectedColumn ||
isMapped ||
oppositeColumns?.find((col) => col.columnName === oppositeSelectedColumn)?.dataType === column.dataType;
return (
<div
key={column.columnName}
className={`border-b border-gray-200 transition-colors ${
isSelected
? "bg-gray-200"
: isMapped
? "bg-gray-100"
: hasDefaultValue
? "bg-gray-100"
: oppositeSelectedColumn && !isTypeCompatible
? "bg-red-50 opacity-60"
: "bg-white"
}`}
>
{/* 컬럼 정보 행 */}
<div
onClick={isClickable && isTypeCompatible ? () => onColumnClick(column.columnName) : undefined}
className={`px-3 py-2 text-xs ${
isClickable && isTypeCompatible
? "cursor-pointer hover:bg-gray-50"
: oppositeSelectedColumn && !isTypeCompatible
? "cursor-not-allowed"
: hasDefaultValue
? "cursor-not-allowed"
: ""
}`}
>
<div className="flex min-w-0 flex-col justify-between">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{column.columnName}</span>
{isSelected && <span className="flex-shrink-0 text-green-500"></span>}
{oppositeSelectedColumn && !isTypeCompatible && (
<span className="flex-shrink-0 text-red-500" title="데이터 타입이 호환되지 않음">
</span>
)}
</div>
<div
className={`mt-1 text-xs ${oppositeSelectedColumn && !isTypeCompatible ? "text-red-400" : "text-gray-500"}`}
>
{column.dataType}
{oppositeSelectedColumn && !isTypeCompatible && (
<span className="ml-1 text-red-400">( )</span>
)}
</div>
{isMapped && (
<div className="mt-2 flex items-center gap-2">
<span className="truncate text-xs text-blue-600"> {mapping.fromColumnName}</span>
<button
onClick={(e) => {
e.stopPropagation();
onRemoveMapping?.(column.columnName);
}}
className="flex-shrink-0 text-red-500 hover:text-red-700"
title="매핑 제거"
>
</button>
</div>
)}
{!isMapped && onDefaultValueChange && (
<div className="mt-2">
<Input
type={getInputTypeForDataType(column.dataType?.toLowerCase() || "string")}
placeholder="기본값 입력..."
value={mapping?.defaultValue || ""}
onChange={(e) => onDefaultValueChange(column.columnName, e.target.value)}
className="h-6 border-gray-200 text-xs focus:border-green-400 focus:ring-0"
onClick={(e) => e.stopPropagation()}
disabled={isSelected || !!oppositeSelectedColumn}
/>
</div>
)}
</div>
</div>
</div>
);
}
})}
</div>
{/* 하단 통계 */}
<div className="rounded-b-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
<div className="flex items-center justify-between">
<span>
{isFromTable ? "매핑됨" : "설정됨"}: {mappedCount}/{columns.length}
</span>
<Badge variant="secondary" className="text-xs">
: {filteredColumns.length}
</Badge>
</div>
</div>
</div>
);
};

View File

@ -135,7 +135,8 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
toTableName={toTableName}
/>
{/* 데이터 분할 설정 */}
{/* 데이터 분할 설정 - DELETE 액션은 제외 */}
{action.actionType !== "delete" && (
<ActionSplitConfig
action={action}
actionIndex={actionIndex}
@ -144,8 +145,10 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
/>
)}
{/* 필드 매핑 */}
{/* 필드 매핑 - DELETE 액션은 제외 */}
{action.actionType !== "delete" && (
<ActionFieldMappings
action={action}
actionIndex={actionIndex}
@ -153,7 +156,35 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
onSettingsChange={onSettingsChange}
availableTables={availableTables}
tableColumnsCache={tableColumnsCache}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
fromTableName={fromTableName}
toTableName={toTableName}
/>
)}
{/* DELETE 액션일 때 안내 메시지 */}
{action.actionType === "delete" && (
<div className="mt-3">
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-xs text-blue-700">
<div className="flex items-start gap-2">
<span></span>
<div>
<div className="font-medium">DELETE </div>
<div className="mt-1">
DELETE <strong></strong> .
<br />
설정: 불필요 ( )
<br />
매핑: 불필요 ( )
<br />
.
</div>
</div>
</div>
</div>
</div>
)}
</div>
))}
</div>

View File

@ -0,0 +1,395 @@
"use client";
import React, { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ColumnInfo } from "@/lib/api/dataflow";
import { DataSaveSettings } from "@/types/connectionTypes";
import { ColumnTableSection } from "./ColumnTableSection";
interface InsertFieldMappingPanelProps {
action: DataSaveSettings["actions"][0];
actionIndex: number;
settings: DataSaveSettings;
onSettingsChange: (settings: DataSaveSettings) => void;
fromTableColumns: ColumnInfo[];
toTableColumns: ColumnInfo[];
fromTableName?: string;
toTableName?: string;
}
interface ColumnMapping {
toColumnName: string;
fromColumnName?: string;
defaultValue?: string;
}
export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = ({
action,
actionIndex,
settings,
onSettingsChange,
fromTableColumns,
toTableColumns,
fromTableName,
toTableName,
}) => {
const [selectedFromColumn, setSelectedFromColumn] = useState<string | null>(null);
const [selectedToColumn, setSelectedToColumn] = useState<string | null>(null);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
// 검색 및 필터링 상태 (FROM과 TO 독립적)
const [fromSearchTerm, setFromSearchTerm] = useState("");
const [toSearchTerm, setToSearchTerm] = useState("");
const [fromDataTypeFilter, setFromDataTypeFilter] = useState("");
const [toDataTypeFilter, setToDataTypeFilter] = useState("");
// FROM 테이블 필터
const [fromShowMappedOnly, setFromShowMappedOnly] = useState(false);
const [fromShowUnmappedOnly, setFromShowUnmappedOnly] = useState(false);
// TO 테이블 필터
const [toShowMappedOnly, setToShowMappedOnly] = useState(false);
const [toShowUnmappedOnly, setToShowUnmappedOnly] = useState(false);
// 기존 매핑 데이터를 columnMappings로 변환
useEffect(() => {
const mappings: ColumnMapping[] = toTableColumns.map((toCol) => {
const existingMapping = action.fieldMappings.find((mapping) => mapping.targetField === toCol.columnName);
return {
toColumnName: toCol.columnName,
fromColumnName: existingMapping?.sourceField || undefined,
defaultValue: existingMapping?.defaultValue || "",
};
});
setColumnMappings(mappings);
}, [action.fieldMappings, toTableColumns]);
// columnMappings 변경 시 settings 업데이트
const updateSettings = (newMappings: ColumnMapping[]) => {
const newActions = [...settings.actions];
// 새로운 fieldMappings 생성
const fieldMappings = newMappings
.filter((mapping) => mapping.fromColumnName || (mapping.defaultValue && mapping.defaultValue.trim()))
.map((mapping) => ({
sourceTable: mapping.fromColumnName ? fromTableName || "" : "",
sourceField: mapping.fromColumnName || "",
targetTable: toTableName || "",
targetField: mapping.toColumnName,
defaultValue: mapping.defaultValue || "",
transformFunction: "",
}));
newActions[actionIndex].fieldMappings = fieldMappings;
onSettingsChange({ ...settings, actions: newActions });
};
// FROM 컬럼 클릭 핸들러
const handleFromColumnClick = (columnName: string) => {
if (selectedFromColumn === columnName) {
setSelectedFromColumn(null);
} else {
setSelectedFromColumn(columnName);
// TO 컬럼이 이미 선택되어 있으면 매핑 시도
if (selectedToColumn) {
const fromColumn = fromTableColumns.find((col) => col.columnName === columnName);
const toColumn = toTableColumns.find((col) => col.columnName === selectedToColumn);
if (fromColumn && toColumn) {
// 데이터 타입 호환성 체크
if (fromColumn.dataType !== toColumn.dataType) {
alert(`데이터 타입이 다릅니다. FROM: ${fromColumn.dataType}, TO: ${toColumn.dataType}`);
return;
}
// 매핑 생성
createMapping(columnName, selectedToColumn);
setSelectedFromColumn(null);
setSelectedToColumn(null);
}
}
}
};
// 공통 매핑 생성 함수
const createMapping = (fromColumnName: string, toColumnName: string) => {
const newMappings = columnMappings.map((mapping) => {
if (mapping.toColumnName === toColumnName) {
return {
...mapping,
fromColumnName: fromColumnName,
defaultValue: "", // 매핑이 설정되면 기본값 초기화
};
}
return mapping;
});
setColumnMappings(newMappings);
updateSettings(newMappings);
};
// TO 컬럼 클릭 핸들러
const handleToColumnClick = (toColumnName: string) => {
const currentMapping = columnMappings.find((m) => m.toColumnName === toColumnName);
// 이미 매핑된 컬럼인 경우 처리하지 않음
if (currentMapping?.fromColumnName) return;
if (selectedToColumn === toColumnName) {
setSelectedToColumn(null);
} else {
setSelectedToColumn(toColumnName);
// FROM 컬럼이 이미 선택되어 있으면 매핑 시도
if (selectedFromColumn) {
const fromColumn = fromTableColumns.find((col) => col.columnName === selectedFromColumn);
const toColumn = toTableColumns.find((col) => col.columnName === toColumnName);
if (fromColumn && toColumn) {
// 데이터 타입 호환성 체크
if (fromColumn.dataType !== toColumn.dataType) {
alert(`데이터 타입이 다릅니다. FROM: ${fromColumn.dataType}, TO: ${toColumn.dataType}`);
return;
}
// 매핑 생성
createMapping(selectedFromColumn, toColumnName);
setSelectedFromColumn(null);
setSelectedToColumn(null);
}
}
}
};
// 기본값 변경 핸들러
const handleDefaultValueChange = (toColumnName: string, value: string) => {
const newMappings = columnMappings.map((mapping) => {
if (mapping.toColumnName === toColumnName) {
return {
...mapping,
fromColumnName: value.trim() ? undefined : mapping.fromColumnName, // 기본값이 있으면 매핑 제거
defaultValue: value,
};
}
return mapping;
});
setColumnMappings(newMappings);
updateSettings(newMappings);
};
// 매핑 제거 핸들러
const handleRemoveMapping = (toColumnName: string) => {
const newMappings = columnMappings.map((mapping) => {
if (mapping.toColumnName === toColumnName) {
return {
...mapping,
fromColumnName: undefined,
defaultValue: "",
};
}
return mapping;
});
setColumnMappings(newMappings);
updateSettings(newMappings);
};
// FROM 컬럼이 클릭 가능한지 확인 (데이터 타입 호환성 + 1대1 매핑 제약)
const isFromColumnClickable = (fromColumn: ColumnInfo) => {
// 이미 다른 TO 컬럼과 매핑된 FROM 컬럼은 클릭 불가
const isAlreadyMapped = columnMappings.some((mapping) => mapping.fromColumnName === fromColumn.columnName);
if (isAlreadyMapped) return false;
if (!selectedToColumn) return true; // TO가 선택되지 않았으면 모든 FROM 클릭 가능
const toColumn = toTableColumns.find((col) => col.columnName === selectedToColumn);
if (!toColumn) return true;
return fromColumn.dataType === toColumn.dataType;
};
// TO 컬럼이 클릭 가능한지 확인 (데이터 타입 호환성)
const isToColumnClickable = (toColumn: ColumnInfo) => {
const currentMapping = columnMappings.find((m) => m.toColumnName === toColumn.columnName);
// 이미 매핑된 컬럼은 클릭 불가
if (currentMapping?.fromColumnName) return false;
// 기본값이 설정된 컬럼은 클릭 불가
if (currentMapping?.defaultValue && currentMapping.defaultValue.trim()) return false;
if (!selectedFromColumn) return true; // FROM이 선택되지 않았으면 모든 TO 클릭 가능
const fromColumn = fromTableColumns.find((col) => col.columnName === selectedFromColumn);
if (!fromColumn) return true;
return fromColumn.dataType === toColumn.dataType;
};
return (
<div className="mt-4">
{/* 헤더 섹션 */}
<Card className="mb-6 from-blue-50 to-green-50 py-2">
<CardContent className="pt-0">
<p className="text-sm leading-relaxed text-gray-700">
, .
. FROM TO .
</p>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-6">
<ColumnTableSection
type="from"
tableName={fromTableName || "소스 테이블"}
columns={fromTableColumns}
selectedColumn={selectedFromColumn}
onColumnClick={handleFromColumnClick}
searchTerm={fromSearchTerm}
onSearchChange={setFromSearchTerm}
dataTypeFilter={fromDataTypeFilter}
onDataTypeFilterChange={setFromDataTypeFilter}
showMappedOnly={fromShowMappedOnly}
onShowMappedOnlyChange={setFromShowMappedOnly}
showUnmappedOnly={fromShowUnmappedOnly}
onShowUnmappedOnlyChange={setFromShowUnmappedOnly}
columnMappings={columnMappings}
isColumnClickable={isFromColumnClickable}
oppositeSelectedColumn={selectedToColumn}
oppositeColumns={toTableColumns}
/>
<ColumnTableSection
type="to"
tableName={toTableName || "대상 테이블"}
columns={toTableColumns}
selectedColumn={selectedToColumn}
onColumnClick={handleToColumnClick}
searchTerm={toSearchTerm}
onSearchChange={setToSearchTerm}
dataTypeFilter={toDataTypeFilter}
onDataTypeFilterChange={setToDataTypeFilter}
showMappedOnly={toShowMappedOnly}
onShowMappedOnlyChange={setToShowMappedOnly}
showUnmappedOnly={toShowUnmappedOnly}
onShowUnmappedOnlyChange={setToShowUnmappedOnly}
columnMappings={columnMappings}
onDefaultValueChange={handleDefaultValueChange}
onRemoveMapping={handleRemoveMapping}
isColumnClickable={isToColumnClickable}
oppositeSelectedColumn={selectedFromColumn}
oppositeColumns={fromTableColumns}
/>
</div>
{/* 빠른 필터 액션 */}
<Card className="mt-6 py-2">
<CardContent className="flex flex-wrap gap-2 p-3">
<span className="self-center text-sm font-medium text-gray-700"> :</span>
<Button
variant="secondary"
size="sm"
onClick={() => {
setFromSearchTerm("");
setToSearchTerm("");
setFromDataTypeFilter("");
setToDataTypeFilter("");
setFromShowMappedOnly(false);
setFromShowUnmappedOnly(false);
setToShowMappedOnly(false);
setToShowUnmappedOnly(false);
}}
className="h-7 text-xs"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
// FROM: 미매핑만, TO: 미설정만
setFromShowMappedOnly(false);
setFromShowUnmappedOnly(true);
setToShowMappedOnly(false);
setToShowUnmappedOnly(true);
}}
className="h-7 bg-orange-100 text-xs text-orange-700 hover:bg-orange-200"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
// FROM: 매핑됨만, TO: 설정됨만
setFromShowMappedOnly(true);
setFromShowUnmappedOnly(false);
setToShowMappedOnly(true);
setToShowUnmappedOnly(false);
}}
className="h-7 bg-green-100 text-xs text-green-700 hover:bg-green-200"
>
</Button>
<div className="ml-auto flex gap-2">
<Badge variant="outline" className="text-xs">
FROM: {fromTableColumns.length}
</Badge>
<Badge variant="outline" className="text-xs">
TO: {toTableColumns.length}
</Badge>
</div>
</CardContent>
</Card>
{/* 매핑 통계 */}
<Card className="mt-4 bg-gradient-to-r from-gray-50 to-gray-100 py-2">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<div className="font-semibold text-gray-800"> </div>
<div className="text-sm text-gray-600">
{toTableColumns.length} {" "}
<span className="font-bold text-blue-600">
{columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length}
</span>{" "}
</div>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-800">
{Math.round(
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
toTableColumns.length) *
100,
)}
%
</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
<div className="mt-3">
<Progress
value={
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
toTableColumns.length) *
100
}
className="h-2"
/>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@ -1,16 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { checkComponentDuplicate } from "@/lib/api/componentApi";
export const useComponentDuplicateCheck = (componentCode: string, enabled: boolean = true) => {
return useQuery({
queryKey: ["componentDuplicateCheck", componentCode],
queryFn: async () => {
const result = await checkComponentDuplicate(componentCode);
console.log(`🔍 중복 체크 응답 데이터:`, { componentCode, result, isDuplicate: result.isDuplicate });
return result;
},
enabled: enabled && !!componentCode && componentCode.length > 0,
staleTime: 0, // 항상 최신 데이터 확인
retry: false, // 실패 시 재시도 안함
});
};

View File

@ -358,7 +358,7 @@ export class DataFlowAPI {
}
/**
*
* ( )
*/
static async getTableColumns(tableName: string): Promise<ColumnInfo[]> {
try {
@ -369,7 +369,7 @@ export class DataFlowAPI {
total: number;
totalPages: number;
}>
>(`/table-management/tables/${tableName}/columns`);
>(`/table-management/tables/${tableName}/columns?size=1000`);
if (!response.data.success) {
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");

View File

@ -220,9 +220,9 @@ export const tableTypeApi = {
}
},
// 테이블 컬럼 정보 조회
// 테이블 컬럼 정보 조회 (모든 컬럼)
getColumns: async (tableName: string): Promise<any[]> => {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`);
// 새로운 API 응답 구조에 맞게 수정: { columns, total, page, size, totalPages }
const data = response.data.data || response.data;
return data.columns || data || [];

View File

@ -0,0 +1,70 @@
#!/bin/bash
echo "============================================"
echo "백엔드 권한 문제 긴급 수정"
echo "============================================"
echo ""
echo "🔧 백엔드 컨테이너 권한 문제를 수정합니다..."
echo ""
# 1. 기존 백엔드 컨테이너 중지 및 제거
echo "1. 기존 백엔드 컨테이너 중지 중..."
docker-compose -f docker/prod/docker-compose.backend.prod.yml down -v --remove-orphans 2>/dev/null || true
# 강제로 컨테이너 제거
docker stop pms-backend-prod 2>/dev/null || true
docker rm pms-backend-prod 2>/dev/null || true
# 2. 기존 이미지 제거 (캐시 무효화)
echo "2. 기존 백엔드 이미지 제거 중..."
docker rmi $(docker images | grep -E "(pms-backend|erp-node.*backend)" | awk '{print $3}') 2>/dev/null || true
# 3. 새로운 이미지 빌드
echo "3. 수정된 백엔드 이미지 빌드 중..."
docker-compose -f docker/prod/docker-compose.backend.prod.yml build --no-cache --pull
if [ $? -ne 0 ]; then
echo "❌ 백엔드 빌드 실패"
exit 1
fi
# 4. 백엔드 컨테이너 시작
echo "4. 백엔드 컨테이너 시작 중..."
docker-compose -f docker/prod/docker-compose.backend.prod.yml up -d
# 5. 잠시 대기 후 상태 확인
echo "5. 백엔드 서비스 안정화 대기 중... (30초)"
sleep 30
# 6. 상태 확인
echo "6. 백엔드 컨테이너 상태 확인:"
docker-compose -f docker/prod/docker-compose.backend.prod.yml ps
echo ""
echo "백엔드 최근 로그:"
docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=15
# 7. 헬스체크
echo ""
echo "7. 헬스체크 수행 중..."
for i in {1..10}; do
if curl -s http://localhost:8080/health >/dev/null 2>&1; then
echo " ✅ 백엔드 서비스 정상 복구!"
echo ""
echo "🎉 수정 완료! 백엔드가 정상적으로 실행 중입니다."
exit 0
else
echo " ⏳ 백엔드 응답 대기 중... ($i/10)"
sleep 3
fi
done
echo " ❌ 여전히 문제가 있습니다. 로그를 확인해주세요:"
docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=20
echo ""
echo "🔧 추가 디버깅 명령어:"
echo " 실시간 로그: docker-compose -f docker/prod/docker-compose.backend.prod.yml logs -f"
echo " 컨테이너 접속: docker exec -it pms-backend-prod bash"
echo " 권한 확인: docker exec -it pms-backend-prod ls -la /app/"

View File

@ -117,16 +117,35 @@ echo ""
# 백엔드 헬스체크 (최대 60초 대기)
echo "백엔드 헬스체크..."
backend_healthy=false
for i in {1..12}; do
if curl -s http://localhost:8080/actuator/health >/dev/null 2>&1; then
if curl -s http://localhost:8080/health >/dev/null 2>&1; then
echo " ✅ 백엔드 서비스 정상"
backend_healthy=true
break
else
echo " ⏳ 백엔드 응답 대기 중... ($i/12)"
if [ $i -eq 6 ]; then
echo " 📋 백엔드 컨테이너 상태 확인:"
docker-compose -f docker/prod/docker-compose.backend.prod.yml ps
echo " 📋 백엔드 로그 (최근 10줄):"
docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=10
fi
sleep 5
fi
done
if [ "$backend_healthy" = false ]; then
echo " ❌ 백엔드 헬스체크 실패"
echo " 📋 상세 진단 정보:"
echo " 컨테이너 상태:"
docker-compose -f docker/prod/docker-compose.backend.prod.yml ps
echo " 최근 로그:"
docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=20
echo " 포트 8080 사용 현황:"
netstat -tln 2>/dev/null | grep ':8080' || echo " 포트 8080이 사용되지 않음"
fi
# 프론트엔드 헬스체크 (최대 30초 대기)
echo "프론트엔드 헬스체크..."
for i in {1..6}; do