Files
vexplor/frontend/components/common/ImageUpload.tsx
kjs 5d4cf8d462 Add equipment and department management pages with comprehensive features
- Introduced a new equipment information page that displays a list of equipment alongside inspection items and consumables.
- Implemented dynamic loading of categories for equipment types and inspection methods to enhance data representation.
- Added functionality for equipment editing, inspection item management, and consumable tracking.
- Introduced a department management page that allows for department registration and user assignment.
- Enhanced user experience with responsive design, modals for data entry, and real-time data updates.
2026-03-24 18:25:46 +09:00

165 lines
5.7 KiB
TypeScript

"use client";
/**
* ImageUpload — 공통 이미지 업로드 컴포넌트
*
* 기능:
* - 이미지 파일 선택 (클릭 or 드래그)
* - 미리보기 표시
* - /api/files/upload API로 업로드
* - 업로드 후 파일 objid 반환
* - 기존 이미지 표시 (objid 또는 URL)
*
* 사용법:
* <ImageUpload
* value={form.image_path} // 기존 파일 objid 또는 URL
* onChange={(objid) => setForm(...)} // 업로드 완료 시 objid 반환
* tableName="equipment_mng" // 연결 테이블
* recordId="xxx" // 연결 레코드 ID
* columnName="image_path" // 연결 컬럼명
* />
*/
import React, { useState, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Upload, X, Loader2, ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
interface ImageUploadProps {
/** 현재 이미지 값 (파일 objid, URL, 또는 빈 문자열) */
value?: string;
/** 업로드 완료 시 콜백 (파일 objid) */
onChange?: (value: string) => void;
/** 연결할 테이블명 */
tableName?: string;
/** 연결할 레코드 ID */
recordId?: string;
/** 연결할 컬럼명 */
columnName?: string;
/** 높이 (기본 160px) */
height?: string;
/** 비활성화 */
disabled?: boolean;
className?: string;
}
export function ImageUpload({
value, onChange, tableName, recordId, columnName,
height = "h-40", disabled = false, className,
}: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
// 이미지 URL 결정
const imageUrl = value
? (value.startsWith("http") || value.startsWith("/"))
? value
: `/api/files/preview/${value}`
: null;
const handleUpload = useCallback(async (file: File) => {
if (!file.type.startsWith("image/")) {
toast.error("이미지 파일만 업로드 가능합니다.");
return;
}
if (file.size > 10 * 1024 * 1024) {
toast.error("파일 크기는 10MB 이하만 가능합니다.");
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append("files", file);
formData.append("docType", "IMAGE");
formData.append("docTypeName", "이미지");
if (tableName) formData.append("linkedTable", tableName);
if (recordId) formData.append("recordId", recordId);
if (columnName) formData.append("columnName", columnName);
if (tableName && recordId) {
formData.append("autoLink", "true");
if (columnName) formData.append("isVirtualFileColumn", "true");
}
const res = await apiClient.post("/files/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
if (res.data?.success && (res.data.files?.length > 0 || res.data.data?.length > 0)) {
const file = res.data.files?.[0] || res.data.data?.[0];
const objid = file.objid;
onChange?.(objid);
toast.success("이미지가 업로드되었습니다.");
} else {
toast.error(res.data?.message || "업로드에 실패했습니다.");
}
} catch (err: any) {
console.error("이미지 업로드 실패:", err);
toast.error(err.response?.data?.message || "업로드에 실패했습니다.");
} finally {
setUploading(false);
}
}, [tableName, recordId, columnName, onChange]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.target.value = "";
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) handleUpload(file);
};
const handleRemove = () => {
onChange?.("");
};
return (
<div className={cn("relative", className)}>
<div
className={cn(
"border-2 border-dashed rounded-lg flex flex-col items-center justify-center cursor-pointer transition-colors overflow-hidden",
height,
dragOver ? "border-primary bg-primary/5" : imageUrl ? "border-transparent" : "border-muted-foreground/25 hover:border-primary hover:bg-muted/50",
disabled && "opacity-50 cursor-not-allowed",
)}
onClick={() => !disabled && !uploading && fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={!disabled ? handleDrop : undefined}
>
{uploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground"> ...</span>
</div>
) : imageUrl ? (
<img src={imageUrl} alt="이미지" className="w-full h-full object-contain" />
) : (
<div className="flex flex-col items-center gap-2">
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
<span className="text-xs text-muted-foreground"> </span>
</div>
)}
</div>
{/* 삭제 버튼 */}
{imageUrl && !disabled && (
<Button variant="destructive" size="sm" className="absolute top-1 right-1 h-6 w-6 p-0 rounded-full"
onClick={(e) => { e.stopPropagation(); handleRemove(); }}>
<X className="h-3 w-3" />
</Button>
)}
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileChange} className="hidden" />
</div>
);
}