From 7d008b481d5fc66eb2049b9bbef95da053eb24e7 Mon Sep 17 00:00:00 2001 From: shin Date: Tue, 24 Feb 2026 15:40:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-card-list):=20PopCardList=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20+=20ksh-v2-w?= =?UTF-8?q?ork=20rebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PopCardList 컴포넌트 추가 (NumberInputModal, PackageUnitModal 포함) - ComponentEditorPanel, PopRenderer 충돌 해결 (modals + onRequestResize 통합) - ksh-v2-work 최신 커밋 (pop-search, pop-string-list, pop-button 등) rebase 반영 Co-authored-by: Cursor --- .cursor/mcp.json | 4 + .gitignore | 3 + backend-node/README.md | 2 +- .../01_master-data/item-info.md | 2 +- .../app/(pop)/pop/screens/[screenId]/page.tsx | 24 + .../components/pop/designer/PopCanvas.tsx | 4 + .../components/pop/designer/PopDesigner.tsx | 52 + .../designer/panels/ComponentEditorPanel.tsx | 6 +- .../pop/designer/renderers/PopRenderer.tsx | 47 +- .../pop-card-list/NumberInputModal.tsx | 209 ++++ .../pop-card-list/PackageUnitModal.tsx | 77 ++ .../pop-card-list/PopCardListComponent.tsx | 918 +++++++++++++++--- .../pop-card-list/PopCardListConfig.tsx | 751 +++++++++++--- .../pop-card-list/PopCardListPreview.tsx | 31 +- .../pop-components/pop-card-list/index.tsx | 17 +- frontend/lib/registry/pop-components/types.ts | 139 ++- frontend/package-lock.json | 90 +- frontend/package.json | 1 + frontend/scripts/test-card-list-e2e.ts | 94 ++ 19 files changed, 2099 insertions(+), 372 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx create mode 100644 frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx create mode 100644 frontend/scripts/test-card-list-e2e.ts diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 7a87d1a0..84a8729c 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -3,6 +3,10 @@ "agent-orchestrator": { "command": "node", "args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"] + }, + "Framelink Figma MCP": { + "command": "npx", + "args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"] } } } diff --git a/.gitignore b/.gitignore index f9c578ad..0194c053 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Claude Code (로컬 전용 - Git 제외) +.claude/ + # Dependencies node_modules/ npm-debug.log* diff --git a/backend-node/README.md b/backend-node/README.md index a2d34209..84bff2a1 100644 --- a/backend-node/README.md +++ b/backend-node/README.md @@ -1,4 +1,4 @@ -# PLM System Backend - Node.js + TypeScript +re# PLM System Backend - Node.js + TypeScript Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다. diff --git a/docs/screen-implementation-guide/01_master-data/item-info.md b/docs/screen-implementation-guide/01_master-data/item-info.md index b0ddd9e0..223eed42 100644 --- a/docs/screen-implementation-guide/01_master-data/item-info.md +++ b/docs/screen-implementation-guide/01_master-data/item-info.md @@ -8,7 +8,7 @@ ## ⚠️ 문서 사용 안내 -> **이 문서는 "품목정보" 화면의 구현 예시입니다.** + > > ### 📌 중요: JSON 데이터는 참고용입니다! > diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 4bf78be0..861795b5 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -146,6 +146,28 @@ function PopScreenViewPage() { } }, [screenId]); + // 뷰어 모드에서도 컴포넌트 크기 변경 지원 (더보기 등) + const handleRequestResize = React.useCallback((componentId: string, newRowSpan: number, newColSpan?: number) => { + setLayout((prev) => { + const comp = prev.components[componentId]; + if (!comp) return prev; + return { + ...prev, + components: { + ...prev.components, + [componentId]: { + ...comp, + position: { + ...comp.position, + rowSpan: newRowSpan, + ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), + }, + }, + }, + }; + }); + }, []); + const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"]; const hasComponents = Object.keys(layout.components).length > 0; @@ -301,6 +323,8 @@ function PopScreenViewPage() { currentMode={currentModeKey} overrideGap={adjustedGap} overridePadding={adjustedPadding} + onRequestResize={handleRequestResize} + currentScreenId={screenId} /> ); })()} diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index 19a0fd55..2edefb3a 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -116,6 +116,8 @@ interface PopCanvasProps { onLockLayout?: () => void; onResetOverride?: (mode: GridMode) => void; onChangeGapPreset?: (preset: GapPreset) => void; + /** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */ + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; /** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */ previewPageIndex?: number; /** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */ @@ -147,6 +149,7 @@ export default function PopCanvas({ onLockLayout, onResetOverride, onChangeGapPreset, + onRequestResize, previewPageIndex, activeCanvasId = "main", onActiveCanvasChange, @@ -761,6 +764,7 @@ export default function PopCanvas({ onComponentMove={onMoveComponent} onComponentResize={onResizeComponent} onComponentResizeEnd={onResizeEnd} + onRequestResize={onRequestResize} overrideGap={adjustedGap} overridePadding={adjustedPadding} previewPageIndex={previewPageIndex} diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 6fd5738e..902eb9a9 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -55,6 +55,7 @@ export default function PopDesigner({ onBackToList, onScreenUpdate, }: PopDesignerProps) { + // ======================================== // 레이아웃 상태 // ======================================== @@ -489,6 +490,56 @@ export default function PopDesigner({ [layout, saveToHistory] ); + // 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등) + const handleRequestResize = useCallback( + (componentId: string, newRowSpan: number, newColSpan?: number) => { + const component = layout.components[componentId]; + if (!component) return; + + const newPosition = { + ...component.position, + rowSpan: newRowSpan, + ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), + }; + + // 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정 + if (currentMode === "tablet_landscape") { + const newLayout = { + ...layout, + components: { + ...layout.components, + [componentId]: { + ...component, + position: newPosition, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + } else { + // 다른 모드인 경우: 오버라이드에 저장 + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + positions: { + ...layout.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + } + }, + [layout, currentMode, saveToHistory] + ); + // ======================================== // Gap 프리셋 관리 // ======================================== @@ -830,6 +881,7 @@ export default function PopDesigner({ onLockLayout={handleLockLayout} onResetOverride={handleResetOverride} onChangeGapPreset={handleChangeGapPreset} + onRequestResize={handleRequestResize} previewPageIndex={previewPageIndex} activeCanvasId={activeCanvasId} onActiveCanvasChange={navigateToCanvas} diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 6bf0ef38..8a5fa621 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -209,6 +209,7 @@ export default function ComponentEditorPanel({ ) => void; + currentMode?: GridMode; previewPageIndex?: number; onPreviewPage?: (pageIndex: number) => void; modals?: PopModalDefinition[]; } -function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) { +function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) { // PopComponentRegistry에서 configPanel 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const ConfigPanel = registeredComp?.configPanel; @@ -433,6 +435,8 @@ function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPrevie void; /** 컴포넌트 크기 조정 완료 (히스토리 저장용) */ onComponentResizeEnd?: (componentId: string) => void; + /** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */ + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; /** Gap 오버라이드 (Gap 프리셋 적용된 값) */ overrideGap?: number; /** Padding 오버라이드 (Gap 프리셋 적용된 값) */ @@ -91,6 +93,7 @@ export default function PopRenderer({ onComponentMove, onComponentResize, onComponentResizeEnd, + onRequestResize, overrideGap, overridePadding, className, @@ -270,6 +273,7 @@ export default function PopRenderer({ onComponentMove={onComponentMove} onComponentResize={onComponentResize} onComponentResizeEnd={onComponentResizeEnd} + onRequestResize={onRequestResize} previewPageIndex={previewPageIndex} /> ); @@ -279,7 +283,7 @@ export default function PopRenderer({ return (
); @@ -315,6 +320,7 @@ interface DraggableComponentProps { onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResizeEnd?: (componentId: string) => void; + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; previewPageIndex?: number; } @@ -333,6 +339,7 @@ function DraggableComponent({ onComponentMove, onComponentResize, onComponentResizeEnd, + onRequestResize, previewPageIndex, }: DraggableComponentProps) { const [{ isDragging }, drag] = useDrag( @@ -373,7 +380,8 @@ function DraggableComponent({ isDesignMode={isDesignMode} isSelected={isSelected} previewPageIndex={previewPageIndex} - screenId="" + onRequestResize={onRequestResize} + screenId={undefined} /> {/* 리사이즈 핸들 (선택된 컴포넌트만) */} @@ -525,11 +533,12 @@ interface ComponentContentProps { isDesignMode: boolean; isSelected: boolean; previewPageIndex?: number; + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; /** 화면 ID (이벤트 버스/액션 실행용) */ screenId?: string; } -function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex, screenId }: ComponentContentProps) { +function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex, onRequestResize, screenId }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // PopComponentRegistry에서 등록된 컴포넌트 가져오기 @@ -543,7 +552,8 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect // 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등) if (ActualComp) { // 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용 - const needsPointerEvents = component.type === "pop-icon"; + // CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용 + const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list"; return (
); @@ -575,23 +589,36 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect ); } - // 실제 모드: 컴포넌트 렌더링 - return renderActualComponent(component, screenId); + // 실제 모드: 컴포넌트 렌더링 (뷰어 모드에서도 리사이즈 지원) + return renderActualComponent(component, effectivePosition, onRequestResize, screenId); } // ======================================== // 실제 컴포넌트 렌더링 (뷰어 모드) // ======================================== -function renderActualComponent(component: PopComponentDefinitionV5, screenId?: string): React.ReactNode { +function renderActualComponent( + component: PopComponentDefinitionV5, + effectivePosition?: PopGridPosition, + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void, + screenId?: string, +): React.ReactNode { // 레지스트리에서 등록된 실제 컴포넌트 조회 const registeredComp = PopComponentRegistry.getComponent(component.type); const ActualComp = registeredComp?.component; if (ActualComp) { return ( -
- +
+
); } diff --git a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx new file mode 100644 index 00000000..806dedb1 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx @@ -0,0 +1,209 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Delete } from "lucide-react"; +import { + Dialog, + DialogPortal, + DialogOverlay, +} from "@/components/ui/dialog"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { + PackageUnitModal, + PACKAGE_UNITS, + type PackageUnit, +} from "./PackageUnitModal"; + +interface NumberInputModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + unit?: string; + initialValue?: number; + initialPackageUnit?: string; + min?: number; + maxValue?: number; + onConfirm: (value: number, packageUnit?: string) => void; +} + +export function NumberInputModal({ + open, + onOpenChange, + unit = "EA", + initialValue = 0, + initialPackageUnit, + min = 0, + maxValue = 999999, + onConfirm, +}: NumberInputModalProps) { + const [displayValue, setDisplayValue] = useState(""); + const [packageUnit, setPackageUnit] = useState(undefined); + const [isPackageModalOpen, setIsPackageModalOpen] = useState(false); + + useEffect(() => { + if (open) { + setDisplayValue(initialValue > 0 ? String(initialValue) : ""); + setPackageUnit(initialPackageUnit); + } + }, [open, initialValue, initialPackageUnit]); + + const handleNumberClick = (num: string) => { + const newStr = displayValue + num; + const numericValue = parseInt(newStr, 10); + setDisplayValue(numericValue > maxValue ? String(maxValue) : newStr); + }; + + const handleBackspace = () => + setDisplayValue((prev) => prev.slice(0, -1)); + const handleClear = () => setDisplayValue(""); + const handleMax = () => setDisplayValue(String(maxValue)); + + const handleConfirm = () => { + const numericValue = parseInt(displayValue, 10) || 0; + const finalValue = Math.max(min, Math.min(maxValue, numericValue)); + onConfirm(finalValue, packageUnit); + onOpenChange(false); + }; + + const handlePackageUnitSelect = (selected: PackageUnit) => { + setPackageUnit(selected); + }; + + const matchedUnit = packageUnit + ? PACKAGE_UNITS.find((u) => u.value === packageUnit) + : null; + const packageUnitLabel = matchedUnit?.label ?? null; + const packageUnitEmoji = matchedUnit?.emoji ?? "📦"; + + const displayText = displayValue + ? parseInt(displayValue, 10).toLocaleString() + : ""; + + return ( + <> + + + + + {/* 파란 헤더 */} +
+ + 최대 {maxValue.toLocaleString()} {unit} + + +
+ +
+ {/* 숫자 표시 영역 */} +
+ {displayText ? ( + + {displayText} + + ) : ( + 0 + )} +
+ + {/* 안내 텍스트 */} +

+ 수량을 입력하세요 +

+ + {/* 키패드 4x4 */} +
+ {/* 1행: 7 8 9 ← (주황) */} + {["7", "8", "9"].map((n) => ( + + ))} + + + {/* 2행: 4 5 6 C (주황) */} + {["4", "5", "6"].map((n) => ( + + ))} + + + {/* 3행: 1 2 3 MAX (파란) */} + {["1", "2", "3"].map((n) => ( + + ))} + + + {/* 4행: 0 / 확인 (초록, 3칸) */} + + +
+
+ +
+
+
+ + {/* 포장 단위 선택 모달 */} + + + ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx new file mode 100644 index 00000000..0911bc47 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, +} from "@/components/ui/dialog"; + +export const PACKAGE_UNITS = [ + { value: "box", label: "박스", emoji: "📦" }, + { value: "bag", label: "포대", emoji: "🛍️" }, + { value: "pack", label: "팩", emoji: "📋" }, + { value: "bundle", label: "묶음", emoji: "🔗" }, + { value: "roll", label: "롤", emoji: "🧻" }, + { value: "barrel", label: "통", emoji: "🪣" }, +] as const; + +export type PackageUnit = (typeof PACKAGE_UNITS)[number]["value"]; + +interface PackageUnitModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (unit: PackageUnit) => void; +} + +export function PackageUnitModal({ + open, + onOpenChange, + onSelect, +}: PackageUnitModalProps) { + const handleSelect = (unit: PackageUnit) => { + onSelect(unit); + onOpenChange(false); + }; + + return ( + + + + + + {/* 헤더 */} +
+

📦 포장 단위 선택

+
+ + {/* 3x2 그리드 */} +
+ {PACKAGE_UNITS.map((unit) => ( + + ))} +
+ + {/* X 닫기 버튼 */} + + + Close + +
+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index 49a06462..bf7d71ed 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -1,7 +1,7 @@ "use client"; /** - * pop-card-list 런타임 컴포넌트 (V2 - 이미지 참조 기반 재설계) + * pop-card-list 런타임 컴포넌트 * * 테이블의 각 행이 하나의 카드로 표시됩니다. * 카드 구조: @@ -9,44 +9,355 @@ * - 본문: 이미지(왼쪽) + 라벨-값 목록(오른쪽) */ -import React, { useEffect, useState, useRef } from "react"; -import { Loader2 } from "lucide-react"; +import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X } from "lucide-react"; +import * as LucideIcons from "lucide-react"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; import type { PopCardListConfig, CardTemplateConfig, CardFieldBinding, + CardInputFieldConfig, + CardCalculatedFieldConfig, + CardCartActionConfig, + CardPresetSpec, + CartItem, +} from "../types"; +import { + DEFAULT_CARD_IMAGE, + CARD_PRESET_SPECS, } from "../types"; -import { DEFAULT_CARD_IMAGE } from "../types"; import { dataApi } from "@/lib/api/data"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { NumberInputModal } from "./NumberInputModal"; + +// Lucide 아이콘 동적 렌더링 +function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { + if (!name) return ; + const icons = LucideIcons as unknown as Record>; + const IconComp = icons[name]; + if (!IconComp) return ; + return ; +} + +// 마퀴 애니메이션 keyframes (한 번만 삽입) +const MARQUEE_STYLE_ID = "pop-card-marquee-style"; +if (typeof document !== "undefined" && !document.getElementById(MARQUEE_STYLE_ID)) { + const style = document.createElement("style"); + style.id = MARQUEE_STYLE_ID; + style.textContent = ` + @keyframes pop-marquee { + 0%, 15% { transform: translateX(0); } + 85%, 100% { transform: translateX(var(--marquee-offset)); } + } + `; + document.head.appendChild(style); +} + +// 텍스트가 컨테이너보다 넓을 때 자동 슬라이딩하는 컴포넌트 +function MarqueeText({ + children, + className, + style, +}: { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + const containerRef = useRef(null); + const textRef = useRef(null); + const [overflowPx, setOverflowPx] = useState(0); + + const measure = useCallback(() => { + const container = containerRef.current; + const text = textRef.current; + if (!container || !text) return; + const diff = text.scrollWidth - container.clientWidth; + setOverflowPx(diff > 1 ? diff : 0); + }, []); + + useEffect(() => { + measure(); + }, [children, measure]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const ro = new ResizeObserver(() => measure()); + ro.observe(container); + return () => ro.disconnect(); + }, [measure]); + + return ( +
+ 0 + ? { + ["--marquee-offset" as string]: `-${overflowPx}px`, + animation: "pop-marquee 5s ease-in-out infinite alternate", + } + : undefined + } + > + {children} + +
+ ); +} interface PopCardListComponentProps { config?: PopCardListConfig; className?: string; + screenId?: string; + // 동적 크기 변경을 위한 props (PopRenderer에서 전달) + componentId?: string; + currentRowSpan?: number; + currentColSpan?: number; + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; } // 테이블 행 데이터 타입 type RowData = Record; +// 카드 내부 스타일 규격 (프리셋에서 매핑) +interface ScaledConfig { + cardHeight: number; + cardWidth: number; + imageSize: number; + padding: number; + gap: number; + headerPaddingX: number; + headerPaddingY: number; + codeTextSize: number; + titleTextSize: number; + bodyTextSize: number; +} + export function PopCardListComponent({ config, className, + screenId, + componentId, + currentRowSpan, + currentColSpan, + onRequestResize, }: PopCardListComponentProps) { - const layoutMode = config?.layoutMode || "grid"; - const cardSize = config?.cardSize || "medium"; - const cardsPerRow = config?.cardsPerRow || 3; + const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal"; + const maxGridColumns = config?.gridColumns || 2; + const configGridRows = config?.gridRows || 3; const dataSource = config?.dataSource; const template = config?.cardTemplate; + // 이벤트 기반 company_code 필터링 + const [eventCompanyCode, setEventCompanyCode] = useState(); + const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default"); + const router = useRouter(); + + useEffect(() => { + if (!screenId) return; + const unsub = subscribe("company_selected", (payload: unknown) => { + const p = payload as { companyCode?: string } | undefined; + setEventCompanyCode(p?.companyCode); + }); + return unsub; + }, [screenId, subscribe]); + // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // 확장/페이지네이션 상태 + const [isExpanded, setIsExpanded] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [originalRowSpan, setOriginalRowSpan] = useState(null); + + // 컨테이너 ref + 크기 측정 + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + const baseContainerHeight = useRef(0); + + useEffect(() => { + if (!containerRef.current) return; + const observer = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + if (width > 0) setContainerWidth(width); + if (height > 0) setContainerHeight(height); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + // 이미지 URL 없는 항목 카운트 (toast 중복 방지용) const missingImageCountRef = useRef(0); const toastShownRef = useRef(false); + const spec: CardPresetSpec = CARD_PRESET_SPECS.large; + + // 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열 + const maxAllowedColumns = useMemo(() => { + if (!currentColSpan) return maxGridColumns; + if (currentColSpan >= 8) return maxGridColumns; + return 1; + }, [currentColSpan, maxGridColumns]); + + // 카드 최소 너비 기준으로 컨테이너에 들어갈 수 있는 열 개수 자동 계산 + const minCardWidth = Math.round(spec.height * 1.6); + const autoColumns = containerWidth > 0 + ? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap))) + : maxGridColumns; + const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns); + + // 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지) + const effectiveGridRows = useMemo(() => { + if (containerHeight <= 0) return configGridRows; + + const controlBarHeight = 44; + const effectiveHeight = baseContainerHeight.current > 0 + ? baseContainerHeight.current + : containerHeight; + const availableHeight = effectiveHeight - controlBarHeight; + + const cardHeightWithGap = spec.height + spec.gap; + const fittableRows = Math.max(1, Math.floor( + (availableHeight + spec.gap) / cardHeightWithGap + )); + + return Math.min(configGridRows, fittableRows); + }, [containerHeight, configGridRows, spec]); + + const gridRows = effectiveGridRows; + + // 카드 크기: 컨테이너 실측 크기에서 gridColumns x gridRows 기준으로 동적 계산 + const scaled = useMemo((): ScaledConfig => { + const gap = spec.gap; + const controlBarHeight = 44; + + const buildScaledConfig = (cardWidth: number, cardHeight: number): ScaledConfig => { + const scale = cardHeight / spec.height; + return { + cardHeight, + cardWidth, + imageSize: Math.round(spec.imageSize * scale), + padding: Math.round(spec.padding * scale), + gap, + headerPaddingX: Math.round(spec.headerPadX * scale), + headerPaddingY: Math.round(spec.headerPadY * scale), + codeTextSize: Math.round(spec.codeText * scale), + titleTextSize: Math.round(spec.titleText * scale), + bodyTextSize: Math.round(spec.bodyText * scale), + }; + }; + + if (containerWidth <= 0 || containerHeight <= 0) { + return buildScaledConfig(Math.round(spec.height * 1.6), spec.height); + } + + const effectiveHeight = baseContainerHeight.current > 0 + ? baseContainerHeight.current + : containerHeight; + + const availableHeight = effectiveHeight - controlBarHeight; + const availableWidth = containerWidth; + + const cardHeight = Math.max(spec.height, + Math.floor((availableHeight - gap * (gridRows - 1)) / gridRows)); + const cardWidth = Math.max(Math.round(spec.height * 1.6), + Math.floor((availableWidth - gap * (gridColumns - 1)) / gridColumns)); + + return buildScaledConfig(cardWidth, cardHeight); + }, [spec, containerWidth, containerHeight, gridColumns, gridRows]); + + // 기본 상태에서 표시할 카드 수 + const visibleCardCount = useMemo(() => { + return gridColumns * gridRows; + }, [gridColumns, gridRows]); + + // 더보기 버튼 표시 여부 + const hasMoreCards = rows.length > visibleCardCount; + + // 확장 상태에서 표시할 카드 수 계산 + const expandedCardsPerPage = useMemo(() => { + // 가로/세로 모두: 기본 표시 수의 2배 + 스크롤 유도를 위해 1줄 추가 + // 가로: 컴포넌트 크기 변경 없이 카드 2배 → 가로 스크롤로 탐색 + // 세로: rowSpan 2배 → 2배 영역에 카드 채움 + 세로 스크롤 + return Math.max(1, visibleCardCount * 2 + gridColumns); + }, [visibleCardCount, gridColumns]); + + // 스크롤 영역 ref + const scrollAreaRef = useRef(null); + + // 현재 표시할 카드 결정 + const displayCards = useMemo(() => { + if (!isExpanded) { + // 기본 상태: visibleCardCount만큼만 표시 + return rows.slice(0, visibleCardCount); + } else { + // 확장 상태: 현재 페이지의 카드들 (스크롤 가능하도록 더 많이) + const start = (currentPage - 1) * expandedCardsPerPage; + const end = start + expandedCardsPerPage; + return rows.slice(start, end); + } + }, [rows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); + + // 총 페이지 수 + const totalPages = isExpanded + ? Math.ceil(rows.length / expandedCardsPerPage) + : 1; + // 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때 + const needsPagination = isExpanded && totalPages > 1; + + // 페이지 변경 핸들러 + const handlePrevPage = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + + const handleNextPage = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + // 확장/접기 토글: 세로 모드에서만 rowSpan 2배 확장, 가로 모드에서는 크기 변경 없이 카드만 추가 표시 + const toggleExpand = () => { + if (isExpanded) { + if (!isHorizontalMode && originalRowSpan !== null && componentId && onRequestResize) { + onRequestResize(componentId, originalRowSpan); + } + setCurrentPage(1); + setOriginalRowSpan(null); + baseContainerHeight.current = 0; + setIsExpanded(false); + } else { + baseContainerHeight.current = containerHeight; + if (!isHorizontalMode && componentId && onRequestResize && currentRowSpan !== undefined) { + setOriginalRowSpan(currentRowSpan); + onRequestResize(componentId, currentRowSpan * 2); + } + setIsExpanded(true); + } + }; + + // 페이지 변경 시 스크롤 위치 초기화 (가로/세로 모두) + useEffect(() => { + if (scrollAreaRef.current && isExpanded) { + scrollAreaRef.current.scrollTop = 0; + scrollAreaRef.current.scrollLeft = 0; + } + }, [currentPage, isExpanded]); + // 데이터 조회 useEffect(() => { if (!dataSource?.tableName) { @@ -67,12 +378,16 @@ export function PopCardListComponent({ if (dataSource.filters && dataSource.filters.length > 0) { dataSource.filters.forEach((f) => { if (f.column && f.value) { - // 간단한 = 연산자만 지원 (추후 확장 가능) filters[f.column] = f.value; } }); } + // 이벤트로 수신한 company_code 필터 병합 + if (eventCompanyCode) { + filters["company_code"] = eventCompanyCode; + } + // 정렬 조건 const sortBy = dataSource.sort?.column; const sortOrder = dataSource.sort?.direction; @@ -105,7 +420,7 @@ export function PopCardListComponent({ }; fetchData(); - }, [dataSource]); + }, [dataSource, eventCompanyCode]); // 이미지 URL 없는 항목 체크 및 toast 표시 useEffect(() => { @@ -130,134 +445,181 @@ export function PopCardListComponent({ }, [loading, rows, template?.image]); - // 레이아웃 클래스 (스크롤 지원) - const layoutClass = - layoutMode === "vertical" - ? "flex flex-col gap-3 h-full overflow-y-auto" - : layoutMode === "horizontal" - ? "flex flex-row gap-3 h-full overflow-x-auto pb-2" - : "grid gap-3 h-full overflow-y-auto"; + // 카드 영역 스타일 + const cardAreaStyle: React.CSSProperties = { + gap: `${scaled.gap}px`, + ...(isHorizontalMode + ? { + gridTemplateRows: `repeat(${gridRows}, ${scaled.cardHeight}px)`, + gridAutoFlow: "column", + gridAutoColumns: `${scaled.cardWidth}px`, + } + : { + // 세로 모드: 1fr 비율 기반으로 컨테이너 너비 초과 방지 + gridTemplateColumns: `repeat(${gridColumns}, 1fr)`, + gridAutoRows: `${scaled.cardHeight}px`, + }), + }; - // 그리드 스타일 - const gridStyle = - layoutMode === "grid" - ? { gridTemplateColumns: `repeat(${cardsPerRow}, minmax(0, 1fr))` } - : undefined; - - // 설정 미완료 상태 - if (!dataSource?.tableName) { - return ( -
-

- 데이터 소스를 설정해주세요. -

-
- ); - } - - // 로딩 중 - if (loading) { - return ( -
- -
- ); - } - - // 에러 상태 - if (error) { - return ( -
-

{error}

-
- ); - } - - // 데이터 없음 - if (rows.length === 0) { - return ( -
-

데이터가 없습니다.

-
- ); - } + // 세로 모드 스크롤 클래스: 비확장 시 overflow hidden, 확장 시에만 세로 스크롤 허용 + const scrollClassName = isHorizontalMode + ? "overflow-x-auto overflow-y-hidden" + : isExpanded + ? "overflow-y-auto overflow-x-hidden" + : "overflow-hidden"; return ( -
- {rows.map((row, index) => ( - - ))} +
+ {!dataSource?.tableName ? ( +
+

+ 데이터 소스를 설정해주세요. +

+
+ ) : loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : rows.length === 0 ? ( +
+

데이터가 없습니다.

+
+ ) : ( + <> + {/* 카드 영역 (스크롤 가능) */} +
+ {displayCards.map((row, index) => ( + + ))} +
+ + {/* 하단 컨트롤 영역 */} + {hasMoreCards && ( +
+
+
+ + + {rows.length}건 + +
+ + {isExpanded && needsPagination && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+
+ )} + + )}
); } -// ===== 카드 크기별 설정 ===== -const CARD_SIZE_CONFIG = { - small: { - minHeight: "min-h-[120px]", - minWidth: "min-w-[200px]", - imageSize: "h-14 w-14", - padding: "p-2", - gap: "gap-2", - headerPadding: "px-2 py-1.5", - codeText: "text-[10px]", - titleText: "text-xs", - }, - medium: { - minHeight: "min-h-[140px]", - minWidth: "min-w-[260px]", - imageSize: "h-16 w-16", - padding: "p-3", - gap: "gap-3", - headerPadding: "px-3 py-2", - codeText: "text-xs", - titleText: "text-sm", - }, - large: { - minHeight: "min-h-[180px]", - minWidth: "min-w-[320px]", - imageSize: "h-20 w-20", - padding: "p-4", - gap: "gap-4", - headerPadding: "px-4 py-2.5", - codeText: "text-xs", - titleText: "text-base", - }, -}; - // ===== 카드 컴포넌트 ===== function Card({ row, template, - cardSize, - isHorizontal, + scaled, + inputField, + calculatedField, + cartAction, + publish, + getSharedData, + setSharedData, + router, }: { row: RowData; template?: CardTemplateConfig; - cardSize: "small" | "medium" | "large"; - isHorizontal: boolean; + scaled: ScaledConfig; + inputField?: CardInputFieldConfig; + calculatedField?: CardCalculatedFieldConfig; + cartAction?: CardCartActionConfig; + publish: (eventName: string, payload?: unknown) => void; + getSharedData: (key: string) => T | undefined; + setSharedData: (key: string, value: unknown) => void; + router: ReturnType; }) { const header = template?.header; const image = template?.image; const body = template?.body; - // 크기별 설정 - const sizeConfig = CARD_SIZE_CONFIG[cardSize]; + // 입력 필드 상태 + const [inputValue, setInputValue] = useState( + inputField?.defaultValue || 0 + ); + const [packageUnit, setPackageUnit] = useState(undefined); + const [isModalOpen, setIsModalOpen] = useState(false); + + // 담기/취소 토글 상태 + const [isCarted, setIsCarted] = useState(false); // 헤더 값 추출 const codeValue = header?.codeField ? row[header.codeField] : null; @@ -269,67 +631,248 @@ function Card({ ? String(row[image.imageColumn]) : image?.defaultImage || DEFAULT_CARD_IMAGE; + // 계산 필드 값 계산 + const calculatedValue = useMemo(() => { + if (!calculatedField?.enabled || !calculatedField?.formula) return null; + return evaluateFormula(calculatedField.formula, row, inputValue); + }, [calculatedField, row, inputValue]); + + // effectiveMax: DB 컬럼 우선, 없으면 inputField.max 폴백 + const effectiveMax = useMemo(() => { + if (inputField?.maxColumn) { + const colVal = Number(row[inputField.maxColumn]); + if (!isNaN(colVal) && colVal > 0) return colVal; + } + return inputField?.max ?? 999999; + }, [inputField, row]); + + // 기본값이 설정되지 않은 경우 최대값으로 자동 초기화 + useEffect(() => { + if (inputField?.enabled && !inputField?.defaultValue && effectiveMax > 0 && effectiveMax < 999999) { + setInputValue(effectiveMax); + } + }, [effectiveMax, inputField?.enabled, inputField?.defaultValue]); + + const cardStyle: React.CSSProperties = { + height: `${scaled.cardHeight}px`, + overflow: "hidden", + }; + + const headerStyle: React.CSSProperties = { + padding: `${scaled.headerPaddingY}px ${scaled.headerPaddingX}px`, + }; + + const bodyStyle: React.CSSProperties = { + padding: `${scaled.padding}px`, + gap: `${scaled.gap}px`, + }; + + const imageContainerStyle: React.CSSProperties = { + width: `${scaled.imageSize}px`, + height: `${scaled.imageSize}px`, + }; + + const handleInputClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsModalOpen(true); + }; + + const handleInputConfirm = (value: number, unit?: string) => { + setInputValue(value); + setPackageUnit(unit); + }; + + // 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글 + const handleCartAdd = () => { + const cartItem: CartItem = { + row, + quantity: inputValue, + packageUnit: packageUnit || undefined, + }; + + const existing = getSharedData("cart_items") || []; + setSharedData("cart_items", [...existing, cartItem]); + publish("cart_item_added", cartItem); + + setIsCarted(true); + toast.success("장바구니에 담겼습니다."); + + if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) { + router.push(`/pop/screens/${cartAction.targetScreenId}`); + } + }; + + // 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원 + const handleCartCancel = () => { + const existing = getSharedData("cart_items") || []; + const rowKey = JSON.stringify(row); + const filtered = existing.filter( + (item) => JSON.stringify(item.row) !== rowKey + ); + setSharedData("cart_items", filtered); + publish("cart_item_removed", { row }); + + setIsCarted(false); + toast.info("장바구니에서 제거되었습니다."); + }; + + // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) + const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11))); + const cartLabel = cartAction?.label || "담기"; + const cancelLabel = cartAction?.cancelLabel || "취소"; + return (
{/* 헤더 영역 */} {(codeValue !== null || titleValue !== null) && ( -
+
{codeValue !== null && ( - + {formatValue(codeValue)} )} {titleValue !== null && ( - + {formatValue(titleValue)} - + )}
)} {/* 본문 영역 */} -
+
{/* 이미지 (왼쪽) */} {image?.enabled && ( -
-
+
+
{ - // 이미지 로드 실패 시 기본 이미지로 대체 const target = e.target as HTMLImageElement; - if (target.src !== DEFAULT_CARD_IMAGE) { - target.src = DEFAULT_CARD_IMAGE; - } + if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE; }} />
)} - {/* 필드 목록 (오른쪽) */} -
- {body?.fields && body.fields.length > 0 ? ( -
- {body.fields.map((field) => ( - - ))} -
- ) : ( -
- 본문 필드를 추가하세요 -
- )} + {/* 필드 목록 (중간, flex-1) */} +
+
+ {body?.fields && body.fields.length > 0 ? ( + body.fields.map((field) => ( + + )) + ) : ( +
+ 본문 필드를 추가하세요 +
+ )} + + {/* 계산 필드 */} + {calculatedField?.enabled && calculatedValue !== null && ( +
+ + {calculatedField.label || "계산값"} + + + {calculatedValue.toLocaleString()}{calculatedField.unit ? ` ${calculatedField.unit}` : ""} + +
+ )} +
+ + {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */} + {inputField?.enabled && ( +
+ {/* 수량 버튼 */} + + + {/* pop-icon 스타일 담기/취소 토글 버튼 */} + {isCarted ? ( + + ) : ( + + )} +
+ )}
+ + {/* 숫자 입력 모달 */} + {inputField?.enabled && ( + + )}
); } @@ -339,31 +882,36 @@ function Card({ function FieldRow({ field, row, - cardSize, + scaled, }: { field: CardFieldBinding; row: RowData; - cardSize: "small" | "medium" | "large"; + scaled: ScaledConfig; }) { const value = row[field.columnName]; - // 크기별 텍스트 설정 - const textSize = cardSize === "small" ? "text-[10px]" : "text-xs"; - const labelMinWidth = cardSize === "small" ? "min-w-[50px]" : "min-w-[60px]"; + // 비율 기반 라벨 최소 너비 + const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12)); return ( -
+
{/* 라벨 */} - + {field.label} - {/* 값 */} - {formatValue(value)} - +
); } @@ -393,3 +941,61 @@ function formatValue(value: unknown): string { } return String(value); } + +// ===== 계산식 평가 ===== + +/** + * 간단한 계산식을 평가합니다. + * 지원 연산: +, -, *, / + * 특수 변수: $input (입력 필드 값) + * + * @param formula 계산식 (예: "order_qty - inbound_qty", "$input - received_qty") + * @param row 데이터 행 + * @param inputValue 입력 필드 값 + * @returns 계산 결과 또는 null (계산 실패 시) + */ +function evaluateFormula( + formula: string, + row: RowData, + inputValue: number +): number | null { + try { + // 수식에서 컬럼명과 $input을 실제 값으로 치환 + let expression = formula; + + // $input을 입력값으로 치환 + expression = expression.replace(/\$input/g, String(inputValue)); + + // 컬럼명을 값으로 치환 (알파벳, 숫자, 언더스코어로 구성된 식별자) + const columnPattern = /[a-zA-Z_][a-zA-Z0-9_]*/g; + expression = expression.replace(columnPattern, (match) => { + // 이미 숫자로 치환된 경우 스킵 + if (/^\d+$/.test(match)) return match; + + const value = row[match]; + if (value === null || value === undefined) return "0"; + if (typeof value === "number") return String(value); + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? "0" : String(parsed); + }); + + // 안전한 계산 (기본 산술 연산만 허용) + // 허용: 숫자, +, -, *, /, (, ), 공백, 소수점 + if (!/^[\d\s+\-*/().]+$/.test(expression)) { + console.warn("Invalid formula expression:", expression); + return null; + } + + // eval 대신 Function 사용 (더 안전) + const result = new Function(`return (${expression})`)(); + + if (typeof result !== "number" || isNaN(result) || !isFinite(result)) { + return null; + } + + return Math.round(result * 100) / 100; // 소수점 2자리까지 + } catch (error) { + console.warn("Formula evaluation error:", error); + return null; + } +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index 3d73070d..d8b31c34 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -1,7 +1,7 @@ "use client"; /** - * pop-card-list 설정 패널 (V2 - 이미지 참조 기반 재설계) + * pop-card-list 설정 패널 * * 3개 탭: * [테이블] - 데이터 테이블 선택 @@ -9,8 +9,10 @@ * [데이터 소스] - 조인/필터/정렬/개수 설정 */ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { ChevronDown, ChevronRight, Plus, Trash2, Database } from "lucide-react"; +import type { GridMode } from "@/components/pop/designer/types/pop-layout"; +import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -32,13 +34,14 @@ import type { CardFieldBinding, CardColumnJoin, CardColumnFilter, - CardSize, - CardLayoutMode, + CardScrollDirection, FilterOperator, + CardInputFieldConfig, + CardCalculatedFieldConfig, + CardCartActionConfig, } from "../types"; import { - CARD_SIZE_LABELS, - CARD_LAYOUT_MODE_LABELS, + CARD_SCROLL_DIRECTION_LABELS, DEFAULT_CARD_IMAGE, } from "../types"; import { @@ -53,6 +56,8 @@ import { interface ConfigPanelProps { config: PopCardListConfig | undefined; onUpdate: (config: PopCardListConfig) => void; + currentMode?: GridMode; + currentColSpan?: number; } // ===== 기본값 ===== @@ -85,9 +90,10 @@ const DEFAULT_TEMPLATE: CardTemplateConfig = { const DEFAULT_CONFIG: PopCardListConfig = { dataSource: DEFAULT_DATA_SOURCE, cardTemplate: DEFAULT_TEMPLATE, - layoutMode: "grid", - cardsPerRow: 3, - cardSize: "medium", + scrollDirection: "vertical", + gridColumns: 2, + gridRows: 3, + cardSize: "large", }; // ===== 색상 옵션 ===== @@ -105,10 +111,10 @@ const COLOR_OPTIONS = [ // ===== 메인 컴포넌트 ===== -export function PopCardListConfigPanel({ config, onUpdate }: ConfigPanelProps) { - // 3탭 구조: 테이블 선택 → 카드 템플릿 → 데이터 소스 - const [activeTab, setActiveTab] = useState<"table" | "template" | "dataSource">( - "table" +export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) { + // 3탭 구조: 기본 설정 (테이블+레이아웃) → 데이터 소스 → 카드 템플릿 + const [activeTab, setActiveTab] = useState<"basic" | "template" | "dataSource">( + "basic" ); // config가 없으면 기본값 사용 @@ -129,27 +135,13 @@ export function PopCardListConfigPanel({ config, onUpdate }: ConfigPanelProps) { - +
{/* 탭 내용 */}
- {activeTab === "table" && ( - - )} - {activeTab === "template" && ( - + {activeTab === "basic" && ( + )} {activeTab === "dataSource" && ( )} + {activeTab === "template" && ( + + )}
); } -// ===== 테이블 선택 탭 ===== +// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) ===== -function TableSelectTab({ +function BasicSettingsTab({ config, onUpdate, + currentMode, + currentColSpan, }: { config: PopCardListConfig; onUpdate: (partial: Partial) => void; + currentMode?: GridMode; + currentColSpan?: number; }) { const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const [tables, setTables] = useState([]); @@ -200,57 +215,163 @@ function TableSelectTab({ fetchTableList().then(setTables); }, []); + // 모드별 추천값 계산 + const recommendation = useMemo(() => { + if (!currentMode) return null; + const columns = GRID_BREAKPOINTS[currentMode].columns; + if (columns >= 8) return { rows: 3, cols: 2 }; + if (columns >= 6) return { rows: 3, cols: 1 }; + return { rows: 2, cols: 1 }; + }, [currentMode]); + + // 열 최대값: colSpan 기반 제한 + const maxColumns = useMemo(() => { + if (!currentColSpan) return 2; + return currentColSpan >= 8 ? 2 : 1; + }, [currentColSpan]); + + // 현재 모드 라벨 + const modeLabel = currentMode ? GRID_BREAKPOINTS[currentMode].label : null; + + // 모드 변경 시 열/행 자동 적용 + useEffect(() => { + if (!recommendation) return; + const currentRows = config.gridRows || 3; + const currentCols = config.gridColumns || 2; + if (currentRows !== recommendation.rows || currentCols !== recommendation.cols) { + onUpdate({ + gridRows: recommendation.rows, + gridColumns: recommendation.cols, + }); + } + }, [currentMode]); // eslint-disable-line react-hooks/exhaustive-deps + return (
- {/* 테이블 선택 */} -
- -

- 카드 리스트에 표시할 데이터가 있는 테이블을 선택하세요 -

- -
+ {/* 테이블 선택 섹션 */} + +
+
+ + +
- {/* 선택된 테이블 정보 */} - {dataSource.tableName && ( -
-
-
+ {dataSource.tableName && ( +
+ {dataSource.tableName}
-
-

{dataSource.tableName}

-

선택된 테이블

+ )} +
+ + + {/* 레이아웃 설정 섹션 */} + +
+ {/* 현재 모드 뱃지 */} + {modeLabel && ( +
+ 현재: + + {modeLabel} + +
+ )} + + {/* 스크롤 방향 */} +
+ +
+ {(["horizontal", "vertical"] as CardScrollDirection[]).map((dir) => ( + + ))}
+ + {/* 그리드 배치 설정 (행 x 열) */} +
+ +
+ + onUpdate({ gridRows: parseInt(e.target.value, 10) || 3 }) + } + className="h-7 w-16 text-center text-xs" + /> + x + + onUpdate({ gridColumns: Math.min(parseInt(e.target.value, 10) || 1, maxColumns) }) + } + className="h-7 w-16 text-center text-xs" + disabled={maxColumns === 1} + /> +
+ +

+ {config.scrollDirection === "horizontal" + ? "격자로 배치, 가로 스크롤" + : "격자로 배치, 세로 스크롤"} +

+

+ {maxColumns === 1 + ? "현재 모드에서 열 최대 1 (모드 변경 시 자동 적용)" + : "모드 변경 시 열/행 자동 적용 / 열 최대 2"} +

+
- )} +
); } @@ -443,9 +564,30 @@ function CardTemplateTab({ /> - {/* 레이아웃 설정 */} - - + {/* 입력 필드 설정 */} + + onUpdate({ inputField })} + /> + + + {/* 계산 필드 설정 */} + + onUpdate({ calculatedField })} + /> + + + {/* 담기 버튼 설정 */} + + onUpdate({ cartAction })} + />
); @@ -867,92 +1009,263 @@ function FieldEditor({ ); } -// ===== 레이아웃 설정 섹션 ===== -function LayoutSettingsSection({ - config, +// ===== 입력 필드 설정 섹션 ===== + +function InputFieldSettingsSection({ + inputField, + columns, onUpdate, }: { - config: PopCardListConfig; - onUpdate: (partial: Partial) => void; + inputField?: CardInputFieldConfig; + columns: ColumnInfo[]; + onUpdate: (inputField: CardInputFieldConfig) => void; }) { - const isGridMode = config.layoutMode === "grid"; + const field = inputField || { + enabled: false, + label: "발주 수량", + unit: "EA", + defaultValue: 0, + min: 0, + max: 999999, + step: 1, + }; + + const updateField = (partial: Partial) => { + onUpdate({ ...field, ...partial }); + }; return (
- {/* 카드 크기 */} -
- -
- {(["small", "medium", "large"] as CardSize[]).map((size) => ( -
+ ); +} + +// ===== 계산 필드 설정 섹션 ===== + +function CalculatedFieldSettingsSection({ + calculatedField, + columns, + onUpdate, +}: { + calculatedField?: CardCalculatedFieldConfig; + columns: ColumnInfo[]; + onUpdate: (calculatedField: CardCalculatedFieldConfig) => void; +}) { + const field = calculatedField || { + enabled: false, + label: "미입고", + formula: "", + sourceColumns: [], + unit: "EA", + }; + + const updateField = (partial: Partial) => { + onUpdate({ ...field, ...partial }); + }; + + return ( +
+ {/* 활성화 스위치 */} +
+ + updateField({ enabled })} + />
- {/* 배치 방식 */} -
- -
- {(["grid", "horizontal", "vertical"] as CardLayoutMode[]).map( - (mode) => ( - - ) - )} -
-
+ {field.enabled && ( + <> + {/* 라벨 */} +
+ + updateField({ label: e.target.value })} + className="mt-1 h-7 text-xs" + placeholder="미입고" + /> +
- {/* 격자 배치일 때만 한 줄 카드 수 표시 */} - {isGridMode && ( -
- - -
+ {/* 계산식 */} +
+ + updateField({ formula: e.target.value })} + className="mt-1 h-7 text-xs font-mono" + placeholder="$input - received_qty" + /> +

+ 사용 가능: 컬럼명, $input (입력값), +, -, *, / +

+
+ + {/* 단위 */} +
+ + updateField({ unit: e.target.value })} + className="mt-1 h-7 text-xs" + placeholder="EA" + /> +
+ + {/* 사용 가능한 컬럼 목록 */} +
+ +
+
+ {columns.map((col) => ( + { + // 클릭 시 계산식에 컬럼명 추가 + const currentFormula = field.formula || ""; + updateField({ formula: currentFormula + col.name }); + }} + > + {col.name} + + ))} +
+
+

+ 클릭하면 계산식에 추가됩니다 +

+
+ )}
); @@ -1437,3 +1750,125 @@ function LimitSettingsSection({
); } + +// ===== 담기 버튼 설정 섹션 ===== + +function CartActionSettingsSection({ + cartAction, + onUpdate, +}: { + cartAction?: CardCartActionConfig; + onUpdate: (cartAction: CardCartActionConfig) => void; +}) { + const action: CardCartActionConfig = cartAction || { + navigateMode: "none", + iconType: "lucide", + iconValue: "ShoppingCart", + label: "담기", + cancelLabel: "취소", + }; + + const update = (partial: Partial) => { + onUpdate({ ...action, ...partial }); + }; + + return ( +
+ {/* 네비게이션 모드 */} +
+ + +
+ + {/* 대상 화면 ID (screen 모드일 때만) */} + {action.navigateMode === "screen" && ( +
+ + update({ targetScreenId: e.target.value })} + placeholder="예: 15" + className="mt-1 h-7 text-xs" + /> +

+ 담기 클릭 시 이동할 POP 화면의 screenId +

+
+ )} + + {/* 아이콘 타입 */} +
+ + +
+ + {/* 아이콘 값 */} +
+ + update({ iconValue: e.target.value })} + placeholder={ + action.iconType === "emoji" ? "예: 🛒" : "예: ShoppingCart" + } + className="mt-1 h-7 text-xs" + /> + {action.iconType === "lucide" && ( +

+ PascalCase로 입력 (ShoppingCart, Package, Truck 등) +

+ )} +
+ + {/* 담기 라벨 */} +
+ + update({ label: e.target.value })} + placeholder="담기" + className="mt-1 h-7 text-xs" + /> +
+ + {/* 취소 라벨 */} +
+ + update({ cancelLabel: e.target.value })} + placeholder="취소" + className="mt-1 h-7 text-xs" + /> +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx index 6b77c59e..312567b9 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx @@ -11,7 +11,7 @@ import React from "react"; import { LayoutGrid, Package } from "lucide-react"; import type { PopCardListConfig } from "../types"; import { - CARD_LAYOUT_MODE_LABELS, + CARD_SCROLL_DIRECTION_LABELS, CARD_SIZE_LABELS, DEFAULT_CARD_IMAGE, } from "../types"; @@ -23,22 +23,18 @@ interface PopCardListPreviewProps { export function PopCardListPreviewComponent({ config, }: PopCardListPreviewProps) { - const layoutMode = config?.layoutMode || "grid"; + const scrollDirection = config?.scrollDirection || "vertical"; const cardSize = config?.cardSize || "medium"; - const cardsPerRow = config?.cardsPerRow || 3; const dataSource = config?.dataSource; const template = config?.cardTemplate; - // 설정 상태 확인 const hasTable = !!dataSource?.tableName; const hasHeader = !!template?.header?.codeField || !!template?.header?.titleField; const hasImage = template?.image?.enabled ?? true; const fieldCount = template?.body?.fields?.length || 0; - // 샘플 카드 개수 (미리보기용) - const sampleCardCount = - layoutMode === "grid" ? Math.min(cardsPerRow, 3) : 2; + const sampleCardCount = 2; return (
@@ -52,7 +48,7 @@ export function PopCardListPreviewComponent({ {/* 설정 배지 */}
- {CARD_LAYOUT_MODE_LABELS[layoutMode]} + {CARD_SCROLL_DIRECTION_LABELS[scrollDirection]} {CARD_SIZE_LABELS[cardSize]} @@ -82,11 +78,9 @@ export function PopCardListPreviewComponent({ {/* 카드 미리보기 */}
{Array.from({ length: sampleCardCount }).map((_, idx) => ( @@ -97,7 +91,7 @@ export function PopCardListPreviewComponent({ hasImage={hasImage} fieldCount={fieldCount} cardSize={cardSize} - layoutMode={layoutMode} + scrollDirection={scrollDirection} /> ))}
@@ -124,16 +118,15 @@ function PreviewCard({ hasImage, fieldCount, cardSize, - layoutMode, + scrollDirection, }: { index: number; hasHeader: boolean; hasImage: boolean; fieldCount: number; cardSize: string; - layoutMode: string; + scrollDirection: string; }) { - // 카드 크기 const sizeClass = cardSize === "small" ? "min-h-[60px]" @@ -142,11 +135,9 @@ function PreviewCard({ : "min-h-[80px]"; const widthClass = - layoutMode === "vertical" + scrollDirection === "vertical" ? "w-full" - : layoutMode === "horizontal" - ? "min-w-[140px] flex-shrink-0" - : "w-[140px]"; + : "min-w-[140px] flex-shrink-0"; return (
= { large: "크게", }; -// ----- 카드 배치 방식 (방향 기반) ----- +// ----- 카드 스크롤 방향 ----- -export type CardLayoutMode = "grid" | "horizontal" | "vertical"; -// grid: 격자 배치 (행/열로 정렬) -// horizontal: 가로 배치 (가로 스크롤) -// vertical: 세로 배치 (세로 스크롤) +export type CardScrollDirection = "horizontal" | "vertical"; -export const CARD_LAYOUT_MODE_LABELS: Record = { - grid: "격자 배치", - horizontal: "가로 배치", - vertical: "세로 배치", +export const CARD_SCROLL_DIRECTION_LABELS: Record = { + horizontal: "가로 스크롤", + vertical: "세로 스크롤", }; +// ----- 카드 내 입력 필드 설정 ----- + +export interface CardInputFieldConfig { + enabled: boolean; + columnName?: string; // 입력값이 저장될 컬럼 + label?: string; // 표시 라벨 (예: "발주 수량") + unit?: string; // 단위 (예: "EA", "개") + defaultValue?: number; // 기본값 + min?: number; // 최소값 + max?: number; // 최대값 + maxColumn?: string; // 최대값을 DB 컬럼에서 동적으로 가져올 컬럼명 (설정 시 row[maxColumn] 우선) + step?: number; // 증감 단위 +} + +// ----- 카드 내 계산 필드 설정 ----- + +export interface CardCalculatedFieldConfig { + enabled: boolean; + label?: string; // 표시 라벨 (예: "미입고") + formula: string; // 계산식 (예: "order_qty - inbound_qty") + sourceColumns: string[]; // 계산에 사용되는 컬럼들 + resultColumn?: string; // 결과를 저장할 컬럼 (선택) + unit?: string; // 단위 (예: "EA") +} + +// ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) ----- + +export interface CartItem { + row: Record; // 카드 원본 행 데이터 + quantity: number; // 입력 수량 + packageUnit?: string; // 포장 단위 (box/bag/pack/bundle/roll/barrel) +} + +// ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) ----- + +export interface CardCartActionConfig { + navigateMode: "none" | "screen"; // 담기 후 이동 모드 + targetScreenId?: string; // 장바구니 POP 화면 ID (screen 모드) + iconType?: "lucide" | "emoji"; // 아이콘 타입 + iconValue?: string; // Lucide 아이콘명 또는 이모지 값 + label?: string; // 담기 라벨 (기본: "담기") + cancelLabel?: string; // 취소 라벨 (기본: "취소") +} + // ----- pop-card-list 전체 설정 ----- +// ----- 카드 프리셋별 고정 규격 ----- +// 각 프리셋은 카드 내용이 잘리지 않도록 계산된 고정 크기 +// 구성: 헤더(코드+제목) + 본문(이미지+필드 3개) + 입력/계산 필드 + 패딩 +export interface CardPresetSpec { + height: number; // 카드 고정 높이 (px) + imageSize: number; // 이미지 크기 (px) + padding: number; // 내부 여백 (px) + gap: number; // 요소 간격 (px) + headerPadY: number; // 헤더 상하 패딩 (px) + headerPadX: number; // 헤더 좌우 패딩 (px) + codeText: number; // 코드 폰트 크기 (px) + titleText: number; // 제목 폰트 크기 (px) + bodyText: number; // 본문 폰트 크기 (px) +} + +export const CARD_PRESET_SPECS: Record = { + // 작게: 컴팩트 - 헤더(20) + 본문(이미지36+필드3x14=42) + 패딩(6*3) = ~80px + small: { + height: 88, + imageSize: 36, + padding: 6, + gap: 4, + headerPadY: 3, + headerPadX: 6, + codeText: 9, + titleText: 11, + bodyText: 10, + }, + // 보통: 기본 - 헤더(26) + 본문(이미지48+필드3x16=48) + 패딩(8*3) = ~122px + medium: { + height: 120, + imageSize: 48, + padding: 8, + gap: 6, + headerPadY: 4, + headerPadX: 8, + codeText: 10, + titleText: 13, + bodyText: 11, + }, + // 크게: 여유 - 헤더(32) + 본문(이미지64+필드3x18=54) + 패딩(10*3) = ~156px + large: { + height: 160, + imageSize: 64, + padding: 10, + gap: 8, + headerPadY: 6, + headerPadX: 10, + codeText: 11, + titleText: 15, + bodyText: 12, + }, +}; + export interface PopCardListConfig { // 데이터 소스 (테이블 단위) dataSource: CardListDataSource; @@ -449,8 +540,24 @@ export interface PopCardListConfig { // 카드 템플릿 (헤더 + 이미지 + 본문) cardTemplate: CardTemplateConfig; - // 레이아웃 설정 - layoutMode: CardLayoutMode; - cardsPerRow?: number; // 격자 배치일 때만 사용 - cardSize: CardSize; + // 스크롤 방향 + scrollDirection: CardScrollDirection; + cardsPerRow?: number; // deprecated, gridColumns 사용 + cardSize: CardSize; // 프리셋 크기 (small/medium/large) + + // 그리드 배치 설정 (가로 x 세로) + gridColumns?: number; // 가로 카드 수 (기본값: 3) + gridRows?: number; // 세로 카드 수 (기본값: 2) + + // 확장 설정 (더보기 클릭 시 그리드 rowSpan 변경) + // expandedRowSpan 제거됨 - 항상 원래 크기의 2배로 확장 + + // 입력 필드 설정 (수량 입력 등) + inputField?: CardInputFieldConfig; + + // 계산 필드 설정 (미입고 등 자동 계산) + calculatedField?: CardCalculatedFieldConfig; + + // 담기 버튼 액션 설정 (pop-icon 스타일) + cartAction?: CardCartActionConfig; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d35b97c..d8b262a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -109,6 +109,7 @@ "eslint-config-next": "15.4.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", + "playwright": "^1.58.2", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", "prisma": "^6.14.0", @@ -261,6 +262,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -302,6 +304,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -335,6 +338,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2665,6 +2669,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.32.0", @@ -3318,6 +3323,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -3385,6 +3391,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3698,6 +3705,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -6198,6 +6206,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6208,6 +6217,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6250,6 +6260,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -6332,6 +6343,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -6964,6 +6976,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8114,7 +8127,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3": { "version": "7.9.0", @@ -8436,6 +8450,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -9195,6 +9210,7 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9283,6 +9299,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9384,6 +9401,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10001,6 +10019,21 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/fstream": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", @@ -10540,6 +10573,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11320,7 +11354,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/levn": { "version": "0.4.1", @@ -12497,6 +12532,38 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -12617,6 +12684,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12910,6 +12978,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -12939,6 +13008,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -12987,6 +13057,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -13113,6 +13184,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13182,6 +13254,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -13232,6 +13305,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13264,7 +13338,8 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-leaflet": { "version": "5.0.0", @@ -13572,6 +13647,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -13594,7 +13670,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", @@ -14624,7 +14701,8 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -14712,6 +14790,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15060,6 +15139,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/package.json b/frontend/package.json index 6f4101f1..2de7c057 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -118,6 +118,7 @@ "eslint-config-next": "15.4.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", + "playwright": "^1.58.2", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", "prisma": "^6.14.0", diff --git a/frontend/scripts/test-card-list-e2e.ts b/frontend/scripts/test-card-list-e2e.ts new file mode 100644 index 00000000..6381546a --- /dev/null +++ b/frontend/scripts/test-card-list-e2e.ts @@ -0,0 +1,94 @@ +/** + * 카드 목록 컴포넌트 E2E 테스트 + * 실행: npx tsx scripts/test-card-list-e2e.ts + */ +import { chromium } from "playwright"; +import path from "path"; +import fs from "fs"; + +const BASE_URL = "http://localhost:9771"; +const SCREEN_URL = "/pop/screens/4114"; +const SCREENSHOT_DIR = path.join(process.cwd(), "test-screenshots"); + +async function ensureDir(dir: string) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +async function main() { + console.log("카드 목록 컴포넌트 E2E 테스트 시작..."); + await ensureDir(SCREENSHOT_DIR); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await context.newPage(); + + const results: string[] = []; + + try { + // 1. 페이지 로드 + console.log("1. 페이지 로드 중..."); + await page.goto(`${BASE_URL}${SCREEN_URL}`, { waitUntil: "networkidle", timeout: 15000 }); + await page.waitForTimeout(2000); + + // 카드 목록 컴포넌트 확인 + const cardContainer = await page.locator('[class*="grid"]').first(); + const cardCount = await page.locator(".rounded-lg.border.bg-card").count(); + const hasCards = cardCount > 0; + results.push(`1. 카드 목록 표시: ${hasCards ? "OK" : "FAIL"} (카드 ${cardCount}개)`); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "01-loaded.png") }); + + // 2. "더보기" 버튼 클릭 + const moreBtn = page.getByRole("button", { name: /더보기/ }); + const moreBtnCount = await moreBtn.count(); + + if (moreBtnCount > 0) { + console.log("2. 더보기 버튼 클릭..."); + await moreBtn.first().click(); + await page.waitForTimeout(1500); + + const cardCountAfter = await page.locator(".rounded-lg.border.bg-card").count(); + const expanded = cardCountAfter > cardCount; + results.push(`2. 더보기 클릭 후 확장: ${expanded ? "OK" : "카드 수 변화 없음"} (${cardCount} -> ${cardCountAfter})`); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "02-expanded.png") }); + + // 3. 페이지네이션 확인 + const prevBtn = page.getByRole("button", { name: /이전/ }); + const nextBtn = page.getByRole("button", { name: /다음/ }); + const hasPagination = (await prevBtn.count() > 0) || (await nextBtn.count() > 0); + results.push(`3. 페이지네이션 버튼: ${hasPagination ? "OK" : "없음 (데이터 적음 시 정상)"}`); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "03-pagination.png") }); + + // 4. 접기 버튼 클릭 + const collapseBtn = page.getByRole("button", { name: /접기/ }); + if (await collapseBtn.count() > 0) { + console.log("4. 접기 버튼 클릭..."); + await collapseBtn.first().click(); + await page.waitForTimeout(1000); + const cardCountCollapsed = await page.locator(".rounded-lg.border.bg-card").count(); + results.push(`4. 접기 후: OK (카드 ${cardCountCollapsed}개로 복원)`); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "04-collapsed.png") }); + } else { + results.push("4. 접기 버튼: 없음 (확장 안됐을 수 있음)"); + } + } else { + results.push("2. 더보기 버튼: 없음 (카드가 적거나 모두 표시됨)"); + results.push("3. 페이지네이션: N/A"); + results.push("4. 접기: N/A"); + } + + // 결과 출력 + console.log("\n=== 테스트 결과 ==="); + results.forEach((r) => console.log(r)); + console.log(`\n스크린샷 저장: ${SCREENSHOT_DIR}`); + } catch (err) { + console.error("테스트 실패:", err); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, "error.png") }); + process.exit(1); + } finally { + await browser.close(); + } +} + +main();