diff --git a/frontend/components/common/AttachFileDropZone.tsx b/frontend/components/common/AttachFileDropZone.tsx new file mode 100644 index 00000000..8cfd1e1d --- /dev/null +++ b/frontend/components/common/AttachFileDropZone.tsx @@ -0,0 +1,307 @@ +"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([]); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const inputRef = useRef(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) => { + e.preventDefault(); + e.stopPropagation(); + if (readOnly) return; + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + setDragOver(true); + }; + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (readOnly) return; + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + setDragOver(true); + }; + const onDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragOver(false); + }; + const onDrop = (e: DragEvent) => { + 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) => { + 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 ( +
+ {showDropZone && ( +
+ {uploading ? ( + <> + + 업로드 중... + + ) : ( + <> + + + {targetObjid + ? "여기에 파일을 끌어다 놓거나 클릭하여 선택하세요." + : "저장 대상 미지정 — 등록 후 업로드 가능"} + + + )} + +
+ )} + + {loading ? ( +
+ 목록 로드 중... +
+ ) : files.length > 0 ? ( +
    + {files.map((f) => ( +
  • + + + {f.realFileName} + + + {formatBytes(f.fileSize)} + + {!readOnly && ( + + )} +
  • + ))} +
+ ) : ( + showEmptyHint && ( +
+ 등록된 파일이 없습니다. +
+ ) + )} +
+ ); +} + +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]}`; +} diff --git a/frontend/components/development/PartDetailDialog.tsx b/frontend/components/development/PartDetailDialog.tsx index 720d83af..0e8313a4 100644 --- a/frontend/components/development/PartDetailDialog.tsx +++ b/frontend/components/development/PartDetailDialog.tsx @@ -5,7 +5,7 @@ // 운영판은 form 과 동일 화면을 disabled 로 표시 후 "수정" 클릭 시 활성화. // RPS 에서는 PartFormDialog 와 분리 유지 (호환). 본 다이얼로그는 Form 레이아웃 readonly + // 부속 정보 행 추가 (EO_NO / EO_DATE / EO구분(CHANGE_TYPE) / EO사유(CHANGE_OPTION)) + -// CAD Data 영역 (3D / 2D(Drawing) / 2D(PDF)) 표시. +// CAD Data 영역 (3D / 2D(Drawing) / 2D(PDF)) — AttachFileDropZone readonly (목록·다운로드). // // "수정" 버튼: 부모가 본 다이얼로그를 닫고 PartFormDialog(mode='edit') 오픈하도록 onEdit 콜백 호출. @@ -14,10 +14,11 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Loader2, Pencil, FileText } from "lucide-react"; +import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; import { devPartApi, PartRow } from "@/lib/api/devPart"; import { cn } from "@/lib/utils"; +import { AttachFileDropZone } from "@/components/common/AttachFileDropZone"; const LABEL_ODRFG: Record = { "0": "구매", "1": "생산", "8": "Phantom" }; const LABEL_LOT_FG: Record = { "0": "미사용", "1": "사용" }; @@ -134,34 +135,45 @@ export function PartDetailDialog({ open, onOpenChange, objid, onEdit }: Props) { EO사유{row.change_option_name ?? row.change_option} - {/* CAD Data */} + {/* CAD Data — readonly (목록·다운로드만) */} CAD Data 3D - + 2D(Drawing) - + 2D(PDF) - + - -
- CAD Data 첨부 다운로드/미리보기는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다. -
)} @@ -208,18 +220,3 @@ function Ro({ children, align }: { children: React.ReactNode; align?: "left" | " ); } -function CadCount({ label, count }: { label: string; count: number }) { - if (count > 0) { - return ( -
- - {label} 첨부 {count.toLocaleString()}건 -
- ); - } - return ( -
- 첨부된 {label} 파일 없음 -
- ); -} diff --git a/frontend/components/development/PartFormDialog.tsx b/frontend/components/development/PartFormDialog.tsx index d7110842..2b3cab54 100644 --- a/frontend/components/development/PartFormDialog.tsx +++ b/frontend/components/development/PartFormDialog.tsx @@ -15,9 +15,10 @@ // ⑩ SET품여부* (0=부, 1=여) | 의뢰여부* (0=부, 1=여) // ⑪ 개당길이 | 개당소요량 // ⑫ 비고 (1행) -// ⑬ CAD Data: 3D / 2D(Drawing) / 2D(PDF) Drag&Drop — 별 PR(DEV-7) 도면업로드. 본 PR은 UI placeholder +// ⑬ CAD Data: 3D / 2D(Drawing) / 2D(PDF) Drag&Drop — wace fnc_setFileDropZone 3종 1:1 (DEV-7) // -// 신규: POST /api/development/part (운영 폼 22컬럼) +// 신규: POST /api/development/part (운영 폼 22컬럼) — part_objid 선채번해서 전달 +// (도면이 PART INSERT 전에 attach_file_info 로 먼저 들어갈 수 있으므로 wace resultMap.OBJID 패턴) // 수정: PUT /api/development/part/:objid (wace updatePartDetail 21컬럼 1:1) import React, { useCallback, useEffect, useState } from "react"; @@ -27,11 +28,13 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Loader2, Save, Upload } from "lucide-react"; +import { Loader2, Save } from "lucide-react"; import { toast } from "sonner"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart"; import { cn } from "@/lib/utils"; +import { createObjId } from "@/lib/utils/objidUtil"; +import { AttachFileDropZone } from "@/components/common/AttachFileDropZone"; // comm_code group ids (vexplor_rps DB) const GROUP_PART_TYPE = "0000062"; @@ -92,6 +95,10 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: const [form, setForm] = useState(EMPTY_FORM); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + // CAD Data 업로드 target_objid: + // - 수정 모드: editObjid 그대로 + // - 신규 모드: 다이얼로그 열릴 때 createObjId() 로 선채번 (wace partMngFormPopUp resultMap.OBJID 패턴) + const [partObjid, setPartObjid] = useState(null); const setField = useCallback( (key: K, value: FormState[K]) => @@ -100,9 +107,17 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: ); useEffect(() => { - if (!open) return; - if (isEdit && editObjid) loadDetail(editObjid); - else setForm(EMPTY_FORM); + if (!open) { + setPartObjid(null); + return; + } + if (isEdit && editObjid) { + setPartObjid(editObjid); + loadDetail(editObjid); + } else { + setForm(EMPTY_FORM); + setPartObjid(createObjId()); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); @@ -176,6 +191,8 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: toast.success("PART가 수정되었습니다."); } else { const body: PartCreateBody = { + // CAD Data 도면이 선업로드 되었을 수 있으므로 선채번된 objid 전달 (wace 1:1) + part_objid: partObjid ?? undefined, part_no: form.part_no, part_name: form.part_name, part_type: form.part_type, @@ -366,34 +383,43 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }: onChange={(e) => setField("remark", e.target.value)} /> - {/* ⑬ CAD Data — placeholder (DEV-7 도면업로드 별 PR) */} + {/* ⑬ CAD Data — wace fnc_setFileDropZone 3종 1:1 */} CAD Data 3D - + 2D(Drawing) - + 2D(PDF) - + - -
- CAD Data 첨부는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다. -
)} @@ -447,15 +473,6 @@ function BasicSelect({ ); } -function DropPlaceholder({ label }: { label: string }) { - return ( -
- - Drag & Drop Files Here ({label}) -
- ); -} - // ─── PartRow → FormState ──────────────────────────────────── function rowToForm(r: PartRow): FormState { diff --git a/frontend/lib/utils/objidUtil.ts b/frontend/lib/utils/objidUtil.ts new file mode 100644 index 00000000..1474ed2b --- /dev/null +++ b/frontend/lib/utils/objidUtil.ts @@ -0,0 +1,33 @@ +// part_mng / attach_file_info 등 wace 운영판 `objid bigint` 컬럼 채번 유틸. +// 백엔드 `backend-node/src/utils/objidUtil.ts` 와 1:1 동일 알고리즘. +// +// wace java `com.pms.common.CommonUtils.createObjId()` 1:1 이식: +// 1) UUID v4 생성 +// 2) 하이픈 제거 → 32 hex 문자열 +// 3) Java String.hashCode() (int32) 적용 +// 4) 결과 정수를 문자열로 반환 + +function javaStringHashCode(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + } + return h; +} + +function uuidv4(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // 폴백: getRandomValues 기반 RFC4122 v4 + const buf = new Uint8Array(16); + (crypto as Crypto).getRandomValues(buf); + buf[6] = (buf[6] & 0x0f) | 0x40; + buf[8] = (buf[8] & 0x3f) | 0x80; + const hex = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function createObjId(): string { + return String(javaStringHashCode(uuidv4().replace(/-/g, ""))); +}