feat(pop-card-list): PopCardList 컴포넌트 구현 + ksh-v2-work rebase
- PopCardList 컴포넌트 추가 (NumberInputModal, PackageUnitModal 포함) - ComponentEditorPanel, PopRenderer 충돌 해결 (modals + onRequestResize 통합) - ksh-v2-work 최신 커밋 (pop-search, pop-string-list, pop-button 등) rebase 반영 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3336384434
commit
7d008b481d
|
|
@ -3,6 +3,10 @@
|
||||||
"agent-orchestrator": {
|
"agent-orchestrator": {
|
||||||
"command": "node",
|
"command": "node",
|
||||||
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
"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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
# Claude Code (로컬 전용 - Git 제외)
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# PLM System Backend - Node.js + TypeScript
|
re# PLM System Backend - Node.js + TypeScript
|
||||||
|
|
||||||
Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다.
|
Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
## ⚠️ 문서 사용 안내
|
## ⚠️ 문서 사용 안내
|
||||||
|
|
||||||
> **이 문서는 "품목정보" 화면의 구현 예시입니다.**
|
|
||||||
>
|
>
|
||||||
> ### 📌 중요: JSON 데이터는 참고용입니다!
|
> ### 📌 중요: JSON 데이터는 참고용입니다!
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,28 @@ function PopScreenViewPage() {
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [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 currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
|
||||||
const hasComponents = Object.keys(layout.components).length > 0;
|
const hasComponents = Object.keys(layout.components).length > 0;
|
||||||
|
|
||||||
|
|
@ -301,6 +323,8 @@ function PopScreenViewPage() {
|
||||||
currentMode={currentModeKey}
|
currentMode={currentModeKey}
|
||||||
overrideGap={adjustedGap}
|
overrideGap={adjustedGap}
|
||||||
overridePadding={adjustedPadding}
|
overridePadding={adjustedPadding}
|
||||||
|
onRequestResize={handleRequestResize}
|
||||||
|
currentScreenId={screenId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,8 @@ interface PopCanvasProps {
|
||||||
onLockLayout?: () => void;
|
onLockLayout?: () => void;
|
||||||
onResetOverride?: (mode: GridMode) => void;
|
onResetOverride?: (mode: GridMode) => void;
|
||||||
onChangeGapPreset?: (preset: GapPreset) => void;
|
onChangeGapPreset?: (preset: GapPreset) => void;
|
||||||
|
/** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */
|
||||||
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||||
/** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */
|
/** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */
|
||||||
previewPageIndex?: number;
|
previewPageIndex?: number;
|
||||||
/** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */
|
/** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */
|
||||||
|
|
@ -147,6 +149,7 @@ export default function PopCanvas({
|
||||||
onLockLayout,
|
onLockLayout,
|
||||||
onResetOverride,
|
onResetOverride,
|
||||||
onChangeGapPreset,
|
onChangeGapPreset,
|
||||||
|
onRequestResize,
|
||||||
previewPageIndex,
|
previewPageIndex,
|
||||||
activeCanvasId = "main",
|
activeCanvasId = "main",
|
||||||
onActiveCanvasChange,
|
onActiveCanvasChange,
|
||||||
|
|
@ -761,6 +764,7 @@ export default function PopCanvas({
|
||||||
onComponentMove={onMoveComponent}
|
onComponentMove={onMoveComponent}
|
||||||
onComponentResize={onResizeComponent}
|
onComponentResize={onResizeComponent}
|
||||||
onComponentResizeEnd={onResizeEnd}
|
onComponentResizeEnd={onResizeEnd}
|
||||||
|
onRequestResize={onRequestResize}
|
||||||
overrideGap={adjustedGap}
|
overrideGap={adjustedGap}
|
||||||
overridePadding={adjustedPadding}
|
overridePadding={adjustedPadding}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export default function PopDesigner({
|
||||||
onBackToList,
|
onBackToList,
|
||||||
onScreenUpdate,
|
onScreenUpdate,
|
||||||
}: PopDesignerProps) {
|
}: PopDesignerProps) {
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레이아웃 상태
|
// 레이아웃 상태
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -489,6 +490,56 @@ export default function PopDesigner({
|
||||||
[layout, saveToHistory]
|
[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 프리셋 관리
|
// Gap 프리셋 관리
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -830,6 +881,7 @@ export default function PopDesigner({
|
||||||
onLockLayout={handleLockLayout}
|
onLockLayout={handleLockLayout}
|
||||||
onResetOverride={handleResetOverride}
|
onResetOverride={handleResetOverride}
|
||||||
onChangeGapPreset={handleChangeGapPreset}
|
onChangeGapPreset={handleChangeGapPreset}
|
||||||
|
onRequestResize={handleRequestResize}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
activeCanvasId={activeCanvasId}
|
activeCanvasId={activeCanvasId}
|
||||||
onActiveCanvasChange={navigateToCanvas}
|
onActiveCanvasChange={navigateToCanvas}
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,7 @@ export default function ComponentEditorPanel({
|
||||||
<ComponentSettingsForm
|
<ComponentSettingsForm
|
||||||
component={component}
|
component={component}
|
||||||
onUpdate={onUpdateComponent}
|
onUpdate={onUpdateComponent}
|
||||||
|
currentMode={currentMode}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
onPreviewPage={onPreviewPage}
|
onPreviewPage={onPreviewPage}
|
||||||
modals={modals}
|
modals={modals}
|
||||||
|
|
@ -399,12 +400,13 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
|
||||||
interface ComponentSettingsFormProps {
|
interface ComponentSettingsFormProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinitionV5;
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
|
currentMode?: GridMode;
|
||||||
previewPageIndex?: number;
|
previewPageIndex?: number;
|
||||||
onPreviewPage?: (pageIndex: number) => void;
|
onPreviewPage?: (pageIndex: number) => void;
|
||||||
modals?: PopModalDefinition[];
|
modals?: PopModalDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) {
|
function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) {
|
||||||
// PopComponentRegistry에서 configPanel 가져오기
|
// PopComponentRegistry에서 configPanel 가져오기
|
||||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||||
const ConfigPanel = registeredComp?.configPanel;
|
const ConfigPanel = registeredComp?.configPanel;
|
||||||
|
|
@ -433,6 +435,8 @@ function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPrevie
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
config={component.config || {}}
|
config={component.config || {}}
|
||||||
onUpdate={handleConfigUpdate}
|
onUpdate={handleConfigUpdate}
|
||||||
|
currentMode={currentMode}
|
||||||
|
currentColSpan={component.position.colSpan}
|
||||||
onPreviewPage={onPreviewPage}
|
onPreviewPage={onPreviewPage}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
modals={modals}
|
modals={modals}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ interface PopRendererProps {
|
||||||
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
/** 컴포넌트 크기 조정 완료 (히스토리 저장용) */
|
/** 컴포넌트 크기 조정 완료 (히스토리 저장용) */
|
||||||
onComponentResizeEnd?: (componentId: string) => void;
|
onComponentResizeEnd?: (componentId: string) => void;
|
||||||
|
/** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */
|
||||||
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||||
/** Gap 오버라이드 (Gap 프리셋 적용된 값) */
|
/** Gap 오버라이드 (Gap 프리셋 적용된 값) */
|
||||||
overrideGap?: number;
|
overrideGap?: number;
|
||||||
/** Padding 오버라이드 (Gap 프리셋 적용된 값) */
|
/** Padding 오버라이드 (Gap 프리셋 적용된 값) */
|
||||||
|
|
@ -91,6 +93,7 @@ export default function PopRenderer({
|
||||||
onComponentMove,
|
onComponentMove,
|
||||||
onComponentResize,
|
onComponentResize,
|
||||||
onComponentResizeEnd,
|
onComponentResizeEnd,
|
||||||
|
onRequestResize,
|
||||||
overrideGap,
|
overrideGap,
|
||||||
overridePadding,
|
overridePadding,
|
||||||
className,
|
className,
|
||||||
|
|
@ -270,6 +273,7 @@ export default function PopRenderer({
|
||||||
onComponentMove={onComponentMove}
|
onComponentMove={onComponentMove}
|
||||||
onComponentResize={onComponentResize}
|
onComponentResize={onComponentResize}
|
||||||
onComponentResizeEnd={onComponentResizeEnd}
|
onComponentResizeEnd={onComponentResizeEnd}
|
||||||
|
onRequestResize={onRequestResize}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -279,7 +283,7 @@ export default function PopRenderer({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={comp.id}
|
key={comp.id}
|
||||||
className="relative rounded-lg border-2 border-gray-200 bg-white transition-all z-10"
|
className="relative overflow-hidden rounded-lg border-2 border-gray-200 bg-white transition-all z-10"
|
||||||
style={positionStyle}
|
style={positionStyle}
|
||||||
>
|
>
|
||||||
<ComponentContent
|
<ComponentContent
|
||||||
|
|
@ -287,7 +291,8 @@ export default function PopRenderer({
|
||||||
effectivePosition={position}
|
effectivePosition={position}
|
||||||
isDesignMode={false}
|
isDesignMode={false}
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
screenId={String(currentScreenId || "")}
|
onRequestResize={onRequestResize}
|
||||||
|
screenId={currentScreenId ? String(currentScreenId) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -315,6 +320,7 @@ interface DraggableComponentProps {
|
||||||
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
onComponentResizeEnd?: (componentId: string) => void;
|
onComponentResizeEnd?: (componentId: string) => void;
|
||||||
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||||
previewPageIndex?: number;
|
previewPageIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,6 +339,7 @@ function DraggableComponent({
|
||||||
onComponentMove,
|
onComponentMove,
|
||||||
onComponentResize,
|
onComponentResize,
|
||||||
onComponentResizeEnd,
|
onComponentResizeEnd,
|
||||||
|
onRequestResize,
|
||||||
previewPageIndex,
|
previewPageIndex,
|
||||||
}: DraggableComponentProps) {
|
}: DraggableComponentProps) {
|
||||||
const [{ isDragging }, drag] = useDrag(
|
const [{ isDragging }, drag] = useDrag(
|
||||||
|
|
@ -373,7 +380,8 @@ function DraggableComponent({
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
screenId=""
|
onRequestResize={onRequestResize}
|
||||||
|
screenId={undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
|
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
|
||||||
|
|
@ -525,11 +533,12 @@ interface ComponentContentProps {
|
||||||
isDesignMode: boolean;
|
isDesignMode: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
previewPageIndex?: number;
|
previewPageIndex?: number;
|
||||||
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||||
/** 화면 ID (이벤트 버스/액션 실행용) */
|
/** 화면 ID (이벤트 버스/액션 실행용) */
|
||||||
screenId?: string;
|
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;
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||||
|
|
||||||
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
||||||
|
|
@ -543,7 +552,8 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
||||||
// 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등)
|
// 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등)
|
||||||
if (ActualComp) {
|
if (ActualComp) {
|
||||||
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
||||||
const needsPointerEvents = component.type === "pop-icon";
|
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
|
||||||
|
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|
@ -555,7 +565,11 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
||||||
label={component.label}
|
label={component.label}
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
|
componentId={component.id}
|
||||||
screenId={screenId}
|
screenId={screenId}
|
||||||
|
currentRowSpan={effectivePosition.rowSpan}
|
||||||
|
currentColSpan={effectivePosition.colSpan}
|
||||||
|
onRequestResize={onRequestResize}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -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 registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||||
const ActualComp = registeredComp?.component;
|
const ActualComp = registeredComp?.component;
|
||||||
|
|
||||||
if (ActualComp) {
|
if (ActualComp) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-h-full">
|
<div className="h-full w-full overflow-hidden">
|
||||||
<ActualComp config={component.config} label={component.label} screenId={screenId} componentId={component.id} />
|
<ActualComp
|
||||||
|
config={component.config}
|
||||||
|
label={component.label}
|
||||||
|
componentId={component.id}
|
||||||
|
screenId={screenId}
|
||||||
|
currentRowSpan={effectivePosition?.rowSpan}
|
||||||
|
currentColSpan={effectivePosition?.colSpan}
|
||||||
|
onRequestResize={onRequestResize}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string | undefined>(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 (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
|
||||||
|
>
|
||||||
|
{/* 파란 헤더 */}
|
||||||
|
<div className="flex items-center justify-between bg-blue-500 px-4 py-3">
|
||||||
|
<span className="rounded-full bg-blue-400/50 px-3 py-1 text-sm font-medium text-white">
|
||||||
|
최대 {maxValue.toLocaleString()} {unit}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPackageModalOpen(true)}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-white/20 px-3 py-1.5 text-sm font-medium text-white hover:bg-white/30 active:bg-white/40"
|
||||||
|
>
|
||||||
|
{packageUnitEmoji} {packageUnitLabel ? `${packageUnitLabel} ✓` : "포장등록"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{/* 숫자 표시 영역 */}
|
||||||
|
<div className="flex min-h-[72px] items-center justify-end rounded-xl border-2 border-gray-200 bg-gray-50 px-4 py-4">
|
||||||
|
{displayText ? (
|
||||||
|
<span className="text-4xl font-bold tracking-tight text-gray-900">
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-2xl text-gray-300">0</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 안내 텍스트 */}
|
||||||
|
<p className="text-muted-foreground text-center text-sm">
|
||||||
|
수량을 입력하세요
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 키패드 4x4 */}
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{/* 1행: 7 8 9 ← (주황) */}
|
||||||
|
{["7", "8", "9"].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
||||||
|
onClick={() => handleNumberClick(n)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600 active:bg-amber-200"
|
||||||
|
onClick={handleBackspace}
|
||||||
|
>
|
||||||
|
<Delete className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 2행: 4 5 6 C (주황) */}
|
||||||
|
{["4", "5", "6"].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
||||||
|
onClick={() => handleNumberClick(n)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-14 rounded-2xl bg-amber-100 text-base font-bold text-amber-600 active:bg-amber-200"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
C
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 3행: 1 2 3 MAX (파란) */}
|
||||||
|
{["1", "2", "3"].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
||||||
|
onClick={() => handleNumberClick(n)}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-14 rounded-2xl bg-blue-100 text-sm font-bold text-blue-600 active:bg-blue-200"
|
||||||
|
onClick={handleMax}
|
||||||
|
>
|
||||||
|
MAX
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 4행: 0 / 확인 (초록, 3칸) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
|
||||||
|
onClick={() => handleNumberClick("0")}
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="col-span-3 h-14 rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 포장 단위 선택 모달 */}
|
||||||
|
<PackageUnitModal
|
||||||
|
open={isPackageModalOpen}
|
||||||
|
onOpenChange={setIsPackageModalOpen}
|
||||||
|
onSelect={handlePackageUnitSelect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay className="z-1050" />
|
||||||
|
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-1100 w-full max-w-[90vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-lg border shadow-lg duration-200 sm:max-w-[380px]"
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="border-b px-4 py-3 pr-12">
|
||||||
|
<h2 className="text-base font-semibold">📦 포장 단위 선택</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3x2 그리드 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 p-4">
|
||||||
|
{PACKAGE_UNITS.map((unit) => (
|
||||||
|
<button
|
||||||
|
key={unit.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(unit.value as PackageUnit)}
|
||||||
|
className="hover:bg-muted active:bg-muted/70 flex flex-col items-center justify-center gap-2 rounded-xl border bg-background px-3 py-5 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{unit.emoji}</span>
|
||||||
|
<span>{unit.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* X 닫기 버튼 */}
|
||||||
|
<DialogClose className="ring-offset-background focus:ring-ring absolute top-3 right-3 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* pop-card-list 설정 패널 (V2 - 이미지 참조 기반 재설계)
|
* pop-card-list 설정 패널
|
||||||
*
|
*
|
||||||
* 3개 탭:
|
* 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 { 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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -32,13 +34,14 @@ import type {
|
||||||
CardFieldBinding,
|
CardFieldBinding,
|
||||||
CardColumnJoin,
|
CardColumnJoin,
|
||||||
CardColumnFilter,
|
CardColumnFilter,
|
||||||
CardSize,
|
CardScrollDirection,
|
||||||
CardLayoutMode,
|
|
||||||
FilterOperator,
|
FilterOperator,
|
||||||
|
CardInputFieldConfig,
|
||||||
|
CardCalculatedFieldConfig,
|
||||||
|
CardCartActionConfig,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
CARD_SIZE_LABELS,
|
CARD_SCROLL_DIRECTION_LABELS,
|
||||||
CARD_LAYOUT_MODE_LABELS,
|
|
||||||
DEFAULT_CARD_IMAGE,
|
DEFAULT_CARD_IMAGE,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
|
|
@ -53,6 +56,8 @@ import {
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
config: PopCardListConfig | undefined;
|
config: PopCardListConfig | undefined;
|
||||||
onUpdate: (config: PopCardListConfig) => void;
|
onUpdate: (config: PopCardListConfig) => void;
|
||||||
|
currentMode?: GridMode;
|
||||||
|
currentColSpan?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 기본값 =====
|
// ===== 기본값 =====
|
||||||
|
|
@ -85,9 +90,10 @@ const DEFAULT_TEMPLATE: CardTemplateConfig = {
|
||||||
const DEFAULT_CONFIG: PopCardListConfig = {
|
const DEFAULT_CONFIG: PopCardListConfig = {
|
||||||
dataSource: DEFAULT_DATA_SOURCE,
|
dataSource: DEFAULT_DATA_SOURCE,
|
||||||
cardTemplate: DEFAULT_TEMPLATE,
|
cardTemplate: DEFAULT_TEMPLATE,
|
||||||
layoutMode: "grid",
|
scrollDirection: "vertical",
|
||||||
cardsPerRow: 3,
|
gridColumns: 2,
|
||||||
cardSize: "medium",
|
gridRows: 3,
|
||||||
|
cardSize: "large",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===== 색상 옵션 =====
|
// ===== 색상 옵션 =====
|
||||||
|
|
@ -105,10 +111,10 @@ const COLOR_OPTIONS = [
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
// ===== 메인 컴포넌트 =====
|
||||||
|
|
||||||
export function PopCardListConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) {
|
||||||
// 3탭 구조: 테이블 선택 → 카드 템플릿 → 데이터 소스
|
// 3탭 구조: 기본 설정 (테이블+레이아웃) → 데이터 소스 → 카드 템플릿
|
||||||
const [activeTab, setActiveTab] = useState<"table" | "template" | "dataSource">(
|
const [activeTab, setActiveTab] = useState<"basic" | "template" | "dataSource">(
|
||||||
"table"
|
"basic"
|
||||||
);
|
);
|
||||||
|
|
||||||
// config가 없으면 기본값 사용
|
// config가 없으면 기본값 사용
|
||||||
|
|
@ -129,27 +135,13 @@ export function PopCardListConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
|
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
|
||||||
activeTab === "table"
|
activeTab === "basic"
|
||||||
? "border-b-2 border-primary text-primary"
|
? "border-b-2 border-primary text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setActiveTab("table")}
|
onClick={() => setActiveTab("basic")}
|
||||||
>
|
>
|
||||||
테이블
|
기본 설정
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
|
|
||||||
activeTab === "template"
|
|
||||||
? "border-b-2 border-primary text-primary"
|
|
||||||
: hasTable
|
|
||||||
? "text-muted-foreground hover:text-foreground"
|
|
||||||
: "text-muted-foreground/50 cursor-not-allowed"
|
|
||||||
}`}
|
|
||||||
onClick={() => hasTable && setActiveTab("template")}
|
|
||||||
disabled={!hasTable}
|
|
||||||
>
|
|
||||||
카드 템플릿
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -165,32 +157,55 @@ export function PopCardListConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
||||||
>
|
>
|
||||||
데이터 소스
|
데이터 소스
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
|
||||||
|
activeTab === "template"
|
||||||
|
? "border-b-2 border-primary text-primary"
|
||||||
|
: hasTable
|
||||||
|
? "text-muted-foreground hover:text-foreground"
|
||||||
|
: "text-muted-foreground/50 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
onClick={() => hasTable && setActiveTab("template")}
|
||||||
|
disabled={!hasTable}
|
||||||
|
>
|
||||||
|
카드 템플릿
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 내용 */}
|
{/* 탭 내용 */}
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
{activeTab === "table" && (
|
{activeTab === "basic" && (
|
||||||
<TableSelectTab config={cfg} onUpdate={updateConfig} />
|
<BasicSettingsTab
|
||||||
)}
|
config={cfg}
|
||||||
{activeTab === "template" && (
|
onUpdate={updateConfig}
|
||||||
<CardTemplateTab config={cfg} onUpdate={updateConfig} />
|
currentMode={currentMode}
|
||||||
|
currentColSpan={currentColSpan}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "dataSource" && (
|
{activeTab === "dataSource" && (
|
||||||
<DataSourceTab config={cfg} onUpdate={updateConfig} />
|
<DataSourceTab config={cfg} onUpdate={updateConfig} />
|
||||||
)}
|
)}
|
||||||
|
{activeTab === "template" && (
|
||||||
|
<CardTemplateTab config={cfg} onUpdate={updateConfig} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 테이블 선택 탭 =====
|
// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) =====
|
||||||
|
|
||||||
function TableSelectTab({
|
function BasicSettingsTab({
|
||||||
config,
|
config,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
currentMode,
|
||||||
|
currentColSpan,
|
||||||
}: {
|
}: {
|
||||||
config: PopCardListConfig;
|
config: PopCardListConfig;
|
||||||
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
||||||
|
currentMode?: GridMode;
|
||||||
|
currentColSpan?: number;
|
||||||
}) {
|
}) {
|
||||||
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
|
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
|
||||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
|
@ -200,57 +215,163 @@ function TableSelectTab({
|
||||||
fetchTableList().then(setTables);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 테이블 선택 */}
|
{/* 테이블 선택 섹션 */}
|
||||||
<div>
|
<CollapsibleSection title="테이블 선택" defaultOpen>
|
||||||
<Label className="text-xs font-medium">데이터 테이블 선택</Label>
|
<div className="space-y-3">
|
||||||
<p className="mb-2 mt-1 text-[10px] text-muted-foreground">
|
<div>
|
||||||
카드 리스트에 표시할 데이터가 있는 테이블을 선택하세요
|
<Label className="text-[10px] text-muted-foreground">데이터 테이블</Label>
|
||||||
</p>
|
<Select
|
||||||
<Select
|
value={dataSource.tableName || ""}
|
||||||
value={dataSource.tableName || ""}
|
onValueChange={(val) => {
|
||||||
onValueChange={(val) => {
|
onUpdate({
|
||||||
// 테이블 변경 시 관련 설정 초기화
|
dataSource: {
|
||||||
onUpdate({
|
tableName: val,
|
||||||
dataSource: {
|
joins: undefined,
|
||||||
tableName: val,
|
filters: undefined,
|
||||||
joins: undefined,
|
sort: undefined,
|
||||||
filters: undefined,
|
limit: undefined,
|
||||||
sort: undefined,
|
},
|
||||||
limit: undefined,
|
cardTemplate: DEFAULT_TEMPLATE,
|
||||||
},
|
});
|
||||||
cardTemplate: DEFAULT_TEMPLATE,
|
}}
|
||||||
});
|
>
|
||||||
}}
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
>
|
<SelectValue placeholder="테이블을 선택하세요" />
|
||||||
<SelectTrigger className="h-9 text-xs">
|
</SelectTrigger>
|
||||||
<SelectValue placeholder="테이블을 선택하세요" />
|
<SelectContent>
|
||||||
</SelectTrigger>
|
{tables.map((table) => (
|
||||||
<SelectContent>
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
{tables.map((table) => (
|
{table.displayName || table.tableName}
|
||||||
<SelectItem key={table.tableName} value={table.tableName}>
|
</SelectItem>
|
||||||
{table.displayName || table.tableName}
|
))}
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
))}
|
</Select>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 선택된 테이블 정보 */}
|
{dataSource.tableName && (
|
||||||
{dataSource.tableName && (
|
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||||
<div className="rounded-md border bg-muted/30 p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10">
|
|
||||||
<Database className="h-4 w-4 text-primary" />
|
<Database className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-xs font-medium">{dataSource.tableName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<p className="text-xs font-medium">{dataSource.tableName}</p>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground">선택된 테이블</p>
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* 레이아웃 설정 섹션 */}
|
||||||
|
<CollapsibleSection title="레이아웃 설정" defaultOpen>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 현재 모드 뱃지 */}
|
||||||
|
{modeLabel && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">현재:</span>
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
||||||
|
{modeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 스크롤 방향 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">스크롤 방향</Label>
|
||||||
|
<div className="mt-1.5 space-y-1.5">
|
||||||
|
{(["horizontal", "vertical"] as CardScrollDirection[]).map((dir) => (
|
||||||
|
<button
|
||||||
|
key={dir}
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-xs transition-colors ${
|
||||||
|
config.scrollDirection === dir
|
||||||
|
? "border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-input bg-background hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
onClick={() => onUpdate({ scrollDirection: dir })}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-3 w-3 rounded-full border-2 ${
|
||||||
|
config.scrollDirection === dir
|
||||||
|
? "border-primary bg-primary"
|
||||||
|
: "border-muted-foreground"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{CARD_SCROLL_DIRECTION_LABELS[dir]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 그리드 배치 설정 (행 x 열) */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">카드 배치 (행 x 열)</Label>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={config.gridRows || 3}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate({ gridRows: parseInt(e.target.value, 10) || 3 })
|
||||||
|
}
|
||||||
|
className="h-7 w-16 text-center text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">x</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={maxColumns}
|
||||||
|
value={Math.min(config.gridColumns || 2, maxColumns)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate({ gridColumns: Math.min(parseInt(e.target.value, 10) || 1, maxColumns) })
|
||||||
|
}
|
||||||
|
className="h-7 w-16 text-center text-xs"
|
||||||
|
disabled={maxColumns === 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||||
|
{config.scrollDirection === "horizontal"
|
||||||
|
? "격자로 배치, 가로 스크롤"
|
||||||
|
: "격자로 배치, 세로 스크롤"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[9px] text-muted-foreground">
|
||||||
|
{maxColumns === 1
|
||||||
|
? "현재 모드에서 열 최대 1 (모드 변경 시 자동 적용)"
|
||||||
|
: "모드 변경 시 열/행 자동 적용 / 열 최대 2"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -443,9 +564,30 @@ function CardTemplateTab({
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* 레이아웃 설정 */}
|
{/* 입력 필드 설정 */}
|
||||||
<CollapsibleSection title="레이아웃 설정" defaultOpen>
|
<CollapsibleSection title="입력 필드" defaultOpen={false}>
|
||||||
<LayoutSettingsSection config={config} onUpdate={onUpdate} />
|
<InputFieldSettingsSection
|
||||||
|
inputField={config.inputField}
|
||||||
|
columns={columns}
|
||||||
|
onUpdate={(inputField) => onUpdate({ inputField })}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* 계산 필드 설정 */}
|
||||||
|
<CollapsibleSection title="계산 필드" defaultOpen={false}>
|
||||||
|
<CalculatedFieldSettingsSection
|
||||||
|
calculatedField={config.calculatedField}
|
||||||
|
columns={columns}
|
||||||
|
onUpdate={(calculatedField) => onUpdate({ calculatedField })}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* 담기 버튼 설정 */}
|
||||||
|
<CollapsibleSection title="담기 버튼 (pop-icon)" defaultOpen={false}>
|
||||||
|
<CartActionSettingsSection
|
||||||
|
cartAction={config.cartAction}
|
||||||
|
onUpdate={(cartAction) => onUpdate({ cartAction })}
|
||||||
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -867,92 +1009,263 @@ function FieldEditor({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 레이아웃 설정 섹션 =====
|
|
||||||
|
|
||||||
function LayoutSettingsSection({
|
// ===== 입력 필드 설정 섹션 =====
|
||||||
config,
|
|
||||||
|
function InputFieldSettingsSection({
|
||||||
|
inputField,
|
||||||
|
columns,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
config: PopCardListConfig;
|
inputField?: CardInputFieldConfig;
|
||||||
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
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<CardInputFieldConfig>) => {
|
||||||
|
onUpdate({ ...field, ...partial });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 카드 크기 */}
|
{/* 활성화 스위치 */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-[10px]">카드 크기</Label>
|
<Label className="text-[10px]">입력 필드 사용</Label>
|
||||||
<div className="mt-1.5 flex gap-2">
|
<Switch
|
||||||
{(["small", "medium", "large"] as CardSize[]).map((size) => (
|
checked={field.enabled}
|
||||||
<button
|
onCheckedChange={(enabled) => updateField({ enabled })}
|
||||||
key={size}
|
/>
|
||||||
type="button"
|
</div>
|
||||||
className={`flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
|
||||||
config.cardSize === size
|
{field.enabled && (
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
<>
|
||||||
: "border-input bg-background hover:bg-accent"
|
{/* 라벨 */}
|
||||||
}`}
|
<div>
|
||||||
onClick={() => onUpdate({ cardSize: size })}
|
<Label className="text-[10px] text-muted-foreground">라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={field.label || ""}
|
||||||
|
onChange={(e) => updateField({ label: e.target.value })}
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
placeholder="발주 수량"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단위 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground">단위</Label>
|
||||||
|
<Input
|
||||||
|
value={field.unit || ""}
|
||||||
|
onChange={(e) => updateField({ unit: e.target.value })}
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
placeholder="EA"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본값 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground">기본값</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={field.defaultValue || 0}
|
||||||
|
onChange={(e) => updateField({ defaultValue: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최소/최대값 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground">최소값</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={field.min || 0}
|
||||||
|
onChange={(e) => updateField({ min: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground">최대값</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={field.max || 999999}
|
||||||
|
onChange={(e) => updateField({ max: parseInt(e.target.value, 10) || 999999 })}
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
placeholder="999999"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최대값 컬럼 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground">
|
||||||
|
최대값 컬럼 (선택)
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={field.maxColumn || "__none__"}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
updateField({ maxColumn: val === "__none__" ? undefined : val })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{CARD_SIZE_LABELS[size]}
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||||
</button>
|
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||||
))}
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">선택 안함 (위 고정 최대값 사용)</SelectItem>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<SelectItem key={col.name} value={col.name}>
|
||||||
|
{col.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||||
|
설정 시 각 카드 행의 해당 컬럼 값이 숫자패드 최대값으로 사용됨 (예: unreceived_qty)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 저장 컬럼 (선택사항) */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground">
|
||||||
|
저장 컬럼 (선택사항)
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={field.columnName || "__none__"}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
updateField({ columnName: val === "__none__" ? undefined : val })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<SelectItem key={col.name} value={col.name}>
|
||||||
|
{col.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||||
|
입력값을 저장할 DB 컬럼 (현재는 로컬 상태만 유지)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 계산 필드 설정 섹션 =====
|
||||||
|
|
||||||
|
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<CardCalculatedFieldConfig>) => {
|
||||||
|
onUpdate({ ...field, ...partial });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 활성화 스위치 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px]">계산 필드 사용</Label>
|
||||||
|
<Switch
|
||||||
|
checked={field.enabled}
|
||||||
|
onCheckedChange={(enabled) => updateField({ enabled })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 배치 방식 */}
|
{field.enabled && (
|
||||||
<div>
|
<>
|
||||||
<Label className="text-[10px]">배치 방식</Label>
|
{/* 라벨 */}
|
||||||
<div className="mt-1.5 space-y-1.5">
|
<div>
|
||||||
{(["grid", "horizontal", "vertical"] as CardLayoutMode[]).map(
|
<Label className="text-[10px] text-muted-foreground">라벨</Label>
|
||||||
(mode) => (
|
<Input
|
||||||
<button
|
value={field.label || ""}
|
||||||
key={mode}
|
onChange={(e) => updateField({ label: e.target.value })}
|
||||||
type="button"
|
className="mt-1 h-7 text-xs"
|
||||||
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-xs transition-colors ${
|
placeholder="미입고"
|
||||||
config.layoutMode === mode
|
/>
|
||||||
? "border-primary bg-primary/10 text-primary"
|
</div>
|
||||||
: "border-input bg-background hover:bg-accent"
|
|
||||||
}`}
|
|
||||||
onClick={() => onUpdate({ layoutMode: mode })}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`h-3 w-3 rounded-full border-2 ${
|
|
||||||
config.layoutMode === mode
|
|
||||||
? "border-primary bg-primary"
|
|
||||||
: "border-muted-foreground"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{CARD_LAYOUT_MODE_LABELS[mode]}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 격자 배치일 때만 한 줄 카드 수 표시 */}
|
{/* 계산식 */}
|
||||||
{isGridMode && (
|
<div>
|
||||||
<div>
|
<Label className="text-[10px] text-muted-foreground">계산식</Label>
|
||||||
<Label className="text-[10px]">한 줄 카드 수</Label>
|
<Input
|
||||||
<Select
|
value={field.formula || ""}
|
||||||
value={String(config.cardsPerRow || 3)}
|
onChange={(e) => updateField({ formula: e.target.value })}
|
||||||
onValueChange={(val) =>
|
className="mt-1 h-7 text-xs font-mono"
|
||||||
onUpdate({ cardsPerRow: parseInt(val, 10) })
|
placeholder="$input - received_qty"
|
||||||
}
|
/>
|
||||||
>
|
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||||
<SelectTrigger className="mt-1.5 h-7 text-xs">
|
사용 가능: 컬럼명, $input (입력값), +, -, *, /
|
||||||
<SelectValue />
|
</p>
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
|
||||||
{[1, 2, 3, 4, 5, 6].map((num) => (
|
{/* 단위 */}
|
||||||
<SelectItem key={num} value={String(num)}>
|
<div>
|
||||||
{num}개
|
<Label className="text-[10px] text-muted-foreground">단위</Label>
|
||||||
</SelectItem>
|
<Input
|
||||||
))}
|
value={field.unit || ""}
|
||||||
</SelectContent>
|
onChange={(e) => updateField({ unit: e.target.value })}
|
||||||
</Select>
|
className="mt-1 h-7 text-xs"
|
||||||
</div>
|
placeholder="EA"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용 가능한 컬럼 목록 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground">
|
||||||
|
사용 가능한 컬럼
|
||||||
|
</Label>
|
||||||
|
<div className="mt-1 max-h-24 overflow-y-auto rounded border bg-muted/30 p-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<span
|
||||||
|
key={col.name}
|
||||||
|
className="cursor-pointer rounded bg-muted px-1.5 py-0.5 text-[9px] hover:bg-primary/20"
|
||||||
|
onClick={() => {
|
||||||
|
// 클릭 시 계산식에 컬럼명 추가
|
||||||
|
const currentFormula = field.formula || "";
|
||||||
|
updateField({ formula: currentFormula + col.name });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{col.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||||
|
클릭하면 계산식에 추가됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1437,3 +1750,125 @@ function LimitSettingsSection({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 담기 버튼 설정 섹션 =====
|
||||||
|
|
||||||
|
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<CardCartActionConfig>) => {
|
||||||
|
onUpdate({ ...action, ...partial });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 네비게이션 모드 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">담기 후 이동</Label>
|
||||||
|
<Select
|
||||||
|
value={action.navigateMode}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
update({ navigateMode: v as "none" | "screen" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">없음 (토스트만)</SelectItem>
|
||||||
|
<SelectItem value="screen">POP 화면 이동</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 화면 ID (screen 모드일 때만) */}
|
||||||
|
{action.navigateMode === "screen" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">장바구니 화면 ID</Label>
|
||||||
|
<Input
|
||||||
|
value={action.targetScreenId || ""}
|
||||||
|
onChange={(e) => update({ targetScreenId: e.target.value })}
|
||||||
|
placeholder="예: 15"
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
담기 클릭 시 이동할 POP 화면의 screenId
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 아이콘 타입 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">아이콘 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={action.iconType || "lucide"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
update({ iconType: v as "lucide" | "emoji" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="lucide">Lucide 아이콘</SelectItem>
|
||||||
|
<SelectItem value="emoji">이모지</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 아이콘 값 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">
|
||||||
|
{action.iconType === "emoji" ? "이모지" : "Lucide 아이콘명"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={action.iconValue || ""}
|
||||||
|
onChange={(e) => update({ iconValue: e.target.value })}
|
||||||
|
placeholder={
|
||||||
|
action.iconType === "emoji" ? "예: 🛒" : "예: ShoppingCart"
|
||||||
|
}
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
/>
|
||||||
|
{action.iconType === "lucide" && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
PascalCase로 입력 (ShoppingCart, Package, Truck 등)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 담기 라벨 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">담기 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={action.label || ""}
|
||||||
|
onChange={(e) => update({ label: e.target.value })}
|
||||||
|
placeholder="담기"
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 취소 라벨 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">취소 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={action.cancelLabel || ""}
|
||||||
|
onChange={(e) => update({ cancelLabel: e.target.value })}
|
||||||
|
placeholder="취소"
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import React from "react";
|
||||||
import { LayoutGrid, Package } from "lucide-react";
|
import { LayoutGrid, Package } from "lucide-react";
|
||||||
import type { PopCardListConfig } from "../types";
|
import type { PopCardListConfig } from "../types";
|
||||||
import {
|
import {
|
||||||
CARD_LAYOUT_MODE_LABELS,
|
CARD_SCROLL_DIRECTION_LABELS,
|
||||||
CARD_SIZE_LABELS,
|
CARD_SIZE_LABELS,
|
||||||
DEFAULT_CARD_IMAGE,
|
DEFAULT_CARD_IMAGE,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
@ -23,22 +23,18 @@ interface PopCardListPreviewProps {
|
||||||
export function PopCardListPreviewComponent({
|
export function PopCardListPreviewComponent({
|
||||||
config,
|
config,
|
||||||
}: PopCardListPreviewProps) {
|
}: PopCardListPreviewProps) {
|
||||||
const layoutMode = config?.layoutMode || "grid";
|
const scrollDirection = config?.scrollDirection || "vertical";
|
||||||
const cardSize = config?.cardSize || "medium";
|
const cardSize = config?.cardSize || "medium";
|
||||||
const cardsPerRow = config?.cardsPerRow || 3;
|
|
||||||
const dataSource = config?.dataSource;
|
const dataSource = config?.dataSource;
|
||||||
const template = config?.cardTemplate;
|
const template = config?.cardTemplate;
|
||||||
|
|
||||||
// 설정 상태 확인
|
|
||||||
const hasTable = !!dataSource?.tableName;
|
const hasTable = !!dataSource?.tableName;
|
||||||
const hasHeader =
|
const hasHeader =
|
||||||
!!template?.header?.codeField || !!template?.header?.titleField;
|
!!template?.header?.codeField || !!template?.header?.titleField;
|
||||||
const hasImage = template?.image?.enabled ?? true;
|
const hasImage = template?.image?.enabled ?? true;
|
||||||
const fieldCount = template?.body?.fields?.length || 0;
|
const fieldCount = template?.body?.fields?.length || 0;
|
||||||
|
|
||||||
// 샘플 카드 개수 (미리보기용)
|
const sampleCardCount = 2;
|
||||||
const sampleCardCount =
|
|
||||||
layoutMode === "grid" ? Math.min(cardsPerRow, 3) : 2;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col bg-muted/30 p-3">
|
<div className="flex h-full w-full flex-col bg-muted/30 p-3">
|
||||||
|
|
@ -52,7 +48,7 @@ export function PopCardListPreviewComponent({
|
||||||
{/* 설정 배지 */}
|
{/* 설정 배지 */}
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
|
||||||
{CARD_LAYOUT_MODE_LABELS[layoutMode]}
|
{CARD_SCROLL_DIRECTION_LABELS[scrollDirection]}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-secondary-foreground">
|
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-secondary-foreground">
|
||||||
{CARD_SIZE_LABELS[cardSize]}
|
{CARD_SIZE_LABELS[cardSize]}
|
||||||
|
|
@ -82,11 +78,9 @@ export function PopCardListPreviewComponent({
|
||||||
{/* 카드 미리보기 */}
|
{/* 카드 미리보기 */}
|
||||||
<div
|
<div
|
||||||
className={`flex-1 flex gap-2 ${
|
className={`flex-1 flex gap-2 ${
|
||||||
layoutMode === "vertical"
|
scrollDirection === "vertical"
|
||||||
? "flex-col"
|
? "flex-col"
|
||||||
: layoutMode === "horizontal"
|
: "flex-row overflow-x-auto"
|
||||||
? "flex-row overflow-x-auto"
|
|
||||||
: "flex-wrap justify-center content-start"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{Array.from({ length: sampleCardCount }).map((_, idx) => (
|
{Array.from({ length: sampleCardCount }).map((_, idx) => (
|
||||||
|
|
@ -97,7 +91,7 @@ export function PopCardListPreviewComponent({
|
||||||
hasImage={hasImage}
|
hasImage={hasImage}
|
||||||
fieldCount={fieldCount}
|
fieldCount={fieldCount}
|
||||||
cardSize={cardSize}
|
cardSize={cardSize}
|
||||||
layoutMode={layoutMode}
|
scrollDirection={scrollDirection}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -124,16 +118,15 @@ function PreviewCard({
|
||||||
hasImage,
|
hasImage,
|
||||||
fieldCount,
|
fieldCount,
|
||||||
cardSize,
|
cardSize,
|
||||||
layoutMode,
|
scrollDirection,
|
||||||
}: {
|
}: {
|
||||||
index: number;
|
index: number;
|
||||||
hasHeader: boolean;
|
hasHeader: boolean;
|
||||||
hasImage: boolean;
|
hasImage: boolean;
|
||||||
fieldCount: number;
|
fieldCount: number;
|
||||||
cardSize: string;
|
cardSize: string;
|
||||||
layoutMode: string;
|
scrollDirection: string;
|
||||||
}) {
|
}) {
|
||||||
// 카드 크기
|
|
||||||
const sizeClass =
|
const sizeClass =
|
||||||
cardSize === "small"
|
cardSize === "small"
|
||||||
? "min-h-[60px]"
|
? "min-h-[60px]"
|
||||||
|
|
@ -142,11 +135,9 @@ function PreviewCard({
|
||||||
: "min-h-[80px]";
|
: "min-h-[80px]";
|
||||||
|
|
||||||
const widthClass =
|
const widthClass =
|
||||||
layoutMode === "vertical"
|
scrollDirection === "vertical"
|
||||||
? "w-full"
|
? "w-full"
|
||||||
: layoutMode === "horizontal"
|
: "min-w-[140px] flex-shrink-0";
|
||||||
? "min-w-[140px] flex-shrink-0"
|
|
||||||
: "w-[140px]";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import { PopCardListPreviewComponent } from "./PopCardListPreview";
|
||||||
import type { PopCardListConfig } from "../types";
|
import type { PopCardListConfig } from "../types";
|
||||||
import { DEFAULT_CARD_IMAGE } from "../types";
|
import { DEFAULT_CARD_IMAGE } from "../types";
|
||||||
|
|
||||||
// 기본 설정값 (V2 구조)
|
|
||||||
const defaultConfig: PopCardListConfig = {
|
const defaultConfig: PopCardListConfig = {
|
||||||
// 데이터 소스 (테이블 단위)
|
// 데이터 소스 (테이블 단위)
|
||||||
dataSource: {
|
dataSource: {
|
||||||
|
|
@ -34,10 +33,20 @@ const defaultConfig: PopCardListConfig = {
|
||||||
fields: [],
|
fields: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 레이아웃 설정
|
// 스크롤 방향
|
||||||
layoutMode: "grid",
|
scrollDirection: "vertical",
|
||||||
cardsPerRow: 3,
|
|
||||||
cardSize: "medium",
|
cardSize: "medium",
|
||||||
|
// 그리드 배치 (가로 x 세로)
|
||||||
|
gridColumns: 3,
|
||||||
|
gridRows: 2,
|
||||||
|
// 담기 버튼 기본 설정
|
||||||
|
cartAction: {
|
||||||
|
navigateMode: "none",
|
||||||
|
iconType: "lucide",
|
||||||
|
iconValue: "ShoppingCart",
|
||||||
|
label: "담기",
|
||||||
|
cancelLabel: "취소",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 레지스트리 등록
|
// 레지스트리 등록
|
||||||
|
|
|
||||||
|
|
@ -343,9 +343,6 @@ export interface PopDashboardConfig {
|
||||||
dataSource?: DataSourceConfig;
|
dataSource?: DataSourceConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// pop-card-list 전용 타입 (V2 - 이미지 참조 기반 재설계)
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// ----- 조인 설정 -----
|
// ----- 조인 설정 -----
|
||||||
|
|
||||||
|
|
@ -427,21 +424,115 @@ export const CARD_SIZE_LABELS: Record<CardSize, string> = {
|
||||||
large: "크게",
|
large: "크게",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----- 카드 배치 방식 (방향 기반) -----
|
// ----- 카드 스크롤 방향 -----
|
||||||
|
|
||||||
export type CardLayoutMode = "grid" | "horizontal" | "vertical";
|
export type CardScrollDirection = "horizontal" | "vertical";
|
||||||
// grid: 격자 배치 (행/열로 정렬)
|
|
||||||
// horizontal: 가로 배치 (가로 스크롤)
|
|
||||||
// vertical: 세로 배치 (세로 스크롤)
|
|
||||||
|
|
||||||
export const CARD_LAYOUT_MODE_LABELS: Record<CardLayoutMode, string> = {
|
export const CARD_SCROLL_DIRECTION_LABELS: Record<CardScrollDirection, string> = {
|
||||||
grid: "격자 배치",
|
horizontal: "가로 스크롤",
|
||||||
horizontal: "가로 배치",
|
vertical: "세로 스크롤",
|
||||||
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<string, unknown>; // 카드 원본 행 데이터
|
||||||
|
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 전체 설정 -----
|
// ----- 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<CardSize, CardPresetSpec> = {
|
||||||
|
// 작게: 컴팩트 - 헤더(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 {
|
export interface PopCardListConfig {
|
||||||
// 데이터 소스 (테이블 단위)
|
// 데이터 소스 (테이블 단위)
|
||||||
dataSource: CardListDataSource;
|
dataSource: CardListDataSource;
|
||||||
|
|
@ -449,8 +540,24 @@ export interface PopCardListConfig {
|
||||||
// 카드 템플릿 (헤더 + 이미지 + 본문)
|
// 카드 템플릿 (헤더 + 이미지 + 본문)
|
||||||
cardTemplate: CardTemplateConfig;
|
cardTemplate: CardTemplateConfig;
|
||||||
|
|
||||||
// 레이아웃 설정
|
// 스크롤 방향
|
||||||
layoutMode: CardLayoutMode;
|
scrollDirection: CardScrollDirection;
|
||||||
cardsPerRow?: number; // 격자 배치일 때만 사용
|
cardsPerRow?: number; // deprecated, gridColumns 사용
|
||||||
cardSize: CardSize;
|
cardSize: CardSize; // 프리셋 크기 (small/medium/large)
|
||||||
|
|
||||||
|
// 그리드 배치 설정 (가로 x 세로)
|
||||||
|
gridColumns?: number; // 가로 카드 수 (기본값: 3)
|
||||||
|
gridRows?: number; // 세로 카드 수 (기본값: 2)
|
||||||
|
|
||||||
|
// 확장 설정 (더보기 클릭 시 그리드 rowSpan 변경)
|
||||||
|
// expandedRowSpan 제거됨 - 항상 원래 크기의 2배로 확장
|
||||||
|
|
||||||
|
// 입력 필드 설정 (수량 입력 등)
|
||||||
|
inputField?: CardInputFieldConfig;
|
||||||
|
|
||||||
|
// 계산 필드 설정 (미입고 등 자동 계산)
|
||||||
|
calculatedField?: CardCalculatedFieldConfig;
|
||||||
|
|
||||||
|
// 담기 버튼 액션 설정 (pop-icon 스타일)
|
||||||
|
cartAction?: CardCartActionConfig;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@
|
||||||
"eslint-config-next": "15.4.4",
|
"eslint-config-next": "15.4.4",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
|
|
@ -261,6 +262,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
|
|
@ -302,6 +304,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -335,6 +338,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|
@ -2665,6 +2669,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.17.8",
|
"@babel/runtime": "^7.17.8",
|
||||||
"@types/react-reconciler": "^0.32.0",
|
"@types/react-reconciler": "^0.32.0",
|
||||||
|
|
@ -3318,6 +3323,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.6"
|
"@tanstack/query-core": "5.90.6"
|
||||||
},
|
},
|
||||||
|
|
@ -3385,6 +3391,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
|
@ -3698,6 +3705,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-collab": "^1.3.1",
|
"prosemirror-collab": "^1.3.1",
|
||||||
|
|
@ -6198,6 +6206,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
|
|
@ -6208,6 +6217,7 @@
|
||||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -6250,6 +6260,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
"@tweenjs/tween.js": "~23.1.3",
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
|
@ -6332,6 +6343,7 @@
|
||||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.2",
|
"@typescript-eslint/scope-manager": "8.46.2",
|
||||||
"@typescript-eslint/types": "8.46.2",
|
"@typescript-eslint/types": "8.46.2",
|
||||||
|
|
@ -6964,6 +6976,7 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -8114,7 +8127,8 @@
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/d3": {
|
"node_modules/d3": {
|
||||||
"version": "7.9.0",
|
"version": "7.9.0",
|
||||||
|
|
@ -8436,6 +8450,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
|
@ -9195,6 +9210,7 @@
|
||||||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -9283,6 +9299,7 @@
|
||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
|
|
@ -9384,6 +9401,7 @@
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
|
|
@ -10001,6 +10019,21 @@
|
||||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/fstream": {
|
||||||
"version": "1.0.12",
|
"version": "1.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/immer"
|
"url": "https://opencollective.com/immer"
|
||||||
|
|
@ -11320,7 +11354,8 @@
|
||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
|
|
@ -12497,6 +12532,38 @@
|
||||||
"pathe": "^2.0.3"
|
"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": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
|
@ -12617,6 +12684,7 @@
|
||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
|
|
@ -12910,6 +12978,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"orderedmap": "^2.0.0"
|
"orderedmap": "^2.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -12939,6 +13008,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.0.0",
|
"prosemirror-model": "^1.0.0",
|
||||||
"prosemirror-transform": "^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",
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.20.0",
|
"prosemirror-model": "^1.20.0",
|
||||||
"prosemirror-state": "^1.0.0",
|
"prosemirror-state": "^1.0.0",
|
||||||
|
|
@ -13113,6 +13184,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -13182,6 +13254,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"scheduler": "^0.26.0"
|
||||||
},
|
},
|
||||||
|
|
@ -13232,6 +13305,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -13264,7 +13338,8 @@
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-leaflet": {
|
"node_modules/react-leaflet": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
|
|
@ -13572,6 +13647,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
|
@ -13594,7 +13670,8 @@
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/recharts/node_modules/redux-thunk": {
|
"node_modules/recharts/node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
|
|
@ -14624,7 +14701,8 @@
|
||||||
"version": "0.180.0",
|
"version": "0.180.0",
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/three-mesh-bvh": {
|
"node_modules/three-mesh-bvh": {
|
||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
|
|
@ -14712,6 +14790,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -15060,6 +15139,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@
|
||||||
"eslint-config-next": "15.4.4",
|
"eslint-config-next": "15.4.4",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
Loading…
Reference in New Issue