diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-08 11:23:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-08 11:23:40 +0000 |
| commit | b84621f9b2b7161a5ad4f0b194264e9df3e65dbf (patch) | |
| tree | ce5ec30b3d1e5104a3a2d942c71973779436783b /lib/b-rfq/final | |
| parent | 97936ddf280c56a4f122dedcb8dc389d0d2e63a2 (diff) | |
(대표님) 20250708 미반영분 커밋
Diffstat (limited to 'lib/b-rfq/final')
| -rw-r--r-- | lib/b-rfq/final/final-rfq-detail-columns.tsx | 589 | ||||
| -rw-r--r-- | lib/b-rfq/final/final-rfq-detail-table.tsx | 297 | ||||
| -rw-r--r-- | lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx | 201 | ||||
| -rw-r--r-- | lib/b-rfq/final/update-final-rfq-sheet.tsx | 70 |
4 files changed, 1157 insertions, 0 deletions
diff --git a/lib/b-rfq/final/final-rfq-detail-columns.tsx b/lib/b-rfq/final/final-rfq-detail-columns.tsx new file mode 100644 index 00000000..832923eb --- /dev/null +++ b/lib/b-rfq/final/final-rfq-detail-columns.tsx @@ -0,0 +1,589 @@ +// final-rfq-detail-columns.tsx +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { type Row } from "@tanstack/react-table" +import { + Ellipsis, Building, Eye, Edit, + MessageSquare, Settings, CheckCircle2, XCircle, DollarSign, Calendar +} from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuShortcut +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { FinalRfqDetailView } from "@/db/schema" + +// RowAction 타입 정의 +export interface DataTableRowAction<TData> { + row: Row<TData> + type: "update" +} + +interface GetFinalRfqDetailColumnsProps { + onSelectDetail?: (detail: any) => void + setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<FinalRfqDetailView> | null>> +} + +export function getFinalRfqDetailColumns({ + onSelectDetail, + setRowAction +}: GetFinalRfqDetailColumnsProps = {}): ColumnDef<FinalRfqDetailView>[] { + + return [ + /** ───────────── 체크박스 ───────────── */ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + /** 1. RFQ Status */ + { + accessorKey: "finalRfqStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최종 RFQ Status" /> + ), + cell: ({ row }) => { + const status = row.getValue("finalRfqStatus") as string + const getFinalStatusColor = (status: string) => { + switch (status) { + case "DRAFT": return "outline" + case "Final RFQ Sent": return "default" + case "Quotation Received": return "success" + case "Vendor Selected": return "default" + default: return "secondary" + } + } + return ( + <Badge variant={getFinalStatusColor(status) as any}> + {status} + </Badge> + ) + }, + size: 120 + }, + + /** 2. RFQ No. */ + { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ No." /> + ), + cell: ({ row }) => ( + <div className="text-sm font-medium"> + {row.getValue("rfqCode") as string} + </div> + ), + size: 120, + }, + + /** 3. Rev. */ + { + accessorKey: "returnRevision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Rev." /> + ), + cell: ({ row }) => { + const revision = row.getValue("returnRevision") as number + return revision > 0 ? ( + <Badge variant="outline"> + Rev. {revision} + </Badge> + ) : ( + <Badge variant="outline"> + Rev. 0 + </Badge> + ) + }, + size: 80, + }, + + /** 4. Vendor Code */ + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Code" /> + ), + cell: ({ row }) => ( + <div className="text-sm font-medium"> + {row.original.vendorCode} + </div> + ), + size: 100, + }, + + /** 5. Vendor Name */ + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Name" /> + ), + cell: ({ row }) => ( + <div className="text-sm font-medium"> + {row.original.vendorName} + </div> + ), + size: 150, + }, + + /** 6. 업체분류 */ + { + id: "vendorClassification", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="업체분류" /> + ), + cell: ({ row }) => { + const vendorCode = row.original.vendorCode as string + return vendorCode ? ( + <Badge variant="success" className="text-xs"> + 정규업체 + </Badge> + ) : ( + <Badge variant="secondary" className="text-xs"> + 잠재업체 + </Badge> + ) + }, + size: 100, + }, + + /** 7. CP 현황 */ + { + accessorKey: "cpRequestYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="CP 현황" /> + ), + cell: ({ row }) => { + const cpRequest = row.getValue("cpRequestYn") as boolean + return cpRequest ? ( + <Badge variant="success" className="text-xs"> + 신청 + </Badge> + ) : ( + <Badge variant="outline" className="text-xs"> + 미신청 + </Badge> + ) + }, + size: 80, + }, + + /** 8. GTC현황 */ + { + id: "gtcStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="GTC현황" /> + ), + cell: ({ row }) => { + const gtc = row.original.gtc as string + const gtcValidDate = row.original.gtcValidDate as string + const prjectGtcYn = row.original.prjectGtcYn as boolean + + if (prjectGtcYn || gtc) { + return ( + <div className="space-y-1"> + <Badge variant="success" className="text-xs"> + 보유 + </Badge> + {gtcValidDate && ( + <div className="text-xs text-muted-foreground"> + {gtcValidDate} + </div> + )} + </div> + ) + } + return ( + <Badge variant="outline" className="text-xs"> + 미보유 + </Badge> + ) + }, + size: 100, + }, + + /** 9. TBE 결과 (스키마에 없어서 placeholder) */ + { + id: "tbeResult", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="TBE 결과" /> + ), + cell: ({ row }) => { + // TODO: TBE 결과 로직 구현 필요 + return ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 80, + }, + + /** 10. 최종 선정 */ + { + id: "finalSelection", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최종 선정" /> + ), + cell: ({ row }) => { + const status = row.original.finalRfqStatus as string + return status === "Vendor Selected" ? ( + <Badge variant="success" className="text-xs"> + <CheckCircle2 className="h-3 w-3 mr-1" /> + 선정 + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 80, + }, + + /** 11. Currency */ + { + accessorKey: "currency", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Currency" /> + ), + cell: ({ row }) => { + const currency = row.getValue("currency") as string + return currency ? ( + <Badge variant="outline" className="text-xs"> + {/* <DollarSign className="h-3 w-3 mr-1" /> */} + {currency} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 80, + }, + + /** 12. Terms of Payment */ + { + accessorKey: "paymentTermsCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Terms of Payment" /> + ), + cell: ({ row }) => { + const paymentTermsCode = row.getValue("paymentTermsCode") as string + return paymentTermsCode ? ( + <Badge variant="secondary" className="text-xs"> + {paymentTermsCode} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 120, + }, + + /** 13. Payment Desc. */ + { + accessorKey: "paymentTermsDescription", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Payment Desc." /> + ), + cell: ({ row }) => { + const description = row.getValue("paymentTermsDescription") as string + return description ? ( + <div className="text-xs max-w-[150px] truncate" title={description}> + {description} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 150, + }, + + /** 14. TAX */ + { + accessorKey: "taxCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="TAX" /> + ), + cell: ({ row }) => { + const taxCode = row.getValue("taxCode") as string + return taxCode ? ( + <Badge variant="outline" className="text-xs"> + {taxCode} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 80, + }, + + /** 15. Delivery Date* */ + { + accessorKey: "deliveryDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Delivery Date*" /> + ), + cell: ({ row }) => { + const deliveryDate = row.getValue("deliveryDate") as Date + return deliveryDate ? ( + <div className="text-sm"> + {formatDate(deliveryDate)} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 120, + }, + + /** 16. Country */ + { + accessorKey: "vendorCountry", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Country" /> + ), + cell: ({ row }) => { + const country = row.getValue("vendorCountry") as string + const countryDisplay = country === "KR" ? "D" : "F" + return ( + <Badge variant="outline" className="text-xs"> + {countryDisplay} + </Badge> + ) + }, + size: 80, + }, + + /** 17. Place of Shipping */ + { + accessorKey: "placeOfShipping", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Place of Shipping" /> + ), + cell: ({ row }) => { + const placeOfShipping = row.getValue("placeOfShipping") as string + return placeOfShipping ? ( + <div className="text-xs max-w-[120px] truncate" title={placeOfShipping}> + {placeOfShipping} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 120, + }, + + /** 18. Place of Destination */ + { + accessorKey: "placeOfDestination", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Place of Destination" /> + ), + cell: ({ row }) => { + const placeOfDestination = row.getValue("placeOfDestination") as string + return placeOfDestination ? ( + <div className="text-xs max-w-[120px] truncate" title={placeOfDestination}> + {placeOfDestination} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 120, + }, + + /** 19. 초도 여부* */ + { + accessorKey: "firsttimeYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="초도 여부*" /> + ), + cell: ({ row }) => { + const firsttime = row.getValue("firsttimeYn") as boolean + return firsttime ? ( + <Badge variant="success" className="text-xs"> + 초도 + </Badge> + ) : ( + <Badge variant="outline" className="text-xs"> + 재구매 + </Badge> + ) + }, + size: 80, + }, + + /** 20. 연동제 적용* */ + { + accessorKey: "materialPriceRelatedYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="연동제 적용*" /> + ), + cell: ({ row }) => { + const materialPrice = row.getValue("materialPriceRelatedYn") as boolean + return materialPrice ? ( + <Badge variant="success" className="text-xs"> + 적용 + </Badge> + ) : ( + <Badge variant="outline" className="text-xs"> + 미적용 + </Badge> + ) + }, + size: 100, + }, + + /** 21. Business Size */ + { + id: "businessSizeDisplay", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Business Size" /> + ), + cell: ({ row }) => { + const businessSize = row.original.vendorBusinessSize as string + return businessSize ? ( + <Badge variant="outline" className="text-xs"> + {businessSize} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 100, + }, + + /** 22. 최종 Update일 */ + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최종 Update일" /> + ), + cell: ({ row }) => { + const updated = row.getValue("updatedAt") as Date + return updated ? ( + <div className="text-sm"> + {formatDate(updated)} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 120, + }, + + /** 23. 최종 Update담당자 (스키마에 없어서 placeholder) */ + { + id: "updatedByUser", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최종 Update담당자" /> + ), + cell: ({ row }) => { + // TODO: updatedBy 사용자 정보 조인 필요 + return ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 120, + }, + + /** 24. Vendor 설명 */ + { + accessorKey: "vendorRemark", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor 설명" /> + ), + cell: ({ row }) => { + const vendorRemark = row.getValue("vendorRemark") as string + return vendorRemark ? ( + <div className="text-xs max-w-[150px] truncate" title={vendorRemark}> + {vendorRemark} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 150, + }, + + /** 25. 비고 */ + { + accessorKey: "remark", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="비고" /> + ), + cell: ({ row }) => { + const remark = row.getValue("remark") as string + return remark ? ( + <div className="text-xs max-w-[150px] truncate" title={remark}> + {remark} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 150, + }, + + /** ───────────── 액션 ───────────── */ + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + <DropdownMenuItem> + <MessageSquare className="mr-2 h-4 w-4" /> + 벤더 견적 보기 + </DropdownMenuItem> + <DropdownMenuSeparator /> + {setRowAction && ( + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + )} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + }, + ] +}
\ No newline at end of file diff --git a/lib/b-rfq/final/final-rfq-detail-table.tsx b/lib/b-rfq/final/final-rfq-detail-table.tsx new file mode 100644 index 00000000..8ae42e7e --- /dev/null +++ b/lib/b-rfq/final/final-rfq-detail-table.tsx @@ -0,0 +1,297 @@ +"use client" + +import * as React from "react" +import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getFinalRfqDetail } from "../service" // 앞서 만든 서버 액션 +import { + getFinalRfqDetailColumns, + type DataTableRowAction +} from "./final-rfq-detail-columns" +import { FinalRfqDetailTableToolbarActions } from "./final-rfq-detail-toolbar-actions" +import { UpdateFinalRfqSheet } from "./update-final-rfq-sheet" +import { FinalRfqDetailView } from "@/db/schema" + +interface FinalRfqDetailTableProps { + promises: Promise<Awaited<ReturnType<typeof getFinalRfqDetail>>> + rfqId?: number +} + +export function FinalRfqDetailTable({ promises, rfqId }: FinalRfqDetailTableProps) { + const { data, pageCount } = React.use(promises) + + // 선택된 상세 정보 + const [selectedDetail, setSelectedDetail] = React.useState<any>(null) + + // Row action 상태 (update만) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<FinalRfqDetailView> | null>(null) + + const columns = React.useMemo( + () => getFinalRfqDetailColumns({ + onSelectDetail: setSelectedDetail, + setRowAction: setRowAction + }), + [] + ) + + /** + * 필터 필드 정의 + */ + const filterFields: DataTableFilterField<any>[] = [ + { + id: "rfqCode", + label: "RFQ 코드", + placeholder: "RFQ 코드로 검색...", + }, + { + id: "vendorName", + label: "벤더명", + placeholder: "벤더명으로 검색...", + }, + { + id: "rfqStatus", + label: "RFQ 상태", + options: [ + { label: "Draft", value: "DRAFT", count: 0 }, + { label: "문서 접수", value: "Doc. Received", count: 0 }, + { label: "담당자 배정", value: "PIC Assigned", count: 0 }, + { label: "문서 확정", value: "Doc. Confirmed", count: 0 }, + { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 }, + { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 }, + { label: "TBE 시작", value: "TBE started", count: 0 }, + { label: "TBE 완료", value: "TBE finished", count: 0 }, + { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 }, + { label: "견적 접수", value: "Quotation Received", count: 0 }, + { label: "벤더 선정", value: "Vendor Selected", count: 0 }, + ], + }, + { + id: "finalRfqStatus", + label: "최종 RFQ 상태", + options: [ + { label: "초안", value: "DRAFT", count: 0 }, + { label: "발송", value: "Final RFQ Sent", count: 0 }, + { label: "견적 접수", value: "Quotation Received", count: 0 }, + { label: "벤더 선정", value: "Vendor Selected", count: 0 }, + ], + }, + { + id: "vendorCountry", + label: "벤더 국가", + options: [ + { label: "한국", value: "KR", count: 0 }, + { label: "중국", value: "CN", count: 0 }, + { label: "일본", value: "JP", count: 0 }, + { label: "미국", value: "US", count: 0 }, + { label: "독일", value: "DE", count: 0 }, + ], + }, + { + id: "currency", + label: "통화", + options: [ + { label: "USD", value: "USD", count: 0 }, + { label: "EUR", value: "EUR", count: 0 }, + { label: "KRW", value: "KRW", count: 0 }, + { label: "JPY", value: "JPY", count: 0 }, + { label: "CNY", value: "CNY", count: 0 }, + ], + }, + ] + + /** + * 고급 필터 필드 + */ + const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ + { + id: "rfqCode", + label: "RFQ 코드", + type: "text", + }, + { + id: "vendorName", + label: "벤더명", + type: "text", + }, + { + id: "vendorCode", + label: "벤더 코드", + type: "text", + }, + { + id: "vendorCountry", + label: "벤더 국가", + type: "multi-select", + options: [ + { label: "한국", value: "KR" }, + { label: "중국", value: "CN" }, + { label: "일본", value: "JP" }, + { label: "미국", value: "US" }, + { label: "독일", value: "DE" }, + ], + }, + { + id: "rfqStatus", + label: "RFQ 상태", + type: "multi-select", + options: [ + { label: "Draft", value: "DRAFT" }, + { label: "문서 접수", value: "Doc. Received" }, + { label: "담당자 배정", value: "PIC Assigned" }, + { label: "문서 확정", value: "Doc. Confirmed" }, + { label: "초기 RFQ 발송", value: "Init. RFQ Sent" }, + { label: "초기 RFQ 응답", value: "Init. RFQ Answered" }, + { label: "TBE 시작", value: "TBE started" }, + { label: "TBE 완료", value: "TBE finished" }, + { label: "최종 RFQ 발송", value: "Final RFQ Sent" }, + { label: "견적 접수", value: "Quotation Received" }, + { label: "벤더 선정", value: "Vendor Selected" }, + ], + }, + { + id: "finalRfqStatus", + label: "최종 RFQ 상태", + type: "multi-select", + options: [ + { label: "초안", value: "DRAFT" }, + { label: "발송", value: "Final RFQ Sent" }, + { label: "견적 접수", value: "Quotation Received" }, + { label: "벤더 선정", value: "Vendor Selected" }, + ], + }, + { + id: "vendorBusinessSize", + label: "벤더 규모", + type: "multi-select", + options: [ + { label: "대기업", value: "LARGE" }, + { label: "중기업", value: "MEDIUM" }, + { label: "소기업", value: "SMALL" }, + { label: "스타트업", value: "STARTUP" }, + ], + }, + { + id: "incotermsCode", + label: "Incoterms", + type: "text", + }, + { + id: "paymentTermsCode", + label: "Payment Terms", + type: "text", + }, + { + id: "currency", + label: "통화", + type: "multi-select", + options: [ + { label: "USD", value: "USD" }, + { label: "EUR", value: "EUR" }, + { label: "KRW", value: "KRW" }, + { label: "JPY", value: "JPY" }, + { label: "CNY", value: "CNY" }, + ], + }, + { + id: "dueDate", + label: "마감일", + type: "date", + }, + { + id: "validDate", + label: "유효일", + type: "date", + }, + { + id: "deliveryDate", + label: "납기일", + type: "date", + }, + { + id: "shortList", + label: "Short List", + type: "boolean", + }, + { + id: "returnYn", + label: "Return 여부", + type: "boolean", + }, + { + id: "cpRequestYn", + label: "CP Request 여부", + type: "boolean", + }, + { + id: "prjectGtcYn", + label: "Project GTC 여부", + type: "boolean", + }, + { + id: "firsttimeYn", + label: "First Time 여부", + type: "boolean", + }, + { + id: "materialPriceRelatedYn", + label: "Material Price Related 여부", + type: "boolean", + }, + { + id: "classification", + label: "분류", + type: "text", + }, + { + id: "sparepart", + label: "예비부품", + type: "text", + }, + { + id: "createdAt", + label: "등록일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => originalRow.finalRfqId ? originalRow.finalRfqId.toString() : "1", + shallow: false, + clearOnDefault: true, + }) + + return ( + <div className="space-y-6"> + {/* 메인 테이블 */} + <div className="h-full w-full"> + <DataTable table={table} className="h-full"> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <FinalRfqDetailTableToolbarActions table={table} rfqId={rfqId} /> + </DataTableAdvancedToolbar> + </DataTable> + </div> + + {/* Update Sheet */} + <UpdateFinalRfqSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + finalRfq={rowAction?.type === "update" ? rowAction.row.original : null} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx new file mode 100644 index 00000000..d8be4f7b --- /dev/null +++ b/lib/b-rfq/final/final-rfq-detail-toolbar-actions.tsx @@ -0,0 +1,201 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { + Mail, + CheckCircle2, + Loader, + Award, + RefreshCw +} from "lucide-react" +import { FinalRfqDetailView } from "@/db/schema" + +interface FinalRfqDetailTableToolbarActionsProps { + table: Table<FinalRfqDetailView> + rfqId?: number + onRefresh?: () => void // 데이터 새로고침 콜백 +} + +export function FinalRfqDetailTableToolbarActions({ + table, + rfqId, + onRefresh +}: FinalRfqDetailTableToolbarActionsProps) { + const router = useRouter() + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedDetails = selectedRows.map((row) => row.original) + const selectedCount = selectedRows.length + + // 상태 관리 + const [isEmailSending, setIsEmailSending] = React.useState(false) + const [isSelecting, setIsSelecting] = React.useState(false) + + // RFQ 발송 핸들러 (로직 없음) + const handleBulkRfqSend = async () => { + if (selectedCount === 0) { + toast.error("발송할 RFQ를 선택해주세요.") + return + } + + setIsEmailSending(true) + + try { + // TODO: 실제 RFQ 발송 로직 구현 + await new Promise(resolve => setTimeout(resolve, 2000)) // 임시 딜레이 + + toast.success(`${selectedCount}개의 최종 RFQ가 발송되었습니다.`) + + // 선택 해제 + table.toggleAllRowsSelected(false) + + // 데이터 새로고침 + if (onRefresh) { + onRefresh() + } + + } catch (error) { + console.error("RFQ sending error:", error) + toast.error("최종 RFQ 발송 중 오류가 발생했습니다.") + } finally { + setIsEmailSending(false) + } + } + + // 최종 선정 핸들러 (로직 없음) + const handleFinalSelection = async () => { + if (selectedCount === 0) { + toast.error("최종 선정할 벤더를 선택해주세요.") + return + } + + if (selectedCount > 1) { + toast.error("최종 선정은 1개의 벤더만 가능합니다.") + return + } + + setIsSelecting(true) + + try { + // TODO: 실제 최종 선정 로직 구현 + await new Promise(resolve => setTimeout(resolve, 1500)) // 임시 딜레이 + + const selectedVendor = selectedDetails[0] + toast.success(`${selectedVendor.vendorName}이(가) 최종 선정되었습니다.`) + + // 선택 해제 + table.toggleAllRowsSelected(false) + + // 데이터 새로고침 + if (onRefresh) { + onRefresh() + } + + // 계약서 페이지로 이동 (필요시) + if (rfqId) { + setTimeout(() => { + toast.info("계약서 작성 페이지로 이동합니다.") + // router.push(`/evcp/contracts/${rfqId}`) + }, 1500) + } + + } catch (error) { + console.error("Final selection error:", error) + toast.error("최종 선정 중 오류가 발생했습니다.") + } finally { + setIsSelecting(false) + } + } + + // 발송 가능한 RFQ 필터링 (DRAFT 상태) + const sendableRfqs = selectedDetails.filter( + detail => detail.finalRfqStatus === "DRAFT" + ) + const sendableCount = sendableRfqs.length + + // 선정 가능한 벤더 필터링 (견적 접수 상태) + const selectableVendors = selectedDetails.filter( + detail => detail.finalRfqStatus === "Quotation Received" + ) + const selectableCount = selectableVendors.length + + // 전체 벤더 중 견적 접수 완료된 벤더 수 + const allVendors = table.getRowModel().rows.map(row => row.original) + const quotationReceivedCount = allVendors.filter( + vendor => vendor.finalRfqStatus === "Quotation Received" + ).length + + return ( + <div className="flex items-center gap-2"> + {/** 선택된 항목이 있을 때만 표시되는 액션들 */} + {selectedCount > 0 && ( + <> + {/* RFQ 발송 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleBulkRfqSend} + className="h-8" + disabled={isEmailSending || sendableCount === 0} + title={sendableCount === 0 ? "발송 가능한 RFQ가 없습니다 (DRAFT 상태만 가능)" : `${sendableCount}개의 최종 RFQ 발송`} + > + {isEmailSending ? ( + <Loader className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Mail className="mr-2 h-4 w-4" /> + )} + 최종 RFQ 발송 ({sendableCount}/{selectedCount}) + </Button> + + {/* 최종 선정 버튼 */} + <Button + variant="default" + size="sm" + onClick={handleFinalSelection} + className="h-8" + disabled={isSelecting || selectedCount !== 1 || selectableCount === 0} + title={ + selectedCount !== 1 + ? "최종 선정은 1개의 벤더만 선택해주세요" + : selectableCount === 0 + ? "견적 접수가 완료된 벤더만 선정 가능합니다" + : "선택된 벤더를 최종 선정" + } + > + {isSelecting ? ( + <Loader className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Award className="mr-2 h-4 w-4" /> + )} + 최종 선정 + </Button> + </> + )} + + {/* 정보 표시 (선택이 없을 때) */} + {selectedCount === 0 && quotationReceivedCount > 0 && ( + <div className="text-sm text-muted-foreground"> + 견적 접수 완료: {quotationReceivedCount}개 벤더 + </div> + )} + + {/* 새로고침 버튼 */} + {onRefresh && ( + <Button + variant="ghost" + size="sm" + onClick={onRefresh} + className="h-8" + title="데이터 새로고침" + > + <RefreshCw className="h-4 w-4" /> + </Button> + )} + </div> + ) +}
\ No newline at end of file diff --git a/lib/b-rfq/final/update-final-rfq-sheet.tsx b/lib/b-rfq/final/update-final-rfq-sheet.tsx new file mode 100644 index 00000000..65e23a92 --- /dev/null +++ b/lib/b-rfq/final/update-final-rfq-sheet.tsx @@ -0,0 +1,70 @@ +"use client" + +import * as React from "react" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { FinalRfqDetailView } from "@/db/schema" + +interface UpdateFinalRfqSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + finalRfq: FinalRfqDetailView | null +} + +export function UpdateFinalRfqSheet({ + open, + onOpenChange, + finalRfq +}: UpdateFinalRfqSheetProps) { + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="sm:max-w-md"> + <SheetHeader> + <SheetTitle>최종 RFQ 수정</SheetTitle> + <SheetDescription> + 최종 RFQ 정보를 수정합니다. + </SheetDescription> + </SheetHeader> + + <div className="py-6"> + {finalRfq && ( + <div className="space-y-4"> + <div> + <h4 className="font-medium">RFQ 정보</h4> + <p className="text-sm text-muted-foreground"> + RFQ Code: {finalRfq.rfqCode} + </p> + <p className="text-sm text-muted-foreground"> + 벤더: {finalRfq.vendorName} + </p> + <p className="text-sm text-muted-foreground"> + 상태: {finalRfq.finalRfqStatus} + </p> + </div> + + {/* TODO: 실제 업데이트 폼 구현 */} + <div className="text-center text-muted-foreground"> + 업데이트 폼이 여기에 구현됩니다. + </div> + </div> + )} + </div> + + <div className="flex justify-end gap-2"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={() => onOpenChange(false)}> + 저장 + </Button> + </div> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file |
