개발관리>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:
@@ -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 & Drop Files Here ({label})
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PartRow → FormState ────────────────────────────────────
|
||||
|
||||
function rowToForm(r: PartRow): FormState {
|
||||
|
||||
@@ -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, "")));
|
||||
}
|
||||
Reference in New Issue
Block a user