메일관련된거 커밋
This commit is contained in:
@@ -19,19 +19,50 @@ import {
|
||||
Trash2,
|
||||
Settings,
|
||||
Upload,
|
||||
X
|
||||
X,
|
||||
GripVertical,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
LayoutTemplate,
|
||||
Table2,
|
||||
AlertCircle,
|
||||
Minus,
|
||||
Building2,
|
||||
ListOrdered
|
||||
} from "lucide-react";
|
||||
import { getMailTemplates } from "@/lib/api/mail";
|
||||
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer" | "table";
|
||||
type: "text" | "button" | "image" | "spacer" | "table" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
// 헤더 컴포넌트용
|
||||
logoSrc?: string;
|
||||
brandName?: string;
|
||||
sendDate?: string;
|
||||
headerBgColor?: string;
|
||||
// 정보 테이블용
|
||||
rows?: Array<{ label: string; value: string }>;
|
||||
tableTitle?: string;
|
||||
// 강조 박스용
|
||||
alertType?: "info" | "warning" | "danger" | "success";
|
||||
alertTitle?: string;
|
||||
// 푸터용
|
||||
companyName?: string;
|
||||
ceoName?: string;
|
||||
businessNumber?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
copyright?: string;
|
||||
// 번호 리스트용
|
||||
listItems?: string[];
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
export interface QueryConfig {
|
||||
@@ -64,6 +95,10 @@ export default function MailDesigner({
|
||||
const [subject, setSubject] = useState("");
|
||||
const [queries, setQueries] = useState<QueryConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 드래그 앤 드롭 상태
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
// 템플릿 데이터 로드 (수정 모드)
|
||||
useEffect(() => {
|
||||
@@ -96,10 +131,18 @@ export default function MailDesigner({
|
||||
|
||||
// 컴포넌트 타입 정의
|
||||
const componentTypes = [
|
||||
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200" },
|
||||
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30" },
|
||||
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" },
|
||||
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80" },
|
||||
// 레이아웃 컴포넌트
|
||||
{ type: "header", icon: LayoutTemplate, label: "헤더", color: "bg-indigo-100 hover:bg-indigo-200", category: "layout" },
|
||||
{ type: "divider", icon: Minus, label: "구분선", color: "bg-gray-100 hover:bg-gray-200", category: "layout" },
|
||||
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80", category: "layout" },
|
||||
{ type: "footer", icon: Building2, label: "푸터", color: "bg-slate-100 hover:bg-slate-200", category: "layout" },
|
||||
// 컨텐츠 컴포넌트
|
||||
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200", category: "content" },
|
||||
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30", category: "content" },
|
||||
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200", category: "content" },
|
||||
{ type: "infoTable", icon: Table2, label: "정보 테이블", color: "bg-cyan-100 hover:bg-cyan-200", category: "content" },
|
||||
{ type: "alertBox", icon: AlertCircle, label: "안내 박스", color: "bg-amber-100 hover:bg-amber-200", category: "content" },
|
||||
{ type: "numberedList", icon: ListOrdered, label: "번호 리스트", color: "bg-emerald-100 hover:bg-emerald-200", category: "content" },
|
||||
];
|
||||
|
||||
// 컴포넌트 추가
|
||||
@@ -107,21 +150,75 @@ export default function MailDesigner({
|
||||
const newComponent: MailComponent = {
|
||||
id: `comp-${Date.now()}`,
|
||||
type: type as any,
|
||||
content: type === "text" ? "" : undefined, // 🎯 빈 문자열로 시작 (HTML 태그 제거)
|
||||
text: type === "button" ? "버튼 텍스트" : undefined, // 🎯 더 명확한 기본값
|
||||
url: type === "button" || type === "image" ? "" : undefined, // 🎯 빈 문자열로 시작
|
||||
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined, // 🎯 한글 안내
|
||||
height: type === "spacer" ? 30 : undefined, // 🎯 기본값 30px로 증가 (더 적절한 간격)
|
||||
content: type === "text" ? "" : undefined,
|
||||
text: type === "button" ? "버튼 텍스트" : undefined,
|
||||
url: type === "button" || type === "image" ? "" : undefined,
|
||||
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined,
|
||||
height: type === "spacer" ? 30 : type === "divider" ? 1 : undefined,
|
||||
styles: {
|
||||
padding: "10px",
|
||||
padding: type === "divider" ? "0" : "10px",
|
||||
backgroundColor: type === "button" ? "#007bff" : "transparent",
|
||||
color: type === "button" ? "#fff" : "#333",
|
||||
},
|
||||
// 헤더 기본값
|
||||
logoSrc: type === "header" ? "" : undefined,
|
||||
brandName: type === "header" ? "회사명" : undefined,
|
||||
sendDate: type === "header" ? new Date().toLocaleDateString("ko-KR") : undefined,
|
||||
headerBgColor: type === "header" ? "#f8f9fa" : undefined,
|
||||
// 정보 테이블 기본값
|
||||
rows: type === "infoTable" ? [{ label: "항목", value: "내용" }] : undefined,
|
||||
tableTitle: type === "infoTable" ? "" : undefined,
|
||||
// 안내 박스 기본값
|
||||
alertType: type === "alertBox" ? "info" : undefined,
|
||||
alertTitle: type === "alertBox" ? "안내" : undefined,
|
||||
// 푸터 기본값
|
||||
companyName: type === "footer" ? "회사명" : undefined,
|
||||
ceoName: type === "footer" ? "" : undefined,
|
||||
businessNumber: type === "footer" ? "" : undefined,
|
||||
address: type === "footer" ? "" : undefined,
|
||||
phone: type === "footer" ? "" : undefined,
|
||||
email: type === "footer" ? "" : undefined,
|
||||
copyright: type === "footer" ? `© ${new Date().getFullYear()} All rights reserved.` : undefined,
|
||||
// 번호 리스트 기본값
|
||||
listItems: type === "numberedList" ? ["첫 번째 항목"] : undefined,
|
||||
listTitle: type === "numberedList" ? "" : undefined,
|
||||
};
|
||||
|
||||
setComponents([...components, newComponent]);
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null && draggedIndex !== index) {
|
||||
setDragOverIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (index: number) => {
|
||||
if (draggedIndex !== null && draggedIndex !== index) {
|
||||
moveComponent(draggedIndex, index);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const moveComponent = (fromIndex: number, toIndex: number) => {
|
||||
const newComponents = [...components];
|
||||
const [movedItem] = newComponents.splice(fromIndex, 1);
|
||||
newComponents.splice(toIndex, 0, movedItem);
|
||||
setComponents(newComponents);
|
||||
};
|
||||
|
||||
// 컴포넌트 삭제
|
||||
const removeComponent = (id: string) => {
|
||||
setComponents(components.filter(c => c.id !== id));
|
||||
@@ -189,13 +286,35 @@ export default function MailDesigner({
|
||||
<div className="flex h-screen bg-muted/30">
|
||||
{/* 왼쪽: 컴포넌트 팔레트 */}
|
||||
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
|
||||
{/* 레이아웃 컴포넌트 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<LayoutTemplate className="w-4 h-4 mr-2 text-indigo-500" />
|
||||
레이아웃
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{componentTypes.filter(c => c.category === "layout").map(({ type, icon: Icon, label, color }) => (
|
||||
<Button
|
||||
key={type}
|
||||
onClick={() => addComponent(type)}
|
||||
variant="outline"
|
||||
className={`w-full justify-start ${color} border`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 컴포넌트 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Mail className="w-4 h-4 mr-2 text-primary" />
|
||||
컴포넌트
|
||||
컨텐츠
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{componentTypes.map(({ type, icon: Icon, label, color }) => (
|
||||
{componentTypes.filter(c => c.category === "content").map(({ type, icon: Icon, label, color }) => (
|
||||
<Button
|
||||
key={type}
|
||||
onClick={() => addComponent(type)}
|
||||
@@ -274,24 +393,57 @@ export default function MailDesigner({
|
||||
)}
|
||||
|
||||
{/* 컴포넌트 렌더링 */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-6 pl-14 space-y-4">
|
||||
{components.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground/50">
|
||||
<Mail className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
||||
<p>왼쪽에서 컴포넌트를 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
components.map((comp) => (
|
||||
components.map((comp, index) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={() => handleDrop(index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => selectComponent(comp.id)}
|
||||
className={`relative group cursor-pointer rounded-lg transition-all ${
|
||||
selectedComponent === comp.id
|
||||
? "ring-2 ring-orange-500 bg-orange-50/30"
|
||||
: "hover:ring-2 hover:ring-gray-300"
|
||||
} ${draggedIndex === index ? "opacity-50 scale-95" : ""} ${
|
||||
dragOverIndex === index ? "ring-2 ring-primary ring-dashed bg-primary/10" : ""
|
||||
}`}
|
||||
style={comp.styles}
|
||||
>
|
||||
{/* 드래그 핸들 & 순서 이동 버튼 */}
|
||||
<div className="absolute -left-10 top-1/2 -translate-y-1/2 flex flex-col items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (index > 0) moveComponent(index, index - 1); }}
|
||||
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded">
|
||||
<GripVertical className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (index < components.length - 1) moveComponent(index, index + 1); }}
|
||||
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
|
||||
disabled={index === components.length - 1}
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 순서 배지 */}
|
||||
<div className="absolute -left-10 top-0 text-xs text-gray-400 opacity-0 group-hover:opacity-100">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -322,7 +474,82 @@ export default function MailDesigner({
|
||||
<img src={comp.src} alt="메일 이미지" className="w-full rounded" />
|
||||
)}
|
||||
{comp.type === "spacer" && (
|
||||
<div style={{ height: `${comp.height}px` }} />
|
||||
<div style={{ height: `${comp.height}px` }} className="bg-gray-100 rounded flex items-center justify-center text-xs text-gray-400">
|
||||
여백 {comp.height}px
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "header" && (
|
||||
<div className="p-4 rounded-lg" style={{ backgroundColor: comp.headerBgColor || "#f8f9fa" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{comp.logoSrc && <img src={comp.logoSrc} alt="로고" className="h-10" />}
|
||||
<span className="font-bold text-lg">{comp.brandName}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{comp.sendDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "infoTable" && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{comp.tableTitle && (
|
||||
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{comp.tableTitle}</div>
|
||||
)}
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{comp.rows?.map((row, i) => (
|
||||
<tr key={i} className={i % 2 === 0 ? "bg-white" : "bg-gray-50"}>
|
||||
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
|
||||
<td className="px-4 py-2">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "alertBox" && (
|
||||
<div className={`p-4 rounded-lg border-l-4 ${
|
||||
comp.alertType === "info" ? "bg-blue-50 border-blue-500 text-blue-800" :
|
||||
comp.alertType === "warning" ? "bg-amber-50 border-amber-500 text-amber-800" :
|
||||
comp.alertType === "danger" ? "bg-red-50 border-red-500 text-red-800" :
|
||||
"bg-emerald-50 border-emerald-500 text-emerald-800"
|
||||
}`}>
|
||||
{comp.alertTitle && <div className="font-bold mb-1">{comp.alertTitle}</div>}
|
||||
<div>{comp.content}</div>
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "divider" && (
|
||||
<hr className="border-gray-300" style={{ borderWidth: `${comp.height || 1}px` }} />
|
||||
)}
|
||||
{comp.type === "footer" && (
|
||||
<div className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
|
||||
{comp.companyName && <div className="font-semibold text-gray-700">{comp.companyName}</div>}
|
||||
{(comp.ceoName || comp.businessNumber) && (
|
||||
<div className="mt-1">
|
||||
{comp.ceoName && <span>대표: {comp.ceoName}</span>}
|
||||
{comp.ceoName && comp.businessNumber && <span className="mx-2">|</span>}
|
||||
{comp.businessNumber && <span>사업자등록번호: {comp.businessNumber}</span>}
|
||||
</div>
|
||||
)}
|
||||
{comp.address && <div className="mt-1">{comp.address}</div>}
|
||||
{(comp.phone || comp.email) && (
|
||||
<div className="mt-1">
|
||||
{comp.phone && <span>Tel: {comp.phone}</span>}
|
||||
{comp.phone && comp.email && <span className="mx-2">|</span>}
|
||||
{comp.email && <span>Email: {comp.email}</span>}
|
||||
</div>
|
||||
)}
|
||||
{comp.copyright && <div className="mt-2 text-xs text-gray-400">{comp.copyright}</div>}
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "numberedList" && (
|
||||
<div className="p-4">
|
||||
{comp.listTitle && <div className="font-semibold mb-2">{comp.listTitle}</div>}
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{comp.listItems?.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
@@ -571,13 +798,299 @@ export default function MailDesigner({
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">픽셀</span>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-primary/10 rounded-lg border border-primary/20">
|
||||
<p className="text-xs text-primary">
|
||||
<strong>추천값:</strong><br/>
|
||||
• 좁은 간격: 10~20 픽셀<br/>
|
||||
• 보통 간격: 30~50 픽셀<br/>
|
||||
• 넓은 간격: 60~100 픽셀
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 헤더 컴포넌트 */}
|
||||
{selected.type === "header" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>브랜드명</Label>
|
||||
<Input
|
||||
value={selected.brandName || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { brandName: e.target.value })}
|
||||
placeholder="회사명"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>로고 이미지 URL</Label>
|
||||
<Input
|
||||
value={selected.logoSrc || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { logoSrc: e.target.value })}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>발송일</Label>
|
||||
<Input
|
||||
value={selected.sendDate || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { sendDate: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>배경색</Label>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<Input
|
||||
type="color"
|
||||
value={selected.headerBgColor || "#f8f9fa"}
|
||||
onChange={(e) => updateComponent(selected.id, { headerBgColor: e.target.value })}
|
||||
className="w-16 h-10"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{selected.headerBgColor || "#f8f9fa"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정보 테이블 컴포넌트 */}
|
||||
{selected.type === "infoTable" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>테이블 제목</Label>
|
||||
<Input
|
||||
value={selected.tableTitle || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { tableTitle: e.target.value })}
|
||||
placeholder="예: 주문 정보"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>테이블 항목</Label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{selected.rows?.map((row, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<Input
|
||||
value={row.label}
|
||||
onChange={(e) => {
|
||||
const newRows = [...(selected.rows || [])];
|
||||
newRows[i] = { ...newRows[i], label: e.target.value };
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
placeholder="항목명"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(e) => {
|
||||
const newRows = [...(selected.rows || [])];
|
||||
newRows[i] = { ...newRows[i], value: e.target.value };
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
placeholder="값"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newRows = selected.rows?.filter((_, idx) => idx !== i);
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newRows = [...(selected.rows || []), { label: "", value: "" }];
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 박스 컴포넌트 */}
|
||||
{selected.type === "alertBox" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>박스 유형</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{(["info", "warning", "danger", "success"] as const).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={selected.alertType === type ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => updateComponent(selected.id, { alertType: type })}
|
||||
className={
|
||||
type === "info" ? "border-blue-300" :
|
||||
type === "warning" ? "border-amber-300" :
|
||||
type === "danger" ? "border-red-300" : "border-emerald-300"
|
||||
}
|
||||
>
|
||||
{type === "info" ? "정보" : type === "warning" ? "주의" : type === "danger" ? "위험" : "성공"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>제목</Label>
|
||||
<Input
|
||||
value={selected.alertTitle || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { alertTitle: e.target.value })}
|
||||
placeholder="안내 제목"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>내용</Label>
|
||||
<Textarea
|
||||
value={selected.content || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { content: e.target.value })}
|
||||
placeholder="안내 내용을 입력하세요"
|
||||
rows={4}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구분선 컴포넌트 */}
|
||||
{selected.type === "divider" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>선 두께</Label>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={selected.height || 1}
|
||||
onChange={(e) => updateComponent(selected.id, { height: parseInt(e.target.value) || 1 })}
|
||||
className="w-24"
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">픽셀</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 푸터 컴포넌트 */}
|
||||
{selected.type === "footer" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>회사명</Label>
|
||||
<Input
|
||||
value={selected.companyName || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { companyName: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>대표자</Label>
|
||||
<Input
|
||||
value={selected.ceoName || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { ceoName: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>사업자등록번호</Label>
|
||||
<Input
|
||||
value={selected.businessNumber || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { businessNumber: e.target.value })}
|
||||
placeholder="000-00-00000"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>주소</Label>
|
||||
<Input
|
||||
value={selected.address || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { address: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>전화번호</Label>
|
||||
<Input
|
||||
value={selected.phone || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { phone: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>이메일</Label>
|
||||
<Input
|
||||
value={selected.email || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { email: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>저작권 문구</Label>
|
||||
<Input
|
||||
value={selected.copyright || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { copyright: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 번호 리스트 컴포넌트 */}
|
||||
{selected.type === "numberedList" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>리스트 제목</Label>
|
||||
<Input
|
||||
value={selected.listTitle || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { listTitle: e.target.value })}
|
||||
placeholder="예: 안내 사항"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>항목</Label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{selected.listItems?.map((item, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className="flex items-center justify-center w-6 text-sm text-muted-foreground">{i + 1}.</span>
|
||||
<Input
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const newItems = [...(selected.listItems || [])];
|
||||
newItems[i] = e.target.value;
|
||||
updateComponent(selected.id, { listItems: newItems });
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newItems = selected.listItems?.filter((_, idx) => idx !== i);
|
||||
updateComponent(selected.id, { listItems: newItems });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newItems = [...(selected.listItems || []), ""];
|
||||
updateComponent(selected.id, { listItems: newItems });
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,11 +14,13 @@ import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Search, X, Check, ChevronsUpDown, Database } from "lucide-react";
|
||||
import { Search, X, Check, ChevronsUpDown, Database, Globe } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||
|
||||
interface CreateScreenModalProps {
|
||||
open: boolean;
|
||||
@@ -39,12 +40,22 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||
const [tableSearchTerm, setTableSearchTerm] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 데이터 소스 타입 (database: 데이터베이스, restapi: REST API)
|
||||
const [dataSourceType, setDataSourceType] = useState<"database" | "restapi">("database");
|
||||
|
||||
// 외부 DB 연결 관련 상태
|
||||
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal");
|
||||
const [externalConnections, setExternalConnections] = useState<any[]>([]);
|
||||
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
||||
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
||||
const [openDbSourceCombobox, setOpenDbSourceCombobox] = useState(false);
|
||||
|
||||
// REST API 연결 관련 상태
|
||||
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
|
||||
const [selectedRestApiId, setSelectedRestApiId] = useState<number | null>(null);
|
||||
const [openRestApiCombobox, setOpenRestApiCombobox] = useState(false);
|
||||
const [restApiEndpoint, setRestApiEndpoint] = useState("");
|
||||
const [restApiJsonPath, setRestApiJsonPath] = useState("data"); // 응답에서 데이터 추출 경로
|
||||
// 화면 코드 자동 생성
|
||||
const generateCode = async () => {
|
||||
try {
|
||||
@@ -109,6 +120,21 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||
loadConnections();
|
||||
}, [open]);
|
||||
|
||||
// REST API 연결 목록 로드
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const loadRestApiConnections = async () => {
|
||||
try {
|
||||
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
|
||||
setRestApiConnections(connections);
|
||||
} catch (error) {
|
||||
console.error("Failed to load REST API connections:", error);
|
||||
setRestApiConnections([]);
|
||||
}
|
||||
};
|
||||
loadRestApiConnections();
|
||||
}, [open]);
|
||||
|
||||
// 외부 DB 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
if (selectedDbSource === "internal" || !selectedDbSource) {
|
||||
@@ -160,8 +186,15 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||
}, [open, screenCode]);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0;
|
||||
}, [screenName, screenCode, tableName]);
|
||||
const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
|
||||
|
||||
if (dataSourceType === "database") {
|
||||
return baseValid && tableName.trim().length > 0;
|
||||
} else {
|
||||
// REST API: 연결 선택 필수
|
||||
return baseValid && selectedRestApiId !== null;
|
||||
}
|
||||
}, [screenName, screenCode, tableName, dataSourceType, selectedRestApiId]);
|
||||
|
||||
// 테이블 필터링 (내부 DB용)
|
||||
const filteredTables = useMemo(() => {
|
||||
@@ -186,17 +219,30 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||
setSubmitting(true);
|
||||
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
|
||||
|
||||
// DB 소스 정보 추가
|
||||
const created = await screenApi.createScreen({
|
||||
// 데이터 소스 타입에 따라 다른 정보 전달
|
||||
const createData: any = {
|
||||
screenName: screenName.trim(),
|
||||
screenCode: screenCode.trim(),
|
||||
tableName: tableName.trim(),
|
||||
companyCode,
|
||||
description: description.trim() || undefined,
|
||||
createdBy: (user as any)?.userId,
|
||||
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
|
||||
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
|
||||
} as any);
|
||||
dataSourceType: dataSourceType,
|
||||
};
|
||||
|
||||
if (dataSourceType === "database") {
|
||||
// 데이터베이스 소스
|
||||
createData.tableName = tableName.trim();
|
||||
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
|
||||
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
|
||||
} else {
|
||||
// REST API 소스
|
||||
createData.tableName = `_restapi_${selectedRestApiId}`; // REST API용 가상 테이블명
|
||||
createData.restApiConnectionId = selectedRestApiId;
|
||||
createData.restApiEndpoint = restApiEndpoint.trim() || undefined;
|
||||
createData.restApiJsonPath = restApiJsonPath.trim() || "data";
|
||||
}
|
||||
|
||||
const created = await screenApi.createScreen(createData);
|
||||
|
||||
// 날짜 필드 보정
|
||||
const mapped: ScreenDefinition = {
|
||||
@@ -207,11 +253,16 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||
|
||||
onCreated?.(mapped);
|
||||
onOpenChange(false);
|
||||
// 폼 초기화
|
||||
setScreenName("");
|
||||
setScreenCode("");
|
||||
setTableName("");
|
||||
setDescription("");
|
||||
setSelectedDbSource("internal");
|
||||
setDataSourceType("database");
|
||||
setSelectedRestApiId(null);
|
||||
setRestApiEndpoint("");
|
||||
setRestApiJsonPath("data");
|
||||
} catch (e) {
|
||||
// 필요 시 토스트 추가 가능
|
||||
} finally {
|
||||
@@ -263,83 +314,210 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DB 소스 선택 */}
|
||||
{/* 데이터 소스 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dbSource">데이터베이스 소스</Label>
|
||||
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDbSourceCombobox}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{selectedDbSource === "internal"
|
||||
? "내부 데이터베이스"
|
||||
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
|
||||
"선택하세요"}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="데이터베이스 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>데이터베이스를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="internal"
|
||||
onSelect={() => {
|
||||
setSelectedDbSource("internal");
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-blue-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">내부 데이터베이스</span>
|
||||
<span className="text-xs text-gray-500">PostgreSQL (현재 시스템)</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
{externalConnections.map((conn: any) => (
|
||||
<CommandItem
|
||||
key={conn.id}
|
||||
value={`${conn.connection_name} ${conn.db_type}`}
|
||||
onSelect={() => {
|
||||
setSelectedDbSource(conn.id);
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-green-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-gray-500">화면에서 사용할 데이터베이스를 선택합니다</p>
|
||||
<Label>데이터 소스 타입</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={dataSourceType === "database" ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setDataSourceType("database");
|
||||
setSelectedRestApiId(null);
|
||||
}}
|
||||
>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
데이터베이스
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={dataSourceType === "restapi" ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setDataSourceType("restapi");
|
||||
setTableName("");
|
||||
setSelectedDbSource("internal");
|
||||
}}
|
||||
>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
REST API
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{/* 데이터베이스 소스 설정 */}
|
||||
{dataSourceType === "database" && (
|
||||
<>
|
||||
{/* DB 소스 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dbSource">데이터베이스 소스</Label>
|
||||
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDbSourceCombobox}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{selectedDbSource === "internal"
|
||||
? "내부 데이터베이스"
|
||||
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
|
||||
"선택하세요"}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="데이터베이스 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>데이터베이스를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="internal"
|
||||
onSelect={() => {
|
||||
setSelectedDbSource("internal");
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-blue-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">내부 데이터베이스</span>
|
||||
<span className="text-xs text-gray-500">PostgreSQL (현재 시스템)</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
{externalConnections.map((conn: any) => (
|
||||
<CommandItem
|
||||
key={conn.id}
|
||||
value={`${conn.connection_name} ${conn.db_type}`}
|
||||
onSelect={() => {
|
||||
setSelectedDbSource(conn.id);
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-green-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-gray-500">화면에서 사용할 데이터베이스를 선택합니다</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* REST API 소스 설정 */}
|
||||
{dataSourceType === "restapi" && (
|
||||
<>
|
||||
{/* REST API 연결 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="restApiConnection">REST API 연결 *</Label>
|
||||
<Popover open={openRestApiCombobox} onOpenChange={setOpenRestApiCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openRestApiCombobox}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
{selectedRestApiId
|
||||
? restApiConnections.find((conn) => conn.id === selectedRestApiId)?.connection_name ||
|
||||
"선택하세요"
|
||||
: "REST API 연결을 선택하세요"}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="REST API 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>등록된 REST API 연결이 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{restApiConnections.map((conn) => (
|
||||
<CommandItem
|
||||
key={conn.id}
|
||||
value={`${conn.connection_name} ${conn.base_url}`}
|
||||
onSelect={() => {
|
||||
setSelectedRestApiId(conn.id!);
|
||||
setRestApiEndpoint(conn.endpoint_path || "");
|
||||
setOpenRestApiCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedRestApiId === conn.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Globe className="mr-2 h-4 w-4 text-purple-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500 truncate max-w-[300px]">{conn.base_url}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-gray-500">
|
||||
등록된 REST API 연결을 선택합니다.
|
||||
<Link href="/admin/externalRestApi" className="ml-1 text-primary hover:underline">
|
||||
새 연결 등록
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 엔드포인트 경로 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="restApiEndpoint">엔드포인트 경로</Label>
|
||||
<Input
|
||||
id="restApiEndpoint"
|
||||
value={restApiEndpoint}
|
||||
onChange={(e) => setRestApiEndpoint(e.target.value)}
|
||||
placeholder="/api/data 또는 /users"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">기본 URL 뒤에 추가될 경로 (선택사항)</p>
|
||||
</div>
|
||||
|
||||
{/* JSON Path */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="restApiJsonPath">데이터 경로 (JSON Path)</Label>
|
||||
<Input
|
||||
id="restApiJsonPath"
|
||||
value={restApiJsonPath}
|
||||
onChange={(e) => setRestApiJsonPath(e.target.value)}
|
||||
placeholder="data 또는 result.items"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">API 응답에서 데이터 배열을 추출할 경로 (예: data, result.items)</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 테이블 선택 (데이터베이스 모드일 때만) */}
|
||||
{dataSourceType === "database" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tableName">테이블</Label>
|
||||
<Label htmlFor="tableName">테이블 *</Label>
|
||||
<Select
|
||||
value={tableName}
|
||||
onValueChange={setTableName}
|
||||
@@ -422,11 +600,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="mt-4">
|
||||
|
||||
Reference in New Issue
Block a user