ERP-node/frontend/lib/registry/components/split-panel-layout2/ActionButtonConfigModal.tsx

675 lines
25 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useEffect, useCallback } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Plus, GripVertical, Settings, X, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import type { ActionButtonConfig, ModalParamMapping, ColumnConfig } from "./types";
interface ScreenInfo {
screen_id: number;
screen_name: string;
screen_code: string;
}
// 정렬 가능한 버튼 아이템
const SortableButtonItem: React.FC<{
id: string;
button: ActionButtonConfig;
index: number;
onSettingsClick: () => void;
onRemove: () => void;
}> = ({ id, button, index, onSettingsClick, onRemove }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const getVariantColor = (variant?: string) => {
switch (variant) {
case "destructive":
return "bg-destructive/10 text-destructive";
case "outline":
return "bg-background border";
case "ghost":
return "bg-muted/50";
case "secondary":
return "bg-secondary text-secondary-foreground";
default:
return "bg-primary/10 text-primary";
}
};
const getActionLabel = (action?: string) => {
switch (action) {
case "add":
return "추가";
case "edit":
return "수정";
case "delete":
return "삭제";
case "bulk-delete":
return "일괄삭제";
case "api":
return "API";
case "custom":
return "커스텀";
default:
return "추가";
}
};
return (
<div
ref={setNodeRef}
style={style}
className={cn("flex items-center gap-2 rounded-md border bg-card p-3", isDragging && "opacity-50 shadow-lg")}
>
{/* 드래그 핸들 */}
<div {...attributes} {...listeners} className="cursor-grab touch-none text-muted-foreground hover:text-foreground">
<GripVertical className="h-4 w-4" />
</div>
{/* 버튼 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn("px-2 py-0.5 rounded text-xs font-medium", getVariantColor(button.variant))}>
{button.label || `버튼 ${index + 1}`}
</span>
</div>
<div className="flex flex-wrap gap-1 mt-1">
<Badge variant="outline" className="text-[10px] h-4">
{getActionLabel(button.action)}
</Badge>
{button.icon && (
<Badge variant="secondary" className="text-[10px] h-4">
{button.icon}
</Badge>
)}
{button.showCondition && button.showCondition !== "always" && (
<Badge variant="secondary" className="text-[10px] h-4">
{button.showCondition === "selected" ? "선택시만" : "미선택시만"}
</Badge>
)}
</div>
</div>
{/* 액션 버튼들 */}
<div className="flex items-center gap-1 shrink-0">
<Button size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={onSettingsClick} title="세부설정">
<Settings className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={onRemove}
title="삭제"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
};
interface ActionButtonConfigModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
actionButtons: ActionButtonConfig[];
displayColumns?: ColumnConfig[]; // 모달 파라미터 매핑용
onSave: (buttons: ActionButtonConfig[]) => void;
side: "left" | "right";
}
export const ActionButtonConfigModal: React.FC<ActionButtonConfigModalProps> = ({
open,
onOpenChange,
actionButtons: initialButtons,
displayColumns = [],
onSave,
side,
}) => {
// 로컬 상태
const [buttons, setButtons] = useState<ActionButtonConfig[]>([]);
// 버튼 세부설정 모달
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [editingButtonIndex, setEditingButtonIndex] = useState<number | null>(null);
const [editingButton, setEditingButton] = useState<ActionButtonConfig | null>(null);
// 화면 목록
const [screens, setScreens] = useState<ScreenInfo[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
const [screenSelectOpen, setScreenSelectOpen] = useState(false);
// 드래그 센서
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 초기값 설정
useEffect(() => {
if (open) {
setButtons(initialButtons || []);
}
}, [open, initialButtons]);
// 화면 목록 로드
const loadScreens = useCallback(async () => {
setScreensLoading(true);
try {
const response = await apiClient.get("/screen-management/screens?size=1000");
let screenList: any[] = [];
if (response.data?.success && Array.isArray(response.data?.data)) {
screenList = response.data.data;
} else if (Array.isArray(response.data?.data)) {
screenList = response.data.data;
}
const transformedScreens = screenList.map((s: any) => ({
screen_id: s.screenId ?? s.screen_id ?? s.id,
screen_name: s.screenName ?? s.screen_name ?? s.name ?? `화면 ${s.screenId || s.screen_id || s.id}`,
screen_code: s.screenCode ?? s.screen_code ?? s.code ?? "",
}));
setScreens(transformedScreens);
} catch (error) {
console.error("화면 목록 로드 실패:", error);
setScreens([]);
} finally {
setScreensLoading(false);
}
}, []);
useEffect(() => {
if (open) {
loadScreens();
}
}, [open, loadScreens]);
// 드래그 종료 핸들러
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = buttons.findIndex((btn) => btn.id === active.id);
const newIndex = buttons.findIndex((btn) => btn.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
setButtons(arrayMove(buttons, oldIndex, newIndex));
}
}
};
// 버튼 추가
const handleAddButton = () => {
const newButton: ActionButtonConfig = {
id: `btn-${Date.now()}`,
label: "새 버튼",
variant: "default",
action: "add",
showCondition: "always",
};
setButtons([...buttons, newButton]);
};
// 버튼 삭제
const handleRemoveButton = (index: number) => {
setButtons(buttons.filter((_, i) => i !== index));
};
// 버튼 업데이트
const handleUpdateButton = (index: number, updates: Partial<ActionButtonConfig>) => {
const newButtons = [...buttons];
newButtons[index] = { ...newButtons[index], ...updates };
setButtons(newButtons);
};
// 버튼 세부설정 열기
const handleOpenDetailSettings = (index: number) => {
setEditingButtonIndex(index);
setEditingButton({ ...buttons[index] });
setDetailModalOpen(true);
};
// 버튼 세부설정 저장
const handleSaveDetailSettings = () => {
if (editingButtonIndex !== null && editingButton) {
handleUpdateButton(editingButtonIndex, editingButton);
}
setDetailModalOpen(false);
setEditingButtonIndex(null);
setEditingButton(null);
};
// 저장
const handleSave = () => {
onSave(buttons);
onOpenChange(false);
};
// 선택된 화면 정보
const getScreenInfo = (screenId?: number) => {
return screens.find((s) => s.screen_id === screenId);
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{side === "left" ? "좌측" : "우측"}
</DialogTitle>
<DialogDescription>
.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-3">
<Label className="text-sm font-medium">
({buttons.length})
</Label>
<Button size="sm" variant="outline" onClick={handleAddButton}>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
<ScrollArea className="flex-1 pr-4">
{buttons.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground text-sm mb-2">
</p>
<Button size="sm" variant="outline" onClick={handleAddButton}>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={buttons.map((btn) => btn.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{buttons.map((btn, index) => (
<SortableButtonItem
key={btn.id}
id={btn.id}
button={btn}
index={index}
onSettingsClick={() => handleOpenDetailSettings(index)}
onRemove={() => handleRemoveButton(index)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</ScrollArea>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 버튼 세부설정 모달 */}
<Dialog open={detailModalOpen} onOpenChange={setDetailModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[85vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{editingButton?.label || "버튼"} .
</DialogDescription>
</DialogHeader>
{editingButton && (
<ScrollArea className="max-h-[60vh]">
<div className="space-y-4 pr-4">
{/* 기본 설정 */}
<div className="space-y-3 p-3 border rounded-lg">
<h4 className="text-sm font-medium"> </h4>
<div>
<Label className="text-xs"> </Label>
<Input
value={editingButton.label}
onChange={(e) =>
setEditingButton({ ...editingButton, label: e.target.value })
}
placeholder="버튼 라벨"
className="mt-1 h-9"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={editingButton.variant || "default"}
onValueChange={(value) =>
setEditingButton({
...editingButton,
variant: value as ActionButtonConfig["variant"],
})
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> (Primary)</SelectItem>
<SelectItem value="secondary"> (Secondary)</SelectItem>
<SelectItem value="outline"></SelectItem>
<SelectItem value="ghost"></SelectItem>
<SelectItem value="destructive"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select
value={editingButton.icon || "none"}
onValueChange={(value) =>
setEditingButton({
...editingButton,
icon: value === "none" ? undefined : value,
})
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="Plus">+ ()</SelectItem>
<SelectItem value="Edit"></SelectItem>
<SelectItem value="Trash2"></SelectItem>
<SelectItem value="Download"></SelectItem>
<SelectItem value="Upload"></SelectItem>
<SelectItem value="RefreshCw"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={editingButton.showCondition || "always"}
onValueChange={(value) =>
setEditingButton({
...editingButton,
showCondition: value as ActionButtonConfig["showCondition"],
})
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="always"> </SelectItem>
<SelectItem value="selected"> </SelectItem>
<SelectItem value="notSelected"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 동작 설정 */}
<div className="space-y-3 p-3 border rounded-lg">
<h4 className="text-sm font-medium"> </h4>
<div>
<Label className="text-xs"> </Label>
<Select
value={editingButton.action || "add"}
onValueChange={(value) =>
setEditingButton({
...editingButton,
action: value as ActionButtonConfig["action"],
})
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="add"> ( )</SelectItem>
<SelectItem value="edit"> ( )</SelectItem>
<SelectItem value="delete"> ( )</SelectItem>
<SelectItem value="bulk-delete"> ( )</SelectItem>
<SelectItem value="api">API </SelectItem>
<SelectItem value="custom"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 모달 설정 (add, edit 액션) */}
{(editingButton.action === "add" || editingButton.action === "edit") && (
<div>
<Label className="text-xs"> </Label>
<Popover open={screenSelectOpen} onOpenChange={setScreenSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="mt-1 h-9 w-full justify-between"
disabled={screensLoading}
>
{screensLoading
? "로딩 중..."
: editingButton.modalScreenId
? getScreenInfo(editingButton.modalScreenId)?.screen_name ||
`화면 ${editingButton.modalScreenId}`
: "화면 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="화면 검색..." className="h-9" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup>
{screens.map((screen) => (
<CommandItem
key={screen.screen_id}
value={`${screen.screen_id}-${screen.screen_name}`}
onSelect={() => {
setEditingButton({
...editingButton,
modalScreenId: screen.screen_id,
});
setScreenSelectOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
editingButton.modalScreenId === screen.screen_id
? "opacity-100"
: "opacity-0"
)}
/>
<span className="flex flex-col">
<span>{screen.screen_name}</span>
<span className="text-xs text-muted-foreground">
{screen.screen_code}
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* API 설정 */}
{editingButton.action === "api" && (
<>
<div>
<Label className="text-xs">API </Label>
<Input
value={editingButton.apiEndpoint || ""}
onChange={(e) =>
setEditingButton({
...editingButton,
apiEndpoint: e.target.value,
})
}
placeholder="/api/example"
className="mt-1 h-9"
/>
</div>
<div>
<Label className="text-xs">HTTP </Label>
<Select
value={editingButton.apiMethod || "POST"}
onValueChange={(value) =>
setEditingButton({
...editingButton,
apiMethod: value as ActionButtonConfig["apiMethod"],
})
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
{/* 확인 메시지 (삭제 계열) */}
{(editingButton.action === "delete" ||
editingButton.action === "bulk-delete" ||
(editingButton.action === "api" && editingButton.apiMethod === "DELETE")) && (
<div>
<Label className="text-xs"> </Label>
<Input
value={editingButton.confirmMessage || ""}
onChange={(e) =>
setEditingButton({
...editingButton,
confirmMessage: e.target.value,
})
}
placeholder="정말 삭제하시겠습니까?"
className="mt-1 h-9"
/>
</div>
)}
{/* 커스텀 액션 ID */}
{editingButton.action === "custom" && (
<div>
<Label className="text-xs"> ID</Label>
<Input
value={editingButton.customActionId || ""}
onChange={(e) =>
setEditingButton({
...editingButton,
customActionId: e.target.value,
})
}
placeholder="customAction1"
className="mt-1 h-9"
/>
<p className="text-xs text-muted-foreground mt-1">
ID로
</p>
</div>
)}
</div>
</div>
</ScrollArea>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDetailModalOpen(false)}>
</Button>
<Button onClick={handleSaveDetailSettings}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default ActionButtonConfigModal;