Files
hjjeong 119f0f3f2e 개발관리>PART 도면 다중 업로드 (DEV-7) — 공통 AttachFileDropZone 신설 + CAD Data 활성
- 공통 컴포넌트: 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>
2026-05-13 14:04:50 +09:00

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]}`;
}