Files
vexplor_dev/frontend/components/common/ImageUpload.tsx
kjs 2a23cadb41 feat: Enhance user management and reporting features
- Added `end_date` field to user management for better tracking of user status.
- Updated SQL queries in `adminController` to include `end_date` during user save operations.
- Improved purchase report data handling by refining the logic for received quantities.
- Enhanced file preview functionality to streamline file path handling.
- Updated outbound and receiving controllers to ensure accurate updates to shipment and purchase order details.

These changes aim to improve the overall functionality and user experience in managing user data and reporting processes.
2026-04-08 15:33:09 +09:00

168 lines
5.9 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 결정 (Next.js 프록시 우회 — 바이너리 스트림 500 에러 방지)
const apiBase = typeof window !== "undefined"
? (process.env.NEXT_PUBLIC_API_URL || "").replace(/\/api\/?$/, "")
: "";
const imageUrl = value
? (value.startsWith("http") || value.startsWith("/"))
? value
: `${apiBase}/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>
);
}