ERP-node/frontend/components/screen/config-panels/QuickInsertConfigSection.tsx

659 lines
29 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Check, ChevronsUpDown, Plus, X, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { QuickInsertConfig, QuickInsertColumnMapping } from "@/types/screen-management";
import { apiClient } from "@/lib/api/client";
interface QuickInsertConfigSectionProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
allComponents?: ComponentData[];
currentTableName?: string;
}
interface TableOption {
name: string;
label: string;
}
interface ColumnOption {
name: string;
label: string;
}
export const QuickInsertConfigSection: React.FC<QuickInsertConfigSectionProps> = ({
component,
onUpdateProperty,
allComponents = [],
currentTableName,
}) => {
// 현재 설정 가져오기
const config: QuickInsertConfig = component.componentConfig?.action?.quickInsertConfig || {
targetTable: "",
columnMappings: [],
afterInsert: {
refreshData: true,
clearComponents: [],
showSuccessMessage: true,
successMessage: "저장되었습니다.",
},
duplicateCheck: {
enabled: false,
columns: [],
errorMessage: "이미 존재하는 데이터입니다.",
},
};
// 테이블 목록 상태
const [tables, setTables] = useState<TableOption[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
const [tablePopoverOpen, setTablePopoverOpen] = useState(false);
const [tableSearch, setTableSearch] = useState("");
// 대상 테이블 컬럼 목록 상태
const [targetColumns, setTargetColumns] = useState<ColumnOption[]>([]);
const [targetColumnsLoading, setTargetColumnsLoading] = useState(false);
// 매핑별 Popover 상태
const [targetColumnPopoverOpen, setTargetColumnPopoverOpen] = useState<Record<number, boolean>>({});
const [targetColumnSearch, setTargetColumnSearch] = useState<Record<number, string>>({});
const [sourceComponentPopoverOpen, setSourceComponentPopoverOpen] = useState<Record<number, boolean>>({});
const [sourceComponentSearch, setSourceComponentSearch] = useState<Record<number, string>>({});
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setTablesLoading(true);
try {
const response = await apiClient.get("/table-management/tables");
if (response.data?.success && response.data?.data) {
setTables(
response.data.data.map((t: any) => ({
name: t.tableName,
label: t.displayName || t.tableName,
}))
);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setTablesLoading(false);
}
};
loadTables();
}, []);
// 대상 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadTargetColumns = async () => {
if (!config.targetTable) {
setTargetColumns([]);
return;
}
setTargetColumnsLoading(true);
try {
const response = await apiClient.get(`/table-management/tables/${config.targetTable}/columns`);
if (response.data?.success && response.data?.data) {
// columns가 배열인지 확인 (data.columns 또는 data 직접)
const columns = response.data.data.columns || response.data.data;
setTargetColumns(
(Array.isArray(columns) ? columns : []).map((col: any) => ({
name: col.columnName || col.column_name,
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
}))
);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTargetColumns([]);
} finally {
setTargetColumnsLoading(false);
}
};
loadTargetColumns();
}, [config.targetTable]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
(updates: Partial<QuickInsertConfig>) => {
const newConfig = { ...config, ...updates };
onUpdateProperty("componentConfig.action.quickInsertConfig", newConfig);
},
[config, onUpdateProperty]
);
// 컬럼 매핑 추가
const addMapping = () => {
const newMapping: QuickInsertColumnMapping = {
targetColumn: "",
sourceType: "component",
sourceComponentId: "",
};
updateConfig({
columnMappings: [...(config.columnMappings || []), newMapping],
});
};
// 컬럼 매핑 삭제
const removeMapping = (index: number) => {
const newMappings = [...(config.columnMappings || [])];
newMappings.splice(index, 1);
updateConfig({ columnMappings: newMappings });
};
// 컬럼 매핑 업데이트
const updateMapping = (index: number, updates: Partial<QuickInsertColumnMapping>) => {
const newMappings = [...(config.columnMappings || [])];
newMappings[index] = { ...newMappings[index], ...updates };
updateConfig({ columnMappings: newMappings });
};
// 필터링된 테이블 목록
const filteredTables = tables.filter(
(t) =>
t.name.toLowerCase().includes(tableSearch.toLowerCase()) ||
t.label.toLowerCase().includes(tableSearch.toLowerCase())
);
// 컴포넌트 목록 (entity 타입 우선)
const availableComponents = allComponents.filter((comp: any) => {
// entity 타입 또는 select 타입 컴포넌트 필터링
const widgetType = comp.widgetType || comp.componentType || "";
return widgetType === "entity" || widgetType === "select" || widgetType === "text";
});
return (
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4 dark:bg-green-950/20">
<h4 className="text-sm font-medium text-foreground"> </h4>
<p className="text-xs text-muted-foreground">
.
</p>
{/* 대상 테이블 선택 */}
<div>
<Label> *</Label>
<Popover open={tablePopoverOpen} onOpenChange={setTablePopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tablePopoverOpen}
className="h-8 w-full justify-between text-xs"
disabled={tablesLoading}
>
{config.targetTable
? tables.find((t) => t.name === config.targetTable)?.label || config.targetTable
: "테이블을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command>
<CommandInput
placeholder="테이블 검색..."
value={tableSearch}
onValueChange={setTableSearch}
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{filteredTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
updateConfig({ targetTable: table.name, columnMappings: [] });
setTablePopoverOpen(false);
setTableSearch("");
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-4 w-4", config.targetTable === table.name ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span className="font-medium">{table.label}</span>
<span className="text-[10px] text-muted-foreground">{table.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 컬럼 매핑 */}
{config.targetTable && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label> </Label>
<Button type="button" variant="outline" size="sm" onClick={addMapping} className="h-6 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(config.columnMappings || []).length === 0 ? (
<div className="rounded border-2 border-dashed py-4 text-center text-xs text-muted-foreground">
</div>
) : (
<div className="space-y-2">
{(config.columnMappings || []).map((mapping, index) => (
<Card key={index} className="p-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> #{index + 1}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeMapping(index)}
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 대상 컬럼 */}
<div>
<Label className="text-xs"> ( )</Label>
<Popover
open={targetColumnPopoverOpen[index] || false}
onOpenChange={(open) => setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
disabled={targetColumnsLoading}
>
{mapping.targetColumn
? targetColumns.find((c) => c.name === mapping.targetColumn)?.label || mapping.targetColumn
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={targetColumnSearch[index] || ""}
onValueChange={(v) => setTargetColumnSearch((prev) => ({ ...prev, [index]: v }))}
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{targetColumns
.filter(
(c) =>
c.name.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase()) ||
c.label.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase())
)
.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
updateMapping(index, { targetColumn: col.name });
setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: false }));
setTargetColumnSearch((prev) => ({ ...prev, [index]: "" }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.targetColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{col.label}</span>
<span className="text-[10px] text-muted-foreground">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 값 소스 타입 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={mapping.sourceType}
onValueChange={(value: "component" | "leftPanel" | "fixed" | "currentUser") => {
updateMapping(index, {
sourceType: value,
sourceComponentId: undefined,
sourceColumn: undefined,
fixedValue: undefined,
userField: undefined,
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="component" className="text-xs">
</SelectItem>
<SelectItem value="leftPanel" className="text-xs">
</SelectItem>
<SelectItem value="fixed" className="text-xs">
</SelectItem>
<SelectItem value="currentUser" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 소스 타입별 추가 설정 */}
{mapping.sourceType === "component" && (
<div>
<Label className="text-xs"> </Label>
<Popover
open={sourceComponentPopoverOpen[index] || false}
onOpenChange={(open) => setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{mapping.sourceComponentId
? (() => {
const comp = allComponents.find((c: any) => c.id === mapping.sourceComponentId);
return comp?.label || comp?.columnName || mapping.sourceComponentId;
})()
: "컴포넌트 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command>
<CommandInput
placeholder="컴포넌트 검색..."
value={sourceComponentSearch[index] || ""}
onValueChange={(v) => setSourceComponentSearch((prev) => ({ ...prev, [index]: v }))}
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{availableComponents
.filter((comp: any) => {
const search = (sourceComponentSearch[index] || "").toLowerCase();
const label = (comp.label || "").toLowerCase();
const colName = (comp.columnName || "").toLowerCase();
return label.includes(search) || colName.includes(search);
})
.map((comp: any) => (
<CommandItem
key={comp.id}
value={comp.id}
onSelect={() => {
// sourceComponentId와 함께 sourceColumnName도 저장 (formData 접근용)
updateMapping(index, {
sourceComponentId: comp.id,
sourceColumnName: comp.columnName || undefined,
});
setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: false }));
setSourceComponentSearch((prev) => ({ ...prev, [index]: "" }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.sourceComponentId === comp.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{comp.label || comp.columnName || comp.id}</span>
<span className="text-[10px] text-muted-foreground">
{comp.widgetType || comp.componentType}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{mapping.sourceType === "leftPanel" && (
<div>
<Label className="text-xs"> </Label>
<Input
placeholder="예: process_code"
value={mapping.sourceColumn || ""}
onChange={(e) => updateMapping(index, { sourceColumn: e.target.value })}
className="h-7 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
)}
{mapping.sourceType === "fixed" && (
<div>
<Label className="text-xs"></Label>
<Input
placeholder="고정값 입력"
value={mapping.fixedValue || ""}
onChange={(e) => updateMapping(index, { fixedValue: e.target.value })}
className="h-7 text-xs"
/>
</div>
)}
{mapping.sourceType === "currentUser" && (
<div>
<Label className="text-xs"> </Label>
<Select
value={mapping.userField || ""}
onValueChange={(value: "userId" | "userName" | "companyCode" | "deptCode") => {
updateMapping(index, { userField: value });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="userId" className="text-xs">
ID
</SelectItem>
<SelectItem value="userName" className="text-xs">
</SelectItem>
<SelectItem value="companyCode" className="text-xs">
</SelectItem>
<SelectItem value="deptCode" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</Card>
))}
</div>
)}
</div>
)}
{/* 저장 후 동작 설정 */}
{config.targetTable && (
<div className="space-y-3 rounded border bg-background p-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center justify-between">
<Label className="text-xs font-normal"> </Label>
<Switch
checked={config.afterInsert?.refreshData ?? true}
onCheckedChange={(checked) => {
updateConfig({
afterInsert: { ...config.afterInsert, refreshData: checked },
});
}}
/>
</div>
<p className="text-[10px] text-muted-foreground -mt-2">
,
</p>
<div className="flex items-center justify-between">
<Label className="text-xs font-normal"> </Label>
<Switch
checked={config.afterInsert?.showSuccessMessage ?? true}
onCheckedChange={(checked) => {
updateConfig({
afterInsert: { ...config.afterInsert, showSuccessMessage: checked },
});
}}
/>
</div>
{config.afterInsert?.showSuccessMessage && (
<div>
<Label className="text-xs"> </Label>
<Input
placeholder="저장되었습니다."
value={config.afterInsert?.successMessage || ""}
onChange={(e) => {
updateConfig({
afterInsert: { ...config.afterInsert, successMessage: e.target.value },
});
}}
className="h-7 text-xs"
/>
</div>
)}
</div>
)}
{/* 중복 체크 설정 */}
{config.targetTable && (
<div className="space-y-3 rounded border bg-background p-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Switch
checked={config.duplicateCheck?.enabled ?? false}
onCheckedChange={(checked) => {
updateConfig({
duplicateCheck: { ...config.duplicateCheck, enabled: checked },
});
}}
/>
</div>
{config.duplicateCheck?.enabled && (
<>
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 max-h-40 overflow-y-auto rounded border bg-background p-2">
{targetColumns.length === 0 ? (
<p className="text-[10px] text-muted-foreground"> ...</p>
) : (
<div className="space-y-1">
{targetColumns.map((col) => {
const isChecked = (config.duplicateCheck?.columns || []).includes(col.name);
return (
<div
key={col.name}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted"
onClick={() => {
const currentColumns = config.duplicateCheck?.columns || [];
const newColumns = isChecked
? currentColumns.filter((c) => c !== col.name)
: [...currentColumns, col.name];
updateConfig({
duplicateCheck: { ...config.duplicateCheck, columns: newColumns },
});
}}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => {}}
className="h-3 w-3 flex-shrink-0"
/>
<span className="flex-1 text-xs whitespace-nowrap">
{col.label}{col.label !== col.name && ` (${col.name})`}
</span>
</div>
);
})}
</div>
)}
</div>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
placeholder="이미 존재하는 데이터입니다."
value={config.duplicateCheck?.errorMessage || ""}
onChange={(e) => {
updateConfig({
duplicateCheck: { ...config.duplicateCheck, errorMessage: e.target.value },
});
}}
className="h-7 text-xs"
/>
</div>
</>
)}
</div>
)}
{/* 사용 안내 */}
<div className="rounded-md bg-green-100 p-3 dark:bg-green-900/30">
<p className="text-xs text-green-900 dark:text-green-100">
<strong> :</strong>
<br />
1.
<br />
2.
<br />
3.
</p>
</div>
</div>
);
};
export default QuickInsertConfigSection;