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

718 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useMemo } 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { apiClient } from "@/lib/api/client";
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
interface ButtonConfigPanelProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
}
interface ScreenOption {
id: number;
name: string;
description?: string;
}
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
component,
onUpdateProperty,
allComponents = [], // 🆕 기본값 빈 배열
currentTableName, // 현재 화면의 테이블명
}) => {
// 🔧 component에서 직접 읽기 (useMemo 제거)
const config = component.componentConfig || {};
const currentAction = component.componentConfig?.action || {};
// 로컬 상태 관리 (실시간 입력 반영)
const [localInputs, setLocalInputs] = useState({
text: config.text !== undefined ? config.text : "버튼",
modalTitle: config.action?.modalTitle || "",
editModalTitle: config.action?.editModalTitle || "",
editModalDescription: config.action?.editModalDescription || "",
targetUrl: config.action?.targetUrl || "",
});
const [screens, setScreens] = useState<ScreenOption[]>([]);
const [screensLoading, setScreensLoading] = useState(false);
const [modalScreenOpen, setModalScreenOpen] = useState(false);
const [navScreenOpen, setNavScreenOpen] = useState(false);
const [modalSearchTerm, setModalSearchTerm] = useState("");
const [navSearchTerm, setNavSearchTerm] = useState("");
// 테이블 컬럼 목록 상태
const [tableColumns, setTableColumns] = useState<string[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
// 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만)
useEffect(() => {
const latestConfig = component.componentConfig || {};
const latestAction = latestConfig.action || {};
setLocalInputs({
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
modalTitle: latestAction.modalTitle || "",
editModalTitle: latestAction.editModalTitle || "",
editModalDescription: latestAction.editModalDescription || "",
targetUrl: latestAction.targetUrl || "",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [component.id]);
// 화면 목록 가져오기 (전체 목록)
useEffect(() => {
const fetchScreens = async () => {
try {
setScreensLoading(true);
// 전체 목록을 가져오기 위해 size를 큰 값으로 설정
const response = await apiClient.get("/screen-management/screens", {
params: {
page: 1,
size: 9999, // 매우 큰 값으로 설정하여 전체 목록 가져오기
},
});
if (response.data.success && Array.isArray(response.data.data)) {
const screenList = response.data.data.map((screen: any) => ({
id: screen.screenId,
name: screen.screenName,
description: screen.description,
}));
setScreens(screenList);
}
} catch (error) {
// console.error("❌ 화면 목록 로딩 실패:", error);
} finally {
setScreensLoading(false);
}
};
fetchScreens();
}, []);
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
useEffect(() => {
const fetchTableColumns = async () => {
// 테이블 이력 보기 액션이 아니면 스킵
if (config.action?.type !== "view_table_history") {
return;
}
// 1. 수동 입력된 테이블명 우선
// 2. 없으면 현재 화면의 테이블명 사용
const tableName = config.action?.historyTableName || currentTableName;
// 테이블명이 없으면 스킵
if (!tableName) {
return;
}
try {
setColumnsLoading(true);
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`, {
params: {
page: 1,
size: 9999, // 전체 컬럼 가져오기
},
});
// API 응답 구조: { success, data: { columns: [...], total, page, totalPages } }
const columnData = response.data.data?.columns;
if (!columnData || !Array.isArray(columnData)) {
console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData);
setTableColumns([]);
return;
}
if (response.data.success) {
// ID 컬럼과 날짜 관련 컬럼 제외
const filteredColumns = columnData
.filter((col: any) => {
const colName = col.columnName.toLowerCase();
const dataType = col.dataType?.toLowerCase() || "";
// ID 컬럼 제외 (id, _id로 끝나는 컬럼)
if (colName === "id" || colName.endsWith("_id")) {
return false;
}
// 날짜/시간 타입 제외 (데이터 타입 기준)
if (dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp")) {
return false;
}
// 날짜/시간 관련 컬럼명 제외 (컬럼명에 date, time, at 포함)
if (
colName.includes("date") ||
colName.includes("time") ||
colName.endsWith("_at") ||
colName.startsWith("created") ||
colName.startsWith("updated")
) {
return false;
}
return true;
})
.map((col: any) => col.columnName);
setTableColumns(filteredColumns);
}
} catch (error) {
console.error("❌ 테이블 컬럼 로딩 실패:", error);
} finally {
setColumnsLoading(false);
}
};
fetchTableColumns();
}, [config.action?.type, config.action?.historyTableName, currentTableName]);
// 검색 필터링 함수
const filterScreens = (searchTerm: string) => {
if (!searchTerm.trim()) return screens;
return screens.filter(
(screen) =>
screen.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase())),
);
};
// console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
// component,
// config,
// action: config.action,
// actionType: config.action?.type,
// screensCount: screens.length,
// });
return (
<div className="space-y-4">
<div>
<Label htmlFor="button-text"> </Label>
<Input
id="button-text"
value={localInputs.text}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, text: newValue }));
onUpdateProperty("componentConfig.text", newValue);
}}
placeholder="버튼 텍스트를 입력하세요"
/>
</div>
<div>
<Label htmlFor="button-action"> </Label>
<Select
key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`}
value={component.componentConfig?.action?.type || "save"}
onValueChange={(value) => {
// 🔥 action.type 업데이트
onUpdateProperty("componentConfig.action.type", value);
// 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후)
setTimeout(() => {
const newColor = value === "delete" ? "#ef4444" : "#212121";
onUpdateProperty("style.labelColor", newColor);
}, 100); // 0 → 100ms로 증가
}}
>
<SelectTrigger>
<SelectValue placeholder="버튼 액션 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="save"></SelectItem>
<SelectItem value="delete"></SelectItem>
<SelectItem value="edit"></SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 모달 열기 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "modal" && (
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<div>
<Label htmlFor="modal-title"> </Label>
<Input
id="modal-title"
placeholder="모달 제목을 입력하세요"
value={localInputs.modalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
onUpdateProperty("componentConfig.action.modalTitle", newValue);
}}
/>
</div>
<div>
<Label htmlFor="modal-size"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "md"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target-screen-modal"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-10 w-full justify-between"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<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)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-gray-500"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-gray-500"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`modal-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)}
{/* 수정 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "edit" && (
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<div>
<Label htmlFor="edit-screen"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-10 w-full justify-between"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
"수정 폼 화면을 선택하세요..."
: "수정 폼 화면을 선택하세요..."}
<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)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-gray-500"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-gray-500"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`edit-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500">
</p>
</div>
<div>
<Label htmlFor="edit-mode"> </Label>
<Select
value={component.componentConfig?.action?.editMode || "modal"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.editMode", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="수정 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="inline"> </SelectItem>
</SelectContent>
</Select>
</div>
{(component.componentConfig?.action?.editMode || "modal") === "modal" && (
<>
<div>
<Label htmlFor="edit-modal-title"> </Label>
<Input
id="edit-modal-title"
placeholder="모달 제목을 입력하세요 (예: 데이터 수정)"
value={localInputs.editModalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
}}
/>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
<div>
<Label htmlFor="edit-modal-description"> </Label>
<Input
id="edit-modal-description"
placeholder="모달 설명을 입력하세요 (예: 선택한 데이터를 수정합니다)"
value={localInputs.editModalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
}}
/>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
<div>
<Label htmlFor="edit-modal-size"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "md"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
<SelectItem value="full"> (Full)</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
)}
{/* 테이블 이력 보기 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "view_table_history" && (
<div className="mt-4 space-y-4">
<h4 className="text-sm font-medium">📜 </h4>
<div>
<Label>
() <span className="text-red-600">*</span>
</Label>
{!config.action?.historyTableName && !currentTableName ? (
<div className="mt-2 rounded-md border border-yellow-300 bg-yellow-50 p-3">
<p className="text-xs text-yellow-800">
<strong></strong> , .
</p>
</div>
) : (
<>
{!config.action?.historyTableName && currentTableName && (
<div className="mt-2 rounded-md border border-green-300 bg-green-50 p-2">
<p className="text-xs text-green-800">
<strong>{currentTableName}</strong>() .
</p>
</div>
)}
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={displayColumnOpen}
className="mt-2 h-10 w-full justify-between text-sm"
disabled={columnsLoading || tableColumns.length === 0}
>
{columnsLoading
? "로딩 중..."
: config.action?.historyDisplayColumn
? config.action.historyDisplayColumn
: tableColumns.length === 0
? "사용 가능한 컬럼이 없습니다"
: "컬럼을 선택하세요"}
<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>
<CommandEmpty className="text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column}
value={column}
onSelect={(currentValue) => {
onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue);
setDisplayColumnOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.historyDisplayColumn === column ? "opacity-100" : "opacity-0",
)}
/>
{column}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-2 text-xs text-gray-700">
<strong> </strong> .
<br />
: <code className="rounded bg-white px-1">device_code</code> &quot;DTG-001&quot;
.
<br /> .
</p>
{tableColumns.length === 0 && !columnsLoading && (
<p className="mt-2 text-xs text-red-600">
ID .
</p>
)}
</>
)}
</div>
</div>
)}
{/* 페이지 이동 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "navigate" && (
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<div>
<Label htmlFor="target-screen-nav"> </Label>
<Popover open={navScreenOpen} onOpenChange={setNavScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={navScreenOpen}
className="h-10 w-full justify-between"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<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)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={navSearchTerm}
onChange={(e) => setNavSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(navSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-gray-500"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-gray-500"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`navigate-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setNavScreenOpen(false);
setNavSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500">
/screens/{"{"}ID{"}"}
</p>
</div>
<div>
<Label htmlFor="target-url"> URL ()</Label>
<Input
id="target-url"
placeholder="예: /admin/users 또는 https://example.com"
value={localInputs.targetUrl}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, targetUrl: newValue }));
onUpdateProperty("componentConfig.action.targetUrl", newValue);
}}
/>
<p className="mt-1 text-xs text-gray-500">URL을 </p>
</div>
</div>
)}
{/* 제어 기능 섹션 */}
<div className="mt-8 border-t border-gray-200 pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
</div>
{/* 🆕 플로우 단계별 표시 제어 섹션 */}
<div className="mt-8 border-t border-gray-200 pt-6">
<FlowVisibilityConfigPanel
component={component}
allComponents={allComponents}
onUpdateProperty={onUpdateProperty}
/>
</div>
</div>
);
};