웹타입 컴포넌트 분리작업

This commit is contained in:
kjs
2025-09-09 14:29:04 +09:00
parent 540d82e7e4
commit a17602c643
76 changed files with 16660 additions and 1735 deletions

View File

@@ -0,0 +1,40 @@
"use client";
import React from "react";
import { WebTypeComponentProps } from "@/lib/registry/types";
export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
config,
value,
onChange,
onBlur,
disabled,
readonly,
placeholder,
required,
className,
style,
}) => {
const handleClick = () => {
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
console.log("Button clicked:", config);
// onChange를 통해 클릭 이벤트 전달
if (onChange) {
onChange("clicked");
}
};
return (
<button
type="button"
onClick={handleClick}
disabled={disabled || readonly}
className={`rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `}
style={style}
title={config?.tooltip || placeholder}
>
{config?.label || config?.text || value || placeholder || "버튼"}
</button>
);
};

View File

@@ -0,0 +1,58 @@
"use client";
import React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, CheckboxTypeConfig } from "@/types/screen";
export const CheckboxWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
const { required } = widget;
const config = widget.webTypeConfig as CheckboxTypeConfig | undefined;
// 체크박스 값 처리
const isChecked = value === true || value === "true" || value === "Y" || value === 1;
const handleChange = (checked: boolean) => {
// 설정에 따라 값 형식 결정
const outputValue =
config?.outputFormat === "YN"
? checked
? "Y"
: "N"
: config?.outputFormat === "10"
? checked
? 1
: 0
: checked;
onChange?.(outputValue);
};
// 체크박스 텍스트
const checkboxText = config?.text || "체크하세요";
return (
<div className="flex h-full w-full items-center space-x-2">
<Checkbox
id={`checkbox-${widget.id}`}
checked={isChecked}
onCheckedChange={handleChange}
disabled={readonly}
required={required}
/>
<Label
htmlFor={`checkbox-${widget.id}`}
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{checkboxText}
{required && <span className="ml-1 text-red-500">*</span>}
</Label>
</div>
);
};
CheckboxWidget.displayName = "CheckboxWidget";

View File

@@ -0,0 +1,83 @@
"use client";
import React from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, CodeTypeConfig } from "@/types/screen";
export const CodeWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
const { placeholder, required, style } = widget;
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 코드 목록 가져오기
const getCodeOptions = () => {
if (config?.codeCategory) {
// 실제 구현에서는 API를 통해 코드 목록을 가져옴
// 여기서는 예시 데이터 사용
return [
{ code: "CODE001", name: "코드 1", category: config.codeCategory },
{ code: "CODE002", name: "코드 2", category: config.codeCategory },
{ code: "CODE003", name: "코드 3", category: config.codeCategory },
];
}
// 기본 코드 옵션들
return [
{ code: "DEFAULT001", name: "기본 코드 1", category: "DEFAULT" },
{ code: "DEFAULT002", name: "기본 코드 2", category: "DEFAULT" },
{ code: "DEFAULT003", name: "기본 코드 3", category: "DEFAULT" },
];
};
const codeOptions = getCodeOptions();
// 선택된 코드 정보 찾기
const selectedCode = codeOptions.find((option) => option.code === value);
return (
<div className="h-full w-full">
<Select value={value || ""} onValueChange={onChange} disabled={readonly} required={required}>
<SelectTrigger className={`h-full w-full ${hasCustomBorder ? "!border-0" : ""}`} style={style}>
<SelectValue placeholder={placeholder || config?.placeholder || "코드를 선택하세요..."} />
</SelectTrigger>
<SelectContent>
{codeOptions.map((option) => (
<SelectItem key={option.code} value={option.code}>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="text-xs">
{option.code}
</Badge>
<span>{option.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 선택된 코드 정보 표시 */}
{selectedCode && config?.showDetails && (
<div className="text-muted-foreground mt-1 text-xs">
{selectedCode.category} - {selectedCode.code}
</div>
)}
{/* 코드 카테고리 표시 */}
{config?.codeCategory && (
<div className="mt-1">
<Badge variant="secondary" className="text-xs">
{config.codeCategory}
</Badge>
</div>
)}
</div>
);
};
CodeWidget.displayName = "CodeWidget";

View File

@@ -0,0 +1,108 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
const { placeholder, required, style } = widget;
const config = widget.webTypeConfig as DateTypeConfig | undefined;
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
const borderClass = hasCustomBorder ? "!border-0" : "";
// 날짜 포맷팅 함수
const formatDateValue = (val: string) => {
if (!val) return "";
try {
const date = new Date(val);
if (isNaN(date.getTime())) return val;
if (widget.widgetType === "datetime") {
return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
} else {
return date.toISOString().slice(0, 10); // YYYY-MM-DD
}
} catch {
return val;
}
};
// 날짜 유효성 검증
const validateDate = (dateStr: string): boolean => {
if (!dateStr) return true;
const date = new Date(dateStr);
if (isNaN(date.getTime())) return false;
// 최소/최대 날짜 검증
if (config?.minDate) {
const minDate = new Date(config.minDate);
if (date < minDate) return false;
}
if (config?.maxDate) {
const maxDate = new Date(config.maxDate);
if (date > maxDate) return false;
}
return true;
};
// 입력값 처리
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
if (validateDate(inputValue)) {
onChange?.(inputValue);
}
};
// 웹타입에 따른 input type 결정
const getInputType = () => {
switch (widget.widgetType) {
case "datetime":
return "datetime-local";
case "date":
default:
return "date";
}
};
// 기본값 설정 (현재 날짜/시간)
const getDefaultValue = () => {
if (config?.defaultValue === "current") {
const now = new Date();
if (widget.widgetType === "datetime") {
return now.toISOString().slice(0, 16);
} else {
return now.toISOString().slice(0, 10);
}
}
return "";
};
const finalValue = value || getDefaultValue();
return (
<Input
type={getInputType()}
value={formatDateValue(finalValue)}
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
onChange={handleChange}
disabled={readonly}
required={required}
className={`h-full w-full ${borderClass}`}
min={config?.minDate}
max={config?.maxDate}
/>
);
};
DateWidget.displayName = "DateWidget";

View File

@@ -0,0 +1,175 @@
"use client";
import React, { useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Search, ExternalLink } from "lucide-react";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
export const EntityWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
const { placeholder, required, style } = widget;
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
const [searchTerm, setSearchTerm] = useState("");
const [isSearchMode, setIsSearchMode] = useState(false);
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 엔티티 목록 가져오기 (실제로는 API 호출)
const getEntityOptions = () => {
const entityType = config?.entityType || "default";
// 예시 데이터 - 실제로는 API에서 데이터를 가져옴
const entities = {
customer: [
{ id: "CUST001", name: "삼성전자", code: "SAMSUNG", type: "대기업" },
{ id: "CUST002", name: "LG전자", code: "LG", type: "대기업" },
{ id: "CUST003", name: "SK하이닉스", code: "SKHYNIX", type: "대기업" },
],
supplier: [
{ id: "SUPP001", name: "공급업체 A", code: "SUPPA", type: "협력사" },
{ id: "SUPP002", name: "공급업체 B", code: "SUPPB", type: "협력사" },
{ id: "SUPP003", name: "공급업체 C", code: "SUPPC", type: "협력사" },
],
employee: [
{ id: "EMP001", name: "김철수", code: "KCS", type: "정규직" },
{ id: "EMP002", name: "이영희", code: "LYH", type: "정규직" },
{ id: "EMP003", name: "박민수", code: "PMS", type: "계약직" },
],
default: [
{ id: "ENT001", name: "엔티티 1", code: "ENT1", type: "기본" },
{ id: "ENT002", name: "엔티티 2", code: "ENT2", type: "기본" },
{ id: "ENT003", name: "엔티티 3", code: "ENT3", type: "기본" },
],
};
return entities[entityType as keyof typeof entities] || entities.default;
};
const entityOptions = getEntityOptions();
// 검색 필터링
const filteredOptions = entityOptions.filter(
(option) =>
!searchTerm ||
option.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
option.code.toLowerCase().includes(searchTerm.toLowerCase()),
);
// 선택된 엔티티 정보 찾기
const selectedEntity = entityOptions.find((option) => option.id === value);
// 검색 모드 토글
const toggleSearchMode = () => {
setIsSearchMode(!isSearchMode);
setSearchTerm("");
};
// 상세 정보 보기 (팝업 등)
const handleViewDetails = () => {
if (selectedEntity) {
// 실제로는 상세 정보 팝업 또는 새 창 열기
alert(`${selectedEntity.name} 상세 정보`);
}
};
return (
<div className="h-full w-full space-y-2">
{/* 검색 모드 */}
{isSearchMode ? (
<div className="flex space-x-2">
<Input
placeholder="엔티티 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1"
/>
<Button size="sm" variant="outline" onClick={toggleSearchMode}>
</Button>
</div>
) : (
<div className="flex space-x-2">
{/* 엔티티 선택 */}
<div className="flex-1">
<Select value={value || ""} onValueChange={onChange} disabled={readonly} required={required}>
<SelectTrigger className={`w-full ${hasCustomBorder ? "!border-0" : ""}`} style={style}>
<SelectValue placeholder={placeholder || config?.placeholder || "엔티티를 선택하세요..."} />
</SelectTrigger>
<SelectContent>
{filteredOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="text-xs">
{option.code}
</Badge>
<span>{option.name}</span>
<Badge variant="secondary" className="text-xs">
{option.type}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 검색 버튼 */}
<Button size="sm" variant="outline" onClick={toggleSearchMode} disabled={readonly}>
<Search className="h-4 w-4" />
</Button>
{/* 상세보기 버튼 */}
{selectedEntity && config?.showDetails && (
<Button size="sm" variant="outline" onClick={handleViewDetails}>
<ExternalLink className="h-4 w-4" />
</Button>
)}
</div>
)}
{/* 선택된 엔티티 정보 표시 */}
{selectedEntity && (
<div className="bg-muted rounded-md p-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Badge variant="outline" className="text-xs">
{selectedEntity.code}
</Badge>
<span className="text-sm font-medium">{selectedEntity.name}</span>
<Badge variant="secondary" className="text-xs">
{selectedEntity.type}
</Badge>
</div>
{config?.allowClear && !readonly && (
<Button size="sm" variant="ghost" onClick={() => onChange?.("")} className="h-6 w-6 p-0">
×
</Button>
)}
</div>
</div>
)}
{/* 엔티티 타입 표시 */}
{config?.entityType && (
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-xs">
{config.entityType}
</Badge>
{config?.allowMultiple && <span className="text-muted-foreground text-xs"> </span>}
</div>
)}
</div>
);
};
EntityWidget.displayName = "EntityWidget";

View File

@@ -0,0 +1,211 @@
"use client";
import React, { useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Upload, File, X } from "lucide-react";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, FileTypeConfig } from "@/types/screen";
export const FileWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
const { required, style } = widget;
const config = widget.webTypeConfig as FileTypeConfig | undefined;
const fileInputRef = useRef<HTMLInputElement>(null);
// 파일 정보 파싱
const parseFileValue = (val: any) => {
if (!val) return [];
if (typeof val === "string") {
try {
return JSON.parse(val);
} catch {
return [{ name: val, size: 0, url: val }];
}
}
if (Array.isArray(val)) {
return val;
}
return [];
};
const files = parseFileValue(value);
// 파일 크기 포맷팅
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 파일 선택 처리
const handleFileSelect = () => {
if (readonly) return;
fileInputRef.current?.click();
};
// 파일 업로드 처리
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
// 파일 개수 제한 검사
if (config?.maxFiles && files.length + selectedFiles.length > config.maxFiles) {
alert(`최대 ${config.maxFiles}개 파일까지 업로드 가능합니다.`);
return;
}
// 파일 크기 검사
if (config?.maxSize) {
const oversizedFiles = selectedFiles.filter((file) => file.size > config.maxSize! * 1024 * 1024);
if (oversizedFiles.length > 0) {
alert(`파일 크기는 최대 ${config.maxSize}MB까지 가능합니다.`);
return;
}
}
// 파일 형식 검사
if (config?.accept) {
const acceptedTypes = config.accept.split(",").map((type) => type.trim());
const invalidFiles = selectedFiles.filter((file) => {
return !acceptedTypes.some((acceptType) => {
if (acceptType.startsWith(".")) {
return file.name.toLowerCase().endsWith(acceptType.toLowerCase());
}
return file.type.match(acceptType.replace("*", ".*"));
});
});
if (invalidFiles.length > 0) {
alert(`허용되지 않는 파일 형식입니다. 허용 형식: ${config.accept}`);
return;
}
}
// 새 파일 정보 생성
const newFiles = selectedFiles.map((file) => ({
name: file.name,
size: file.size,
type: file.type,
url: URL.createObjectURL(file),
file: file, // 실제 File 객체 (업로드용)
}));
const updatedFiles = config?.multiple !== false ? [...files, ...newFiles] : newFiles;
onChange?.(JSON.stringify(updatedFiles));
};
// 파일 제거
const removeFile = (index: number) => {
if (readonly) return;
const updatedFiles = files.filter((_, i) => i !== index);
onChange?.(JSON.stringify(updatedFiles));
};
// 드래그 앤 드롭 처리
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (readonly) return;
const droppedFiles = Array.from(e.dataTransfer.files);
// 파일 input change 이벤트와 같은 로직 적용
const fakeEvent = {
target: { files: droppedFiles },
} as React.ChangeEvent<HTMLInputElement>;
handleFileChange(fakeEvent);
};
return (
<div className="h-full w-full space-y-2">
{/* 파일 업로드 영역 */}
<div
className="border-muted-foreground/25 hover:border-muted-foreground/50 cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors"
onClick={handleFileSelect}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={style}
>
<Upload className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm">
{readonly ? "파일 업로드 불가" : "파일을 선택하거나 드래그하여 업로드"}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{config?.accept && `허용 형식: ${config.accept}`}
{config?.maxSize && ` (최대 ${config.maxSize}MB)`}
</p>
</div>
{/* 숨겨진 파일 input */}
<Input
ref={fileInputRef}
type="file"
className="hidden"
multiple={config?.multiple !== false}
accept={config?.accept}
onChange={handleFileChange}
/>
{/* 업로드된 파일 목록 */}
{files.length > 0 && (
<div className="max-h-32 space-y-2 overflow-y-auto">
{files.map((file, index) => (
<div key={index} className="bg-muted flex items-center justify-between rounded-md p-2">
<div className="flex min-w-0 flex-1 items-center space-x-2">
<File className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{file.name}</p>
{file.size > 0 && <p className="text-muted-foreground text-xs">{formatFileSize(file.size)}</p>}
</div>
</div>
{!readonly && (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
{/* 파일 개수 표시 */}
{files.length > 0 && (
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-xs">
{files.length}
</Badge>
{config?.maxFiles && <span className="text-muted-foreground text-xs"> {config.maxFiles}</span>}
</div>
)}
{required && files.length === 0 && <div className="text-xs text-red-500">* </div>}
</div>
);
};
FileWidget.displayName = "FileWidget";

View File

@@ -0,0 +1,101 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, NumberTypeConfig } from "@/types/screen";
export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
const { placeholder, required, style } = widget;
const config = widget.webTypeConfig as NumberTypeConfig | undefined;
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
const borderClass = hasCustomBorder ? "!border-0" : "";
// 숫자 포맷팅 함수
const formatNumber = (val: string | number) => {
if (!val) return "";
const numValue = typeof val === "string" ? parseFloat(val) : val;
if (isNaN(numValue)) return "";
if (config?.format === "currency") {
return new Intl.NumberFormat("ko-KR", {
style: "currency",
currency: "KRW",
}).format(numValue);
}
if (config?.format === "percentage") {
return `${numValue}%`;
}
if (config?.thousandSeparator) {
return new Intl.NumberFormat("ko-KR").format(numValue);
}
return numValue.toString();
};
// 입력값 처리
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let inputValue = e.target.value;
// 숫자가 아닌 문자 제거 (소수점과 마이너스 제외)
if (widget.widgetType === "number") {
inputValue = inputValue.replace(/[^0-9-]/g, "");
} else if (widget.widgetType === "decimal") {
inputValue = inputValue.replace(/[^0-9.-]/g, "");
}
// 범위 검증
if (config?.min !== undefined || config?.max !== undefined) {
const numValue = parseFloat(inputValue);
if (!isNaN(numValue)) {
if (config.min !== undefined && numValue < config.min) {
inputValue = config.min.toString();
}
if (config.max !== undefined && numValue > config.max) {
inputValue = config.max.toString();
}
}
}
onChange?.(inputValue);
};
// 웹타입에 따른 input type과 step 결정
const getInputProps = () => {
if (widget.widgetType === "decimal") {
return {
type: "number",
step: config?.step || 0.01,
};
}
return {
type: "number",
step: config?.step || 1,
};
};
const inputProps = getInputProps();
return (
<Input
{...inputProps}
value={value || ""}
placeholder={placeholder || config?.placeholder || "숫자를 입력하세요..."}
onChange={handleChange}
disabled={readonly}
required={required}
className={`h-full w-full ${borderClass}`}
min={config?.min}
max={config?.max}
/>
);
};
NumberWidget.displayName = "NumberWidget";

View File

@@ -0,0 +1,65 @@
"use client";
import React from "react";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, RadioTypeConfig } from "@/types/screen";
export const RadioWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
const { required } = widget;
const config = widget.webTypeConfig as RadioTypeConfig | undefined;
// 옵션 목록 가져오기
const getOptions = () => {
if (config?.options && Array.isArray(config.options)) {
return config.options;
}
// 기본 옵션들
return [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
];
};
const options = getOptions();
// 레이아웃 방향 결정
const isHorizontal = config?.layout === "horizontal";
return (
<div className="h-full w-full">
<RadioGroup
value={value || ""}
onValueChange={onChange}
disabled={readonly}
required={required}
className={isHorizontal ? "flex flex-row space-x-4" : "flex flex-col space-y-2"}
>
{options.map((option, index) => {
const optionValue = option.value || `option_${index}`;
return (
<div key={optionValue} className="flex items-center space-x-2">
<RadioGroupItem value={optionValue} id={`radio-${widget.id}-${optionValue}`} />
<Label
htmlFor={`radio-${widget.id}-${optionValue}`}
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{option.label}
</Label>
</div>
);
})}
</RadioGroup>
{required && <div className="mt-1 text-xs text-red-500">* </div>}
</div>
);
};
RadioWidget.displayName = "RadioWidget";

View File

@@ -0,0 +1,102 @@
"use client";
import React, { useState } from "react";
import { Star } from "lucide-react";
import { WebTypeComponentProps } from "@/lib/registry/types";
export interface RatingWidgetProps extends WebTypeComponentProps {
maxRating?: number;
allowHalf?: boolean;
size?: "sm" | "md" | "lg";
}
export const RatingWidget: React.FC<RatingWidgetProps> = ({
value = 0,
onChange,
onEvent,
disabled = false,
readonly = false,
placeholder = "별점을 선택하세요",
className = "",
webTypeConfig = {},
maxRating = 5,
allowHalf = false,
size = "md",
...props
}) => {
const [hoverRating, setHoverRating] = useState<number>(0);
const [currentRating, setCurrentRating] = useState<number>(Number(value) || 0);
// 웹타입 설정에서 값 가져오기
const finalMaxRating = webTypeConfig.maxRating || maxRating;
const finalAllowHalf = webTypeConfig.allowHalf ?? allowHalf;
const finalSize = webTypeConfig.size || size;
// 크기별 스타일
const sizeClasses = {
sm: "h-4 w-4",
md: "h-5 w-5",
lg: "h-6 w-6",
};
const handleStarClick = (rating: number) => {
if (disabled || readonly) return;
setCurrentRating(rating);
onChange?.(rating);
onEvent?.("change", { value: rating, webType: "rating" });
};
const handleStarHover = (rating: number) => {
if (disabled || readonly) return;
setHoverRating(rating);
};
const handleMouseLeave = () => {
setHoverRating(0);
};
const renderStars = () => {
const stars = [];
const displayRating = hoverRating || currentRating;
for (let i = 1; i <= finalMaxRating; i++) {
const isFilled = i <= displayRating;
const isHalfFilled = finalAllowHalf && i - 0.5 <= displayRating && i > displayRating;
stars.push(
<button
key={i}
type="button"
className={` ${sizeClasses[finalSize]} transition-colors duration-150 ${disabled || readonly ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:scale-110"} ${isFilled ? "text-yellow-400" : "text-gray-300"} `}
onClick={() => handleStarClick(i)}
onMouseEnter={() => handleStarHover(i)}
disabled={disabled || readonly}
>
<Star className={`${sizeClasses[finalSize]} ${isFilled ? "fill-current" : ""}`} />
</button>,
);
}
return stars;
};
return (
<div className={`flex items-center gap-1 ${className}`} onMouseLeave={handleMouseLeave} {...props}>
{/* 별점 표시 */}
<div className="flex items-center gap-0.5">{renderStars()}</div>
{/* 현재 점수 표시 */}
{!readonly && (
<span className="ml-2 text-sm text-gray-600">
{currentRating > 0 ? `${currentRating}/${finalMaxRating}` : placeholder}
</span>
)}
{/* 숨겨진 input (폼 제출용) */}
<input type="hidden" name={props.name} value={currentRating} />
</div>
);
};
RatingWidget.displayName = "RatingWidget";

View File

@@ -0,0 +1,50 @@
"use client";
import React from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, SelectTypeConfig } from "@/types/screen";
export const SelectWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
const { placeholder, required, style } = widget;
const config = widget.webTypeConfig as SelectTypeConfig | undefined;
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 옵션 목록 가져오기
const getOptions = () => {
if (config?.options && Array.isArray(config.options)) {
return config.options;
}
// 기본 옵션들
return [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
];
};
const options = getOptions();
return (
<Select value={value || ""} onValueChange={onChange} disabled={readonly} required={required}>
<SelectTrigger className={`h-full w-full ${hasCustomBorder ? "!border-0" : ""}`} style={style}>
<SelectValue placeholder={placeholder || config?.placeholder || "선택하세요..."} />
</SelectTrigger>
<SelectContent>
{options.map((option, index) => (
<SelectItem key={option.value || index} value={option.value || `option_${index}`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
SelectWidget.displayName = "SelectWidget";

View File

@@ -0,0 +1,110 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, TextTypeConfig } from "@/types/screen";
export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
const { placeholder, required, style } = widget;
const config = widget.webTypeConfig as TextTypeConfig | undefined;
// 입력 타입에 따른 처리
const isAutoInput = widget.inputType === "auto";
// 자동 값 생성 함수
const getAutoValue = (autoValueType: string) => {
switch (autoValueType) {
case "current_datetime":
return new Date().toLocaleString("ko-KR");
case "current_date":
return new Date().toLocaleDateString("ko-KR");
case "current_time":
return new Date().toLocaleTimeString("ko-KR");
case "current_user":
return "현재사용자";
case "uuid":
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
case "sequence":
return "SEQ_001";
case "user_defined":
return "사용자정의값";
default:
return "자동생성값";
}
};
// 자동 값 플레이스홀더 생성 함수
const getAutoPlaceholder = (autoValueType: string) => {
switch (autoValueType) {
case "current_datetime":
return "현재 날짜시간";
case "current_date":
return "현재 날짜";
case "current_time":
return "현재 시간";
case "current_user":
return "현재 사용자";
case "uuid":
return "UUID";
case "sequence":
return "시퀀스";
case "user_defined":
return "사용자 정의";
default:
return "자동 생성됨";
}
};
// 플레이스홀더 처리
const finalPlaceholder = isAutoInput
? getAutoPlaceholder(widget.autoValueType || "")
: placeholder || config?.placeholder || "입력하세요...";
// 값 처리
const finalValue = isAutoInput ? getAutoValue(widget.autoValueType || "") : value || "";
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
// 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기
const borderClass = hasCustomBorder ? "!border-0" : "";
// 웹타입에 따른 input type 결정
const getInputType = () => {
switch (widget.widgetType) {
case "email":
return "email";
case "tel":
return "tel";
case "text":
default:
return "text";
}
};
return (
<Input
type={getInputType()}
value={finalValue}
placeholder={finalPlaceholder}
onChange={(e) => onChange?.(e.target.value)}
disabled={readonly || isAutoInput}
required={required}
className={`h-full w-full ${borderClass}`}
maxLength={config?.maxLength}
minLength={config?.minLength}
pattern={config?.pattern}
autoComplete={config?.autoComplete}
/>
);
};
TextWidget.displayName = "TextWidget";

View File

@@ -0,0 +1,48 @@
"use client";
import React from "react";
import { Textarea } from "@/components/ui/textarea";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, TextareaTypeConfig } from "@/types/screen";
export const TextareaWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent;
const { placeholder, required, style } = widget;
const config = widget.webTypeConfig as TextareaTypeConfig | undefined;
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
const borderClass = hasCustomBorder ? "!border-0" : "";
// 글자 수 계산
const currentLength = (value || "").length;
const maxLength = config?.maxLength;
const showCounter = maxLength && maxLength > 0;
return (
<div className="relative h-full w-full">
<Textarea
value={value || ""}
placeholder={placeholder || config?.placeholder || "내용을 입력하세요..."}
onChange={(e) => onChange?.(e.target.value)}
disabled={readonly}
required={required}
className={`h-full w-full resize-none ${borderClass} ${showCounter ? "pb-6" : ""}`}
maxLength={maxLength}
minLength={config?.minLength}
rows={config?.rows || 3}
/>
{/* 글자 수 카운터 */}
{showCounter && (
<div className="text-muted-foreground absolute right-2 bottom-1 text-xs">
{currentLength} / {maxLength}
</div>
)}
</div>
);
};
TextareaWidget.displayName = "TextareaWidget";

View File

@@ -0,0 +1,161 @@
// 웹타입 컴포넌트들을 내보내는 인덱스 파일
import React from "react";
import { WebTypeComponentProps } from "@/lib/registry/types";
// 개별 컴포넌트 import
import { TextWidget } from "./TextWidget";
import { NumberWidget } from "./NumberWidget";
import { DateWidget } from "./DateWidget";
import { SelectWidget } from "./SelectWidget";
import { TextareaWidget } from "./TextareaWidget";
import { CheckboxWidget } from "./CheckboxWidget";
import { RadioWidget } from "./RadioWidget";
import { FileWidget } from "./FileWidget";
import { CodeWidget } from "./CodeWidget";
import { EntityWidget } from "./EntityWidget";
import { RatingWidget } from "./RatingWidget";
// 개별 컴포넌트 export
export { TextWidget } from "./TextWidget";
export { NumberWidget } from "./NumberWidget";
export { DateWidget } from "./DateWidget";
export { SelectWidget } from "./SelectWidget";
export { TextareaWidget } from "./TextareaWidget";
export { CheckboxWidget } from "./CheckboxWidget";
export { RadioWidget } from "./RadioWidget";
export { FileWidget } from "./FileWidget";
export { CodeWidget } from "./CodeWidget";
export { EntityWidget } from "./EntityWidget";
export { RatingWidget } from "./RatingWidget";
// 컴포넌트 이름으로 직접 매핑하는 함수 (DB의 component_name 필드용)
export const getWidgetComponentByName = (componentName: string): React.ComponentType<WebTypeComponentProps> => {
switch (componentName) {
case "TextWidget":
return TextWidget;
case "NumberWidget":
return NumberWidget;
case "DateWidget":
return DateWidget;
case "SelectWidget":
return SelectWidget;
case "TextareaWidget":
return TextareaWidget;
case "CheckboxWidget":
return CheckboxWidget;
case "RadioWidget":
return RadioWidget;
case "FileWidget":
return FileWidget;
case "CodeWidget":
return CodeWidget;
case "EntityWidget":
return EntityWidget;
case "RatingWidget":
return RatingWidget;
default:
console.warn(`알 수 없는 컴포넌트명: ${componentName}, TextWidget으로 폴백`);
return TextWidget;
}
};
// 기본 컴포넌트 매핑 룰 (카테고리나 타입 기반)
export const getWidgetComponentByWebType = (webType: string): React.ComponentType<WebTypeComponentProps> => {
// 기본 매핑 룰 - 웹타입에 따른 컴포넌트 결정
switch (webType.toLowerCase()) {
case "text":
case "email":
case "tel":
case "url":
case "password":
return TextWidget;
case "number":
case "decimal":
case "integer":
case "float":
return NumberWidget;
case "date":
case "datetime":
case "time":
return DateWidget;
case "select":
case "dropdown":
case "combobox":
return SelectWidget;
case "textarea":
case "text_area":
case "multiline":
return TextareaWidget;
case "checkbox":
case "boolean":
case "toggle":
return CheckboxWidget;
case "radio":
case "radiobutton":
return RadioWidget;
case "file":
case "upload":
case "attachment":
return FileWidget;
case "code":
case "script":
return CodeWidget;
case "entity":
case "reference":
case "lookup":
return EntityWidget;
case "rating":
case "star":
case "score":
return RatingWidget;
default:
// 기본적으로 텍스트 위젯 사용
return TextWidget;
}
};
// 동적 웹타입 컴포넌트 맵 생성 함수
export const createWebTypeComponents = (
webTypes: string[],
): Record<string, React.ComponentType<WebTypeComponentProps>> => {
const components: Record<string, React.ComponentType<WebTypeComponentProps>> = {};
webTypes.forEach((webType) => {
components[webType] = getWidgetComponentByWebType(webType);
});
return components;
};
// 기존 하드코딩된 맵 (호환성 유지)
export const WebTypeComponents: Record<string, React.ComponentType<WebTypeComponentProps>> = {
text: TextWidget,
email: TextWidget,
tel: TextWidget,
number: NumberWidget,
decimal: NumberWidget,
date: DateWidget,
datetime: DateWidget,
select: SelectWidget,
dropdown: SelectWidget,
textarea: TextareaWidget,
text_area: TextareaWidget,
boolean: CheckboxWidget,
checkbox: CheckboxWidget,
radio: RadioWidget,
file: FileWidget,
code: CodeWidget,
entity: EntityWidget,
rating: RatingWidget,
};