119f0f3f2e
- 공통 컴포넌트: frontend/components/common/AttachFileDropZone.tsx
· wace fnc_setFileDropZone + fn_fileCallback2 + fileDelete 1:1
· /api/files (upload·list·delete·download) attach_file_info 기반
· readOnly 옵션 (Detail 다이얼로그용), accept 옵션, dragenter+dropEffect=copy
· 도메인 무관 — ERP/ECR/생산실적 등 어디서나 재사용
- 프론트 채번 유틸: frontend/lib/utils/objidUtil.ts
· backend objidUtil 1:1 (UUID v4 → Java String.hashCode int32)
· 신규 등록 시 다이얼로그 진입 시점에 part_mng.objid 선채번
(wace partMngFormPopUp resultMap.OBJID 패턴)
- PartFormDialog (M1 신규/수정): CAD Data placeholder 제거,
AttachFileDropZone 3종(3D_CAD / 2D_DRAWING_CAD / 2D_PDF_CAD) 활성.
신규 모드는 createObjId 로 선채번 후 part_objid 로 백엔드 전달.
- PartDetailDialog: CadCount 제거, AttachFileDropZone readOnly 로 교체
(목록·다운로드만, 드롭존/삭제 숨김).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
"use client";
|
|
|
|
// attach_file_info 기반 다중 파일 업로드 드롭존 — 도메인 무관 공통 컴포넌트.
|
|
//
|
|
// 운영판 wace `fnc_setFileDropZone` (common.js) + `fn_fileCallback2` +
|
|
// `fileDelete` 흐름 1:1.
|
|
//
|
|
// 백엔드:
|
|
// - GET /api/files?targetObjid=&docType= (wace getFileList.do)
|
|
// - POST /api/files/upload (wace fileUploadProc.do)
|
|
// - DELETE /api/files/:objid (wace deleteFileInfo.do)
|
|
// - GET /api/files/download/:objid (wace fnc_downloadFile)
|
|
//
|
|
// 사용처 예시:
|
|
// - 개발관리 PART CAD Data (3D_CAD / 2D_DRAWING_CAD / 2D_PDF_CAD)
|
|
// - 향후 ERP/ECR/생산실적 등 attach_file_info 쓰는 어떤 화면이든 그대로 재사용.
|
|
//
|
|
// 호출자가 doc_type / doc_type_name 만 지정하면 도메인 독립.
|
|
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
DragEvent,
|
|
ChangeEvent,
|
|
} from "react";
|
|
import { Upload, Download, Trash2, Loader2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { apiClient, API_BASE_URL } from "@/lib/api/client";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface AttachFile {
|
|
objid: string;
|
|
realFileName: string;
|
|
savedFileName: string;
|
|
fileSize: number;
|
|
fileExt: string;
|
|
filePath: string;
|
|
docType: string;
|
|
docTypeName: string;
|
|
}
|
|
|
|
interface Props {
|
|
targetObjid: string | null | undefined;
|
|
docType: string;
|
|
docTypeName: string;
|
|
/** 읽기 전용 — 드롭존/삭제 숨김, 다운로드만 허용 */
|
|
readOnly?: boolean;
|
|
/** 파일 선택창 accept 힌트 (예: ".pdf,application/pdf"). 비우면 모든 파일 */
|
|
accept?: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function AttachFileDropZone({
|
|
targetObjid,
|
|
docType,
|
|
docTypeName,
|
|
readOnly = false,
|
|
accept,
|
|
className,
|
|
}: Props) {
|
|
const [files, setFiles] = useState<AttachFile[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
// ── 목록 조회 (wace fn_fileCallback2) ───────────────────────
|
|
const reload = useCallback(async () => {
|
|
if (!targetObjid) {
|
|
setFiles([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiClient.get("/files", {
|
|
params: { targetObjid, docType },
|
|
});
|
|
// 응답: { success, files: [...] } — fileController.getFileList
|
|
const raw = (res.data?.files ?? res.data?.data ?? res.data ?? []) as any[];
|
|
const list: AttachFile[] = raw.map((f) => ({
|
|
objid: String(f.objid),
|
|
realFileName: f.realFileName ?? f.real_file_name ?? "",
|
|
savedFileName: f.savedFileName ?? f.saved_file_name ?? "",
|
|
fileSize: Number(f.fileSize ?? f.file_size ?? 0),
|
|
fileExt: f.fileExt ?? f.file_ext ?? "",
|
|
filePath: f.filePath ?? f.file_path ?? "",
|
|
docType: f.docType ?? f.doc_type ?? "",
|
|
docTypeName: f.docTypeName ?? f.doc_type_name ?? "",
|
|
}));
|
|
setFiles(list);
|
|
} catch (e: any) {
|
|
console.error("[AttachFileDropZone] reload 실패", e);
|
|
setFiles([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [targetObjid, docType]);
|
|
|
|
useEffect(() => {
|
|
reload();
|
|
}, [reload]);
|
|
|
|
// ── 업로드 (wace fnc_fileMultiUpload) ───────────────────────
|
|
const uploadFiles = useCallback(
|
|
async (selected: FileList | File[]) => {
|
|
if (!targetObjid) {
|
|
toast.error("저장 대상이 지정되지 않았습니다.");
|
|
return;
|
|
}
|
|
const arr = Array.from(selected);
|
|
if (arr.length === 0) return;
|
|
|
|
if (!window.confirm(`${arr.length}개 파일을 업로드 하시겠습니까?`)) {
|
|
return;
|
|
}
|
|
|
|
setUploading(true);
|
|
try {
|
|
const fd = new FormData();
|
|
for (const f of arr) fd.append("files", f);
|
|
fd.append("targetObjid", String(targetObjid));
|
|
fd.append("docType", docType);
|
|
fd.append("docTypeName", docTypeName);
|
|
await apiClient.post("/files/upload", fd, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
toast.success(`${arr.length}개 파일이 업로드되었습니다.`);
|
|
await reload();
|
|
} catch (e: any) {
|
|
toast.error(
|
|
e?.response?.data?.message ?? e?.message ?? "업로드 실패"
|
|
);
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
},
|
|
[targetObjid, docType, docTypeName, reload]
|
|
);
|
|
|
|
// ── 삭제 (wace fileDelete) ──────────────────────────────────
|
|
const removeFile = useCallback(
|
|
async (objid: string) => {
|
|
if (!window.confirm("파일을 삭제하시겠습니까?")) return;
|
|
try {
|
|
await apiClient.delete(`/files/${objid}`);
|
|
await reload();
|
|
} catch (e: any) {
|
|
toast.error(
|
|
e?.response?.data?.message ?? e?.message ?? "삭제 실패"
|
|
);
|
|
}
|
|
},
|
|
[reload]
|
|
);
|
|
|
|
// ── 다운로드 ────────────────────────────────────────────────
|
|
const downloadHref = (objid: string) =>
|
|
`${API_BASE_URL}/files/download/${objid}`;
|
|
|
|
// ── DnD 이벤트 ──────────────────────────────────────────────
|
|
// macOS Chrome 함정: dropEffect 미지정 시 🚫 거부 커서 + drop 차단.
|
|
const onDragEnter = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (readOnly) return;
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
|
setDragOver(true);
|
|
};
|
|
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (readOnly) return;
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
|
setDragOver(true);
|
|
};
|
|
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOver(false);
|
|
};
|
|
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOver(false);
|
|
if (readOnly) return;
|
|
const dropped = e.dataTransfer?.files;
|
|
if (dropped && dropped.length > 0) uploadFiles(dropped);
|
|
};
|
|
|
|
const onPickClick = () => {
|
|
if (readOnly) return;
|
|
inputRef.current?.click();
|
|
};
|
|
const onPickChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
const picked = e.target.files;
|
|
if (picked && picked.length > 0) uploadFiles(picked);
|
|
if (inputRef.current) inputRef.current.value = "";
|
|
};
|
|
|
|
// ── 렌더 ────────────────────────────────────────────────────
|
|
const showDropZone = !readOnly;
|
|
const showEmptyHint = files.length === 0 && readOnly;
|
|
|
|
return (
|
|
<div className={cn("flex flex-col gap-2", className)}>
|
|
{showDropZone && (
|
|
<div
|
|
onDragEnter={onDragEnter}
|
|
onDragOver={onDragOver}
|
|
onDragLeave={onDragLeave}
|
|
onDrop={onDrop}
|
|
onClick={onPickClick}
|
|
className={cn(
|
|
"flex h-[60px] cursor-pointer items-center justify-center gap-2 rounded border-2 border-dashed text-sm transition-colors",
|
|
dragOver
|
|
? "border-blue-500 bg-blue-50 text-blue-700"
|
|
: "border-muted-foreground/30 bg-muted/20 text-muted-foreground hover:border-blue-400 hover:text-blue-600",
|
|
(!targetObjid || uploading) && "pointer-events-none opacity-60"
|
|
)}
|
|
>
|
|
{uploading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<span>업로드 중...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="h-4 w-4" />
|
|
<span>
|
|
{targetObjid
|
|
? "여기에 파일을 끌어다 놓거나 클릭하여 선택하세요."
|
|
: "저장 대상 미지정 — 등록 후 업로드 가능"}
|
|
</span>
|
|
</>
|
|
)}
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
multiple
|
|
accept={accept}
|
|
className="hidden"
|
|
onChange={onPickChange}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="flex items-center gap-2 px-1 py-2 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> 목록 로드 중...
|
|
</div>
|
|
) : files.length > 0 ? (
|
|
<ul className="divide-y rounded border bg-background">
|
|
{files.map((f) => (
|
|
<li
|
|
key={f.objid}
|
|
className="flex items-center justify-between gap-2 px-2 py-1.5 text-sm"
|
|
>
|
|
<a
|
|
href={downloadHref(f.objid)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title={f.realFileName}
|
|
className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-blue-700 hover:underline"
|
|
>
|
|
<Download className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="truncate">{f.realFileName}</span>
|
|
</a>
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{formatBytes(f.fileSize)}
|
|
</span>
|
|
{!readOnly && (
|
|
<button
|
|
type="button"
|
|
onClick={() => removeFile(f.objid)}
|
|
className="rounded p-1 text-muted-foreground hover:bg-red-50 hover:text-red-600"
|
|
title="삭제"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
showEmptyHint && (
|
|
<div className="px-1 py-1 text-xs text-muted-foreground">
|
|
등록된 파일이 없습니다.
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (!bytes || bytes <= 0) return "0 B";
|
|
const units = ["B", "KB", "MB", "GB"];
|
|
let v = bytes;
|
|
let u = 0;
|
|
while (v >= 1024 && u < units.length - 1) {
|
|
v /= 1024;
|
|
u++;
|
|
}
|
|
return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`;
|
|
}
|