Files
vexplor/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx
SeongHyun Kim 55063367ea Merge branch 'ksh-v2-work' into main
ksh-v2-work의 POP 화면 디자이너 기능을 main에 병합한다.
[병합 내용]
- pop-card-list-v2: 슬롯 기반 CSS Grid 카드 컴포넌트 (12종 셀 타입)
- pop-status-bar: 독립 상태 칩 컴포넌트 (카운트 순환 문제 해결)
- pop-scanner: 바코드/QR 스캐너 + 멀티필드 파싱
- pop-profile: 사용자 프로필/PC전환/로그아웃 컴포넌트
- pop-button: 설정 패널 UX 전면 개선 + 제어 실행 기능
- pop-search: 날짜 입력 타입 + 연결 탭 일관성 통합
- POP 모드 네비게이션: PC <-> POP 양방향 전환 + 로그인 POP 모드 토글
- 타임라인 범용화 + 상태 값 매핑 동적 배열 전환
- 다중 액션 체이닝 + 외부 테이블 선택 + 카드 클릭 모달
[충돌 해결 4건]
- authController.ts: 양쪽 통합 (스마트공장 로그 + POP 랜딩 경로)
- AppLayout.tsx: 양쪽 통합 (메뉴 드래그 + POP 모드 메뉴, 리디자인 UI + POP 모드 항목)
- ConnectionEditor.tsx: ksh-v2-work 선택 (하위 테이블 필터 구조) + CSS 변수 적용
- pop-button.tsx: ksh-v2-work 선택 (자연어 UX + 제어 실행) + CSS 변수 스타일 유지
2026-03-12 09:00:52 +09:00

526 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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": "카드 목록",
"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) => {
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-muted text-muted-foreground"
)}
>
<span className="truncate flex-1">{label}</span>
<span className="shrink-0 text-[10px] text-muted-foreground/70">
({comp.position.col},{comp.position.row})
</span>
</button>
);
})}
</div>
<div className="h-px bg-muted/80 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-muted p-3">
<p className="text-xs font-medium text-foreground 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-muted/80" />
{/* 열 크기 */}
<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-muted 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-primary/10 border border-primary/20 p-3">
<p className="text-xs text-primary">
</p>
</div>
</div>
);
}