summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq')
-rw-r--r--lib/techsales-rfq/service.ts245
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx88
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx115
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" />