리사이징, 체크박스,엔터치면 다음 칸으로 이동, 표수정, 컬럼에서 이미지 넣는거 등등

This commit is contained in:
leeheejin
2025-11-06 12:11:49 +09:00
parent 0b676098a5
commit 0839f7f603
38 changed files with 1285 additions and 260 deletions

View File

@@ -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

View 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";

View File

@@ -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,