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

722 lines
22 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 {
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;