diff --git a/.cursor/rules/screen-designer-e2e-guide.mdc b/.cursor/rules/screen-designer-e2e-guide.mdc new file mode 100644 index 00000000..e52ec2dd --- /dev/null +++ b/.cursor/rules/screen-designer-e2e-guide.mdc @@ -0,0 +1,98 @@ +# 화면 디자이너 E2E 테스트 접근 가이드 + +## 화면 디자이너 접근 방법 (Playwright) + +화면 디자이너는 SPA 탭 기반 시스템이라 URL 직접 접근이 안 된다. +다음 3단계를 반드시 따라야 한다. + +### 1단계: 로그인 + +```typescript +await page.goto('http://localhost:9771/login'); +await page.waitForLoadState('networkidle'); +await page.getByPlaceholder('사용자 ID를 입력하세요').fill('wace'); +await page.getByPlaceholder('비밀번호를 입력하세요').fill('qlalfqjsgh11'); +await page.getByRole('button', { name: '로그인' }).click(); +await page.waitForTimeout(8000); +``` + +### 2단계: sessionStorage 탭 상태 주입 + openDesigner 쿼리 + +```typescript +await page.evaluate(() => { + sessionStorage.setItem('erp-tab-store', JSON.stringify({ + state: { + tabs: [{ + id: 'tab-screenmng', + title: '화면 관리', + path: '/admin/screenMng/screenMngList', + isActive: true, + isPinned: false + }], + activeTabId: 'tab-screenmng' + }, + version: 0 + })); +}); + +// openDesigner 쿼리 파라미터로 화면 디자이너 자동 열기 +await page.goto('http://localhost:9771/admin/screenMng/screenMngList?openDesigner=' + screenId); +await page.waitForTimeout(10000); +``` + +### 3단계: 컴포넌트 클릭 + 설정 패널 확인 + +```typescript +// 패널 버튼 클릭 (설정 패널 열기) +const panelBtn = page.locator('button:has-text("패널")'); +if (await panelBtn.count() > 0) { + await panelBtn.first().click(); + await page.waitForTimeout(2000); +} + +// 편집 탭 확인 +const editTab = page.locator('button:has-text("편집")'); +// editTab.count() > 0 이면 설정 패널 열림 확인 +``` + +## 화면 ID 찾기 (API) + +특정 컴포넌트를 포함한 화면을 API로 검색: + +```typescript +const screenId = await page.evaluate(async () => { + const token = localStorage.getItem('authToken') || ''; + const h = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; + + const resp = await fetch('http://localhost:8080/api/screen-management/screens?page=1&size=50', { headers: h }); + const data = await resp.json(); + const items = data.data || []; + + for (const s of items) { + try { + const lr = await fetch('http://localhost:8080/api/screen-management/screens/' + s.screenId + '/layout-v2', { headers: h }); + const ld = await lr.json(); + const raw = JSON.stringify(ld); + // 원하는 컴포넌트 타입 검색 + if (raw.includes('v2-select')) return s.screenId; + } catch {} + } + return items[0]?.screenId || null; +}); +``` + +## 검증 포인트 + +| 확인 항목 | Locator | 기대값 | +|----------|---------|--------| +| 디자이너 열림 | `button:has-text("패널")` | count > 0 | +| 편집 탭 | `button:has-text("편집")` | count > 0 | +| 카드 선택 | `text=이 필드는 어떤 데이터를 선택하나요?` | visible | +| 고급 설정 | `text=고급 설정` | visible | +| JS 에러 없음 | `page.on('pageerror')` | 0건 | + +## 테스트 계정 + +- ID: `wace` +- PW: `qlalfqjsgh11` +- 권한: SUPER_ADMIN (최고 관리자) diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx new file mode 100644 index 00000000..51322c3e --- /dev/null +++ b/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx @@ -0,0 +1,691 @@ +"use client"; + +import React, { useState, useEffect } 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 { 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"; + +interface ButtonConfigPanelProps { + component: ComponentData; + onUpdateProperty: (path: string, value: any) => void; +} + +interface ScreenOption { + id: number; + name: string; + description?: string; +} + +export const ButtonConfigPanel: React.FC = ({ component, onUpdateProperty }) => { + // 🔧 항상 최신 component에서 직접 참조 + const config = component.componentConfig || {}; + const currentAction = component.componentConfig?.action || {}; // 🔧 최신 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 [localSelects, setLocalSelects] = useState({ + variant: config.variant || "default", + size: config.size || "md", // 🔧 기본값을 "md"로 변경 + actionType: config.action?.type, // 🔧 기본값 완전 제거 (undefined) + modalSize: config.action?.modalSize || "md", + editMode: config.action?.editMode || "modal", + }); + + const [screens, setScreens] = useState([]); + const [screensLoading, setScreensLoading] = useState(false); + const [modalScreenOpen, setModalScreenOpen] = useState(false); + const [navScreenOpen, setNavScreenOpen] = useState(false); + const [modalSearchTerm, setModalSearchTerm] = useState(""); + const [navSearchTerm, setNavSearchTerm] = useState(""); + + // 컴포넌트 변경 시 로컬 상태 동기화 + useEffect(() => { + console.log("🔄 ButtonConfigPanel useEffect 실행:", { + componentId: component.id, + "config.action?.type": config.action?.type, + "localSelects.actionType (before)": localSelects.actionType, + fullAction: config.action, + "component.componentConfig.action": component.componentConfig?.action, + }); + + setLocalInputs({ + text: config.text !== undefined ? config.text : "버튼", // 🔧 빈 문자열 허용 + modalTitle: config.action?.modalTitle || "", + editModalTitle: config.action?.editModalTitle || "", + editModalDescription: config.action?.editModalDescription || "", + targetUrl: config.action?.targetUrl || "", + }); + + setLocalSelects((prev) => { + const newSelects = { + variant: config.variant || "default", + size: config.size || "md", // 🔧 기본값을 "md"로 변경 + actionType: config.action?.type, // 🔧 기본값 완전 제거 (undefined) + modalSize: config.action?.modalSize || "md", + editMode: config.action?.editMode || "modal", + }; + + console.log("📝 setLocalSelects 호출:", { + "prev.actionType": prev.actionType, + "new.actionType": newSelects.actionType, + "config.action?.type": config.action?.type, + }); + + return newSelects; + }); + }, [ + component.id, // 🔧 컴포넌트 ID (다른 컴포넌트로 전환 시) + component.componentConfig?.action?.type, // 🔧 액션 타입 (액션 변경 시 즉시 반영) + component.componentConfig?.text, // 🔧 버튼 텍스트 + component.componentConfig?.variant, // 🔧 버튼 스타일 + component.componentConfig?.size, // 🔧 버튼 크기 + ]); + + // 화면 목록 가져오기 + useEffect(() => { + const fetchScreens = async () => { + try { + setScreensLoading(true); + const response = await apiClient.get("/screen-management/screens"); + + 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(); + }, []); + + // 검색 필터링 함수 + 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 ( +
+
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, text: newValue })); + onUpdateProperty("componentConfig.text", newValue); + }} + placeholder="버튼 텍스트를 입력하세요" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {/* 모달 열기 액션 설정 */} + {localSelects.actionType === "modal" && ( +
+

모달 설정

+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, modalTitle: newValue })); + onUpdateProperty("componentConfig.action.modalTitle", newValue); + }} + /> +
+ +
+ + +
+ +
+ + + + + + +
+
+ + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+
+
+ )} + + {/* 수정 액션 설정 */} + {localSelects.actionType === "edit" && ( +
+

수정 설정

+ +
+ + + + + + +
+
+ + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + {screen.name} +
+ )); + })()} +
+
+
+
+
+
+ )} + + {/* 복사 액션 설정 */} + {localSelects.actionType === "copy" && ( +
+

복사 설정 (품목코드 자동 초기화)

+ +
+ + + + + + +
+
+ + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+

+ 선택된 데이터가 복사되며, 품목코드는 자동으로 초기화됩니다 +

+
+ +
+ + +
+ + {localSelects.editMode === "modal" && ( + <> +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue })); + onUpdateProperty("componentConfig.action.editModalTitle", newValue); + onUpdateProperty("webTypeConfig.editModalTitle", newValue); + }} + /> +

비워두면 기본 제목이 표시됩니다

+
+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue })); + onUpdateProperty("componentConfig.action.editModalDescription", newValue); + onUpdateProperty("webTypeConfig.editModalDescription", newValue); + }} + /> +

비워두면 설명이 표시되지 않습니다

+
+ +
+ + +
+ + )} +
+ )} + + {/* 페이지 이동 액션 설정 */} + {localSelects.actionType === "navigate" && ( +
+

페이지 이동 설정

+ +
+ + + + + + +
+
+ + setNavSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(navSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setNavScreenOpen(false); + setNavSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+

+ 선택한 화면으로 /screens/{"{"}화면ID{"}"} 형태로 이동합니다 +

+
+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, targetUrl: newValue })); + onUpdateProperty("componentConfig.action.targetUrl", newValue); + }} + /> +

URL을 입력하면 화면 선택보다 우선 적용됩니다

+
+
+ )} + + {/* 🔥 NEW: 제어관리 기능 섹션 */} +
+
+

🔧 고급 기능

+

버튼 액션과 함께 실행될 추가 기능을 설정합니다

+
+ + +
+
+ ); +}; + diff --git a/frontend/components/v2/config-panels/V2SectionCardConfigPanel.tsx b/frontend/components/v2/config-panels/V2SectionCardConfigPanel.tsx new file mode 100644 index 00000000..89fc3189 --- /dev/null +++ b/frontend/components/v2/config-panels/V2SectionCardConfigPanel.tsx @@ -0,0 +1,277 @@ +"use client"; + +/** + * V2SectionCard 설정 패널 + * 토스식 단계별 UX: 패딩 카드 선택 -> 배경/테두리 설정 -> 고급 설정(접힘) + */ + +import React, { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Settings, + ChevronDown, + Square, + Minus, + SquareDashed, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +// ─── 내부 여백 카드 정의 ─── +const PADDING_CARDS = [ + { value: "none", label: "없음", size: "0px" }, + { value: "sm", label: "작게", size: "12px" }, + { value: "md", label: "중간", size: "24px" }, + { value: "lg", label: "크게", size: "32px" }, +] as const; + +// ─── 배경색 카드 정의 ─── +const BG_CARDS = [ + { value: "default", label: "카드", description: "기본 카드 배경" }, + { value: "muted", label: "회색", description: "연한 회색 배경" }, + { value: "transparent", label: "투명", description: "배경 없음" }, +] as const; + +// ─── 테두리 스타일 카드 정의 ─── +const BORDER_CARDS = [ + { value: "solid", label: "실선", icon: Minus }, + { value: "dashed", label: "점선", icon: SquareDashed }, + { value: "none", label: "없음", icon: Square }, +] as const; + +interface V2SectionCardConfigPanelProps { + config: Record; + onChange: (config: Record) => void; +} + +export const V2SectionCardConfigPanel: React.FC< + V2SectionCardConfigPanelProps +> = ({ config, onChange }) => { + const [advancedOpen, setAdvancedOpen] = useState(false); + + const updateConfig = (field: string, value: any) => { + const newConfig = { ...config, [field]: value }; + onChange(newConfig); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("componentConfigChanged", { + detail: { config: newConfig }, + }) + ); + } + }; + + return ( +
+ {/* ─── 1단계: 헤더 설정 ─── */} +
+
+
+

헤더 표시

+

+ 섹션 상단에 제목과 설명을 표시해요 +

+
+ updateConfig("showHeader", checked)} + /> +
+ + {config.showHeader !== false && ( +
+
+ 제목 + updateConfig("title", e.target.value)} + placeholder="섹션 제목 입력" + className="h-7 w-[180px] text-xs" + /> +
+
+ 설명 (선택) +