웹타입 컴포넌트 분리작업
This commit is contained in:
40
frontend/components/screen/widgets/types/ButtonWidget.tsx
Normal file
40
frontend/components/screen/widgets/types/ButtonWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
58
frontend/components/screen/widgets/types/CheckboxWidget.tsx
Normal file
58
frontend/components/screen/widgets/types/CheckboxWidget.tsx
Normal 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";
|
||||
|
||||
|
||||
83
frontend/components/screen/widgets/types/CodeWidget.tsx
Normal file
83
frontend/components/screen/widgets/types/CodeWidget.tsx
Normal 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";
|
||||
|
||||
|
||||
108
frontend/components/screen/widgets/types/DateWidget.tsx
Normal file
108
frontend/components/screen/widgets/types/DateWidget.tsx
Normal 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";
|
||||
|
||||
|
||||
175
frontend/components/screen/widgets/types/EntityWidget.tsx
Normal file
175
frontend/components/screen/widgets/types/EntityWidget.tsx
Normal 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";
|
||||
|
||||
|
||||
211
frontend/components/screen/widgets/types/FileWidget.tsx
Normal file
211
frontend/components/screen/widgets/types/FileWidget.tsx
Normal 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";
|
||||
|
||||
|
||||
101
frontend/components/screen/widgets/types/NumberWidget.tsx
Normal file
101
frontend/components/screen/widgets/types/NumberWidget.tsx
Normal 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";
|
||||
|
||||
|
||||
65
frontend/components/screen/widgets/types/RadioWidget.tsx
Normal file
65
frontend/components/screen/widgets/types/RadioWidget.tsx
Normal 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";
|
||||
|
||||
|
||||
102
frontend/components/screen/widgets/types/RatingWidget.tsx
Normal file
102
frontend/components/screen/widgets/types/RatingWidget.tsx
Normal 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";
|
||||
50
frontend/components/screen/widgets/types/SelectWidget.tsx
Normal file
50
frontend/components/screen/widgets/types/SelectWidget.tsx
Normal 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";
|
||||
|
||||
|
||||
110
frontend/components/screen/widgets/types/TextWidget.tsx
Normal file
110
frontend/components/screen/widgets/types/TextWidget.tsx
Normal 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";
|
||||
|
||||
|
||||
48
frontend/components/screen/widgets/types/TextareaWidget.tsx
Normal file
48
frontend/components/screen/widgets/types/TextareaWidget.tsx
Normal 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";
|
||||
|
||||
|
||||
161
frontend/components/screen/widgets/types/index.ts
Normal file
161
frontend/components/screen/widgets/types/index.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user