diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-12 11:36:25 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-12 11:36:25 +0000 |
| commit | a6b9cdaf9ea5ed548292632f821e36453f377a83 (patch) | |
| tree | 1c07b92b4173bfe3a12eedba7188fba8dc6f94cb /lib/procurement-rfqs/table | |
| parent | df91418cd28e98ce05845e476e51aa810202bf33 (diff) | |
(대표님) procurement-rfq 작업업
Diffstat (limited to 'lib/procurement-rfqs/table')
| -rw-r--r-- | lib/procurement-rfqs/table/pr-item-dialog.tsx | 258 | ||||
| -rw-r--r-- | lib/procurement-rfqs/table/rfq-table-column.tsx | 373 | ||||
| -rw-r--r-- | lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx | 279 | ||||
| -rw-r--r-- | lib/procurement-rfqs/table/rfq-table.tsx | 209 |
4 files changed, 1119 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/table/pr-item-dialog.tsx b/lib/procurement-rfqs/table/pr-item-dialog.tsx new file mode 100644 index 00000000..4523295d --- /dev/null +++ b/lib/procurement-rfqs/table/pr-item-dialog.tsx @@ -0,0 +1,258 @@ +"use client"; + +import * as React from "react"; +import { useState, useEffect } from "react"; +import { formatDate } from "@/lib/utils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; +import { ProcurementRfqsView } from "@/db/schema"; +import { fetchPrItemsByRfqId } from "../services"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; + +// PR 항목 타입 정의 +interface PrItemView { + id: number; + procurementRfqsId: number; + rfqItem: string | null; + prItem: string | null; + prNo: string | null; + itemId: number | null; + materialCode: string | null; + materialCategory: string | null; + acc: string | null; + materialDescription: string | null; + size: string | null; + deliveryDate: Date | null; + quantity: number | null; + uom: string | null; + grossWeight: number | null; + gwUom: string | null; + specNo: string | null; + specUrl: string | null; + trackingNo: string | null; + majorYn: boolean | null; + projectDef: string | null; + projectSc: string | null; + projectKl: string | null; + projectLc: string | null; + projectDl: string | null; + remark: string | null; + rfqCode: string | null; + itemCode: string | null; + itemName: string | null; +} + +interface PrDetailsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedRfq: ProcurementRfqsView | null; +} + +export function PrDetailsDialog({ + open, + onOpenChange, + selectedRfq, +}: PrDetailsDialogProps) { + const [isLoading, setIsLoading] = useState(false); + const [prItems, setPrItems] = useState<PrItemView[]>([]); + const [searchTerm, setSearchTerm] = useState(""); + + // 검색어로 필터링된 항목들 + const filteredItems = React.useMemo(() => { + if (!searchTerm.trim()) return prItems; + + const term = searchTerm.toLowerCase(); + return prItems.filter(item => + (item.materialDescription || "").toLowerCase().includes(term) || + (item.materialCode || "").toLowerCase().includes(term) || + (item.prNo || "").toLowerCase().includes(term) || + (item.prItem || "").toLowerCase().includes(term) || + (item.rfqItem || "").toLowerCase().includes(term) + ); + }, [prItems, searchTerm]); + + // 선택된 RFQ가 변경되면 PR 항목 데이터를 가져옴 + useEffect(() => { + async function loadPrItems() { + if (!selectedRfq || !open) { + setPrItems([]); + return; + } + + try { + setIsLoading(true); + const result = await fetchPrItemsByRfqId(selectedRfq.id); + const mappedItems: PrItemView[] = result.data.map(item => ({ + ...item, + // procurementRfqsId가 null이면 selectedRfq.id 사용 + procurementRfqsId: item.procurementRfqsId ?? selectedRfq.id, + // 기타 필요한 필드에 대한 기본값 처리 + rfqItem: item.rfqItem ?? null, + prItem: item.prItem ?? null, + prNo: item.prNo ?? null, + // 다른 필드도 필요에 따라 추가 + })); + + setPrItems(mappedItems); + } catch (error) { + console.error("PR 항목 로드 오류:", error); + setPrItems([]); + } finally { + setIsLoading(false); + } + } + + if (open) { + loadPrItems(); + setSearchTerm(""); + } + }, [selectedRfq, open]); + + // 선택된 RFQ가 없는 경우 + if (!selectedRfq) { + return null; + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-screen-sm max-h-[90vh] flex flex-col" style={{ maxWidth: "70vw" }}> + <DialogHeader> + <DialogTitle className="text-xl"> + PR 상세 정보 - {selectedRfq.rfqCode} + </DialogTitle> + <DialogDescription> + 프로젝트: {selectedRfq.projectName} ({selectedRfq.projectCode}) | 건수:{" "} + {selectedRfq.prItemsCount || 0}건 + </DialogDescription> + </DialogHeader> + + {isLoading ? ( + <div className="py-4 space-y-3"> + <Skeleton className="h-8 w-full" /> + <Skeleton className="h-24 w-full" /> + <Skeleton className="h-24 w-full" /> + </div> + ) : ( + <div className="flex-1 flex flex-col"> + {/* 검색 필드 */} + <div className="mb-4 relative"> + <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none"> + <Search className="h-4 w-4 text-muted-foreground" /> + </div> + <Input + placeholder="PR 번호, 자재 코드, 설명 등 검색..." + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-8" + /> +</div> + {filteredItems.length === 0 ? ( + <div className="flex items-center justify-center py-8 text-muted-foreground border rounded-md"> + {prItems.length === 0 ? "PR 항목이 없습니다" : "검색 결과가 없습니다"} + </div> + ) : ( + <div className="rounded-md border flex-1 overflow-hidden"> + <div className="overflow-x-auto" style={{ width: "100%" }}> + <Table style={{ minWidth: "2500px" }}> + <TableCaption> + 총 {filteredItems.length}개 항목 (전체 {prItems.length}개 중) + </TableCaption> + <TableHeader className="bg-muted/50 sticky top-0"> + <TableRow> + <TableHead className="w-[100px] whitespace-nowrap">RFQ Item</TableHead> + <TableHead className="w-[120px] whitespace-nowrap">PR 번호</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">PR Item</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">자재그룹</TableHead> + <TableHead className="w-[120px] whitespace-nowrap">자재 코드</TableHead> + <TableHead className="w-[120px] whitespace-nowrap">자재 카테고리</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">ACC</TableHead> + <TableHead className="min-w-[200px] whitespace-nowrap">자재 설명</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">규격</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">납품일</TableHead> + <TableHead className="w-[80px] whitespace-nowrap">수량</TableHead> + <TableHead className="w-[80px] whitespace-nowrap">UOM</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">총중량</TableHead> + <TableHead className="w-[80px] whitespace-nowrap">중량 단위</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">사양 번호</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">사양 URL</TableHead> + <TableHead className="w-[120px] whitespace-nowrap">추적 번호</TableHead> + <TableHead className="w-[80px] whitespace-nowrap">주요 항목</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">프로젝트 DEF</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">프로젝트 SC</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">프로젝트 KL</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">프로젝트 LC</TableHead> + <TableHead className="w-[100px] whitespace-nowrap">프로젝트 DL</TableHead> + <TableHead className="w-[150px] whitespace-nowrap">비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {filteredItems.map((item) => ( + <TableRow key={item.id}> + <TableCell className="whitespace-nowrap">{item.rfqItem || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.prNo || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.prItem || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.itemCode || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.materialCode || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.materialCategory || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.acc || "-"}</TableCell> + <TableCell>{item.materialDescription || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.size || "-"}</TableCell> + <TableCell className="whitespace-nowrap"> + {item.deliveryDate ? formatDate(item.deliveryDate) : "-"} + </TableCell> + <TableCell className="whitespace-nowrap">{item.quantity || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.uom || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.grossWeight || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.gwUom || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.specNo || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.specUrl || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.trackingNo || "-"}</TableCell> + <TableCell className="whitespace-nowrap"> + {item.majorYn ? ( + <Badge variant="secondary">주요</Badge> + ) : ( + "아니오" + )} + </TableCell> + <TableCell className="whitespace-nowrap">{item.projectDef || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.projectSc || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.projectKl || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.projectLc || "-"}</TableCell> + <TableCell className="whitespace-nowrap">{item.projectDl || "-"}</TableCell> + <TableCell className="text-sm">{item.remark || "-"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </div> + )} + </div> + )} + + <DialogFooter className="mt-2"> + <Button onClick={() => onOpenChange(false)}>닫기</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/rfq-table-column.tsx b/lib/procurement-rfqs/table/rfq-table-column.tsx new file mode 100644 index 00000000..3cf06315 --- /dev/null +++ b/lib/procurement-rfqs/table/rfq-table-column.tsx @@ -0,0 +1,373 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { ProcurementRfqsView } from "@/db/schema" +import { Check, Pencil, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { toast } from "sonner" +import { Input } from "@/components/ui/input" +import { updateRfqRemark } from "../services" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProcurementRfqsView> | null>>; + // 상태와 상태 설정 함수를 props로 받음 + editingCell: EditingCellState | null; + setEditingCell: (state: EditingCellState | null) => void; + updateRemark: (rfqId: number, remark: string) => Promise<void>; +} + +export interface EditingCellState { + rowId: string | number; + value: string; +} + + +export function getColumns({ + setRowAction, + editingCell, + setEditingCell, + updateRemark, +}: GetColumnsProps): ColumnDef<ProcurementRfqsView>[] { + + + + return [ + { + id: "select", + // Remove the "Select all" checkbox in header since we're doing single-select + header: () => <span className="sr-only">Select</span>, + cell: ({ row, table }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => { + // If selecting this row + if (value) { + // First deselect all rows (to ensure single selection) + table.toggleAllRowsSelected(false) + // Then select just this row + row.toggleSelected(true) + // Trigger the same action that was in the "Select" button + setRowAction({ row, type: "select" }) + } else { + // Just deselect this row + row.toggleSelected(false) + } + }} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + enableResizing: false, + size: 40, + minSize: 40, + maxSize: 40, + }, + + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="status" /> + ), + cell: ({ row }) => <div>{row.getValue("status")}</div>, + meta: { + excelHeader: "status" + }, + enableResizing: true, + minSize: 80, + size: 100, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트" /> + ), + cell: ({ row }) => <div>{row.getValue("projectCode")}</div>, + meta: { + excelHeader: "프로젝트" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "series", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="시리즈" /> + ), + cell: ({ row }) => <div>{row.getValue("series")}</div>, + meta: { + excelHeader: "시리즈" + }, + enableResizing: true, + minSize: 80, + size: 100, + }, + { + accessorKey: "rfqSealedYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 밀봉" /> + ), + cell: ({ row }) => <div>{row.getValue("rfqSealedYn") ? "Y":"N"}</div>, + meta: { + excelHeader: "RFQ 밀봉" + }, + enableResizing: true, + minSize: 80, + size: 100, + }, + { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ NO." /> + ), + cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>, + meta: { + excelHeader: "RFQ NO." + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "po_no", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="대표 PR NO." /> + ), + cell: ({ row }) => <div>{row.getValue("po_no")}</div>, + meta: { + excelHeader: "대표 PR NO." + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "itemCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재그룹" /> + ), + cell: ({ row }) => <div>{row.getValue("itemCode")}</div>, + meta: { + excelHeader: "자재그룹" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "majorItemMaterialCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재코드" /> + ), + cell: ({ row }) => <div>{row.getValue("majorItemMaterialCode")}</div>, + meta: { + excelHeader: "자재코드" + }, + enableResizing: true, + minSize: 80, + size: 120, + }, + { + accessorKey: "itemName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재명" /> + ), + cell: ({ row }) => <div>{row.getValue("itemName")}</div>, + meta: { + excelHeader: "자재명" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "prItemsCount", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="PR 건수" /> + ), + cell: ({ row }) => <div>{row.getValue("prItemsCount")}</div>, + meta: { + excelHeader: "PR 건수" + }, + enableResizing: true, + // size: 80, + }, + { + accessorKey: "rfqSendDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 전송일" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "RFQ 전송일" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "earliestQuotationSubmittedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="첫회신 접수일" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "첫회신 접수일" + }, + enableResizing: true, + // size: 140, + }, + { + accessorKey: "dueDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "RFQ 마감일" + }, + enableResizing: true, + minSize: 80, + size: 120, + }, + { + accessorKey: "sentByUserName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 요청자" /> + ), + cell: ({ row }) => <div>{row.getValue("sentByUserName")}</div>, + meta: { + excelHeader: "RFQ 요청자" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDateTime(cell.getValue() as Date, "KR"), + meta: { + excelHeader: "updated At" + }, + enableResizing: true, + size: 140, + }, + + { + accessorKey: "remark", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="비고" /> + ), + cell: ({ row }) => { + const rowId = row.id + const value = row.getValue("remark") as string + const isEditing = editingCell && editingCell.rowId === rowId + + const startEditing = () => { + setEditingCell({ + rowId, + value: value || "" + }) + } + + const cancelEditing = () => { + setEditingCell(null) + } + + const saveChanges = async () => { + if (!editingCell) return + + try { + + // 컴포넌트에서 전달받은 업데이트 함수 사용 + await updateRemark(row.original.id, editingCell.value) + row.original.remark = editingCell.value; + + // 편집 모드 종료 + setEditingCell(null) + } catch (error) { + console.error("비고 업데이트 오류:", error) + } + } + + // 키보드 이벤트 처리 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + saveChanges() + } else if (e.key === "Escape") { + cancelEditing() + } + } + + if (isEditing) { + return ( + <div className="flex items-center space-x-1"> + <Input + value={editingCell.value} + onChange={(e) => setEditingCell({ + ...editingCell, + value: e.target.value + })} + onKeyDown={handleKeyDown} + autoFocus + className="h-8 w-full" + /> + <div className="flex items-center"> + <Button + variant="ghost" + size="icon" + onClick={saveChanges} + className="h-7 w-7" + > + <Check className="h-4 w-4 text-green-500" /> + </Button> + <Button + variant="ghost" + size="icon" + onClick={cancelEditing} + className="h-7 w-7" + > + <X className="h-4 w-4 text-red-500" /> + </Button> + </div> + </div> + ) + } + + return ( + <div + className="flex items-center justify-between group" + onDoubleClick={startEditing} // 더블클릭 이벤트 추가 + > + <div className="truncate">{value || "-"}</div> + <Button + variant="ghost" + size="icon" + onClick={startEditing} + className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity" + > + <Pencil className="h-3.5 w-3.5 text-muted-foreground" /> + </Button> + </div> + ) + }, + meta: { + excelHeader: "비고" + }, + enableResizing: true, + size: 200, + } + ] +}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx b/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx new file mode 100644 index 00000000..26725797 --- /dev/null +++ b/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx @@ -0,0 +1,279 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { ClipboardList, Download, Send, Lock, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { ProcurementRfqsView } from "@/db/schema" +import { PrDetailsDialog } from "./pr-item-dialog" +import { sealRfq, sendRfq, getPORfqs, fetchExternalRfqs } from "../services" + +// total 필드 추가하여 타입 정의 수정 +type PORfqsReturn = Awaited<ReturnType<typeof getPORfqs>> + +interface RFQTableToolbarActionsProps { + table: Table<ProcurementRfqsView>; + // 타입 수정 + localData?: PORfqsReturn; + setLocalData?: React.Dispatch<React.SetStateAction<PORfqsReturn>>; + onSuccess?: () => void; +} + +export function RFQTableToolbarActions({ + table, + localData, + setLocalData, + onSuccess +}: RFQTableToolbarActionsProps) { + // 다이얼로그 열림/닫힘 상태 관리 + const [dialogOpen, setDialogOpen] = React.useState(false) + const [isProcessing, setIsProcessing] = React.useState(false) + + // 선택된 RFQ 가져오기 + const getSelectedRfq = (): ProcurementRfqsView | null => { + const selectedRows = table.getFilteredSelectedRowModel().rows + if (selectedRows.length === 1) { + return selectedRows[0].original + } + return null + } + + // 선택된 RFQ + const selectedRfq = getSelectedRfq() + + // PR 상세보기 버튼 클릭 핸들러 + const handleViewPrDetails = () => { + const rfq = getSelectedRfq() + if (!rfq) { + toast.warning("RFQ를 선택해주세요") + return + } + + if (!rfq.prItemsCount || rfq.prItemsCount <= 0) { + toast.warning("선택한 RFQ에 PR 항목이 없습니다") + return + } + + setDialogOpen(true) + } + + // RFQ 밀봉 버튼 클릭 핸들러 + const handleSealRfq = async () => { + const rfq = getSelectedRfq() + if (!rfq) { + toast.warning("RFQ를 선택해주세요") + return + } + + // 이미 밀봉된 RFQ인 경우 + if (rfq.rfqSealedYn) { + toast.warning("이미 밀봉된 RFQ입니다") + return + } + + try { + setIsProcessing(true) + + // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신) + if (localData?.data && setLocalData) { + // 로컬 데이터에서 해당 행 찾기 + const rowIndex = localData.data.findIndex(row => row.id === rfq.id); + if (rowIndex >= 0) { + // 불변성을 유지하면서 로컬 데이터 업데이트 - 타입 안전하게 복사 + const newData = [...localData.data] as ProcurementRfqsView[]; + newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: "Y" }; + + // 전체 데이터 구조 복사하여 업데이트, total 필드가 있다면 유지 + setLocalData({ + ...localData, + data: newData ?? [], + pageCount: localData.pageCount, + total: localData.total ?? 0 + }); + } + } + + const result = await sealRfq(rfq.id) + + if (result.success) { + toast.success("RFQ가 성공적으로 밀봉되었습니다") + // 데이터 리프레시 + onSuccess?.() + } else { + toast.error(result.message || "RFQ 밀봉 중 오류가 발생했습니다") + + // 서버 요청 실패 시 낙관적 업데이트 되돌리기 + if (localData?.data && setLocalData) { + const rowIndex = localData.data.findIndex(row => row.id === rfq.id); + if (rowIndex >= 0) { + const newData = [...localData.data] as ProcurementRfqsView[]; + newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: rfq.rfqSealedYn }; // 원래 값으로 복원 + setLocalData({ + ...localData, + data: newData ?? [], + pageCount: localData.pageCount, + total: localData.total ?? 0 + }); + } + } + } + } catch (error) { + console.error("RFQ 밀봉 오류:", error) + toast.error("RFQ 밀봉 중 오류가 발생했습니다") + + // 에러 발생 시 낙관적 업데이트 되돌리기 + if (localData?.data && setLocalData) { + const rowIndex = localData.data.findIndex(row => row.id === rfq.id); + if (rowIndex >= 0) { + const newData = [...localData.data] as ProcurementRfqsView[]; + newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: rfq.rfqSealedYn }; // 원래 값으로 복원 + setLocalData({ + ...localData, + data: newData ?? [], + pageCount: localData.pageCount, + total: localData.total ?? 0 + }); + } + } + } finally { + setIsProcessing(false) + } + } + + // RFQ 전송 버튼 클릭 핸들러 + const handleSendRfq = async () => { + const rfq = getSelectedRfq() + if (!rfq) { + toast.warning("RFQ를 선택해주세요") + return + } + + // 전송 가능한 상태인지 확인 + if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") { + toast.warning("벤더가 할당된 RFQ이거나 전송한 적이 있는 RFQ만 전송할 수 있습니다") + return + } + + try { + setIsProcessing(true) + + const result = await sendRfq(rfq.id) + + if (result.success) { + toast.success("RFQ가 성공적으로 전송되었습니다") + // 데이터 리프레시 + onSuccess?.() + } else { + toast.error(result.message || "RFQ 전송 중 오류가 발생했습니다") + } + } catch (error) { + console.error("RFQ 전송 오류:", error) + toast.error("RFQ 전송 중 오류가 발생했습니다") + } finally { + setIsProcessing(false) + } + } + + const handleFetchExternalRfqs = async () => { + try { + setIsProcessing(true); + + const result = await fetchExternalRfqs(); + + if (result.success) { + toast.success(result.message || "외부 RFQ를 성공적으로 가져왔습니다"); + // 데이터 리프레시 + onSuccess?.() + } else { + toast.error(result.message || "외부 RFQ를 가져오는 중 오류가 발생했습니다"); + } + } catch (error) { + console.error("외부 RFQ 가져오기 오류:", error); + toast.error("외부 RFQ를 가져오는 중 오류가 발생했습니다"); + } finally { + setIsProcessing(false); + } + }; + + return ( + <> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "rfq", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + {/* RFQ 가져오기 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleFetchExternalRfqs} + className="gap-2" + disabled={isProcessing} + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">RFQ 가져오기</span> + </Button> + + {/* PR 상세보기 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleViewPrDetails} + className="gap-2" + disabled={!selectedRfq || !(selectedRfq.prItemsCount && selectedRfq.prItemsCount > 0)} + > + <ClipboardList className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">PR 상세보기</span> + </Button> + + {/* RFQ 밀봉 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleSealRfq} + className="gap-2" + disabled={!selectedRfq || selectedRfq.rfqSealedYn === "Y" || selectedRfq.status !== "RFQ Sent" || isProcessing} + > + <Lock className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">RFQ 밀봉</span> + </Button> + + {/* RFQ 전송 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleSendRfq} + className="gap-2" + disabled={ + !selectedRfq || + (selectedRfq.status !== "RFQ Vendor Assignned" && selectedRfq.status !== "RFQ Sent") || + isProcessing + } + > + <Send className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">RFQ 전송</span> + </Button> + </div> + + {/* PR 상세정보 다이얼로그 */} + <PrDetailsDialog + open={dialogOpen} + onOpenChange={setDialogOpen} + selectedRfq={selectedRfq} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/rfq-table.tsx b/lib/procurement-rfqs/table/rfq-table.tsx new file mode 100644 index 00000000..510f474d --- /dev/null +++ b/lib/procurement-rfqs/table/rfq-table.tsx @@ -0,0 +1,209 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { getColumns, EditingCellState } from "./rfq-table-column" +import { useEffect } from "react" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions" +import { ProcurementRfqsView } from "@/db/schema" +import { getPORfqs } from "../services" +import { toast } from "sonner" +import { updateRfqRemark } from "@/lib/procurement-rfqs/services" // 구현 필요 + +interface RFQListTableProps { + data?: Awaited<ReturnType<typeof getPORfqs>>; + onSelectRFQ?: (rfq: ProcurementRfqsView | null) => void; + // 데이터 새로고침을 위한 콜백 추가 + onDataRefresh?: () => void; + maxHeight?: string | number; // Add this prop +} + +// 보다 유연한 타입 정의 +type LocalDataType = Awaited<ReturnType<typeof getPORfqs>>; + +export function RFQListTable({ + data, + onSelectRFQ, + onDataRefresh, + maxHeight +}: RFQListTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null) + // 인라인 에디팅을 위한 상태 추가 + const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null) + // 로컬 데이터를 관리하기 위한 상태 추가 + const [localData, setLocalData] = React.useState<LocalDataType>(data || { data: [], pageCount: 0, total: 0 }); + + // 데이터가 변경될 때 로컬 데이터도 업데이트 + useEffect(() => { + setLocalData(data || { data: [], pageCount: 0, total: 0 }) + }, [data]) + + + // 비고 업데이트 함수 + const updateRemark = async (rfqId: number, remark: string) => { + try { + // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신) + if (localData && localData.data) { + // 로컬 데이터에서 해당 행 찾기 + const rowIndex = localData.data.findIndex(row => row.id === rfqId); + if (rowIndex >= 0) { + // 불변성을 유지하면서 로컬 데이터 업데이트 + const newData = [...localData.data]; + newData[rowIndex] = { ...newData[rowIndex], remark }; + + // 전체 데이터 구조 복사하여 업데이트 + setLocalData({ ...localData, data: newData } as typeof localData); + } + } + + const result = await updateRfqRemark(rfqId, remark); + + if (result.success) { + toast.success("비고가 업데이트되었습니다"); + + // 서버 데이터 리프레시 호출 + if (onDataRefresh) { + onDataRefresh(); + } + } else { + toast.error(result.message || "업데이트 중 오류가 발생했습니다"); + } + } catch (error) { + console.error("비고 업데이트 오류:", error); + toast.error("업데이트 중 오류가 발생했습니다"); + } + } + + // 행 액션 처리 + useEffect(() => { + if (rowAction) { + // 액션 유형에 따라 처리 + switch (rowAction.type) { + case "select": + // 선택된 문서 처리 + if (onSelectRFQ) { + onSelectRFQ(rowAction.row.original) + } + break; + case "update": + // 업데이트 처리 로직 + console.log("Update rfq:", rowAction.row.original) + break; + case "delete": + // 삭제 처리 로직 + console.log("Delete rfq:", rowAction.row.original) + break; + } + + // 액션 처리 후 rowAction 초기화 + setRowAction(null) + } + }, [rowAction, onSelectRFQ]) + + const columns = React.useMemo( + () => getColumns({ + setRowAction, + editingCell, + setEditingCell, + updateRemark + }), + [setRowAction, editingCell, setEditingCell, updateRemark] + ) + + + // Filter fields + const filterFields: DataTableFilterField<ProcurementRfqsView>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<ProcurementRfqsView>[] = [ + { + id: "rfqCode", + label: "RFQ No.", + type: "text", + }, + { + id: "projectCode", + label: "프로젝트", + type: "text", + }, + { + id: "itemCode", + label: "자재그룹", + type: "text", + }, + { + id: "itemName", + label: "자재명", + type: "text", + }, + + { + id: "rfqSealedYn", + label: "RFQ 밀봉여부", + type: "text", + }, + { + id: "majorItemMaterialCode", + label: "자재코드", + type: "text", + }, + { + id: "rfqSendDate", + label: "RFQ 전송일", + type: "date", + }, + { + id: "dueDate", + label: "RFQ 마감일", + type: "date", + }, + { + id: "createdByUserName", + label: "요청자", + type: "text", + }, + ] + + // useDataTable 훅으로 react-table 구성 - 로컬 데이터 사용하도록 수정 + const { table } = useDataTable({ + data: localData?.data || [], + columns, + pageCount: localData?.pageCount || 0, + rowCount: localData?.total || 0, // 총 레코드 수 추가 + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "updatedAt", desc: true }], + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + return ( + <div className="w-full overflow-auto"> + <DataTable table={table} maxHeight={maxHeight}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <RFQTableToolbarActions + table={table} + localData={localData} + setLocalData={setLocalData} + onSuccess={onDataRefresh} + /> + </DataTableAdvancedToolbar> + </DataTable> + </div> + ) +}
\ No newline at end of file |
