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

722 lines
22 KiB
TypeScript
Raw Normal View History

"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
PopComponentDefinitionV4,
PopSizeConstraintV4,
PopContainerV4,
PopComponentType,
} from "../types/pop-layout";
import {
Settings,
Database,
Link2,
MoveHorizontal,
MoveVertical,
Square,
Maximize2,
AlignCenter,
Eye,
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// ========================================
// Props 정의
// ========================================
interface ComponentEditorPanelV4Props {
/** 선택된 컴포넌트 */
component: PopComponentDefinitionV4 | null;
/** 선택된 컨테이너 */
container: PopContainerV4 | null;
/** 현재 뷰포트 모드 */
currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
/** 컴포넌트 업데이트 */
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV4>) => void;
/** 컨테이너 업데이트 */
onUpdateContainer?: (updates: Partial<PopContainerV4>) => void;
/** 추가 className */
className?: string;
}
// ========================================
// 컴포넌트 타입별 라벨
// ========================================
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-field": "필드",
"pop-button": "버튼",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",
"pop-numpad": "숫자패드",
"pop-spacer": "스페이서",
"pop-break": "줄바꿈",
};
// ========================================
// v4 컴포넌트 편집 패널
//
// 핵심:
// - 크기 제약 편집 (fixed/fill/hug)
// - 반응형 숨김 설정
// - 개별 정렬 설정
// ========================================
export function ComponentEditorPanelV4({
component,
container,
currentViewportMode = "tablet_landscape",
onUpdateComponent,
onUpdateContainer,
className,
}: ComponentEditorPanelV4Props) {
// 아무것도 선택되지 않은 경우
if (!component && !container) {
return (
<div className={cn("flex h-full flex-col", 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>
);
}
// 컨테이너가 선택된 경우
if (container) {
const isNonDefaultMode = currentViewportMode !== "tablet_landscape";
return (
<div className={cn("flex h-full flex-col", className)}>
<div className="border-b px-4 py-3">
<h3 className="text-sm font-medium"> </h3>
<p className="text-xs text-muted-foreground">{container.id}</p>
{isNonDefaultMode && (
<p className="text-xs text-amber-600 mt-1">
'고정'
</p>
)}
</div>
<div className="flex-1 overflow-auto p-4">
<ContainerSettingsForm
container={container}
currentViewportMode={currentViewportMode}
onUpdate={onUpdateContainer}
/>
</div>
</div>
);
}
// 컴포넌트가 선택된 경우
return (
<div className={cn("flex h-full flex-col", 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>
</div>
{/* 탭 컨텐츠 */}
<Tabs defaultValue="size" className="flex-1">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
<TabsTrigger value="size" className="gap-1 text-xs">
<Maximize2 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="data" className="gap-1 text-xs">
<Database className="h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 크기 제약 탭 */}
<TabsContent value="size" className="flex-1 overflow-auto p-4">
<SizeConstraintForm
component={component!}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 기본 설정 탭 */}
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
<ComponentSettingsForm
component={component!}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 모드별 표시 탭 */}
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
<VisibilityForm
component={component!}
onUpdate={onUpdateComponent}
/>
</TabsContent>
{/* 데이터 바인딩 탭 */}
<TabsContent value="data" className="flex-1 overflow-auto p-4">
<DataBindingPlaceholder />
</TabsContent>
</Tabs>
</div>
);
}
// ========================================
// 크기 제약 폼
// ========================================
interface SizeConstraintFormProps {
component: PopComponentDefinitionV4;
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
}
function SizeConstraintForm({ component, onUpdate }: SizeConstraintFormProps) {
const { size } = component;
const handleSizeChange = (
field: keyof PopSizeConstraintV4,
value: string | number | undefined
) => {
onUpdate?.({
size: {
...size,
[field]: value,
},
});
};
return (
<div className="space-y-6">
{/* 너비 설정 */}
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveHorizontal className="h-3 w-3" />
</Label>
<div className="flex gap-1">
<SizeButton
active={size.width === "fixed"}
onClick={() => handleSizeChange("width", "fixed")}
label="고정"
description="px"
/>
<SizeButton
active={size.width === "fill"}
onClick={() => handleSizeChange("width", "fill")}
label="채움"
description="flex"
/>
<SizeButton
active={size.width === "hug"}
onClick={() => handleSizeChange("width", "hug")}
label="맞춤"
description="auto"
/>
</div>
{/* 고정 너비 입력 */}
{size.width === "fixed" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={size.fixedWidth || ""}
onChange={(e) =>
handleSizeChange(
"fixedWidth",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="너비"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
{/* 채움일 때 최소/최대 */}
{size.width === "fill" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-20 text-xs"
value={size.minWidth || ""}
onChange={(e) =>
handleSizeChange(
"minWidth",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="최소"
/>
<span className="text-xs text-muted-foreground">~</span>
<Input
type="number"
className="h-8 w-20 text-xs"
value={size.maxWidth || ""}
onChange={(e) =>
handleSizeChange(
"maxWidth",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="최대"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
</div>
{/* 높이 설정 */}
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<MoveVertical className="h-3 w-3" />
</Label>
<div className="flex gap-1">
<SizeButton
active={size.height === "fixed"}
onClick={() => handleSizeChange("height", "fixed")}
label="고정"
description="px"
/>
<SizeButton
active={size.height === "fill"}
onClick={() => handleSizeChange("height", "fill")}
label="채움"
description="flex"
/>
<SizeButton
active={size.height === "hug"}
onClick={() => handleSizeChange("height", "hug")}
label="맞춤"
description="auto"
/>
</div>
{/* 고정 높이 입력 */}
{size.height === "fixed" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={size.fixedHeight || ""}
onChange={(e) =>
handleSizeChange(
"fixedHeight",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="높이"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
{/* 채움일 때 최소 */}
{size.height === "fill" && (
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={size.minHeight || ""}
onChange={(e) =>
handleSizeChange(
"minHeight",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="최소 높이"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
)}
</div>
{/* 개별 정렬 */}
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<AlignCenter className="h-3 w-3" />
</Label>
<Select
value={component.alignSelf || "none"}
onValueChange={(value) =>
onUpdate?.({
alignSelf: value === "none" ? undefined : (value as any),
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컨테이너 설정 따름" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="end"></SelectItem>
<SelectItem value="stretch"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
// ========================================
// 크기 버튼 컴포넌트
// ========================================
interface SizeButtonProps {
active: boolean;
onClick: () => void;
label: string;
description: string;
}
function SizeButton({ active, onClick, label, description }: SizeButtonProps) {
return (
<button
className={cn(
"flex-1 flex flex-col items-center gap-0.5 rounded-md border p-2 text-xs transition-colors",
active
? "border-primary bg-primary/10 text-primary"
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
)}
onClick={onClick}
>
<span className="font-medium">{label}</span>
<span className="text-[10px] text-muted-foreground">{description}</span>
</button>
);
}
// ========================================
// 컨테이너 설정 폼
// ========================================
interface ContainerSettingsFormProps {
container: PopContainerV4;
currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
onUpdate?: (updates: Partial<PopContainerV4>) => void;
}
function ContainerSettingsForm({
container,
currentViewportMode = "tablet_landscape",
onUpdate,
}: ContainerSettingsFormProps) {
const isNonDefaultMode = currentViewportMode !== "tablet_landscape";
return (
<div className="space-y-6">
{/* 방향 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex gap-1">
<Button
variant={container.direction === "horizontal" ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ direction: "horizontal" })}
disabled={isNonDefaultMode}
>
</Button>
<Button
variant={container.direction === "vertical" ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ direction: "vertical" })}
disabled={isNonDefaultMode}
>
</Button>
</div>
{isNonDefaultMode && (
<p className="text-[10px] text-amber-600">
'고정'
</p>
)}
</div>
{/* 줄바꿈 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex gap-1">
<Button
variant={container.wrap ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ wrap: true })}
disabled={isNonDefaultMode}
>
</Button>
<Button
variant={!container.wrap ? "default" : "outline"}
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => onUpdate?.({ wrap: false })}
disabled={isNonDefaultMode}
>
</Button>
</div>
</div>
{/* 간격 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> (gap)</Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={container.gap}
onChange={(e) => onUpdate?.({ gap: Number(e.target.value) || 0 })}
disabled={isNonDefaultMode}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
</div>
{/* 패딩 */}
<div className="space-y-3">
<Label className="text-xs font-medium"></Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={container.padding || 0}
onChange={(e) =>
onUpdate?.({ padding: Number(e.target.value) || undefined })
}
disabled={isNonDefaultMode}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
</div>
{/* 정렬 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<Select
value={container.alignItems}
onValueChange={(value) => onUpdate?.({ alignItems: value as any })}
disabled={isNonDefaultMode}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="end"></SelectItem>
<SelectItem value="stretch"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<Select
value={container.justifyContent}
onValueChange={(value) => onUpdate?.({ justifyContent: value as any })}
disabled={isNonDefaultMode}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="end"></SelectItem>
<SelectItem value="space-between"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
// ========================================
// 컴포넌트 설정 폼
// ========================================
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV4;
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
}
function ComponentSettingsForm({
component,
onUpdate,
}: ComponentSettingsFormProps) {
return (
<div className="space-y-4">
{/* 라벨 입력 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"></Label>
<Input
type="text"
className="h-8 text-xs"
value={component.label || ""}
onChange={(e) => onUpdate?.({ label: e.target.value })}
placeholder="컴포넌트 라벨"
/>
</div>
{/* 타입별 설정 (TODO: 상세 구현) */}
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<p className="text-center text-xs text-muted-foreground">
{COMPONENT_TYPE_LABELS[component.type]}
</p>
<p className="mt-1 text-center text-xs text-muted-foreground">
( )
</p>
</div>
</div>
);
}
// ========================================
// 모드별 표시/숨김 폼
// ========================================
interface VisibilityFormProps {
component: PopComponentDefinitionV4;
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
}
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes = [
{ key: "tablet_landscape" as const, label: "태블릿 가로 (1024×768)" },
{ key: "tablet_portrait" as const, label: "태블릿 세로 (768×1024)" },
{ key: "mobile_landscape" as const, label: "모바일 가로 (667×375)" },
{ key: "mobile_portrait" as const, label: "모바일 세로 (375×667)" },
];
return (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-xs font-medium flex items-center gap-1">
<Eye className="h-3 w-3" />
</Label>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-2 rounded-lg border p-3">
{modes.map(({ key, label }) => {
const isChecked = component.visibility?.[key] !== false;
return (
<div key={key} className="flex items-center gap-2">
<input
type="checkbox"
id={`visibility-${key}`}
checked={isChecked}
onChange={(e) => {
onUpdate?.({
visibility: {
...component.visibility,
[key]: e.target.checked,
},
});
}}
className="h-4 w-4 rounded border-gray-300"
/>
<label
htmlFor={`visibility-${key}`}
className="text-xs cursor-pointer flex-1"
>
{label}
</label>
{!isChecked && (
<span className="text-xs text-muted-foreground">()</span>
)}
</div>
);
})}
</div>
</div>
{/* 반응형 숨김 (픽셀 기반) */}
<div className="space-y-3">
<Label className="text-xs font-medium"> ( )</Label>
<div className="flex items-center gap-2">
<Input
type="number"
className="h-8 w-24 text-xs"
value={component.hideBelow || ""}
onChange={(e) =>
onUpdate?.({
hideBelow: e.target.value ? Number(e.target.value) : undefined,
})
}
placeholder="없음"
/>
<span className="text-xs text-muted-foreground">px </span>
</div>
<p className="text-xs text-muted-foreground">
: 500 500px
</p>
</div>
</div>
);
}
// ========================================
// 데이터 바인딩 플레이스홀더
// ========================================
function DataBindingPlaceholder() {
return (
<div className="space-y-4">
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
<div className="flex flex-col items-center gap-2">
<Database className="h-8 w-8 text-gray-400" />
<p className="text-center text-xs text-muted-foreground">
</p>
<p className="text-center text-xs text-muted-foreground">
- -
</p>
<p className="mt-2 text-center text-xs text-gray-400">
( )
</p>
</div>
</div>
</div>
);
}
export default ComponentEditorPanelV4;