리사이징, 체크박스,엔터치면 다음 칸으로 이동, 표수정, 컬럼에서 이미지 넣는거 등등
This commit is contained in:
@@ -782,17 +782,17 @@ export function FlowWidget({
|
||||
>
|
||||
{/* 콘텐츠 */}
|
||||
<div className="relative flex flex-col items-center justify-center gap-2 pb-5 sm:gap-2.5 sm:pb-6">
|
||||
{/* 스텝 이름 */}
|
||||
{/* 스텝 이름 */}
|
||||
<h4
|
||||
className={`text-base font-semibold leading-tight transition-colors duration-300 sm:text-lg lg:text-xl ${
|
||||
selectedStepId === step.id ? "text-primary" : "text-foreground group-hover:text-primary/80"
|
||||
}`}
|
||||
>
|
||||
{step.stepName}
|
||||
</h4>
|
||||
{step.stepName}
|
||||
</h4>
|
||||
|
||||
{/* 데이터 건수 */}
|
||||
{showStepCount && (
|
||||
{/* 데이터 건수 */}
|
||||
{showStepCount && (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 transition-all duration-300 ${
|
||||
selectedStepId === step.id
|
||||
@@ -804,8 +804,8 @@ export function FlowWidget({
|
||||
{(stepCounts[step.id] || 0).toLocaleString("ko-KR")}
|
||||
</span>
|
||||
<span className="text-xs font-normal sm:text-sm">건</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 선 */}
|
||||
@@ -824,14 +824,14 @@ export function FlowWidget({
|
||||
{displayMode === "horizontal" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-0.5 w-6 bg-border sm:w-8" />
|
||||
<svg
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground sm:h-5 sm:w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div className="h-0.5 w-6 bg-border sm:w-8" />
|
||||
</div>
|
||||
) : (
|
||||
@@ -843,8 +843,8 @@ export function FlowWidget({
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<div className="h-6 w-0.5 bg-border sm:h-8" />
|
||||
</div>
|
||||
)}
|
||||
@@ -910,7 +910,7 @@ export function FlowWidget({
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
@@ -928,11 +928,11 @@ export function FlowWidget({
|
||||
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
|
||||
{groupByColumns.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 그룹 표시 배지 */}
|
||||
{groupByColumns.length > 0 && (
|
||||
@@ -1004,24 +1004,24 @@ export function FlowWidget({
|
||||
selectedRows.has(actualIndex) ? "bg-primary/5 border-primary/30" : ""
|
||||
}`}
|
||||
>
|
||||
{allowDataMove && (
|
||||
<div className="mb-2 flex items-center justify-between border-b pb-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">선택</span>
|
||||
{allowDataMove && (
|
||||
<div className="mb-2 flex items-center justify-between border-b pb-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">선택</span>
|
||||
<Checkbox
|
||||
checked={selectedRows.has(actualIndex)}
|
||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
{stepDataColumns.map((col) => (
|
||||
{stepDataColumns.map((col) => (
|
||||
<div key={col} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground font-medium">{columnLabels[col] || col}:</span>
|
||||
<span className="text-foreground truncate">{formatValue(row[col])}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1081,19 +1081,19 @@ export function FlowWidget({
|
||||
const dataRows = group.items.map((row, itemIndex) => {
|
||||
const actualIndex = displayData.indexOf(row);
|
||||
return (
|
||||
<TableRow
|
||||
<TableRow
|
||||
key={`${group.groupKey}-${itemIndex}`}
|
||||
className={`h-16 transition-colors hover:bg-muted/50 ${selectedRows.has(actualIndex) ? "bg-primary/5" : ""}`}
|
||||
>
|
||||
{allowDataMove && (
|
||||
>
|
||||
{allowDataMove && (
|
||||
<TableCell className="bg-background sticky left-0 z-10 border-b px-6 py-3 text-center">
|
||||
<Checkbox
|
||||
<Checkbox
|
||||
checked={selectedRows.has(actualIndex)}
|
||||
onCheckedChange={() => toggleRowSelection(actualIndex)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||
{formatValue(row[col])}
|
||||
</TableCell>
|
||||
@@ -1126,9 +1126,9 @@ export function FlowWidget({
|
||||
{stepDataColumns.map((col) => (
|
||||
<TableCell key={col} className="h-16 border-b px-6 py-3 text-sm whitespace-nowrap">
|
||||
{formatValue(row[col])}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -1146,7 +1146,7 @@ export function FlowWidget({
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
|
||||
<div className="text-muted-foreground text-xs sm:text-sm">
|
||||
페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length.toLocaleString("ko-KR")}건)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">표시 개수:</span>
|
||||
<Select
|
||||
|
||||
199
frontend/components/screen/widgets/types/ImageWidget.tsx
Normal file
199
frontend/components/screen/widgets/types/ImageWidget.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X } from "lucide-react";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent } from "@/types/screen";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient, getFullImageUrl } from "@/lib/api/client";
|
||||
|
||||
export const ImageWidget: React.FC<WebTypeComponentProps> = ({
|
||||
component,
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
isDesignMode = false // 디자인 모드 여부
|
||||
}) => {
|
||||
const widget = component as WidgetComponent;
|
||||
const { required, style } = widget;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// 이미지 URL 처리 (백엔드 서버 경로로 변환)
|
||||
const rawImageUrl = value || widget.value || "";
|
||||
const imageUrl = rawImageUrl ? getFullImageUrl(rawImageUrl) : "";
|
||||
|
||||
// style에서 width, height 제거 (부모 컨테이너 크기 사용)
|
||||
const filteredStyle = style ? { ...style, width: undefined, height: undefined } : {};
|
||||
|
||||
// 파일 선택 처리
|
||||
const handleFileSelect = () => {
|
||||
// 디자인 모드에서는 업로드 불가
|
||||
if (readonly || isDesignMode) return;
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// 파일 업로드 처리
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 이미지 파일 검증
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("이미지 파일만 업로드 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 크기 검증 (5MB)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
toast.error("파일 크기는 최대 5MB까지 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// FormData 생성
|
||||
const formData = new FormData();
|
||||
formData.append("files", file);
|
||||
formData.append("docType", "IMAGE");
|
||||
formData.append("docTypeName", "이미지");
|
||||
|
||||
// 서버에 업로드 (axios 사용 - 인증 토큰 자동 포함)
|
||||
const response = await apiClient.post("/files/upload", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.files && response.data.files.length > 0) {
|
||||
const uploadedFile = response.data.files[0];
|
||||
const imageUrl = uploadedFile.filePath; // /uploads/company_*/2024/01/01/filename.jpg
|
||||
onChange?.(imageUrl);
|
||||
toast.success("이미지가 업로드되었습니다.");
|
||||
} else {
|
||||
throw new Error(response.data.message || "업로드 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("이미지 업로드 오류:", error);
|
||||
const errorMessage = error.response?.data?.message || error.message || "이미지 업로드에 실패했습니다.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 이미지 제거
|
||||
const handleRemove = () => {
|
||||
// 디자인 모드에서는 제거 불가
|
||||
if (readonly || isDesignMode) return;
|
||||
onChange?.("");
|
||||
toast.success("이미지가 제거되었습니다.");
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 디자인 모드에서는 드롭 불가
|
||||
if (readonly || isDesignMode) return;
|
||||
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 input에 파일 설정
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.files = dataTransfer.files;
|
||||
handleFileChange({ target: fileInputRef.current } as any);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
{imageUrl ? (
|
||||
// 이미지 표시 모드
|
||||
<div
|
||||
className="group relative h-full w-full overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm transition-all hover:shadow-md"
|
||||
style={filteredStyle}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="업로드된 이미지"
|
||||
className="h-full w-full object-contain"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%239ca3af'%3E이미지 로드 실패%3C/text%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 호버 시 제거 버튼 */}
|
||||
{!readonly && !isDesignMode && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleRemove}
|
||||
className="gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
이미지 제거
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 업로드 영역
|
||||
<div
|
||||
className={`group relative flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${
|
||||
isDesignMode
|
||||
? "cursor-default border-gray-200 bg-gray-50"
|
||||
: "cursor-pointer border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 hover:shadow-md"
|
||||
}`}
|
||||
onClick={handleFileSelect}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
style={filteredStyle}
|
||||
>
|
||||
{uploading ? (
|
||||
<p className="text-xs font-medium text-blue-600">업로드 중...</p>
|
||||
) : readonly ? (
|
||||
<p className="text-xs font-medium text-gray-500">업로드 불가</p>
|
||||
) : isDesignMode ? (
|
||||
<p className="text-xs font-medium text-gray-400">이미지 업로드</p>
|
||||
) : (
|
||||
<p className="text-xs font-medium text-gray-700 transition-colors duration-300 group-hover:text-blue-600">
|
||||
이미지 업로드
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 숨겨진 파일 input */}
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
disabled={readonly || uploading}
|
||||
/>
|
||||
|
||||
{/* 필수 필드 경고 */}
|
||||
{required && !imageUrl && (
|
||||
<div className="text-xs text-red-500">* 이미지를 업로드해야 합니다</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ImageWidget.displayName = "ImageWidget";
|
||||
@@ -11,6 +11,7 @@ import { TextareaWidget } from "./TextareaWidget";
|
||||
import { CheckboxWidget } from "./CheckboxWidget";
|
||||
import { RadioWidget } from "./RadioWidget";
|
||||
import { FileWidget } from "./FileWidget";
|
||||
import { ImageWidget } from "./ImageWidget";
|
||||
import { CodeWidget } from "./CodeWidget";
|
||||
import { EntityWidget } from "./EntityWidget";
|
||||
import { RatingWidget } from "./RatingWidget";
|
||||
@@ -24,6 +25,7 @@ export { TextareaWidget } from "./TextareaWidget";
|
||||
export { CheckboxWidget } from "./CheckboxWidget";
|
||||
export { RadioWidget } from "./RadioWidget";
|
||||
export { FileWidget } from "./FileWidget";
|
||||
export { ImageWidget } from "./ImageWidget";
|
||||
export { CodeWidget } from "./CodeWidget";
|
||||
export { EntityWidget } from "./EntityWidget";
|
||||
export { RatingWidget } from "./RatingWidget";
|
||||
@@ -47,6 +49,8 @@ export const getWidgetComponentByName = (componentName: string): React.Component
|
||||
return RadioWidget;
|
||||
case "FileWidget":
|
||||
return FileWidget;
|
||||
case "ImageWidget":
|
||||
return ImageWidget;
|
||||
case "CodeWidget":
|
||||
return CodeWidget;
|
||||
case "EntityWidget":
|
||||
@@ -105,6 +109,12 @@ export const getWidgetComponentByWebType = (webType: string): React.ComponentTyp
|
||||
case "attachment":
|
||||
return FileWidget;
|
||||
|
||||
case "image":
|
||||
case "img":
|
||||
case "picture":
|
||||
case "photo":
|
||||
return ImageWidget;
|
||||
|
||||
case "code":
|
||||
case "script":
|
||||
return CodeWidget;
|
||||
@@ -155,6 +165,7 @@ export const WebTypeComponents: Record<string, React.ComponentType<WebTypeCompon
|
||||
checkbox: CheckboxWidget,
|
||||
radio: RadioWidget,
|
||||
file: FileWidget,
|
||||
image: ImageWidget,
|
||||
code: CodeWidget,
|
||||
entity: EntityWidget,
|
||||
rating: RatingWidget,
|
||||
|
||||
Reference in New Issue
Block a user