diff options
Diffstat (limited to 'lib/techsales-rfq')
3 files changed, 402 insertions, 46 deletions
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index c3c14aff..96d6a3c9 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -11,7 +11,7 @@ import { techSalesRfqItems, biddingProjects } from "@/db/schema"; -import { and, desc, eq, ilike, or, sql, inArray } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql, inArray, count, asc } from "drizzle-orm"; import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import { getErrorMessage } from "@/lib/handle-error"; @@ -3022,9 +3022,9 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType try { // RFQ 타입에 따른 벤더 타입 매핑 - const vendorTypeFilter = rfqType === "SHIP" ? "SHIP" : - rfqType === "TOP" ? "OFFSHORE_TOP" : - rfqType === "HULL" ? "OFFSHORE_HULL" : null; + const vendorTypeFilter = rfqType === "SHIP" ? "조선" : + rfqType === "TOP" ? "해양TOP" : + rfqType === "HULL" ? "해양HULL" : null; const whereConditions = [ eq(techVendors.status, "ACTIVE"), @@ -3034,9 +3034,9 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType ) ]; - // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 + // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 (컴마 구분 문자열에서 검색) if (vendorTypeFilter) { - whereConditions.push(eq(techVendors.techVendorType, vendorTypeFilter)); + whereConditions.push(sql`${techVendors.techVendorType} LIKE ${'%' + vendorTypeFilter + '%'}`); } const results = await db @@ -3058,4 +3058,237 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType console.error("Error searching tech vendors:", err); throw new Error(getErrorMessage(err)); } +} + +/** + * Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함) + */ +export async function getAcceptedTechSalesVendorQuotations(input: { + search?: string; + filters?: Filter<typeof techSalesVendorQuotations>[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; + rfqType?: "SHIP" | "TOP" | "HULL"; +}) { + unstable_noStore(); + + try { + const offset = (input.page - 1) * input.perPage; + + // 기본 WHERE 조건: status = 'Accepted'만 조회 + const baseConditions = [ + eq(techSalesVendorQuotations.status, 'Accepted') + ]; + + // 검색 조건 추가 + const searchConditions = []; + if (input.search) { + searchConditions.push( + ilike(techSalesRfqs.rfqCode, `%${input.search}%`), + ilike(techSalesRfqs.description, `%${input.search}%`), + ilike(sql`vendors.vendor_name`, `%${input.search}%`), + ilike(sql`vendors.vendor_code`, `%${input.search}%`) + ); + } + + // 정렬 조건 변환 + const orderByConditions: OrderByType[] = []; + if (input.sort?.length) { + input.sort.forEach((sortItem) => { + switch (sortItem.id) { + case "rfqCode": + orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.rfqCode) : asc(techSalesRfqs.rfqCode)); + break; + case "description": + orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.description) : asc(techSalesRfqs.description)); + break; + case "vendorName": + orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_name`) : asc(sql`vendors.vendor_name`)); + break; + case "vendorCode": + orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_code`) : asc(sql`vendors.vendor_code`)); + break; + case "totalPrice": + orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.totalPrice) : asc(techSalesVendorQuotations.totalPrice)); + break; + case "acceptedAt": + orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.acceptedAt) : asc(techSalesVendorQuotations.acceptedAt)); + break; + default: + orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt)); + } + }); + } else { + orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt)); + } + + // 필터 조건 추가 + const filterConditions = []; + if (input.filters?.length) { + const { filterWhere, joinOperator } = filterColumns({ + table: techSalesVendorQuotations, + filters: input.filters, + joinOperator: input.joinOperator ?? "and", + }); + if (filterWhere) { + filterConditions.push(filterWhere); + } + } + + // RFQ 타입 필터 + if (input.rfqType) { + filterConditions.push(eq(techSalesRfqs.rfqType, input.rfqType)); + } + + // 모든 조건 결합 + const allConditions = [ + ...baseConditions, + ...filterConditions, + ...(searchConditions.length > 0 ? [or(...searchConditions)] : []) + ]; + + const whereCondition = allConditions.length > 1 + ? and(...allConditions) + : allConditions[0]; + + // 데이터 조회 + const data = await db + .select({ + // Quotation 정보 + id: techSalesVendorQuotations.id, + rfqId: techSalesVendorQuotations.rfqId, + vendorId: techSalesVendorQuotations.vendorId, + quotationCode: techSalesVendorQuotations.quotationCode, + quotationVersion: techSalesVendorQuotations.quotationVersion, + totalPrice: techSalesVendorQuotations.totalPrice, + currency: techSalesVendorQuotations.currency, + validUntil: techSalesVendorQuotations.validUntil, + status: techSalesVendorQuotations.status, + remark: techSalesVendorQuotations.remark, + submittedAt: techSalesVendorQuotations.submittedAt, + acceptedAt: techSalesVendorQuotations.acceptedAt, + createdAt: techSalesVendorQuotations.createdAt, + updatedAt: techSalesVendorQuotations.updatedAt, + + // RFQ 정보 + rfqCode: techSalesRfqs.rfqCode, + rfqType: techSalesRfqs.rfqType, + description: techSalesRfqs.description, + dueDate: techSalesRfqs.dueDate, + rfqStatus: techSalesRfqs.status, + materialCode: techSalesRfqs.materialCode, + + // Vendor 정보 + vendorName: sql<string>`vendors.vendor_name`, + vendorCode: sql<string | null>`vendors.vendor_code`, + vendorEmail: sql<string | null>`vendors.email`, + vendorCountry: sql<string | null>`vendors.country`, + + // Project 정보 + projNm: biddingProjects.projNm, + pspid: biddingProjects.pspid, + sector: biddingProjects.sector, + }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`)) + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) + .where(whereCondition) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`)) + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) + .where(whereCondition); + + const total = totalCount[0]?.count ?? 0; + const pageCount = Math.ceil(total / input.perPage); + + return { + data, + pageCount, + total, + }; + + } catch (error) { + console.error("getAcceptedTechSalesVendorQuotations 오류:", error); + throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`); + } +} + +/** + * 벤더 견적서 거절 처리 (벤더가 직접 거절) + */ +export async function rejectTechSalesVendorQuotations(input: { + quotationIds: number[]; + rejectionReason?: string; +}) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + + const result = await db.transaction(async (tx) => { + // 견적서들이 존재하고 벤더가 권한이 있는지 확인 + const quotations = await tx + .select({ + id: techSalesVendorQuotations.id, + status: techSalesVendorQuotations.status, + vendorId: techSalesVendorQuotations.vendorId, + }) + .from(techSalesVendorQuotations) + .where(inArray(techSalesVendorQuotations.id, input.quotationIds)); + + if (quotations.length !== input.quotationIds.length) { + throw new Error("일부 견적서를 찾을 수 없습니다."); + } + + // 이미 거절된 견적서가 있는지 확인 + const alreadyRejected = quotations.filter(q => q.status === "Rejected"); + if (alreadyRejected.length > 0) { + throw new Error("이미 거절된 견적서가 포함되어 있습니다."); + } + + // 승인된 견적서가 있는지 확인 + const alreadyAccepted = quotations.filter(q => q.status === "Accepted"); + if (alreadyAccepted.length > 0) { + throw new Error("이미 승인된 견적서는 거절할 수 없습니다."); + } + + // 견적서 상태를 거절로 변경 + await tx + .update(techSalesVendorQuotations) + .set({ + status: "Rejected", + rejectionReason: input.rejectionReason || null, + updatedBy: parseInt(session.user.id), + updatedAt: new Date(), + }) + .where(inArray(techSalesVendorQuotations.id, input.quotationIds)); + + return { success: true, updatedCount: quotations.length }; + }); + revalidateTag("techSalesRfqs"); + revalidateTag("techSalesVendorQuotations"); + revalidatePath("/partners/techsales/rfq-ship", "page"); + return { + success: true, + message: `${result.updatedCount}개의 견적서가 거절되었습니다.`, + data: result + }; + } catch (error) { + console.error("견적서 거절 오류:", error); + return { + success: false, + error: getErrorMessage(error) + }; + } }
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx index ddee2317..b89f8953 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx @@ -15,7 +15,8 @@ import { } from "@/components/ui/tooltip" import { TechSalesVendorQuotations, - TECH_SALES_QUOTATION_STATUS_CONFIG + TECH_SALES_QUOTATION_STATUS_CONFIG, + TECH_SALES_QUOTATION_STATUSES } from "@/db/schema" import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" @@ -70,14 +71,21 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge className="translate-y-0.5" /> ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} + cell: ({ row }) => { + const isRejected = row.original.status === TECH_SALES_QUOTATION_STATUSES.REJECTED; + const isAccepted = row.original.status === TECH_SALES_QUOTATION_STATUSES.ACCEPTED; + const isDisabled = isRejected || isAccepted; + + return ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="행 선택" - className="translate-y-0.5" - /> - ), + className="translate-y-0.5" + disabled={isDisabled} + /> + ); + }, enableSorting: false, enableHiding: false, }, @@ -158,33 +166,33 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge // enableSorting: true, // enableHiding: true, // }, - { - accessorKey: "itemName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="자재명" /> - ), - cell: ({ row }) => { - const itemName = row.getValue("itemName") as string; - return ( - <div className="min-w-48 max-w-64"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <span className="truncate block text-sm"> - {itemName || "N/A"} - </span> - </TooltipTrigger> - <TooltipContent> - <p className="max-w-xs">{itemName || "N/A"}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, + // { + // accessorKey: "itemName", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="자재명" /> + // ), + // cell: ({ row }) => { + // const itemName = row.getValue("itemName") as string; + // return ( + // <div className="min-w-48 max-w-64"> + // <TooltipProvider> + // <Tooltip> + // <TooltipTrigger asChild> + // <span className="truncate block text-sm"> + // {itemName || "N/A"} + // </span> + // </TooltipTrigger> + // <TooltipContent> + // <p className="max-w-xs">{itemName || "N/A"}</p> + // </TooltipContent> + // </Tooltip> + // </TooltipProvider> + // </div> + // ); + // }, + // enableSorting: true, + // enableHiding: true, + // }, { accessorKey: "projNm", header: ({ column }) => ( @@ -597,6 +605,9 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge const quotation = row.original; const rfqCode = quotation.rfqCode || "N/A"; const tooltipText = `${rfqCode} 견적서 작성`; + const isRejected = quotation.status === "Rejected"; + const isAccepted = quotation.status === "Accepted"; + const isDisabled = isRejected || isAccepted; return ( <div className="w-16"> @@ -607,16 +618,19 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge variant="ghost" size="icon" onClick={() => { - router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`); + if (!isDisabled) { + router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`); + } }} className="h-8 w-8" + disabled={isDisabled} > <Edit className="h-4 w-4" /> <span className="sr-only">견적서 작성</span> </Button> </TooltipTrigger> <TooltipContent> - <p>{tooltipText}</p> + <p>{isRejected ? "거절된 견적서는 편집할 수 없습니다" : isAccepted ? "승인된 견적서는 편집할 수 없습니다" : tooltipText}</p> </TooltipContent> </Tooltip> </TooltipProvider> diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx index 55dcad92..5e5d4f39 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -12,9 +12,24 @@ import { useRouter } from "next/navigation" import { getColumns } from "./vendor-quotations-table-columns" import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet" import { RfqItemsViewDialog } from "../../table/rfq-items-view-dialog" -import { getTechSalesRfqAttachments, getVendorQuotations } from "@/lib/techsales-rfq/service" +import { getTechSalesRfqAttachments, getVendorQuotations, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service" import { toast } from "sonner" import { Skeleton } from "@/components/ui/skeleton" +import { Button } from "@/components/ui/button" +import { X } from "lucide-react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" interface QuotationWithRfqCode extends TechSalesVendorQuotations { rfqCode?: string | null; @@ -95,8 +110,6 @@ function TableLoadingSkeleton() { ) } - - export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) { const searchParams = useSearchParams() const router = useRouter() @@ -110,6 +123,11 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<{ id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; } | null>(null) + // 거절 다이얼로그 상태 + const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false) + const [rejectionReason, setRejectionReason] = React.useState("") + const [isRejecting, setIsRejecting] = React.useState(false) + // 데이터 로딩 상태 const [data, setData] = React.useState<QuotationWithRfqCode[]>([]) const [pageCount, setPageCount] = React.useState(0) @@ -248,6 +266,54 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab setSelectedRfqForItems(rfq) setItemsDialogOpen(true) }, []) + + // 거절 처리 함수 + const handleRejectQuotations = React.useCallback(async () => { + if (!table) return; + + const selectedRows = table.getFilteredSelectedRowModel().rows; + const quotationIds = selectedRows.map(row => row.original.id); + + if (quotationIds.length === 0) { + toast.error("거절할 견적서를 선택해주세요."); + return; + } + + // 거절할 수 없는 상태의 견적서가 있는지 확인 + const invalidStatuses = selectedRows.filter(row => + row.original.status === "Accepted" || row.original.status === "Rejected" + ); + + if (invalidStatuses.length > 0) { + toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다."); + return; + } + + setIsRejecting(true); + + try { + const result = await rejectTechSalesVendorQuotations({ + quotationIds, + rejectionReason: rejectionReason.trim() || undefined, + }); + + if (result.success) { + toast.success(result.message); + setRejectDialogOpen(false); + setRejectionReason(""); + table.resetRowSelection(); + // 데이터 다시 로드 + await loadData(); + } else { + toast.error(result.error || "견적서 거절 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("견적서 거절 오류:", error); + toast.error("견적서 거절 중 오류가 발생했습니다."); + } finally { + setIsRejecting(false); + } + }, [rejectionReason, loadData]); // 테이블 컬럼 정의 const columns = React.useMemo(() => getColumns({ @@ -322,6 +388,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab enableAdvancedFilter: true, enableColumnResizing: true, columnResizeMode: 'onChange', + enableRowSelection: true, // 행 선택 활성화 initialState: { sorting: initialSettings.sort, columnPinning: { right: ["actions"] }, @@ -366,6 +433,48 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab filterFields={advancedFilterFields} shallow={false} > + {/* 선택된 행이 있을 때 거절 버튼 표시 */} + {table && table.getFilteredSelectedRowModel().rows.length > 0 && ( + <AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}> + <AlertDialogTrigger asChild> + <Button variant="destructive" size="sm"> + <X className="mr-2 h-4 w-4" /> + 선택한 견적서 거절 ({table.getFilteredSelectedRowModel().rows.length}개) + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>견적서 거절</AlertDialogTitle> + <AlertDialogDescription> + 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까? + 거절된 견적서는 다시 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid gap-2"> + <Label htmlFor="rejection-reason">거절 사유 (선택사항)</Label> + <Textarea + id="rejection-reason" + placeholder="거절 사유를 입력하세요..." + value={rejectionReason} + onChange={(e) => setRejectionReason(e.target.value)} + /> + </div> + </div> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleRejectQuotations} + disabled={isRejecting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isRejecting ? "처리 중..." : "거절"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + )} + {!isInitialLoad && isLoading && ( <div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" /> |
