ERP-node/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx

526 lines
17 KiB
TypeScript
Raw Normal View History

"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
PopComponentDefinitionV5,
PopGridPosition,
GridMode,
GRID_BREAKPOINTS,
} from "../types/pop-layout";
import {
Settings,
Link2,
Eye,
Grid3x3,
MoveHorizontal,
MoveVertical,
Layers,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { PopDataConnection, PopModalDefinition } from "../types/pop-layout";
import ConnectionEditor from "./ConnectionEditor";
// ========================================
// Props
// ========================================
interface ComponentEditorPanelProps {
/** 선택된 컴포넌트 */
component: PopComponentDefinitionV5 | null;
/** 현재 모드 */
currentMode: GridMode;
/** 컴포넌트 업데이트 */
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
/** 추가 className */
className?: string;
/** 그리드에 배치된 모든 컴포넌트 */
allComponents?: PopComponentDefinitionV5[];
/** 컴포넌트 선택 콜백 */
onSelectComponent?: (componentId: string) => void;
/** 현재 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
/** 대시보드 페이지 미리보기 인덱스 */
previewPageIndex?: number;
/** 페이지 미리보기 요청 콜백 */
onPreviewPage?: (pageIndex: number) => void;
/** 데이터 흐름 연결 목록 */
connections?: PopDataConnection[];
/** 연결 추가 콜백 */
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
/** 연결 수정 콜백 */
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
/** 연결 삭제 콜백 */
onRemoveConnection?: (connectionId: string) => void;
/** 모달 정의 목록 (설정 패널에 전달) */
modals?: PopModalDefinition[];
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-sample": "샘플",
"pop-text": "텍스트",
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
feat(pop): pop-card-list-v2 슬롯 기반 카드 컴포넌트 신규 + 타임라인 범용화 + 액션 인라인 설정 CSS Grid 기반 슬롯 구조의 pop-card-list-v2 컴포넌트를 추가한다. 기존 pop-card-list의 데이터 로딩/필터링/장바구니 로직을 재활용하되, 카드 내부는 12종 셀 타입(text/field/image/badge/button/number-input/ cart-button/package-summary/status-badge/timeline/action-buttons/ footer-status)의 조합으로 자유롭게 구성할 수 있다. [신규 컴포넌트: pop-card-list-v2] - PopCardListV2Component: 런타임 렌더링 (데이터 조회 + CSS Grid 카드) - PopCardListV2Config: 3탭 설정 패널 (데이터/카드 디자인/동작) - PopCardListV2Preview: 디자이너 미리보기 - cell-renderers: 셀 타입별 독립 렌더러 12종 - migrate: v1 -> v2 설정 마이그레이션 함수 - index: PopComponentRegistry 자동 등록 [타임라인 데이터 소스 범용화] - TimelineDataSource 인터페이스로 공정 테이블/FK/컬럼/상태값 매핑 설정 - 하드코딩(work_orders+work_order_process) 제거 -> 설정 기반 동적 조회 - injectProcessFlow: 설정 기반 공정 데이터 조회 + __processFlow__ 가상 컬럼 주입 - 상태값 정규화(DB값 -> waiting/accepted/in_progress/completed) [액션 버튼 인라인 설정] - actionRules 내 updates 배열로 동작 정의 (별도 DB 테이블 불필요) - execute-action API 재활용 (targetTable/column/valueType) - 백엔드 __CURRENT_USER__/__CURRENT_TIME__ 특수값 치환 [디자이너 통합] - PopComponentType에 "pop-card-list-v2" 추가 - ComponentEditorPanel/ComponentPalette/PopRenderer 등록 - PopDesigner loadLayout: components 존재 확인 null 체크 추가 [기타] - .gitignore: .gradle/ 추가
2026-03-10 16:56:14 +09:00
"pop-card-list-v2": "카드 목록 V2",
"pop-field": "필드",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-search": "검색",
"pop-status-bar": "상태 바",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
// 컴포넌트 편집 패널 (v5 그리드 시스템)
// ========================================
export default function ComponentEditorPanel({
component,
currentMode,
onUpdateComponent,
className,
allComponents,
onSelectComponent,
selectedComponentId,
previewPageIndex,
onPreviewPage,
connections,
onAddConnection,
onUpdateConnection,
onRemoveConnection,
modals,
}: ComponentEditorPanelProps) {
const breakpoint = GRID_BREAKPOINTS[currentMode];
// 선택된 컴포넌트 없음
if (!component) {
return (
<div className={cn("flex h-full flex-col bg-white", className)}>
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"></h3>
</div>
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
</div>
</div>
);
}
// 기본 모드 여부
const isDefaultMode = currentMode === "tablet_landscape";
return (
<div className={cn("flex h-full flex-col bg-white", className)}>
{/* 헤더 */}
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium">
{component.label || COMPONENT_TYPE_LABELS[component.type]}
</h3>
<p className="text-xs text-muted-foreground">{component.type}</p>
{!isDefaultMode && (
<p className="text-xs text-amber-600 mt-1">
(릿 )
</p>
)}
</div>
{/* 탭 */}
<Tabs defaultValue="position" className="flex flex-1 flex-col min-h-0 overflow-hidden">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2 flex-shrink-0">
<TabsTrigger value="position" className="gap-1 text-xs">
<Grid3x3 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="settings" className="gap-1 text-xs">
<Settings className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="visibility" className="gap-1 text-xs">
<Eye className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="connection" className="gap-1 text-xs">
<Link2 className="h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 위치 탭 */}
<TabsContent value="position" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
{/* 배치된 컴포넌트 목록 */}
{allComponents && allComponents.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-1 mb-2">
<Layers className="h-3 w-3 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">
({allComponents.length})
</span>
</div>
<div className="space-y-1">
{allComponents.map((comp) => {
feat(pop): 공정 상태 자동 계산 + 하위 필터 연동 + 타임라인 연동 상태배지 공정 필터 선택 시 상태 뱃지/카운트/버튼이 공정 상태 기준으로 동작하도록 파생 상태 자동 계산, 하위 필터 __subStatus__ 주입, 접수 버튼 공정 행 특정 로직을 구현한다. [파생 상태 자동 계산] - types.ts: StatusValueMapping.isDerived 필드 추가 isDerived=true면 DB에 없는 상태로, 이전 공정 완료 시 자동 변환 - PopCardListV2Component: injectProcessFlow에 derivedRules 기반 변환 로직 같은 semantic의 원본 상태를 자동 추론 (waiting → acceptable) - TimelineProcessStep에 processId, rawData 필드 추가 [하위 필터 __subStatus__ 주입] - PopCardListV2Component: filteredRows를 2단계로 분리 1단계: 하위 테이블(work_order_process) 필터 → 매칭 공정의 상태를 VIRTUAL_SUB_STATUS/SEMANTIC/PROCESS/SEQ 가상 컬럼으로 주입 2단계: 메인 필터에서 status 컬럼을 __subStatus__로 자동 대체 - cell-renderers: StatusBadgeCell/ActionButtonsCell이 __subStatus__ 우선 참조 하드코딩된 접수가능 판별 로직(isAcceptable) 제거 → 설정 기반으로 전환 - all_rows 발행: { rows, subStatusColumn } envelope 구조로 메타 포함 [타임라인 강조(isCurrent) 개선] - "기준" 상태(isDerived) 기반 강조 + 공정 필터 시 매칭 공정 강조 - 폴백: active → pending 순서로 자동 결정 [접수 버튼 공정 행 특정] - cell-renderers: ActionButtonsCell에서 현재 공정의 processId를 __processId로 전달 - PopCardListV2Component: onActionButtonClick에서 __processId로 공정 행 UPDATE [상태배지 타임라인 연동] - PopCardListV2Config: StatusMappingEditor에 "타임라인 연동" 버튼 추가 같은 카드의 타임라인 statusMappings에서 값/라벨/색상/컬럼 자동 가져옴 [타임라인 설정 UI] - PopCardListV2Config: StatusMappingsEditor에 "기준" 라디오 버튼 추가 하나만 선택 가능, 재클릭 시 해제 [연결 탭 하위 테이블 필터 설정] - ConnectionEditor: isSubTable 체크박스 + targetColumn/filterMode 설정 UI - pop-layout.ts: filterConfig.isSubTable 필드 추가 [status-chip 하위 필터 자동 전환] - PopSearchComponent: 카드가 전달한 subStatusColumn 자동 감지 useSubCount 활성 시 집계/필터 컬럼 자동 전환 - PopSearchConfig: useSubCount 체크박스 설정 UI - types.ts: StatusChipConfig.useSubCount 필드 추가 [디자이너 라벨] - ComponentEditorPanel: comp.label || comp.id 패턴으로 통일
2026-03-11 12:07:11 +09:00
const label = comp.label || comp.id;
const isActive = comp.id === selectedComponentId;
return (
<button
key={comp.id}
onClick={() => onSelectComponent?.(comp.id)}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors",
isActive
? "bg-primary/10 text-primary font-medium"
: "hover:bg-gray-100 text-gray-600"
)}
>
<span className="truncate flex-1">{label}</span>
<span className="shrink-0 text-[10px] text-gray-400">
({comp.position.col},{comp.position.row})
</span>
</button>
);
})}
</div>
<div className="h-px bg-gray-200 mt-3" />
</div>
)}
<PositionForm
component={component}
currentMode={currentMode}
isDefaultMode={isDefaultMode}
columns={breakpoint.columns}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 설정 탭 */}
<TabsContent value="settings" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
<ComponentSettingsForm
component={component}
onUpdate={onUpdateComponent}
currentMode={currentMode}
previewPageIndex={previewPageIndex}
onPreviewPage={onPreviewPage}
modals={modals}
allComponents={allComponents}
connections={connections}
/>
</TabsContent>
{/* 표시 탭 */}
<TabsContent value="visibility" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
<VisibilityForm
component={component}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 연결 탭 */}
<TabsContent value="connection" className="flex-1 min-h-0 overflow-y-auto p-4 m-0">
<ConnectionEditor
component={component}
allComponents={allComponents || []}
connections={connections || []}
onAddConnection={onAddConnection}
onUpdateConnection={onUpdateConnection}
onRemoveConnection={onRemoveConnection}
/>
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 위치 편집 폼
// ========================================
interface PositionFormProps {
component: PopComponentDefinitionV5;
currentMode: GridMode;
isDefaultMode: boolean;
columns: number;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
const { position } = component;
const handlePositionChange = (field: keyof PopGridPosition, value: number) => {
// 범위 체크
let clampedValue = Math.max(1, value);
if (field === "col" || field === "colSpan") {
clampedValue = Math.min(columns, clampedValue);
}
if (field === "colSpan" && position.col + clampedValue - 1 > columns) {
clampedValue = columns - position.col + 1;
}
onUpdate?.({
position: {
...position,
[field]: clampedValue,
},
});
};
return (
<div className="space-y-6">
{/* 그리드 정보 */}
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-xs font-medium text-gray-700 mb-1">
: {GRID_BREAKPOINTS[currentMode].label}
</p>
<p className="text-xs text-muted-foreground">
{columns} ×
</p>
</div>
{/* 열 위치 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
(Col)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={columns}
value={position.col}
onChange={(e) => handlePositionChange("col", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~{columns})
</span>
</div>
</div>
{/* 행 위치 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
(Row)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={position.row}
onChange={(e) => handlePositionChange("row", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~)
</span>
</div>
</div>
<div className="h-px bg-gray-200" />
{/* 열 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
(ColSpan)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={columns}
value={position.colSpan}
onChange={(e) => handlePositionChange("colSpan", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
(1~{columns})
</span>
</div>
<p className="text-xs text-muted-foreground">
{Math.round((position.colSpan / columns) * 100)}%
</p>
</div>
{/* 행 크기 */}
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
(RowSpan)
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={position.rowSpan}
onChange={(e) => handlePositionChange("rowSpan", parseInt(e.target.value) || 1)}
disabled={!isDefaultMode}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">
</span>
</div>
<p className="text-xs text-muted-foreground">
: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
</p>
</div>
{/* 비활성화 안내 */}
{!isDefaultMode && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3">
<p className="text-xs text-amber-800">
(릿 ) .
.
</p>
</div>
)}
</div>
);
}
// ========================================
// 설정 폼
// ========================================
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
currentMode?: GridMode;
previewPageIndex?: number;
onPreviewPage?: (pageIndex: number) => void;
modals?: PopModalDefinition[];
allComponents?: PopComponentDefinitionV5[];
connections?: PopDataConnection[];
}
function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals, allComponents, connections }: ComponentSettingsFormProps) {
// PopComponentRegistry에서 configPanel 가져오기
const registeredComp = PopComponentRegistry.getComponent(component.type);
const ConfigPanel = registeredComp?.configPanel;
// config 업데이트 핸들러
const handleConfigUpdate = (newConfig: any) => {
onUpdate?.({ config: newConfig });
};
return (
<div className="space-y-4">
{/* 라벨 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
type="text"
value={component.label || ""}
onChange={(e) => onUpdate?.({ label: e.target.value })}
placeholder="컴포넌트 이름"
className="h-8 text-xs"
/>
</div>
{/* 컴포넌트 타입별 설정 패널 */}
{ConfigPanel ? (
<ConfigPanel
config={component.config || {}}
onUpdate={handleConfigUpdate}
currentMode={currentMode}
currentColSpan={component.position.colSpan}
onPreviewPage={onPreviewPage}
previewPageIndex={previewPageIndex}
modals={modals}
allComponents={allComponents}
connections={connections}
componentId={component.id}
/>
) : (
<div className="rounded-lg bg-gray-50 p-3">
<p className="text-xs text-muted-foreground">
{component.type}
</p>
</div>
)}
</div>
);
}
// ========================================
// 표시/숨김 폼
// ========================================
interface VisibilityFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
}
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes: Array<{ key: GridMode; label: string }> = [
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
];
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
onUpdate?.({
visibility: {
...component.visibility,
[mode]: visible,
},
});
};
return (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{modes.map((mode) => {
const isVisible = component.visibility?.[mode.key] !== false;
return (
<div key={mode.key} className="flex items-center gap-2">
<Checkbox
id={`visibility-${mode.key}`}
checked={isVisible}
onCheckedChange={(checked) =>
handleVisibilityChange(mode.key, checked === true)
}
/>
<label
htmlFor={`visibility-${mode.key}`}
className="text-xs cursor-pointer"
>
{mode.label}
</label>
</div>
);
})}
</div>
<div className="rounded-lg bg-blue-50 border border-blue-200 p-3">
<p className="text-xs text-blue-800">
</p>
</div>
</div>
);
}