"use client"; /** * InvLegacyButtonConfigPanel — 버튼 (v2-button-primary) cp 톤 설정 패널 * * 흐름: * ① 액션 유형 (CPVisualGrid 14종 시각 카드) * ② 표시 모드 (CPVisualGrid 3종 — text/icon/icon-text) * ③ 텍스트 + 변형 (CPSegment) * ④ 액션별 세부 설정 (FormatBody 디스패처 — 10+ case) * ▾ 아이콘 설정 (조건부 — icon/icon-text 일 때) * ▾ 고급 설정 (행 선택 / 데이터 매핑 / 제어 흐름 / 흐름 가시성) * * V2ButtonConfigPanel(2212줄) 폐기 → cp 톤 신규. * * Reference: notes/gbpark/2026-04-28-cp-panel-standard.md */ import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Save, Trash2, Pencil, ArrowRight, Maximize2, SendHorizontal, Download, Upload, Check, Plus, X, Type, Image as ImageIcon, Columns, ScanLine, Truck, Send, Copy, FileSpreadsheet, Settings, Workflow, Info, } from "lucide-react"; import { icons as allLucideIcons } from "lucide-react"; import { CPSection, CPRow, CPGroup, CPText, CPSelect, CPSwitch, CPSegment, CPNumber, CPIconBtn, CPVisualGrid, FeatureChipGrid, Hint, SectionLabel, InlineLoader, } from "./_shared/cp"; import { apiClient } from "@/lib/api/client"; import { actionIconMap, noIconActions, NO_ICON_MESSAGE, iconSizePresets, getLucideIcon, getDefaultIconForAction, sanitizeSvg, } from "@/lib/button-icon-map"; import { ImprovedButtonControlConfigPanel } from "@/components/screen/config-panels/ImprovedButtonControlConfigPanel"; import { FlowVisibilityConfigPanel } from "@/components/screen/config-panels/FlowVisibilityConfigPanel"; import type { ComponentData } from "@/types/screen"; import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils"; // ─────────────────────────────────────────────────────── // 상수: 액션 / 표시 / 변형 // ─────────────────────────────────────────────────────── const ACTION_TYPE_CARDS = [ { value: "save", icon: Save, title: "저장", desc: "데이터 저장" }, { value: "delete", icon: Trash2, title: "삭제", desc: "데이터 삭제" }, { value: "edit", icon: Pencil, title: "편집", desc: "데이터 수정" }, { value: "modal", icon: Maximize2, title: "모달", desc: "팝업 열기" }, { value: "navigate", icon: ArrowRight, title: "이동", desc: "다른 화면" }, { value: "transferData", icon: SendHorizontal, title: "전달", desc: "다른 테이블로" }, { value: "excel_download", icon: Download, title: "엑셀 ⇣", desc: "엑셀 다운" }, { value: "excel_upload", icon: Upload, title: "엑셀 ⇡", desc: "엑셀 업로드" }, { value: "approval", icon: Check, title: "결재", desc: "결재 요청" }, { value: "control", icon: Settings, title: "제어", desc: "흐름 제어" }, { value: "copy", icon: Copy, title: "복사", desc: "데이터 복사" }, { value: "barcode_scan", icon: ScanLine, title: "바코드", desc: "스캔" }, { value: "operation_control", icon: Truck, title: "운행", desc: "운행 관리" }, { value: "multi_table_excel_upload", icon: FileSpreadsheet, title: "다중 ⇡", desc: "여러 테이블" }, ] as const; const DISPLAY_MODE_CARDS = [ { value: "text", icon: Type, title: "텍스트" }, { value: "icon", icon: ImageIcon, title: "아이콘" }, { value: "icon-text", icon: Columns, title: "아이콘+텍스트" }, ] as const; const VARIANT_OPTIONS = [ { value: "primary", label: "기본" }, { value: "secondary", label: "보조" }, { value: "danger", label: "위험" }, ] as const; const MODAL_SIZE_OPTIONS = [ { value: "sm", label: "작게" }, { value: "md", label: "보통" }, { value: "lg", label: "크게" }, { value: "xl", label: "더 크게" }, { value: "full", label: "전체" }, ] as const; // canonical table / legacy table-list / hidden v2-table-list / data-table / datatable // 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송 // 호환 대상으로 함께 인식. const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const; const isDataTransferComponentType = (typeValue: unknown): boolean => { if (isTableLikeComponentType(typeValue)) return true; if (typeof typeValue !== "string") return false; return DATA_TRANSFER_EXTRA_PATTERNS.some((t) => typeValue.includes(t)); }; const TRANSFER_MODE_OPTIONS = [ { value: "append", label: "추가" }, { value: "replace", label: "교체" }, { value: "merge", label: "병합" }, ] as const; const ICON_TEXT_POSITION_OPTIONS = [ { value: "left", label: "왼쪽" }, { value: "right", label: "오른쪽" }, { value: "top", label: "위" }, { value: "bottom", label: "아래" }, ] as const; interface ScreenOption { id: number; name: string; description?: string; } interface InvLegacyButtonConfigPanelProps { config: Record; onChange: (config: Record) => void; component?: ComponentData; currentComponent?: ComponentData; onUpdateProperty?: (path: string, value: any) => void; allComponents?: ComponentData[]; currentTableName?: string; screenTableName?: string; currentScreenCompanyCode?: string; [key: string]: any; } // ─────────────────────────────────────────────────────── // Main Component // ─────────────────────────────────────────────────────── export const InvLegacyButtonConfigPanel: React.FC = ({ config, onChange, component, currentComponent, allComponents = [], currentTableName, screenTableName, }) => { const effectiveComponent = component || currentComponent; const effectiveTableName = currentTableName || screenTableName; const actionType = String(config.action?.type || "save"); const displayMode = (config.displayMode as "text" | "icon" | "icon-text") || "text"; const variant = config.variant || "primary"; const buttonText = config.text !== undefined ? config.text : "버튼"; // ── State ──────────────────────────────────────────── const [selectedIcon, setSelectedIcon] = useState(config.icon?.name || ""); const [selectedIconType, setSelectedIconType] = useState<"lucide" | "svg">( config.icon?.type || "lucide", ); const [iconSize, setIconSize] = useState(config.icon?.size || "보통"); const [screens, setScreens] = useState([]); const [screensLoading, setScreensLoading] = useState(false); const [availableTables, setAvailableTables] = useState>( [], ); const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState< Record> >({}); const [mappingTargetColumns, setMappingTargetColumns] = useState< Array<{ name: string; label: string }> >([]); const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0); const [lucideSearchTerm, setLucideSearchTerm] = useState(""); const [svgInput, setSvgInput] = useState(""); const [svgName, setSvgName] = useState(""); const [svgError, setSvgError] = useState(""); const showIconSettings = displayMode === "icon" || displayMode === "icon-text"; const currentActionIcons = actionIconMap[actionType] || []; const isNoIconAction = noIconActions.has(actionType); const customIcons: string[] = config.customIcons || []; const customSvgIcons: Array<{ name: string; svg: string }> = config.customSvgIcons || []; const hasFlowWidget = useMemo(() => { return allComponents.some((comp: any) => { const compType = comp.componentType || comp.widgetType || ""; return compType === "flow-widget" || compType?.toLowerCase().includes("flow"); }); }, [allComponents]); // ── Helpers ────────────────────────────────────────── const updateConfig = useCallback( (field: string, value: any) => { onChange({ ...config, [field]: value }); }, [config, onChange], ); const updateActionConfig = useCallback( (field: string, value: any) => { const currentAction = config.action || {}; onChange({ ...config, action: { ...currentAction, [field]: value } }); }, [config, onChange], ); const updateAction = useCallback( (key: string, value: boolean) => updateActionConfig(key, value), [updateActionConfig], ); const handleUpdateProperty = useCallback( (path: string, value: any) => { const normalized = path.replace(/^componentConfig\./, "").replace(/^webTypeConfig\./, ""); const parts = normalized.split("."); const newConfig = { ...config }; let current: any = newConfig; for (let i = 0; i < parts.length - 1; i++) { if (!current[parts[i]]) current[parts[i]] = {}; current[parts[i]] = { ...current[parts[i]] }; current = current[parts[i]]; } current[parts[parts.length - 1]] = value; onChange(newConfig); }, [config, onChange], ); useEffect(() => { setSelectedIcon(config.icon?.name || ""); setSelectedIconType(config.icon?.type || "lucide"); setIconSize(config.icon?.size || "보통"); }, [config.icon?.name, config.icon?.type, config.icon?.size]); // 화면 목록 로드 (modal/edit/navigate) useEffect(() => { if (actionType !== "modal" && actionType !== "navigate" && actionType !== "edit") return; if (screens.length > 0) return; const load = async () => { setScreensLoading(true); try { const r = await apiClient.get("/screen-management/screens?size=1000"); if (r.data.success && r.data.data) { setScreens( r.data.data.map((s: any) => ({ id: s.id || s.screen_id, name: s.name || s.screen_name, description: s.description || "", })), ); } } catch { setScreens([]); } finally { setScreensLoading(false); } }; load(); }, [actionType, screens.length]); // 테이블 목록 로드 (transferData) useEffect(() => { if (actionType !== "transferData") return; if (availableTables.length > 0) return; const load = async () => { try { const r = await apiClient.get("/table-management/tables"); if (r.data.success && r.data.data) { setAvailableTables( r.data.data.map((t: any) => ({ name: t.table_name || t.name, label: t.display_name || t.table_label || t.label || t.table_name || t.name, })), ); } } catch { setAvailableTables([]); } }; load(); }, [actionType, availableTables.length]); const loadTableColumns = useCallback( async (tableName: string): Promise> => { try { const r = await apiClient.get(`/table-management/tables/${tableName}/columns`); if (r.data.success) { let data = r.data.data; if (!Array.isArray(data) && data?.columns) data = data.columns; if (!Array.isArray(data) && data?.data) data = data.data; if (Array.isArray(data)) { return data.map((c: any) => ({ name: c.name || c.column_name, label: c.display_name || c.label || c.column_label || c.name || c.column_name, })); } } } catch { /* ignore */ } return []; }, [], ); // 멀티 테이블 매핑: 소스/타겟 컬럼 로드 useEffect(() => { if (actionType !== "transferData") return; const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || []; const targetTable = config.action?.dataTransfer?.targetTable; const loadAll = async () => { const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean); const newMap: Record> = {}; for (const tbl of sourceTableNames) { if (!mappingSourceColumnsMap[tbl]) { newMap[tbl] = await loadTableColumns(tbl); } } if (Object.keys(newMap).length > 0) { setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap })); } if (targetTable) { const cols = await loadTableColumns(targetTable); setMappingTargetColumns(cols); } else { setMappingTargetColumns([]); } }; loadAll(); }, [ actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, loadTableColumns, ]); const handleSelectIcon = (iconName: string, iconType: "lucide" | "svg" = "lucide") => { setSelectedIcon(iconName); setSelectedIconType(iconType); updateConfig("icon", { name: iconName, type: iconType, size: iconSize }); }; const revertToDefaultIcon = () => { const def = getDefaultIconForAction(actionType); handleSelectIcon(def.name, def.type); }; const handleActionTypeChange = (newType: string) => { const currentAction = config.action || {}; onChange({ ...config, action: { ...currentAction, type: newType } }); const newActionIcons = actionIconMap[newType] || []; if ( selectedIcon && selectedIconType === "lucide" && !newActionIcons.includes(selectedIcon) && !customIcons.includes(selectedIcon) ) { setSelectedIcon(""); updateConfig("icon", undefined); } }; const componentData = useMemo(() => { if (effectiveComponent) { return { ...effectiveComponent, componentConfig: config, webTypeConfig: config, } as unknown as ComponentData; } return { id: "virtual", type: "widget" as const, position: { x: 0, y: 0 }, size: { width: 120, height: 40 }, componentConfig: config, webTypeConfig: config, componentType: "v2-button-primary", } as unknown as ComponentData; }, [effectiveComponent, config]); // ── Render ─────────────────────────────────────────── return (
{/* ── ① 액션 유형 ─────────────────────────── */} handleActionTypeChange(v)} options={ACTION_TYPE_CARDS.map((c) => { const Icon = c.icon; return { value: c.value, label: c.title, desc: c.desc, preview: , }; })} /> {/* ── ② 표시 모드 ─────────────────────────── */} { updateConfig("displayMode", v); if ((v === "icon" || v === "icon-text") && !selectedIcon) { revertToDefaultIcon(); } }} options={DISPLAY_MODE_CARDS.map((c) => { const Icon = c.icon; return { value: c.value, label: c.title, preview: }; })} /> {/* ── ③ 텍스트 + 변형 ─────────────────────────── */} {(displayMode === "text" || displayMode === "icon-text") && ( updateConfig("text", v)} placeholder="버튼" /> )} updateConfig("variant", v)} options={VARIANT_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} /> {/* ── ④ 액션별 세부 설정 ─────────────────────────── */} {/* ── ▾ 아이콘 설정 ─────────────────────────── */} {showIconSettings && ( { setIconSize(preset); if (selectedIcon) updateConfig("icon", { ...config.icon, size: preset }); }} onIconTextPositionChange={(pos) => updateConfig("iconTextPosition", pos)} onIconGapChange={(gap) => updateConfig("iconGap", gap)} onCustomIconsChange={(icons) => updateConfig("customIcons", icons)} onCustomSvgIconsChange={(icons) => updateConfig("customSvgIcons", icons)} /> )} {/* ── ▾ 고급 설정 ─────────────────────────── */} {/* 행 선택 활성화 */} updateAction("requireRowSelection", v)} /> {config.action?.requireRowSelection && (
updateActionConfig("rowSelectionSource", v)} searchable={false} > updateAction("allowMultiRowSelection", v)} />
)} {/* 데이터 전달 매핑 (transferData 일 때) */} {actionType === "transferData" && (
)} {/* 제어 흐름 (excel_upload / multi_table_excel_upload 제외) */} {actionType !== "excel_upload" && actionType !== "multi_table_excel_upload" && (
} text="제어 흐름" />
)} {/* 흐름 가시성 (flow widget 있을 때) */} {hasFlowWidget && (
} text="플로우 단계별 표시 제어" primary />
)}
); }; InvLegacyButtonConfigPanel.displayName = "InvLegacyButtonConfigPanel"; // ─────────────────────────────────────────────────────── // describeAction — ④ 섹션 desc 동적 텍스트 // ─────────────────────────────────────────────────────── function describeAction(t: string): string { const card = ACTION_TYPE_CARDS.find((c) => c.value === t); return card ? `${card.title} — ${card.desc}` : "선택한 액션의 옵션"; } // ─────────────────────────────────────────────────────── // ActionDetailBody — 액션별 세부 설정 (10+ case) // ─────────────────────────────────────────────────────── interface ActionDetailBodyProps { actionType: string; config: Record; updateActionConfig: (field: string, value: any) => void; screens: ScreenOption[]; screensLoading: boolean; allComponents: ComponentData[]; } function ActionDetailBody(p: ActionDetailBodyProps) { const action = p.config.action || {}; const messageRows = ( <> p.updateActionConfig("successMessage", v)} placeholder="처리되었습니다." /> p.updateActionConfig("errorMessage", v)} placeholder="처리 중 오류가 발생했습니다." /> ); // ── save / delete / quickInsert if (p.actionType === "save" || p.actionType === "delete" || p.actionType === "quickInsert") { return ( <> {messageRows} {p.actionType === "delete" && ( p.updateActionConfig("confirmBeforeDelete", v)} /> )} ); } // ── edit if (p.actionType === "edit") { const editMode = action.editMode || "modal"; return ( <> {p.screensLoading ? ( ) : ( p.updateActionConfig("targetScreenId", v ? Number(v) : undefined)} > {p.screens.map((s) => ( ))} )} p.updateActionConfig("editMode", v)} options={[ { value: "modal", label: "모달" }, { value: "navigate", label: "페이지 이동" }, ]} /> {editMode === "modal" && ( <> p.updateActionConfig("editModalTitle", v)} placeholder="데이터 수정" /> p.updateActionConfig("editModalDescription", v)} placeholder="모달 설명" /> p.updateActionConfig("modalSize", v)} options={MODAL_SIZE_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} /> )} {messageRows} ); } // ── modal if (p.actionType === "modal") { return ( <> {p.screensLoading ? ( ) : ( p.updateActionConfig("targetScreenId", v ? Number(v) : undefined)} > {p.screens.map((s) => ( ))} )} p.updateActionConfig("modalTitle", v)} placeholder="모달 제목" /> p.updateActionConfig("modalDescription", v)} placeholder="모달 설명" /> p.updateActionConfig("autoDetectDataSource", v)} /> {messageRows} ); } // ── navigate if (p.actionType === "navigate") { return ( <> p.updateActionConfig("targetUrl", v)} placeholder="/admin/example" /> {messageRows} ); } // ── excel_download if (p.actionType === "excel_download") { return ( <> p.updateActionConfig("applyCurrentFilters", v)} /> p.updateActionConfig("selectedRowsOnly", v)} /> {messageRows} ); } // ── excel_upload / multi_table_excel_upload if (p.actionType === "excel_upload" || p.actionType === "multi_table_excel_upload") { return <>{messageRows}; } // ── transferData if (p.actionType === "transferData") { const dt = action.dataTransfer || {}; const updateDt = (k: string, v: any) => p.updateActionConfig("dataTransfer", { ...dt, [k]: v }); return ( <> updateDt("sourceComponentId", v)} > {p.allComponents .filter((c: any) => { const t = c.componentType || c.type || ""; return isDataTransferComponentType(t); }) .map((c: any) => ( ))} updateDt("targetType", v)} options={[ { value: "component", label: "컴포넌트" }, { value: "splitPanel", label: "분할 반대편" }, ]} /> {dt.targetType === "component" && ( updateDt("targetComponentId", v)} > {p.allComponents .filter((c: any) => { const t = c.componentType || c.type || ""; const ok = isDataTransferComponentType(t); return ok && c.id !== dt.sourceComponentId; }) .map((c: any) => ( ))} )} updateDt("mode", v)} options={TRANSFER_MODE_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} /> updateDt("clearAfterTransfer", v)} /> updateDt("confirmBeforeTransfer", v)} /> {dt.confirmBeforeTransfer && ( updateDt("confirmMessage", v)} placeholder="선택한 항목을 전달하시겠습니까?" /> )} 매핑 규칙은 ▾ 고급 설정의 "데이터 전달 매핑" 섹션에서 편집 {messageRows} ); } // ── event if (p.actionType === "event") { return ( <> p.updateActionConfig("eventName", v)} placeholder="이벤트 이름" /> {messageRows} ); } // ── default (approval / control / copy / barcode_scan / operation_control) return ( <> 이 액션은 기본 메시지 외 추가 옵션이 없습니다 (제어 흐름은 ▾ 고급 설정). {messageRows} ); } // ─────────────────────────────────────────────────────── // IconOptionsBody — 아이콘 설정 (추천 / 커스텀 / 검색 / SVG / 크기 / 위치 / 간격) // ─────────────────────────────────────────────────────── interface IconOptionsBodyProps { actionType: string; isNoIconAction: boolean; currentActionIcons: string[]; selectedIcon: string; selectedIconType: "lucide" | "svg"; iconSize: string; displayMode: string; iconTextPosition: string; iconGap: number; customIcons: string[]; customSvgIcons: Array<{ name: string; svg: string }>; lucideSearchTerm: string; setLucideSearchTerm: (v: string) => void; svgInput: string; setSvgInput: (v: string) => void; svgName: string; setSvgName: (v: string) => void; svgError: string; setSvgError: (v: string) => void; onSelectIcon: (name: string, type?: "lucide" | "svg") => void; onRevertToDefault: () => void; onIconSizeChange: (preset: string) => void; onIconTextPositionChange: (pos: string) => void; onIconGapChange: (gap: number) => void; onCustomIconsChange: (icons: string[]) => void; onCustomSvgIconsChange: (icons: Array<{ name: string; svg: string }>) => void; } function IconOptionsBody(p: IconOptionsBodyProps) { const [showLucideSearch, setShowLucideSearch] = useState(false); const [showSvgPaste, setShowSvgPaste] = useState(false); return ( <> {/* 추천 아이콘 */} {p.isNoIconAction ? ( {NO_ICON_MESSAGE} ) : ( <> )} {/* 커스텀 아이콘 */} {(p.customIcons.length > 0 || p.customSvgIcons.length > 0) && (
{p.customIcons.map((iconName) => ( p.onSelectIcon(iconName, "lucide")} onRemove={() => { p.onCustomIconsChange(p.customIcons.filter((n) => n !== iconName)); if (p.selectedIcon === iconName) p.onRevertToDefault(); }} /> ))} {p.customSvgIcons.map((s) => ( p.onSelectIcon(s.name, "svg")} onRemove={() => { p.onCustomSvgIconsChange(p.customSvgIcons.filter((x) => x.name !== s.name)); if (p.selectedIcon === s.name) p.onRevertToDefault(); }} /> ))}
)} {/* 추가 버튼 */}
{/* Lucide 검색 (인라인) */} {showLucideSearch && (
p.setLucideSearchTerm(e.target.value)} placeholder="아이콘 이름 검색..." style={searchInputStyle} autoFocus />
n.toLowerCase().includes(p.lucideSearchTerm.toLowerCase())) .slice(0, 32)} selectedIcon={p.selectedIcon} selectedType={p.selectedIconType} type="lucide" onSelect={(name) => { if (!p.customIcons.includes(name)) { p.onCustomIconsChange([...p.customIcons, name]); } p.onSelectIcon(name, "lucide"); }} />
)} {/* SVG 붙여넣기 (인라인) */} {showSvgPaste && (