feat: 화면 그룹 관리 기능 추가
- 화면 그룹 CRUD API 및 라우트 구현 - 화면 그룹 목록 조회, 생성, 수정, 삭제 기능 추가 - 화면-그룹 연결 및 데이터 흐름 관리 기능 포함 - 프론트엔드에서 화면 그룹 필터링 및 시각화 기능
This commit is contained in:
457
frontend/components/screen/panels/DataFlowPanel.tsx
Normal file
457
frontend/components/screen/panels/DataFlowPanel.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, ArrowRight, Trash2, Pencil, GitBranch, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
getDataFlows,
|
||||
createDataFlow,
|
||||
updateDataFlow,
|
||||
deleteDataFlow,
|
||||
DataFlow,
|
||||
} from "@/lib/api/screenGroup";
|
||||
|
||||
interface DataFlowPanelProps {
|
||||
groupId?: number;
|
||||
screenId?: number;
|
||||
screens?: Array<{ screen_id: number; screen_name: string }>;
|
||||
}
|
||||
|
||||
export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataFlowPanelProps) {
|
||||
// 상태 관리
|
||||
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedFlow, setSelectedFlow] = useState<DataFlow | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
source_screen_id: 0,
|
||||
source_action: "",
|
||||
target_screen_id: 0,
|
||||
target_action: "",
|
||||
data_mapping: "",
|
||||
flow_type: "unidirectional",
|
||||
flow_label: "",
|
||||
condition_expression: "",
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
const loadDataFlows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getDataFlows(groupId);
|
||||
if (response.success && response.data) {
|
||||
setDataFlows(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("데이터 흐름 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDataFlows();
|
||||
}, [loadDataFlows]);
|
||||
|
||||
// 모달 열기
|
||||
const openModal = (flow?: DataFlow) => {
|
||||
if (flow) {
|
||||
setSelectedFlow(flow);
|
||||
setFormData({
|
||||
source_screen_id: flow.source_screen_id,
|
||||
source_action: flow.source_action || "",
|
||||
target_screen_id: flow.target_screen_id,
|
||||
target_action: flow.target_action || "",
|
||||
data_mapping: flow.data_mapping ? JSON.stringify(flow.data_mapping, null, 2) : "",
|
||||
flow_type: flow.flow_type,
|
||||
flow_label: flow.flow_label || "",
|
||||
condition_expression: flow.condition_expression || "",
|
||||
is_active: flow.is_active,
|
||||
});
|
||||
} else {
|
||||
setSelectedFlow(null);
|
||||
setFormData({
|
||||
source_screen_id: screenId || 0,
|
||||
source_action: "",
|
||||
target_screen_id: 0,
|
||||
target_action: "",
|
||||
data_mapping: "",
|
||||
flow_type: "unidirectional",
|
||||
flow_label: "",
|
||||
condition_expression: "",
|
||||
is_active: "Y",
|
||||
});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.source_screen_id || !formData.target_screen_id) {
|
||||
toast.error("소스 화면과 타겟 화면을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let dataMappingJson = null;
|
||||
if (formData.data_mapping) {
|
||||
try {
|
||||
dataMappingJson = JSON.parse(formData.data_mapping);
|
||||
} catch {
|
||||
toast.error("데이터 매핑 JSON 형식이 올바르지 않습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
group_id: groupId,
|
||||
source_screen_id: formData.source_screen_id,
|
||||
source_action: formData.source_action || null,
|
||||
target_screen_id: formData.target_screen_id,
|
||||
target_action: formData.target_action || null,
|
||||
data_mapping: dataMappingJson,
|
||||
flow_type: formData.flow_type,
|
||||
flow_label: formData.flow_label || null,
|
||||
condition_expression: formData.condition_expression || null,
|
||||
is_active: formData.is_active,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (selectedFlow) {
|
||||
response = await updateDataFlow(selectedFlow.id, payload);
|
||||
} else {
|
||||
response = await createDataFlow(payload);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(selectedFlow ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadDataFlows();
|
||||
} else {
|
||||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("이 데이터 흐름을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
const response = await deleteDataFlow(id);
|
||||
if (response.success) {
|
||||
toast.success("데이터 흐름이 삭제되었습니다.");
|
||||
loadDataFlows();
|
||||
} else {
|
||||
toast.error(response.message || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 액션 옵션
|
||||
const sourceActions = [
|
||||
{ value: "click", label: "클릭" },
|
||||
{ value: "submit", label: "제출" },
|
||||
{ value: "select", label: "선택" },
|
||||
{ value: "change", label: "변경" },
|
||||
{ value: "doubleClick", label: "더블클릭" },
|
||||
];
|
||||
|
||||
const targetActions = [
|
||||
{ value: "open", label: "열기" },
|
||||
{ value: "load", label: "로드" },
|
||||
{ value: "refresh", label: "새로고침" },
|
||||
{ value: "save", label: "저장" },
|
||||
{ value: "filter", label: "필터" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">데이터 흐름</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={loadDataFlows} className="h-8 w-8 p-0">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
화면 간 데이터 전달 흐름을 정의합니다. (예: 목록 화면에서 행 클릭 시 상세 화면 열기)
|
||||
</p>
|
||||
|
||||
{/* 흐름 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : dataFlows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
|
||||
<GitBranch className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">정의된 데이터 흐름이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{dataFlows.map((flow) => (
|
||||
<div
|
||||
key={flow.id}
|
||||
className="flex items-center justify-between rounded-lg border bg-card p-3 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* 소스 화면 */}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium truncate max-w-[100px]">
|
||||
{flow.source_screen_name || `화면 ${flow.source_screen_id}`}
|
||||
</span>
|
||||
{flow.source_action && (
|
||||
<span className="text-muted-foreground">{flow.source_action}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="flex items-center gap-1 text-primary">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
{flow.flow_type === "bidirectional" && (
|
||||
<ArrowRight className="h-4 w-4 rotate-180" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 타겟 화면 */}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium truncate max-w-[100px]">
|
||||
{flow.target_screen_name || `화면 ${flow.target_screen_id}`}
|
||||
</span>
|
||||
{flow.target_action && (
|
||||
<span className="text-muted-foreground">{flow.target_action}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
{flow.flow_label && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-muted-foreground">
|
||||
{flow.flow_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(flow)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(flow.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{selectedFlow ? "데이터 흐름 수정" : "데이터 흐름 추가"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
화면 간 데이터 전달 흐름을 설정합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 소스 화면 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">소스 화면 *</Label>
|
||||
<Select
|
||||
value={formData.source_screen_id.toString()}
|
||||
onValueChange={(value) => setFormData({ ...formData, source_screen_id: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="화면 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{screens.map((screen) => (
|
||||
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
|
||||
{screen.screen_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">소스 액션</Label>
|
||||
<Select
|
||||
value={formData.source_action}
|
||||
onValueChange={(value) => setFormData({ ...formData, source_action: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="액션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceActions.map((action) => (
|
||||
<SelectItem key={action.value} value={action.value}>
|
||||
{action.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타겟 화면 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">타겟 화면 *</Label>
|
||||
<Select
|
||||
value={formData.target_screen_id.toString()}
|
||||
onValueChange={(value) => setFormData({ ...formData, target_screen_id: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="화면 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{screens.map((screen) => (
|
||||
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
|
||||
{screen.screen_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">타겟 액션</Label>
|
||||
<Select
|
||||
value={formData.target_action}
|
||||
onValueChange={(value) => setFormData({ ...formData, target_action: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="액션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetActions.map((action) => (
|
||||
<SelectItem key={action.value} value={action.value}>
|
||||
{action.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 흐름 설정 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">흐름 타입</Label>
|
||||
<Select
|
||||
value={formData.flow_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, flow_type: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unidirectional">단방향</SelectItem>
|
||||
<SelectItem value="bidirectional">양방향</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">흐름 라벨</Label>
|
||||
<Input
|
||||
value={formData.flow_label}
|
||||
onChange={(e) => setFormData({ ...formData, flow_label: e.target.value })}
|
||||
placeholder="예: 상세 보기"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 매핑 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">데이터 매핑 (JSON)</Label>
|
||||
<Textarea
|
||||
value={formData.data_mapping}
|
||||
onChange={(e) => setFormData({ ...formData, data_mapping: e.target.value })}
|
||||
placeholder='{"source_field": "target_field"}'
|
||||
className="min-h-[80px] font-mono text-xs sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
소스 화면의 필드를 타겟 화면의 필드로 매핑합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 조건식 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">실행 조건 (선택)</Label>
|
||||
<Input
|
||||
value={formData.condition_expression}
|
||||
onChange={(e) => setFormData({ ...formData, condition_expression: e.target.value })}
|
||||
placeholder="예: data.status === 'active'"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{selectedFlow ? "수정" : "추가"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
409
frontend/components/screen/panels/FieldJoinPanel.tsx
Normal file
409
frontend/components/screen/panels/FieldJoinPanel.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2, Link2, Database } from "lucide-react";
|
||||
import {
|
||||
getFieldJoins,
|
||||
createFieldJoin,
|
||||
updateFieldJoin,
|
||||
deleteFieldJoin,
|
||||
FieldJoin,
|
||||
} from "@/lib/api/screenGroup";
|
||||
|
||||
interface FieldJoinPanelProps {
|
||||
screenId: number;
|
||||
componentId?: string;
|
||||
layoutId?: number;
|
||||
}
|
||||
|
||||
export default function FieldJoinPanel({ screenId, componentId, layoutId }: FieldJoinPanelProps) {
|
||||
// 상태 관리
|
||||
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedJoin, setSelectedJoin] = useState<FieldJoin | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
field_name: "",
|
||||
save_table: "",
|
||||
save_column: "",
|
||||
join_table: "",
|
||||
join_column: "",
|
||||
display_column: "",
|
||||
join_type: "LEFT",
|
||||
filter_condition: "",
|
||||
sort_column: "",
|
||||
sort_direction: "ASC",
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
const loadFieldJoins = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getFieldJoins(screenId);
|
||||
if (response.success && response.data) {
|
||||
// 현재 컴포넌트에 해당하는 조인만 필터링
|
||||
const filtered = componentId
|
||||
? response.data.filter(join => join.component_id === componentId)
|
||||
: response.data;
|
||||
setFieldJoins(filtered);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("필드 조인 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [screenId, componentId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFieldJoins();
|
||||
}, [loadFieldJoins]);
|
||||
|
||||
// 모달 열기
|
||||
const openModal = (join?: FieldJoin) => {
|
||||
if (join) {
|
||||
setSelectedJoin(join);
|
||||
setFormData({
|
||||
field_name: join.field_name || "",
|
||||
save_table: join.save_table,
|
||||
save_column: join.save_column,
|
||||
join_table: join.join_table,
|
||||
join_column: join.join_column,
|
||||
display_column: join.display_column,
|
||||
join_type: join.join_type,
|
||||
filter_condition: join.filter_condition || "",
|
||||
sort_column: join.sort_column || "",
|
||||
sort_direction: join.sort_direction || "ASC",
|
||||
is_active: join.is_active,
|
||||
});
|
||||
} else {
|
||||
setSelectedJoin(null);
|
||||
setFormData({
|
||||
field_name: "",
|
||||
save_table: "",
|
||||
save_column: "",
|
||||
join_table: "",
|
||||
join_column: "",
|
||||
display_column: "",
|
||||
join_type: "LEFT",
|
||||
filter_condition: "",
|
||||
sort_column: "",
|
||||
sort_direction: "ASC",
|
||||
is_active: "Y",
|
||||
});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.save_table || !formData.save_column || !formData.join_table || !formData.join_column || !formData.display_column) {
|
||||
toast.error("필수 필드를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
screen_id: screenId,
|
||||
layout_id: layoutId,
|
||||
component_id: componentId,
|
||||
...formData,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (selectedJoin) {
|
||||
response = await updateFieldJoin(selectedJoin.id, payload);
|
||||
} else {
|
||||
response = await createFieldJoin(payload);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(selectedJoin ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadFieldJoins();
|
||||
} else {
|
||||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("이 조인 설정을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
const response = await deleteFieldJoin(id);
|
||||
if (response.success) {
|
||||
toast.success("조인 설정이 삭제되었습니다.");
|
||||
loadFieldJoins();
|
||||
} else {
|
||||
toast.error(response.message || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">필드 조인 설정</h3>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
이 필드가 다른 테이블의 값을 참조하여 표시할 때 조인 설정을 추가하세요.
|
||||
</p>
|
||||
|
||||
{/* 조인 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : fieldJoins.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
|
||||
<Database className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">설정된 조인이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="h-8 text-xs">저장 테이블.컬럼</TableHead>
|
||||
<TableHead className="h-8 text-xs">조인 테이블.컬럼</TableHead>
|
||||
<TableHead className="h-8 text-xs">표시 컬럼</TableHead>
|
||||
<TableHead className="h-8 w-[60px] text-xs">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fieldJoins.map((join) => (
|
||||
<TableRow key={join.id} className="text-xs">
|
||||
<TableCell className="py-2">
|
||||
<span className="font-mono">{join.save_table}.{join.save_column}</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<span className="font-mono">{join.join_table}.{join.join_column}</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<span className="font-mono">{join.display_column}</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(join)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(join.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{selectedJoin ? "조인 설정 수정" : "조인 설정 추가"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
필드가 참조할 테이블과 컬럼을 설정합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 필드명 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">필드명</Label>
|
||||
<Input
|
||||
value={formData.field_name}
|
||||
onChange={(e) => setFormData({ ...formData, field_name: e.target.value })}
|
||||
placeholder="화면에 표시될 필드명"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 저장 테이블/컬럼 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">저장 테이블 *</Label>
|
||||
<Input
|
||||
value={formData.save_table}
|
||||
onChange={(e) => setFormData({ ...formData, save_table: e.target.value })}
|
||||
placeholder="예: work_orders"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">저장 컬럼 *</Label>
|
||||
<Input
|
||||
value={formData.save_column}
|
||||
onChange={(e) => setFormData({ ...formData, save_column: e.target.value })}
|
||||
placeholder="예: item_code"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조인 테이블/컬럼 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">조인 테이블 *</Label>
|
||||
<Input
|
||||
value={formData.join_table}
|
||||
onChange={(e) => setFormData({ ...formData, join_table: e.target.value })}
|
||||
placeholder="예: item_mng"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">조인 컬럼 *</Label>
|
||||
<Input
|
||||
value={formData.join_column}
|
||||
onChange={(e) => setFormData({ ...formData, join_column: e.target.value })}
|
||||
placeholder="예: id"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표시 컬럼 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">표시 컬럼 *</Label>
|
||||
<Input
|
||||
value={formData.display_column}
|
||||
onChange={(e) => setFormData({ ...formData, display_column: e.target.value })}
|
||||
placeholder="예: item_name (화면에 표시될 컬럼)"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조인 타입/정렬 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">조인 타입</Label>
|
||||
<Select
|
||||
value={formData.join_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, join_type: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LEFT">LEFT JOIN</SelectItem>
|
||||
<SelectItem value="INNER">INNER JOIN</SelectItem>
|
||||
<SelectItem value="RIGHT">RIGHT JOIN</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">정렬 컬럼</Label>
|
||||
<Input
|
||||
value={formData.sort_column}
|
||||
onChange={(e) => setFormData({ ...formData, sort_column: e.target.value })}
|
||||
placeholder="예: name"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">정렬 방향</Label>
|
||||
<Select
|
||||
value={formData.sort_direction}
|
||||
onValueChange={(value) => setFormData({ ...formData, sort_direction: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ASC">오름차순</SelectItem>
|
||||
<SelectItem value="DESC">내림차순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 조건 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">필터 조건 (선택)</Label>
|
||||
<Textarea
|
||||
value={formData.filter_condition}
|
||||
onChange={(e) => setFormData({ ...formData, filter_condition: e.target.value })}
|
||||
placeholder="예: is_active = 'Y'"
|
||||
className="min-h-[60px] font-mono text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{selectedJoin ? "수정" : "추가"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user