dev #46

Merged
kjs merged 344 commits from dev into main 2025-09-22 18:17:24 +09:00
7 changed files with 935 additions and 510 deletions
Showing only changes of commit 089249fb65 - Show all commits

View File

@ -0,0 +1,257 @@
"use client";
import React from "react";
import { ComponentData, WebType } from "@/types/screen";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
// import { Checkbox } from "@/components/ui/checkbox";
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Database, Type, Hash, List, AlignLeft, CheckSquare, Radio, Calendar, Code, Building, File } from "lucide-react";
interface RealtimePreviewProps {
component: ComponentData;
isSelected?: boolean;
onClick?: () => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
}
// 웹 타입에 따른 위젯 렌더링
const renderWidget = (component: ComponentData) => {
const { widgetType, label, placeholder, required, readonly, columnName } = component;
// 디버깅: 실제 widgetType 값 확인
console.log('RealtimePreview - widgetType:', widgetType, 'columnName:', columnName);
const commonProps = {
placeholder: placeholder || `입력하세요...`,
disabled: readonly,
required: required,
className: "w-full",
};
switch (widgetType) {
case "text":
case "email":
case "tel":
return <Input type={widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text"} {...commonProps} />;
case "number":
case "decimal":
return <Input type="number" step={widgetType === "decimal" ? "0.01" : "1"} {...commonProps} />;
case "date":
case "datetime":
return (
<Input
type={widgetType === "datetime" ? "datetime-local" : "date"}
{...commonProps}
/>
);
case "select":
case "dropdown":
return (
<select
disabled={readonly}
required={required}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<option value="">{placeholder || "선택하세요..."}</option>
<option value="option1"> 1</option>
<option value="option2"> 2</option>
<option value="option3"> 3</option>
</select>
);
case "textarea":
case "text_area":
return <Textarea {...commonProps} rows={3} />;
case "boolean":
case "checkbox":
return (
<div className="flex items-center space-x-2">
<input
type="checkbox"
id={`checkbox-${component.id}`}
disabled={readonly}
required={required}
className="h-4 w-4"
/>
<Label htmlFor={`checkbox-${component.id}`} className="text-sm">
{label || columnName}
</Label>
</div>
);
case "radio":
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input
type="radio"
id={`radio1-${component.id}`}
name={`radio-${component.id}`}
disabled={readonly}
className="h-4 w-4"
/>
<Label htmlFor={`radio1-${component.id}`} className="text-sm">
1
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="radio"
id={`radio2-${component.id}`}
name={`radio-${component.id}`}
disabled={readonly}
className="h-4 w-4"
/>
<Label htmlFor={`radio2-${component.id}`} className="text-sm">
2
</Label>
</div>
</div>
);
case "code":
return (
<Textarea
{...commonProps}
rows={4}
className="w-full font-mono text-sm"
placeholder="코드를 입력하세요..."
/>
);
case "entity":
return (
<select
disabled={readonly}
required={required}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<option value=""> ...</option>
<option value="user"></option>
<option value="product"></option>
<option value="order"></option>
</select>
);
case "file":
return (
<input
type="file"
disabled={readonly}
required={required}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
);
default:
return <Input type="text" {...commonProps} />;
}
};
// 위젯 타입 아이콘
const getWidgetIcon = (widgetType: WebType | undefined) => {
switch (widgetType) {
case "text":
case "email":
case "tel":
return <Type className="h-4 w-4 text-blue-600" />;
case "number":
case "decimal":
return <Hash className="h-4 w-4 text-green-600" />;
case "date":
case "datetime":
return <Calendar className="h-4 w-4 text-purple-600" />;
case "select":
case "dropdown":
return <List className="h-4 w-4 text-orange-600" />;
case "textarea":
case "text_area":
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
case "boolean":
case "checkbox":
return <CheckSquare className="h-4 w-4 text-blue-600" />;
case "radio":
return <Radio className="h-4 w-4 text-blue-600" />;
case "code":
return <Code className="h-4 w-4 text-gray-600" />;
case "entity":
return <Building className="h-4 w-4 text-cyan-600" />;
case "file":
return <File className="h-4 w-4 text-yellow-600" />;
default:
return <Type className="h-4 w-4 text-gray-500" />;
}
};
export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
component,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
}) => {
const { type, label, tableName, columnName, widgetType, size, style } = component;
return (
<div
className={`absolute rounded border-2 transition-all ${
isSelected ? "border-blue-500 bg-blue-50 shadow-lg" : "border-gray-300 bg-white hover:border-gray-400"
}`}
style={{
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${size.width * 80 - 16}px`,
height: `${size.height}px`,
...style,
}}
onClick={onClick}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{type === "container" && (
<div className="flex h-full flex-col items-center justify-center p-4">
<div className="flex flex-col items-center space-y-2">
<Database className="h-8 w-8 text-blue-600" />
<div className="text-center">
<div className="text-sm font-medium">{label}</div>
<div className="text-xs text-gray-500">{tableName}</div>
</div>
</div>
</div>
)}
{type === "widget" && (
<div className="flex h-full flex-col p-3">
{/* 위젯 헤더 */}
<div className="mb-2 flex items-center space-x-2">
{getWidgetIcon(widgetType)}
<div className="flex-1">
<Label className="text-sm font-medium">
{label || columnName}
{component.required && <span className="ml-1 text-red-500">*</span>}
</Label>
</div>
</div>
{/* 위젯 본체 */}
<div className="flex-1">{renderWidget(component)}</div>
{/* 위젯 정보 */}
<div className="mt-2 text-xs text-gray-500">
{columnName} ({widgetType})
</div>
</div>
)}
</div>
);
};
export default RealtimePreview;

View File

@ -25,9 +25,12 @@ import {
Table,
Settings,
ChevronDown,
ChevronRight,
Code,
Building,
File,
List,
AlignLeft,
ChevronRight,
} from "lucide-react";
import {
ScreenDefinition,
@ -53,6 +56,7 @@ import StyleEditor from "./StyleEditor";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { RealtimePreview } from "./RealtimePreview";
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
@ -226,6 +230,30 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
dataType: "BOOLEAN",
isNullable: "NO",
},
{
tableName: "user_info",
columnName: "profile_code",
columnLabel: "프로필 코드",
webType: "code",
dataType: "TEXT",
isNullable: "YES",
},
{
tableName: "user_info",
columnName: "department",
columnLabel: "부서",
webType: "entity",
dataType: "VARCHAR",
isNullable: "YES",
},
{
tableName: "user_info",
columnName: "profile_image",
columnLabel: "프로필 이미지",
webType: "file",
dataType: "VARCHAR",
isNullable: "YES",
},
],
},
{
@ -353,29 +381,44 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 웹타입에 따른 위젯 타입 매핑
const getWidgetTypeFromWebType = useCallback((webType: string): string => {
console.log("getWidgetTypeFromWebType - input webType:", webType);
switch (webType) {
case "text":
case "email":
case "tel":
return "text";
case "email":
return "email";
case "tel":
return "tel";
case "number":
case "decimal":
return "number";
case "decimal":
return "decimal";
case "date":
case "datetime":
return "date";
case "datetime":
return "datetime";
case "select":
case "dropdown":
return "select";
case "dropdown":
return "dropdown";
case "textarea":
case "text_area":
return "textarea";
case "text_area":
return "text_area";
case "checkbox":
case "boolean":
return "checkbox";
case "boolean":
return "boolean";
case "radio":
return "radio";
case "code":
return "code";
case "entity":
return "entity";
case "file":
return "file";
default:
console.log("getWidgetTypeFromWebType - default case, returning text for:", webType);
return "text";
}
}, []);
@ -682,31 +725,41 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
key={column.columnName}
className="flex cursor-pointer items-center space-x-2 p-2 pl-6 hover:bg-gray-100"
draggable
onDragStart={(e) =>
onDragStart={(e) => {
console.log("Drag start - column:", column.columnName, "webType:", column.webType);
const widgetType = getWidgetTypeFromWebType(column.webType || "text");
console.log("Drag start - widgetType:", widgetType);
startDrag(
{
type: "widget",
tableName: table.tableName,
columnName: column.columnName,
widgetType: getWidgetTypeFromWebType(column.webType || "text"),
widgetType: widgetType as WebType,
label: column.columnLabel || column.columnName,
size: { width: 6, height: 60 },
},
e,
)
}
);
}}
>
<div className="flex-shrink-0">
{column.webType === "text" && <Type className="h-3 w-3 text-blue-600" />}
{column.webType === "email" && <Type className="h-3 w-3 text-blue-600" />}
{column.webType === "tel" && <Type className="h-3 w-3 text-blue-600" />}
{column.webType === "number" && <Hash className="h-3 w-3 text-green-600" />}
{column.webType === "decimal" && <Hash className="h-3 w-3 text-green-600" />}
{column.webType === "date" && <Calendar className="h-3 w-3 text-purple-600" />}
{column.webType === "datetime" && <Calendar className="h-3 w-3 text-purple-600" />}
{column.webType === "select" && <List className="h-3 w-3 text-orange-600" />}
{column.webType === "dropdown" && <List className="h-3 w-3 text-orange-600" />}
{column.webType === "textarea" && <AlignLeft className="h-3 w-3 text-indigo-600" />}
{column.webType === "text_area" && <AlignLeft className="h-3 w-3 text-indigo-600" />}
{column.webType === "checkbox" && <CheckSquare className="h-3 w-3 text-blue-600" />}
{column.webType === "boolean" && <CheckSquare className="h-3 w-3 text-blue-600" />}
{column.webType === "radio" && <Radio className="h-3 w-3 text-blue-600" />}
{!["text", "number", "date", "select", "textarea", "checkbox", "radio"].includes(
column.webType,
) && <Type className="h-3 w-3 text-blue-600" />}
{column.webType === "code" && <Code className="h-3 w-3 text-gray-600" />}
{column.webType === "entity" && <Building className="h-3 w-3 text-cyan-600" />}
{column.webType === "file" && <File className="h-3 w-3 text-yellow-600" />}
</div>
<div className="flex-1">
<div className="text-sm font-medium">{column.columnLabel || column.columnName}</div>
@ -777,56 +830,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
</div>
</div>
{/* 컴포넌트들 */}
{/* 컴포넌트들 - 실시간 미리보기 */}
{layout.components.map((component) => (
<div
<RealtimePreview
key={component.id}
className={`absolute cursor-move rounded border-2 ${
selectedComponent?.id === component.id
? "border-blue-500 bg-blue-50"
: "border-gray-300 bg-white hover:border-gray-400"
}`}
style={{
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${component.size.width * 80 - 16}px`,
height: `${component.size.height}px`,
}}
component={component}
isSelected={selectedComponent?.id === component.id}
onClick={() => handleComponentClick(component)}
draggable
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
>
<div className="flex h-full items-center justify-center p-2">
{component.type === "container" && (
<div className="flex flex-col items-center space-y-1">
<Database className="h-6 w-6 text-blue-600" />
<div className="text-center">
<div className="text-sm font-medium">{component.label}</div>
<div className="text-xs text-gray-500">{component.tableName}</div>
</div>
</div>
)}
{component.type === "widget" && (
<div className="flex flex-col items-center space-y-1">
{component.widgetType === "text" && <Type className="h-6 w-6 text-blue-600" />}
{component.widgetType === "number" && <Hash className="h-6 w-6 text-green-600" />}
{component.widgetType === "date" && <Calendar className="h-6 w-6 text-purple-600" />}
{component.widgetType === "select" && <List className="h-6 w-6 text-orange-600" />}
{component.widgetType === "textarea" && <AlignLeft className="h-6 w-6 text-indigo-600" />}
{component.widgetType === "checkbox" && <CheckSquare className="h-6 w-6 text-blue-600" />}
{component.widgetType === "radio" && <Radio className="h-6 w-6 text-blue-600" />}
{!["text", "number", "date", "select", "textarea", "checkbox", "radio"].includes(
component.widgetType || "text",
) && <Type className="h-6 w-6 text-blue-600" />}
<div className="text-center">
<div className="text-sm font-medium">{component.label}</div>
<div className="text-xs text-gray-500">{component.columnName}</div>
</div>
</div>
)}
</div>
</div>
/>
))}
</div>
)}

View File

@ -0,0 +1,56 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -0,0 +1,38 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"border-primary text-primary ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

File diff suppressed because it is too large Load Diff

View File

@ -16,10 +16,13 @@
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
@ -32,6 +35,7 @@
"lucide-react": "^0.525.0",
"next": "15.4.4",
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.61.1",
"sonner": "^2.0.7",