687 lines
26 KiB
TypeScript
687 lines
26 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useEffect, useCallback } 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 { Switch } from "@/components/ui/switch";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import {
|
||
|
|
AlertDialog,
|
||
|
|
AlertDialogAction,
|
||
|
|
AlertDialogCancel,
|
||
|
|
AlertDialogContent,
|
||
|
|
AlertDialogDescription,
|
||
|
|
AlertDialogFooter,
|
||
|
|
AlertDialogHeader,
|
||
|
|
AlertDialogTitle,
|
||
|
|
} from "@/components/ui/alert-dialog";
|
||
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
||
|
|
import { Separator } from "@/components/ui/separator";
|
||
|
|
import {
|
||
|
|
Check,
|
||
|
|
ChevronsUpDown,
|
||
|
|
Plus,
|
||
|
|
Pencil,
|
||
|
|
Trash2,
|
||
|
|
Search,
|
||
|
|
RefreshCw,
|
||
|
|
ArrowRight,
|
||
|
|
X,
|
||
|
|
GripVertical,
|
||
|
|
} from "lucide-react";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill";
|
||
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||
|
|
|
||
|
|
interface TableColumn {
|
||
|
|
columnName: string;
|
||
|
|
columnLabel?: string;
|
||
|
|
dataType?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function AutoFillTab() {
|
||
|
|
// 목록 상태
|
||
|
|
const [groups, setGroups] = useState<AutoFillGroup[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [searchText, setSearchText] = useState("");
|
||
|
|
|
||
|
|
// 모달 상태
|
||
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||
|
|
const [editingGroup, setEditingGroup] = useState<AutoFillGroup | null>(null);
|
||
|
|
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||
|
|
|
||
|
|
// 테이블/컬럼 목록
|
||
|
|
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||
|
|
const [masterColumns, setMasterColumns] = useState<TableColumn[]>([]);
|
||
|
|
|
||
|
|
// 폼 데이터
|
||
|
|
const [formData, setFormData] = useState({
|
||
|
|
groupName: "",
|
||
|
|
description: "",
|
||
|
|
masterTable: "",
|
||
|
|
masterValueColumn: "",
|
||
|
|
masterLabelColumn: "",
|
||
|
|
isActive: "Y",
|
||
|
|
});
|
||
|
|
|
||
|
|
// 매핑 데이터
|
||
|
|
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
|
||
|
|
|
||
|
|
// 테이블 Combobox 상태
|
||
|
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||
|
|
|
||
|
|
// 목록 로드
|
||
|
|
const loadGroups = useCallback(async () => {
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const response = await cascadingAutoFillApi.getGroups();
|
||
|
|
if (response.success && response.data) {
|
||
|
|
setGroups(response.data);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("그룹 목록 로드 실패:", error);
|
||
|
|
toast.error("그룹 목록을 불러오는데 실패했습니다.");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 테이블 목록 로드
|
||
|
|
const loadTableList = useCallback(async () => {
|
||
|
|
try {
|
||
|
|
const response = await tableManagementApi.getTableList();
|
||
|
|
if (response.success && response.data) {
|
||
|
|
setTableList(response.data);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("테이블 목록 로드 실패:", error);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 테이블 컬럼 로드
|
||
|
|
const loadColumns = useCallback(async (tableName: string) => {
|
||
|
|
if (!tableName) {
|
||
|
|
setMasterColumns([]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const response = await tableManagementApi.getColumnList(tableName);
|
||
|
|
if (response.success && response.data?.columns) {
|
||
|
|
setMasterColumns(
|
||
|
|
response.data.columns.map((col: any) => ({
|
||
|
|
columnName: col.columnName || col.column_name,
|
||
|
|
columnLabel: col.columnLabel || col.column_label || col.columnName,
|
||
|
|
dataType: col.dataType || col.data_type,
|
||
|
|
})),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("컬럼 목록 로드 실패:", error);
|
||
|
|
setMasterColumns([]);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadGroups();
|
||
|
|
loadTableList();
|
||
|
|
}, [loadGroups, loadTableList]);
|
||
|
|
|
||
|
|
// 테이블 변경 시 컬럼 로드
|
||
|
|
useEffect(() => {
|
||
|
|
if (formData.masterTable) {
|
||
|
|
loadColumns(formData.masterTable);
|
||
|
|
}
|
||
|
|
}, [formData.masterTable, loadColumns]);
|
||
|
|
|
||
|
|
// 필터된 목록
|
||
|
|
const filteredGroups = groups.filter(
|
||
|
|
(g) =>
|
||
|
|
g.groupCode.toLowerCase().includes(searchText.toLowerCase()) ||
|
||
|
|
g.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
|
||
|
|
g.masterTable?.toLowerCase().includes(searchText.toLowerCase()),
|
||
|
|
);
|
||
|
|
|
||
|
|
// 모달 열기 (생성)
|
||
|
|
const handleOpenCreate = () => {
|
||
|
|
setEditingGroup(null);
|
||
|
|
setFormData({
|
||
|
|
groupName: "",
|
||
|
|
description: "",
|
||
|
|
masterTable: "",
|
||
|
|
masterValueColumn: "",
|
||
|
|
masterLabelColumn: "",
|
||
|
|
isActive: "Y",
|
||
|
|
});
|
||
|
|
setMappings([]);
|
||
|
|
setMasterColumns([]);
|
||
|
|
setIsModalOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 모달 열기 (수정)
|
||
|
|
const handleOpenEdit = async (group: AutoFillGroup) => {
|
||
|
|
setEditingGroup(group);
|
||
|
|
|
||
|
|
// 상세 정보 로드
|
||
|
|
const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode);
|
||
|
|
if (detailResponse.success && detailResponse.data) {
|
||
|
|
const detail = detailResponse.data;
|
||
|
|
|
||
|
|
// 컬럼 먼저 로드
|
||
|
|
if (detail.masterTable) {
|
||
|
|
await loadColumns(detail.masterTable);
|
||
|
|
}
|
||
|
|
|
||
|
|
setFormData({
|
||
|
|
groupCode: detail.groupCode,
|
||
|
|
groupName: detail.groupName,
|
||
|
|
description: detail.description || "",
|
||
|
|
masterTable: detail.masterTable,
|
||
|
|
masterValueColumn: detail.masterValueColumn,
|
||
|
|
masterLabelColumn: detail.masterLabelColumn || "",
|
||
|
|
isActive: detail.isActive || "Y",
|
||
|
|
});
|
||
|
|
|
||
|
|
// 매핑 데이터 변환 (snake_case → camelCase)
|
||
|
|
const convertedMappings = (detail.mappings || []).map((m: any) => ({
|
||
|
|
sourceColumn: m.source_column || m.sourceColumn,
|
||
|
|
targetField: m.target_field || m.targetField,
|
||
|
|
targetLabel: m.target_label || m.targetLabel || "",
|
||
|
|
isEditable: m.is_editable || m.isEditable || "Y",
|
||
|
|
isRequired: m.is_required || m.isRequired || "N",
|
||
|
|
defaultValue: m.default_value || m.defaultValue || "",
|
||
|
|
sortOrder: m.sort_order || m.sortOrder || 0,
|
||
|
|
}));
|
||
|
|
setMappings(convertedMappings);
|
||
|
|
}
|
||
|
|
|
||
|
|
setIsModalOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 삭제 확인
|
||
|
|
const handleDeleteConfirm = (groupCode: string) => {
|
||
|
|
setDeletingGroupCode(groupCode);
|
||
|
|
setIsDeleteDialogOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 삭제 실행
|
||
|
|
const handleDelete = async () => {
|
||
|
|
if (!deletingGroupCode) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode);
|
||
|
|
if (response.success) {
|
||
|
|
toast.success("자동 입력 그룹이 삭제되었습니다.");
|
||
|
|
loadGroups();
|
||
|
|
} else {
|
||
|
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||
|
|
} finally {
|
||
|
|
setIsDeleteDialogOpen(false);
|
||
|
|
setDeletingGroupCode(null);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 저장
|
||
|
|
const handleSave = async () => {
|
||
|
|
// 유효성 검사
|
||
|
|
if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) {
|
||
|
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const saveData = {
|
||
|
|
...formData,
|
||
|
|
mappings,
|
||
|
|
};
|
||
|
|
|
||
|
|
let response;
|
||
|
|
if (editingGroup) {
|
||
|
|
response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData);
|
||
|
|
} else {
|
||
|
|
response = await cascadingAutoFillApi.createGroup(saveData);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||
|
|
setIsModalOpen(false);
|
||
|
|
loadGroups();
|
||
|
|
} else {
|
||
|
|
toast.error(response.error || "저장에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 매핑 추가
|
||
|
|
const handleAddMapping = () => {
|
||
|
|
setMappings([
|
||
|
|
...mappings,
|
||
|
|
{
|
||
|
|
sourceColumn: "",
|
||
|
|
targetField: "",
|
||
|
|
targetLabel: "",
|
||
|
|
isEditable: "Y",
|
||
|
|
isRequired: "N",
|
||
|
|
defaultValue: "",
|
||
|
|
sortOrder: mappings.length + 1,
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 매핑 삭제
|
||
|
|
const handleRemoveMapping = (index: number) => {
|
||
|
|
setMappings(mappings.filter((_, i) => i !== index));
|
||
|
|
};
|
||
|
|
|
||
|
|
// 매핑 수정
|
||
|
|
const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => {
|
||
|
|
const updated = [...mappings];
|
||
|
|
updated[index] = { ...updated[index], [field]: value };
|
||
|
|
setMappings(updated);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* 검색 및 액션 */}
|
||
|
|
<Card>
|
||
|
|
<CardContent className="pt-6">
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<div className="relative flex-1">
|
||
|
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||
|
|
<Input
|
||
|
|
placeholder="그룹 코드, 이름, 테이블명으로 검색..."
|
||
|
|
value={searchText}
|
||
|
|
onChange={(e) => setSearchText(e.target.value)}
|
||
|
|
className="pl-10"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<Button variant="outline" onClick={loadGroups}>
|
||
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||
|
|
새로고침
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 목록 */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<CardTitle>자동 입력 그룹</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
마스터 선택 시 여러 필드를 자동으로 입력합니다. (총 {filteredGroups.length}개)
|
||
|
|
</CardDescription>
|
||
|
|
</div>
|
||
|
|
<Button onClick={handleOpenCreate}>
|
||
|
|
<Plus className="mr-2 h-4 w-4" />새 그룹 추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{loading ? (
|
||
|
|
<div className="flex items-center justify-center py-8">
|
||
|
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||
|
|
<span className="ml-2">로딩 중...</span>
|
||
|
|
</div>
|
||
|
|
) : filteredGroups.length === 0 ? (
|
||
|
|
<div className="text-muted-foreground py-8 text-center">
|
||
|
|
{searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead>그룹 코드</TableHead>
|
||
|
|
<TableHead>그룹명</TableHead>
|
||
|
|
<TableHead>마스터 테이블</TableHead>
|
||
|
|
<TableHead>매핑 수</TableHead>
|
||
|
|
<TableHead>상태</TableHead>
|
||
|
|
<TableHead className="text-right">작업</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{filteredGroups.map((group) => (
|
||
|
|
<TableRow key={group.groupCode}>
|
||
|
|
<TableCell className="font-mono text-sm">{group.groupCode}</TableCell>
|
||
|
|
<TableCell className="font-medium">{group.groupName}</TableCell>
|
||
|
|
<TableCell className="text-muted-foreground">{group.masterTable}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<Badge variant="secondary">{group.mappingCount || 0}개</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<Badge variant={group.isActive === "Y" ? "default" : "outline"}>
|
||
|
|
{group.isActive === "Y" ? "활성" : "비활성"}
|
||
|
|
</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-right">
|
||
|
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||
|
|
<Pencil className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||
|
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||
|
|
</Button>
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 생성/수정 모달 */}
|
||
|
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||
|
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>{editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"}</DialogTitle>
|
||
|
|
<DialogDescription>마스터 데이터 선택 시 자동으로 입력할 필드들을 설정합니다.</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* 기본 정보 */}
|
||
|
|
<div className="space-y-4">
|
||
|
|
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>그룹명 *</Label>
|
||
|
|
<Input
|
||
|
|
value={formData.groupName}
|
||
|
|
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||
|
|
placeholder="예: 고객사 정보 자동입력"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>설명</Label>
|
||
|
|
<Textarea
|
||
|
|
value={formData.description}
|
||
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
|
|
placeholder="이 자동 입력 그룹에 대한 설명"
|
||
|
|
rows={2}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Switch
|
||
|
|
checked={formData.isActive === "Y"}
|
||
|
|
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked ? "Y" : "N" })}
|
||
|
|
/>
|
||
|
|
<Label>활성화</Label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Separator />
|
||
|
|
|
||
|
|
{/* 마스터 테이블 설정 */}
|
||
|
|
<div className="space-y-4">
|
||
|
|
<h3 className="text-sm font-semibold">마스터 테이블 설정</h3>
|
||
|
|
<p className="text-muted-foreground text-xs">
|
||
|
|
사용자가 선택할 마스터 데이터의 테이블과 컬럼을 지정합니다.
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-3 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>마스터 테이블 *</Label>
|
||
|
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
role="combobox"
|
||
|
|
aria-expanded={tableComboOpen}
|
||
|
|
className="h-10 w-full justify-between text-sm"
|
||
|
|
>
|
||
|
|
{formData.masterTable
|
||
|
|
? tableList.find((t) => t.tableName === formData.masterTable)?.displayName ||
|
||
|
|
formData.masterTable
|
||
|
|
: "테이블 선택"}
|
||
|
|
<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="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||
|
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||
|
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||
|
|
<CommandGroup>
|
||
|
|
{tableList.map((table) => (
|
||
|
|
<CommandItem
|
||
|
|
key={table.tableName}
|
||
|
|
value={`${table.tableName} ${table.displayName || ""}`}
|
||
|
|
onSelect={() => {
|
||
|
|
setFormData({
|
||
|
|
...formData,
|
||
|
|
masterTable: table.tableName,
|
||
|
|
masterValueColumn: "",
|
||
|
|
masterLabelColumn: "",
|
||
|
|
});
|
||
|
|
setTableComboOpen(false);
|
||
|
|
}}
|
||
|
|
className="text-sm"
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-4 w-4",
|
||
|
|
formData.masterTable === table.tableName ? "opacity-100" : "opacity-0",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<div className="flex flex-col">
|
||
|
|
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||
|
|
{table.displayName && table.displayName !== table.tableName && (
|
||
|
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</CommandItem>
|
||
|
|
))}
|
||
|
|
</CommandGroup>
|
||
|
|
</CommandList>
|
||
|
|
</Command>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>값 컬럼 *</Label>
|
||
|
|
<Select
|
||
|
|
value={formData.masterValueColumn}
|
||
|
|
onValueChange={(value) => setFormData({ ...formData, masterValueColumn: value })}
|
||
|
|
disabled={!formData.masterTable}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="값 컬럼 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{masterColumns.map((col) => (
|
||
|
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||
|
|
{col.columnLabel || col.columnName}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>라벨 컬럼</Label>
|
||
|
|
<Select
|
||
|
|
value={formData.masterLabelColumn}
|
||
|
|
onValueChange={(value) => setFormData({ ...formData, masterLabelColumn: value })}
|
||
|
|
disabled={!formData.masterTable}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="라벨 컬럼 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{masterColumns.map((col) => (
|
||
|
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||
|
|
{col.columnLabel || col.columnName}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Separator />
|
||
|
|
|
||
|
|
{/* 필드 매핑 */}
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h3 className="text-sm font-semibold">필드 매핑</h3>
|
||
|
|
<p className="text-muted-foreground text-xs">
|
||
|
|
마스터 테이블의 컬럼을 폼의 어떤 필드에 자동 입력할지 설정합니다.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Button variant="outline" size="sm" onClick={handleAddMapping}>
|
||
|
|
<Plus className="mr-2 h-4 w-4" />
|
||
|
|
매핑 추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{mappings.length === 0 ? (
|
||
|
|
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
|
||
|
|
매핑이 없습니다. "매핑 추가" 버튼을 클릭하여 추가하세요.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-3">
|
||
|
|
{mappings.map((mapping, index) => (
|
||
|
|
<div key={index} className="bg-muted/30 flex items-center gap-3 rounded-lg border p-3">
|
||
|
|
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||
|
|
|
||
|
|
{/* 소스 컬럼 */}
|
||
|
|
<div className="w-40">
|
||
|
|
<Select
|
||
|
|
value={mapping.sourceColumn}
|
||
|
|
onValueChange={(value) => handleMappingChange(index, "sourceColumn", value)}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-8 text-xs">
|
||
|
|
<SelectValue placeholder="소스 컬럼" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{masterColumns.map((col) => (
|
||
|
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||
|
|
{col.columnLabel || col.columnName}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||
|
|
|
||
|
|
{/* 타겟 필드 */}
|
||
|
|
<div className="flex-1">
|
||
|
|
<Input
|
||
|
|
value={mapping.targetField}
|
||
|
|
onChange={(e) => handleMappingChange(index, "targetField", e.target.value)}
|
||
|
|
placeholder="타겟 필드명 (예: contact_name)"
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 타겟 라벨 */}
|
||
|
|
<div className="w-28">
|
||
|
|
<Input
|
||
|
|
value={mapping.targetLabel || ""}
|
||
|
|
onChange={(e) => handleMappingChange(index, "targetLabel", e.target.value)}
|
||
|
|
placeholder="라벨"
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 옵션 */}
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<div className="flex items-center space-x-1">
|
||
|
|
<Checkbox
|
||
|
|
id={`editable-${index}`}
|
||
|
|
checked={mapping.isEditable === "Y"}
|
||
|
|
onCheckedChange={(checked) => handleMappingChange(index, "isEditable", checked ? "Y" : "N")}
|
||
|
|
/>
|
||
|
|
<Label htmlFor={`editable-${index}`} className="text-xs">
|
||
|
|
수정
|
||
|
|
</Label>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center space-x-1">
|
||
|
|
<Checkbox
|
||
|
|
id={`required-${index}`}
|
||
|
|
checked={mapping.isRequired === "Y"}
|
||
|
|
onCheckedChange={(checked) => handleMappingChange(index, "isRequired", checked ? "Y" : "N")}
|
||
|
|
/>
|
||
|
|
<Label htmlFor={`required-${index}`} className="text-xs">
|
||
|
|
필수
|
||
|
|
</Label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 삭제 버튼 */}
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-8 w-8"
|
||
|
|
onClick={() => handleRemoveMapping(index)}
|
||
|
|
>
|
||
|
|
<X className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
{/* 삭제 확인 다이얼로그 */}
|
||
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>자동 입력 그룹 삭제</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
이 자동 입력 그룹을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||
|
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||
|
|
삭제
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|