From e484964b1d78cedabbe182c789a8e4c9b53e29d3 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Thu, 29 May 2025 05:12:36 +0000 Subject: (김준회) 기술영업 조선 RFQ 파일첨부 및 채팅 기능 구현 / menuConfig 수정 (벤더 기술영업) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/techsales-rfq/table/create-rfq-dialog.tsx | 244 ++++++++-- .../table/detail-table/rfq-detail-column.tsx | 17 +- .../table/detail-table/rfq-detail-table.tsx | 91 +--- .../table/detail-table/update-vendor-sheet.tsx | 449 ----------------- .../detail-table/vendor-communication-drawer.tsx | 22 +- lib/techsales-rfq/table/rfq-table-column.tsx | 178 +++---- lib/techsales-rfq/table/rfq-table.tsx | 117 +++-- .../table/tech-sales-rfq-attachments-sheet.tsx | 540 +++++++++++++++++++++ 8 files changed, 920 insertions(+), 738 deletions(-) delete mode 100644 lib/techsales-rfq/table/detail-table/update-vendor-sheet.tsx create mode 100644 lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx (limited to 'lib/techsales-rfq/table') diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-dialog.tsx index cc652b44..5faa3a0b 100644 --- a/lib/techsales-rfq/table/create-rfq-dialog.tsx +++ b/lib/techsales-rfq/table/create-rfq-dialog.tsx @@ -197,20 +197,60 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { throw new Error("로그인이 필요합니다") } - // 자재코드(item_code) 배열을 materialGroupCodes로 전달 - const result = await createTechSalesRfq({ - biddingProjectId: data.biddingProjectId, - materialGroupCodes: data.materialCodes, // item_code를 자재코드로 사용 - createdBy: Number(session.user.id), - dueDate: data.dueDate, + // 선택된 아이템들을 아이템명(itemList)으로 그룹핑 + const groupedItems = selectedItems.reduce((groups, item) => { + const actualItemName = item.itemList // 실제 조선 아이템명 + if (!actualItemName) { + throw new Error(`아이템 "${item.itemCode}"의 아이템명(itemList)이 없습니다.`) + } + if (!groups[actualItemName]) { + groups[actualItemName] = [] + } + groups[actualItemName].push(item) + return groups + }, {} as Record) + + const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => { + const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들 + const joinedItemCodes = itemCodes.join(',') + return { + actualItemName, + items, + itemCodes, + joinedItemCodes, + codeLength: joinedItemCodes.length, + isOverLimit: joinedItemCodes.length > 255 + } }) + + // 255자 초과 그룹 확인 + const overLimitGroups = rfqGroups.filter(group => group.isOverLimit) + if (overLimitGroups.length > 0) { + const groupNames = overLimitGroups.map(g => `"${g.actualItemName}" (${g.codeLength}자)`).join(', ') + throw new Error(`다음 아이템 그룹의 자재코드가 255자를 초과합니다: ${groupNames}`) + } + + // 각 그룹별로 RFQ 생성 + const createPromises = rfqGroups.map(group => + createTechSalesRfq({ + biddingProjectId: data.biddingProjectId, + materialGroupCodes: [group.joinedItemCodes], // 그룹화된 자재코드들 + createdBy: Number(session.user.id), + dueDate: data.dueDate, + }) + ) + + const results = await Promise.all(createPromises) - if (result.error) { - throw new Error(result.error) + // 오류 확인 + const errors = results.filter(result => result.error) + if (errors.length > 0) { + throw new Error(errors.map(e => e.error).join(', ')) } // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시 - toast.success(`${result.data?.length || 0}개의 RFQ가 성공적으로 생성되었습니다`) + const totalRfqs = results.reduce((sum, result) => sum + (result.data?.length || 0), 0) + toast.success(`${rfqGroups.length}개 아이템 그룹으로 총 ${totalRfqs}개의 RFQ가 성공적으로 생성되었습니다`) setIsDialogOpen(false) form.reset({ biddingProjectId: undefined, @@ -423,38 +463,45 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { 아이템을 불러오는 중... ) : availableItems.length > 0 ? ( - availableItems.map((item) => { - const isSelected = selectedItems.some(selected => selected.id === item.id) - return ( -
handleItemToggle(item)} - > -
- {isSelected ? ( - - ) : ( - + [...availableItems] + .sort((a, b) => { + // itemList 기준으로 정렬 (없는 경우 itemName 사용, 둘 다 없으면 맨 뒤로) + const aName = a.itemList || a.itemName || 'zzz' + const bName = b.itemList || b.itemName || 'zzz' + return aName.localeCompare(bName, 'ko', { numeric: true }) + }) + .map((item) => { + const isSelected = selectedItems.some(selected => selected.id === item.id) + return ( +
-
- {item.itemList || item.itemName} -
-
- {item.itemCode} • {item.description || '설명 없음'} -
-
- 공종: {item.workType} • 선종: {item.shipTypes} + onClick={() => handleItemToggle(item)} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+
+ {item.itemList || item.itemName || '아이템명 없음'} +
+
+ {item.itemCode} • {item.description || '설명 없음'} +
+
+ 공종: {item.workType} • 선종: {item.shipTypes} +
-
- ) - }) + ) + }) ) : (
{itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} @@ -480,7 +527,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { variant="secondary" className="flex items-center gap-1" > - {item.itemList || item.itemName} ({item.itemCode}) + {item.itemList || item.itemName || '아이템명 없음'} ({item.itemCode}) handleRemoveItem(item.id)} @@ -498,19 +545,93 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) { )} /> + + {/* RFQ 그룹핑 미리보기 */} + {selectedItems.length > 0 && ( +
+ 생성될 RFQ 그룹 미리보기 +
+ {(() => { + // 아이템명(itemList)으로 그룹핑 + const groupedItems = selectedItems.reduce((groups, item) => { + const actualItemName = item.itemList // 실제 조선 아이템명 + if (!actualItemName) { + return groups // itemList가 없는 경우 제외 + } + if (!groups[actualItemName]) { + groups[actualItemName] = [] + } + groups[actualItemName].push(item) + return groups + }, {} as Record) + + const rfqGroups = Object.entries(groupedItems).map(([actualItemName, items]) => { + const itemCodes = items.map(item => item.itemCode) // 자재그룹코드들 + const joinedItemCodes = itemCodes.join(',') + return { + actualItemName, + items, + itemCodes, + joinedItemCodes, + codeLength: joinedItemCodes.length, + isOverLimit: joinedItemCodes.length > 255 + } + }) + + return ( +
+
+ 총 {rfqGroups.length}개의 RFQ가 생성됩니다 (아이템명별로 그룹핑) +
+ {rfqGroups.map((group, index) => ( +
+
+
+ RFQ #{index + 1}: {group.actualItemName} +
+ + {group.itemCodes.length}개 자재코드 ({group.codeLength}/255자) + +
+
+ 자재코드: {group.joinedItemCodes} +
+ {group.isOverLimit && ( +
+ ⚠️ 자재코드 길이가 255자를 초과합니다. 일부 아이템을 제거해주세요. +
+ )} +
+ 포함된 아이템: {group.items.map(item => `${item.itemCode}`).join(', ')} +
+
+ ))} +
+ ) + })()} +
+
+ )}
)} {/* 안내 메시지 */} - {selectedProject && ( + {/* {selectedProject && (

• 공종별 조선 아이템을 선택하세요.

-

• 선택된 아이템의 자재코드(item_code)별로 개별 RFQ가 생성됩니다.

-

• 아이템 코드가 자재 그룹 코드로 사용됩니다.

+

같은 아이템명의 다른 자재코드들은 하나의 RFQ로 그룹핑됩니다.

+

• 그룹핑된 자재코드들은 comma로 구분되어 저장됩니다.

+

• 자재코드 길이는 최대 255자까지 가능합니다.

• 마감일은 벤더가 견적을 제출해야 하는 날짜입니다.

- )} + )} */}
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx index c4a7edde..cfae0bd7 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx @@ -14,10 +14,11 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Ellipsis, MessageCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { useRouter } from "next/navigation"; export interface DataTableRowAction { row: Row; - type: "delete" | "update" | "communicate"; + type: "delete" | "communicate"; } // 벤더 견적 데이터 타입 정의 @@ -232,6 +233,13 @@ export function getRfqDetailColumns({ cell: function Cell({ row }) { const vendorId = row.original.vendorId; const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0; + const router = useRouter(); + + const handleViewDetails = () => { + if (vendorId) { + router.push(`/ko/evcp/vendors/${vendorId}/info`); + } + }; return (
@@ -269,9 +277,12 @@ export function getRfqDetailColumns({ setRowAction({ row, type: "update" })} + onClick={handleViewDetails} + disabled={!vendorId} + className="gap-2" > - 벤더 수정 + {/* */} + 벤더 상세정보 setRowAction({ row, type: "delete" })} diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx index 4f8ac37b..a2f012ad 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -12,11 +12,10 @@ import { toast } from "sonner" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Loader2, UserPlus, BarChart2, Send, Trash2, MessageCircle } from "lucide-react" +import { Loader2, UserPlus, BarChart2, Send, Trash2 } from "lucide-react" import { ClientDataTable } from "@/components/client-data-table/data-table" import { AddVendorDialog } from "./add-vendor-dialog" import { DeleteVendorDialog } from "./delete-vendor-dialog" -import { UpdateVendorSheet } from "./update-vendor-sheet" import { VendorCommunicationDrawer } from "./vendor-communication-drawer" import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" @@ -41,28 +40,6 @@ interface RfqDetailTablesProps { maxHeight?: string | number } -// 데이터 타입 정의 -interface Vendor { - id: number; - vendorName: string; - vendorCode: string; - // 기타 필요한 벤더 속성들 -} - -interface Currency { - code: string; - name: string; -} - -interface PaymentTerm { - code: string; - description: string; -} - -interface Incoterm { - code: string; - description: string; -} export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) { // console.log("selectedRfq", selectedRfq) @@ -71,14 +48,9 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps const [isLoading, setIsLoading] = useState(false) const [details, setDetails] = useState([]) const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) const [selectedDetail, setSelectedDetail] = React.useState(null) - const [vendors, setVendors] = React.useState([]) - const [currencies, setCurrencies] = React.useState([]) - const [paymentTerms, setPaymentTerms] = React.useState([]) - const [incoterms, setIncoterms] = React.useState([]) const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) const [rowAction, setRowAction] = React.useState | null>(null) @@ -159,21 +131,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps const handleAddVendor = useCallback(async () => { try { setIsAdddialogLoading(true) - - // TODO: 기술영업용 벤더, 통화, 지불조건, 인코텀즈 데이터 로드 함수 구현 필요 - // const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ - // fetchVendors(), - // fetchCurrencies(), - // fetchPaymentTerms(), - // fetchIncoterms() - // ]) - - // 임시 데이터 - setVendors([]) - setCurrencies([]) - setPaymentTerms([]) - setIncoterms([]) - setVendorDialogOpen(true) } catch (error) { console.error("데이터 로드 오류:", error) @@ -417,39 +374,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps return; } - // 다른 액션들은 기존과 동일하게 처리 - setIsAdddialogLoading(true); - - // TODO: 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) - // const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ - // fetchVendors(), - // fetchCurrencies(), - // fetchPaymentTerms(), - // fetchIncoterms() - // ]); - - // 임시 데이터 - setVendors([]); - setCurrencies([]); - setPaymentTerms([]); - setIncoterms([]); - - // 이제 데이터가 로드되었으므로 필요한 작업 수행 - if (rowAction.type === "update") { - setSelectedDetail(rowAction.row.original); - setUpdateSheetOpen(true); - } else if (rowAction.type === "delete") { + // 삭제 액션인 경우 + if (rowAction.type === "delete") { setSelectedDetail(rowAction.row.original); setDeleteDialogOpen(true); + setRowAction(null); + return; } } catch (error) { - console.error("데이터 로드 오류:", error); - toast.error("데이터를 불러오는 중 오류가 발생했습니다"); - } finally { - // communicate 타입이 아닌 경우에만 로딩 상태 변경 - if (rowAction && rowAction.type !== "communicate") { - setIsAdddialogLoading(false); - } + console.error("액션 처리 오류:", error); + toast.error("작업을 처리하는 중 오류가 발생했습니다"); } }; @@ -615,17 +549,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps onSuccess={handleRefreshData} /> - - - -// 데이터 타입 정의 -interface Vendor { - id: number; - vendorName: string; - vendorCode: string; -} - -interface Currency { - code: string; - name: string; -} - -interface PaymentTerm { - code: string; - description: string; -} - -interface Incoterm { - code: string; - description: string; -} - -interface UpdateRfqDetailSheetProps - extends React.ComponentPropsWithRef { - detail: RfqDetailView | null; - vendors: Vendor[]; - currencies: Currency[]; - paymentTerms: PaymentTerm[]; - incoterms: Incoterm[]; - onSuccess?: () => void; -} - -export function UpdateVendorSheet({ - detail, - vendors, - currencies, - paymentTerms, - incoterms, - onSuccess, - ...props -}: UpdateRfqDetailSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const [vendorOpen, setVendorOpen] = React.useState(false) - - const form = useForm({ - resolver: zodResolver(updateRfqDetailSchema), - defaultValues: { - vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "", - currency: detail?.currency || "", - paymentTermsCode: detail?.paymentTermsCode || "", - incotermsCode: detail?.incotermsCode || "", - incotermsDetail: detail?.incotermsDetail || "", - deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", - taxCode: detail?.taxCode || "", - placeOfShipping: detail?.placeOfShipping || "", - placeOfDestination: detail?.placeOfDestination || "", - materialPriceRelatedYn: detail?.materialPriceRelatedYn || false, - }, - }) - - // detail이 변경될 때 form 값 업데이트 - React.useEffect(() => { - if (detail) { - const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id - - form.reset({ - vendorId: vendorId ? String(vendorId) : "", - currency: detail.currency || "", - paymentTermsCode: detail.paymentTermsCode || "", - incotermsCode: detail.incotermsCode || "", - incotermsDetail: detail.incotermsDetail || "", - deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", - taxCode: detail.taxCode || "", - placeOfShipping: detail.placeOfShipping || "", - placeOfDestination: detail.placeOfDestination || "", - materialPriceRelatedYn: detail.materialPriceRelatedYn || false, - }) - } - }, [detail, form, vendors]) - - function onSubmit(values: UpdateRfqDetailFormValues) { - if (!detail) return - - startUpdateTransition(async () => { - try { - const result = await updateRfqDetail(detail.detailId, values) - - if (!result.success) { - toast.error(result.message || "수정 중 오류가 발생했습니다") - return - } - - props.onOpenChange?.(false) - toast.success("RFQ 벤더 정보가 수정되었습니다") - onSuccess?.() - } catch (error) { - console.error("RFQ 벤더 수정 오류:", error) - toast.error("수정 중 오류가 발생했습니다") - } - }) - } - - return ( - - - - RFQ 벤더 정보 수정 - - 벤더 정보를 수정하고 저장하세요 - - - -
- - {/* 검색 가능한 벤더 선택 필드 */} - ( - - 벤더 * - - - - - - - - - - 검색 결과가 없습니다 - - - {vendors.map((vendor) => ( - { - form.setValue("vendorId", String(vendor.id), { - shouldValidate: true, - }) - setVendorOpen(false) - }} - > - - {vendor.vendorName} ({vendor.vendorCode}) - - ))} - - - - - - - - )} - /> - - ( - - 통화 * - - - - )} - /> - -
- ( - - 지불 조건 * - - - - )} - /> - - ( - - 인코텀즈 * - - - - )} - /> -
- - ( - - 인코텀즈 세부사항 - - - - - - )} - /> - -
- ( - - 납품 예정일 - - - - - - )} - /> - - ( - - 세금 코드 - - - - - - )} - /> -
- -
- ( - - 선적지 - - - - - - )} - /> - - ( - - 도착지 - - - - - - )} - /> -
- - ( - - - - -
- 자재 가격 관련 여부 -
-
- )} - /> - - -
- - - - - - -
-
- ) -} \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx index 51ef7b38..958cc8d1 100644 --- a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx +++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx @@ -37,7 +37,7 @@ import { } from "@/components/ui/dialog" import { formatDateTime } from "@/lib/utils" import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트 -import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services" +import { fetchTechSalesVendorComments, markTechSalesMessagesAsRead } from "@/lib/techsales-rfq/service" // 타입 정의 interface Comment { @@ -59,7 +59,7 @@ interface Attachment { id: number; fileName: string; fileSize: number; - fileType: string; + fileType: string | null; filePath: string; uploadedAt: Date; } @@ -99,8 +99,8 @@ async function sendComment(params: { }); } - // API 엔드포인트 구성 - const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; + // API 엔드포인트 구성 - techSales용으로 변경 + const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; // API 호출 const response = await fetch(url, { @@ -169,11 +169,11 @@ export function VendorCommunicationDrawer({ setIsLoading(true); // Server Action을 사용하여 코멘트 데이터 가져오기 - const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId || 0); + const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0); setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅 // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경 - await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0); + await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0); } catch (error) { console.error("코멘트 로드 오류:", error); toast.error("메시지를 불러오는 중 오류가 발생했습니다"); @@ -269,15 +269,15 @@ export function VendorCommunicationDrawer({ const renderAttachmentPreviewDialog = () => { if (!selectedAttachment) return null; - const isImage = selectedAttachment.fileType.startsWith("image/"); - const isPdf = selectedAttachment.fileType.includes("pdf"); + const isImage = selectedAttachment.fileType?.startsWith("image/"); + const isPdf = selectedAttachment.fileType?.includes("pdf"); return ( - {getFileIcon(selectedAttachment.fileType)} + {getFileIcon(selectedAttachment.fileType || '')} {selectedAttachment.fileName} @@ -300,7 +300,7 @@ export function VendorCommunicationDrawer({ /> ) : (
- {getFileIcon(selectedAttachment.fileType)} + {getFileIcon(selectedAttachment.fileType || '')}

미리보기를 지원하지 않는 파일 형식입니다.

+ ) + }, + enableSorting: false, + enableResizing: true, + size: 80, + meta: { + excelHeader: "첨부파일" + }, + }, { accessorKey: "rfqSendDate", header: ({ column }) => ( @@ -283,103 +320,6 @@ export function getColumns({ enableResizing: true, size: 160, }, - // { - // accessorKey: "remark", - // header: ({ column }) => ( - // - // ), - // cell: ({ row }) => { - // const id = row.original.id; - // const value = row.getValue("remark") as string; - - // const isEditing = - // editingCell?.rowId === row.id && - // editingCell.value !== undefined; - - // const startEditing = () => { - // setEditingCell({ - // rowId: row.id, - // value: value || "" - // }); - // }; - - // const cancelEditing = () => { - // setEditingCell(null); - // }; - - // const saveChanges = async () => { - // if (!editingCell) return; - - // try { - // await updateRemark(id, editingCell.value); - // setEditingCell(null); - // } catch (error) { - // toast.error("비고 업데이트 중 오류가 발생했습니다."); - // console.error("Error updating remark:", error); - // } - // }; - - // const handleKeyDown = (e: React.KeyboardEvent) => { - // if (e.key === "Enter") { - // saveChanges(); - // } else if (e.key === "Escape") { - // cancelEditing(); - // } - // }; - - // if (isEditing) { - // return ( - //
- // setEditingCell({ - // rowId: row.id, - // value: e.target.value - // })} - // onKeyDown={handleKeyDown} - // autoFocus - // className="h-8 w-full" - // /> - // - // - //
- // ); - // } - - // return ( - //
- // {value || ""} - // - //
- // ); - // }, - // meta: { - // excelHeader: "비고" - // }, - // enableResizing: true, - // size: 200, - // }, { id: "actions", header: ({ column }) => ( @@ -390,7 +330,7 @@ export function getColumns({
) } \ No newline at end of file diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx new file mode 100644 index 00000000..ecdf6d81 --- /dev/null +++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx @@ -0,0 +1,540 @@ +"use client" + +import * as React from "react" +import { z } from "zod" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription +} from "@/components/ui/form" +import { Loader, Download, X, Eye, AlertCircle } from "lucide-react" +import { toast } from "sonner" +import { Badge } from "@/components/ui/badge" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" + +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list" + +import prettyBytes from "pretty-bytes" +import { formatDate } from "@/lib/utils" +import { processTechSalesRfqAttachments, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service" + +const MAX_FILE_SIZE = 6e8 // 600MB + +/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */ +export interface ExistingTechSalesAttachment { + id: number + techSalesRfqId: number + fileName: string + originalFileName: string + filePath: string + fileSize?: number + fileType?: string + attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" + description?: string + createdBy: number + createdAt: Date +} + +/** 새로 업로드할 파일 */ +const newUploadSchema = z.object({ + fileObj: z.any().optional(), // 실제 File + attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]).default("RFQ_COMMON"), + description: z.string().optional(), +}) + +/** 기존 첨부 (react-hook-form에서 관리) */ +const existingAttachSchema = z.object({ + id: z.number(), + techSalesRfqId: z.number(), + fileName: z.string(), + originalFileName: z.string(), + filePath: z.string(), + fileSize: z.number().optional(), + fileType: z.string().optional(), + attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]), + description: z.string().optional(), + createdBy: z.number(), + createdAt: z.custom(), +}) + +/** RHF 폼 전체 스키마 */ +const attachmentsFormSchema = z.object({ + techSalesRfqId: z.number().int(), + existing: z.array(existingAttachSchema), + newUploads: z.array(newUploadSchema), +}) + +type AttachmentsFormValues = z.infer + +// TechSalesRfq 타입 (간단 버전) +interface TechSalesRfq { + id: number + rfqCode: string | null + status: string + // 필요한 다른 필드들... +} + +interface TechSalesRfqAttachmentsSheetProps + extends React.ComponentPropsWithRef { + defaultAttachments?: ExistingTechSalesAttachment[] + rfq: TechSalesRfq | null + /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */ + onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void + /** 강제 읽기 전용 모드 (파트너/벤더용) */ + readOnly?: boolean +} + +export function TechSalesRfqAttachmentsSheet({ + defaultAttachments = [], + onAttachmentsUpdated, + rfq, + readOnly = false, + ...props +}: TechSalesRfqAttachmentsSheetProps) { + const [isPending, setIsPending] = React.useState(false) + + // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false) + const isEditable = React.useMemo(() => { + if (!rfq || readOnly) return false + // RFQ Created, RFQ Vendor Assignned 상태에서만 편집 가능 + return ["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status) + }, [rfq, readOnly]) + + const form = useForm({ + resolver: zodResolver(attachmentsFormSchema), + defaultValues: { + techSalesRfqId: rfq?.id || 0, + existing: defaultAttachments.map(att => ({ + id: att.id, + techSalesRfqId: att.techSalesRfqId, + fileName: att.fileName, + originalFileName: att.originalFileName, + filePath: att.filePath, + fileSize: att.fileSize || undefined, + fileType: att.fileType || undefined, + attachmentType: att.attachmentType, + description: att.description || undefined, + createdBy: att.createdBy, + createdAt: att.createdAt, + })), + newUploads: [], + }, + }) + + // useFieldArray for existing and new uploads + const { + fields: existingFields, + remove: removeExisting, + } = useFieldArray({ + control: form.control, + name: "existing", + }) + + const { + fields: newUploadFields, + append: appendNewUpload, + remove: removeNewUpload, + } = useFieldArray({ + control: form.control, + name: "newUploads", + }) + + // Reset form when defaultAttachments changes + React.useEffect(() => { + if (defaultAttachments) { + form.reset({ + techSalesRfqId: rfq?.id || 0, + existing: defaultAttachments.map(att => ({ + id: att.id, + techSalesRfqId: att.techSalesRfqId, + fileName: att.fileName, + originalFileName: att.originalFileName, + filePath: att.filePath, + fileSize: att.fileSize || undefined, + fileType: att.fileType || undefined, + attachmentType: att.attachmentType, + description: att.description || undefined, + createdBy: att.createdBy, + createdAt: att.createdAt, + })), + newUploads: [], + }) + } + }, [defaultAttachments, rfq?.id, form]) + + // Handle dropzone accept + const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => { + acceptedFiles.forEach((file) => { + appendNewUpload({ + fileObj: file, + attachmentType: "RFQ_COMMON", + description: "", + }) + }) + }, [appendNewUpload]) + + // Handle dropzone reject + const handleDropRejected = React.useCallback(() => { + toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.") + }, []) + + // Handle remove existing attachment + const handleRemoveExisting = React.useCallback((index: number) => { + removeExisting(index) + }, [removeExisting]) + + // Handle form submission + const onSubmit = async (data: AttachmentsFormValues) => { + if (!rfq) { + toast.error("RFQ 정보를 찾을 수 없습니다.") + return + } + + setIsPending(true) + try { + // 삭제할 첨부파일 ID 수집 + const deleteAttachmentIds = defaultAttachments + .filter((original) => !data.existing.find(existing => existing.id === original.id)) + .map(attachment => attachment.id) + + // 새 파일 정보 수집 + const newFiles = data.newUploads + .filter(upload => upload.fileObj) + .map(upload => ({ + file: upload.fileObj as File, + attachmentType: upload.attachmentType, + description: upload.description, + })) + + // 실제 API 호출 + const result = await processTechSalesRfqAttachments({ + techSalesRfqId: rfq.id, + newFiles, + deleteAttachmentIds, + createdBy: 1, // TODO: 실제 사용자 ID로 변경 + }) + + if (result.error) { + toast.error(result.error) + return + } + + // 성공 메시지 표시 (업로드된 파일 수 포함) + const uploadedCount = newFiles.length + const deletedCount = deleteAttachmentIds.length + + let successMessage = "첨부파일이 저장되었습니다." + if (uploadedCount > 0 && deletedCount > 0) { + successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료` + } else if (uploadedCount > 0) { + successMessage = `${uploadedCount}개 파일이 업로드되었습니다.` + } else if (deletedCount > 0) { + successMessage = `${deletedCount}개 파일이 삭제되었습니다.` + } + + toast.success(successMessage) + + // 즉시 첨부파일 목록 새로고침 + const refreshResult = await getTechSalesRfqAttachments(rfq.id) + if (refreshResult.error) { + console.error("첨부파일 목록 새로고침 실패:", refreshResult.error) + toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.") + } else { + // 새로운 첨부파일 목록으로 폼 업데이트 + const refreshedAttachments = refreshResult.data.map(att => ({ + id: att.id, + techSalesRfqId: att.techSalesRfqId || rfq.id, + fileName: att.fileName, + originalFileName: att.originalFileName, + filePath: att.filePath, + fileSize: att.fileSize, + fileType: att.fileType, + attachmentType: att.attachmentType as "RFQ_COMMON" | "VENDOR_SPECIFIC", + description: att.description, + createdBy: att.createdBy, + createdAt: att.createdAt, + })) + + // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움) + form.reset({ + techSalesRfqId: rfq.id, + existing: refreshedAttachments.map(att => ({ + ...att, + fileSize: att.fileSize || undefined, + fileType: att.fileType || undefined, + description: att.description || undefined, + })), + newUploads: [], + }) + + // 즉시 UI 업데이트를 위한 추가 피드백 + if (uploadedCount > 0) { + toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 }) + } + } + + // 콜백으로 상위 컴포넌트에 변경사항 알림 + const newAttachmentCount = refreshResult.error ? + (data.existing.length + newFiles.length - deleteAttachmentIds.length) : + refreshResult.data.length + onAttachmentsUpdated?.(rfq.id, newAttachmentCount) + + } catch (error) { + console.error("첨부파일 저장 오류:", error) + toast.error("첨부파일 저장 중 오류가 발생했습니다.") + } finally { + setIsPending(false) + } + } + + return ( + + + + 첨부파일 관리 + + RFQ: {rfq?.rfqCode || "N/A"} + {!isEditable && ( +
+ + 현재 상태에서는 편집할 수 없습니다 +
+ )} +
+
+ +
+ + {/* 1) Existing attachments */} +
+
+ 기존 첨부파일 ({existingFields.length}개) +
+ {existingFields.map((field, index) => { + const typeLabel = field.attachmentType === "RFQ_COMMON" ? "공통" : "벤더별" + const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음" + const dateText = field.createdAt ? formatDate(field.createdAt) : "" + + return ( +
+
+
+

+ {field.originalFileName || field.fileName} +

+ + {typeLabel} + +
+

+ {sizeText} • {dateText} +

+ {field.description && ( +

+ {field.description} +

+ )} +
+ +
+ {/* Download button */} + {field.filePath && ( + + + + )} + {/* Remove button - 편집 가능할 때만 표시 */} + {isEditable && ( + + )} +
+
+ ) + })} +
+ + {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} + {isEditable ? ( + <> + + {({ maxSize }) => ( + ( + + 새 파일 업로드 + + + + +
+ +
+ 파일을 드래그하거나 클릭하세요 + + 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"} + +
+
+
+ 파일을 여러 개 선택할 수 있습니다. + +
+ )} + /> + )} +
+ + {/* newUpload fields -> FileList */} + {newUploadFields.length > 0 && ( +
+
+ 새 파일 ({newUploadFields.length}개) +
+ + {newUploadFields.map((field, idx) => { + const fileObj = form.getValues(`newUploads.${idx}.fileObj`) + if (!fileObj) return null + + const fileName = fileObj.name + const fileSize = fileObj.size + return ( + + + + + {fileName} + + {prettyBytes(fileSize)} + + + removeNewUpload(idx)}> + + 제거 + + + + {/* 파일별 설정 */} +
+ ( + + 파일 타입 + + + + )} + /> +
+
+ ) + })} +
+
+ )} + + ) : ( +
+
+ +

보기 모드에서는 파일 첨부를 할 수 없습니다.

+
+
+ )} + + + + + + {isEditable && ( + + )} + +
+ +
+
+ ) +} \ No newline at end of file -- cgit v1.2.3