개발관리>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>
This commit is contained in:
hjjeong
2026-05-13 14:04:50 +09:00
parent 7d67a5ab1d
commit 119f0f3f2e
4 changed files with 402 additions and 48 deletions
@@ -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<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]}`;
}
@@ -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<string, string> = { "0": "구매", "1": "생산", "8": "Phantom" };
const LABEL_LOT_FG: Record<string, string> = { "0": "미사용", "1": "사용" };
@@ -134,34 +135,45 @@ export function PartDetailDialog({ open, onOpenChange, objid, onEdit }: Props) {
<Th>EO사유</Th><Td><Ro>{row.change_option_name ?? row.change_option}</Ro></Td>
</Tr>
{/* CAD Data */}
{/* CAD Data — readonly (목록·다운로드만) */}
<tr>
<th className="border bg-muted/30 px-3 py-2 text-left align-top font-medium" rowSpan={3}>
CAD Data
</th>
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">3D</th>
<td className="border px-3 py-2" colSpan={3}>
<CadCount label="3D" count={Number(row.cu01_cnt ?? 0)} />
<AttachFileDropZone
targetObjid={row.objid}
docType="3D_CAD"
docTypeName="3D CAD 첨부파일"
readOnly
/>
</td>
</tr>
<tr>
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(Drawing)</th>
<td className="border px-3 py-2" colSpan={3}>
<CadCount label="2D(Drawing)" count={Number(row.cu02_cnt ?? 0)} />
<AttachFileDropZone
targetObjid={row.objid}
docType="2D_DRAWING_CAD"
docTypeName="2D(Drawing) CAD 첨부파일"
readOnly
/>
</td>
</tr>
<tr>
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(PDF)</th>
<td className="border px-3 py-2" colSpan={3}>
<CadCount label="2D(PDF)" count={Number(row.cu03_cnt ?? 0)} />
<AttachFileDropZone
targetObjid={row.objid}
docType="2D_PDF_CAD"
docTypeName="2D(PDF) CAD 첨부파일"
readOnly
/>
</td>
</tr>
</tbody>
</table>
<div className="mt-2 text-[11px] text-muted-foreground">
CAD Data / DEV-7 () PR .
</div>
</div>
)}
@@ -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 (
<div className="flex items-center gap-2 text-xs">
<FileText className="h-4 w-4 text-blue-600" />
<span>{label} {count.toLocaleString()}</span>
</div>
);
}
return (
<div className="border-2 border-dashed border-muted-foreground/30 rounded text-muted-foreground py-3 text-center text-xs">
{label}
</div>
);
}
@@ -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<FormState>(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<string | null>(null);
const setField = useCallback(
<K extends keyof FormState>(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)} /></Td>
</Tr>
{/* ⑬ CAD Data — placeholder (DEV-7 도면업로드 별 PR) */}
{/* ⑬ CAD Data — wace fnc_setFileDropZone 3종 1:1 */}
<tr>
<th className="border bg-muted/30 px-3 py-2 text-left align-top font-medium" rowSpan={3}>
CAD Data
</th>
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">3D</th>
<td className="border px-3 py-2" colSpan={3}>
<DropPlaceholder label="3D" />
<AttachFileDropZone
targetObjid={partObjid}
docType="3D_CAD"
docTypeName="3D CAD 첨부파일"
/>
</td>
</tr>
<tr>
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(Drawing)</th>
<td className="border px-3 py-2" colSpan={3}>
<DropPlaceholder label="2D(Drawing)" />
<AttachFileDropZone
targetObjid={partObjid}
docType="2D_DRAWING_CAD"
docTypeName="2D(Drawing) CAD 첨부파일"
/>
</td>
</tr>
<tr>
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(PDF)</th>
<td className="border px-3 py-2" colSpan={3}>
<DropPlaceholder label="2D(PDF)" />
<AttachFileDropZone
targetObjid={partObjid}
docType="2D_PDF_CAD"
docTypeName="2D(PDF) CAD 첨부파일"
accept=".pdf,application/pdf"
/>
</td>
</tr>
</tbody>
</table>
<div className="mt-2 text-[11px] text-muted-foreground">
CAD Data DEV-7 () PR .
</div>
</div>
)}
@@ -447,15 +473,6 @@ function BasicSelect({
</select>
);
}
function DropPlaceholder({ label }: { label: string }) {
return (
<div className="border-2 border-dashed border-muted-foreground/30 rounded text-muted-foreground py-6 text-center text-xs">
<Upload className="h-5 w-5 mx-auto mb-1" />
Drag &amp; Drop Files Here ({label})
</div>
);
}
// ─── PartRow → FormState ────────────────────────────────────
function rowToForm(r: PartRow): FormState {
+33
View File
@@ -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, "")));
}