diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-18 00:23:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-18 00:23:40 +0000 |
| commit | cf8dac0c6490469dab88a560004b0c07dbd48612 (patch) | |
| tree | b9e76061e80d868331e6b4277deecb9086f845f3 /lib/tbe-last | |
| parent | e5745fc0268bbb5770bc14a55fd58a0ec30b466e (diff) | |
(대표님) rfq, 계약, 서명 등
Diffstat (limited to 'lib/tbe-last')
| -rw-r--r-- | lib/tbe-last/service.ts | 181 | ||||
| -rw-r--r-- | lib/tbe-last/table/tbe-last-table-columns.tsx | 49 | ||||
| -rw-r--r-- | lib/tbe-last/table/tbe-last-table.tsx | 118 | ||||
| -rw-r--r-- | lib/tbe-last/vendor-tbe-service.ts | 4 |
4 files changed, 330 insertions, 22 deletions
diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts index 32d5a5f5..34c274f5 100644 --- a/lib/tbe-last/service.ts +++ b/lib/tbe-last/service.ts @@ -1,7 +1,7 @@ // lib/tbe-last/service.ts 'use server' -import { unstable_cache } from "next/cache"; +import { revalidatePath, unstable_cache } from "next/cache"; import db from "@/db/db"; import { and, desc, asc, eq, sql, or, isNull, isNotNull, ne, inArray } from "drizzle-orm"; import { tbeLastView, tbeDocumentsView } from "@/db/schema"; @@ -547,4 +547,181 @@ function getReviewStatusClass(status?: string): string { default: return "unreviewed" } -}
\ No newline at end of file +} + + +interface RfqInfo { + rfqCode: string; + rfqTitle: string; + rfqDueDate: Date | null; + projectCode: string; + projectName: string; + packageNo: string; + packageName: string; + picName: string; + rfqId: number; // rfqLastId 추가 +} + +interface VendorInfo { + sessionId: number; + vendorId: number; // vendor ID 추가 + vendorCode: string; + vendorName: string; +} + +export async function requestTBEForRFQ( + rfqInfo: RfqInfo, + vendors: VendorInfo[] +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return { success: false, error: "인증이 필요합니다" } + } + + // 벤더별 이메일 정보 조회 + const vendorEmails = await db + .select({ + vendorId: rfqLastDetails.vendorsId, + emailSentTo: rfqLastDetails.emailSentTo, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqInfo.rfqId), + inArray(rfqLastDetails.vendorsId, vendors.map(v => v.vendorId)), + eq(rfqLastDetails.isLatest, true) + ) + ); + + // 이메일 정보 매핑 + const vendorEmailMap = new Map(); + vendorEmails.forEach(ve => { + if (ve.emailSentTo) { + try { + const emailData = JSON.parse(ve.emailSentTo); + vendorEmailMap.set(ve.vendorId, emailData); + } catch (error) { + console.error(`이메일 파싱 실패 - vendorId: ${ve.vendorId}`, error); + } + } + }); + + // 1. 트랜잭션으로 모든 세션 상태 업데이트 + await db.transaction(async (tx) => { + // 세션 상태 업데이트 + const sessionIds = vendors.map(v => v.sessionId); + + await tx + .update(rfqLastTbeSessions) + .set({ + status: "진행중", + updatedAt: new Date(), + }) + .where(inArray(rfqLastTbeSessions.id, sessionIds)); + }); + + // 2. 각 벤더에게 이메일 발송 + const emailPromises = vendors.map(async (vendor) => { + const emailInfo = vendorEmailMap.get(vendor.vendorId); + + if (!emailInfo) { + console.warn(`벤더 ${vendor.vendorName}의 이메일 정보가 없습니다.`); + return { success: false, vendor: vendor.vendorName, error: "이메일 정보 없음" }; + } + + try { + // to와 cc 이메일 추출 + const toEmails = Array.isArray(emailInfo.to) ? emailInfo.to : [emailInfo.to]; + const ccEmails = Array.isArray(emailInfo.cc) ? emailInfo.cc : []; + + // 모든 to 이메일 주소로 발송 + const emailResults = await Promise.all( + toEmails.filter(Boolean).map(toEmail => + sendEmail({ + to: toEmail, + template: "tbe-request", + subject: `[TBE 요청] ${rfqInfo.rfqCode} - 기술입찰평가 서류 제출 요청`, + context: { + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + rfqCode: rfqInfo.rfqCode, + rfqTitle: rfqInfo.rfqTitle, + rfqDueDate: rfqInfo.rfqDueDate ? + new Date(rfqInfo.rfqDueDate).toLocaleDateString("ko-KR") : + "미정", + projectCode: rfqInfo.projectCode, + projectName: rfqInfo.projectName, + packageNo: rfqInfo.packageNo, + packageName: rfqInfo.packageName, + picName: rfqInfo.picName, + picEmail: session.user.email, + picPhone: process.env.DEFAULT_PIC_PHONE || "", + tbeDeadline: calculateTBEDeadline(rfqInfo.rfqDueDate), + companyName: process.env.COMPANY_NAME || "Your Company", + }, + cc: [ + ...ccEmails.filter(Boolean) + ], + }) + ) + ); + + return { + success: true, + vendor: vendor.vendorName, + emailsSent: emailResults.length + }; + + } catch (error) { + console.error(`이메일 발송 실패 - ${vendor.vendorName}:`, error); + return { success: false, vendor: vendor.vendorName, error }; + } + }); + + const emailResults = await Promise.allSettled(emailPromises); + + // 3. 결과 확인 + const successResults = emailResults.filter( + r => r.status === "fulfilled" && r.value?.success + ); + const failedResults = emailResults.filter( + r => r.status === "rejected" || (r.status === "fulfilled" && !r.value?.success) + ); + + if (failedResults.length > 0) { + console.warn(`${failedResults.length}개 벤더의 이메일 발송 실패`); + } + + revalidatePath("/evcp/tbe-last"); + + return { + success: true, + message: `${successResults.length}개 벤더에 TBE 요청 완료`, + emailsSent: successResults.length, + emailsFailed: failedResults.length + }; + + } catch (error) { + console.error("TBE 요청 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "TBE 요청 중 오류가 발생했습니다." + }; + } +} + +// TBE 제출 기한 계산 (RFQ 마감일 7일 전) +function calculateTBEDeadline(rfqDueDate: Date | null): string { + if (!rfqDueDate) return "추후 공지"; + + const deadline = new Date(rfqDueDate); + deadline.setDate(deadline.getDate() - 7); // 7일 전 + + return deadline.toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + diff --git a/lib/tbe-last/table/tbe-last-table-columns.tsx b/lib/tbe-last/table/tbe-last-table-columns.tsx index 726d8925..b18e51c0 100644 --- a/lib/tbe-last/table/tbe-last-table-columns.tsx +++ b/lib/tbe-last/table/tbe-last-table-columns.tsx @@ -86,6 +86,55 @@ export function getColumns({ cell: ({ row }) => row.original.rfqCode, size: 120, }, + + { + id: "tbeRequired", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="TBE 필요" /> + ), + cell: ({ row, table }) => { + const rfqCode = row.original.rfqCode; + const sessionStatus = row.original.sessionStatus; + + // 같은 RFQ의 첫 번째 행에만 체크박스 표시 + const allRows = table.getRowModel().rows; + const isFirstInGroup = allRows.findIndex( + r => r.original.rfqCode === rfqCode + ) === allRows.indexOf(row); + + if (!isFirstInGroup) return null; + + // 같은 RFQ의 모든 row + const rfqRows = allRows.filter( + r => r.original.rfqCode === rfqCode + ); + + const vendorCount = rfqRows.length; + + // 같은 RFQ의 row들이 선택되었는지 확인 + const isChecked = rfqRows.every(r => r.getIsSelected()); + const isIndeterminate = rfqRows.some(r => r.getIsSelected()) && !isChecked; + + return ( + <div className="flex items-center gap-2"> + <Checkbox + checked={isChecked} + indeterminate={isIndeterminate} + onCheckedChange={(checked) => { + // RFQ의 모든 벤더 선택/해제 + rfqRows.forEach(r => { + r.toggleSelected(!!checked); + }); + }} + /> + <span className="text-xs text-muted-foreground"> + ({vendorCount} vendors) + </span> + </div> + ); + }, + size: 120, + }, // RFQ Title { diff --git a/lib/tbe-last/table/tbe-last-table.tsx b/lib/tbe-last/table/tbe-last-table.tsx index a9328bdf..c18768cd 100644 --- a/lib/tbe-last/table/tbe-last-table.tsx +++ b/lib/tbe-last/table/tbe-last-table.tsx @@ -10,7 +10,7 @@ import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { getColumns } from "./tbe-last-table-columns" import { TbeLastView } from "@/db/schema" -import { getAllTBELast, getTBESessionDetail } from "@/lib/tbe-last/service" +import { getAllTBELast, getTBESessionDetail, requestTBEForRFQ } from "@/lib/tbe-last/service" import { Button } from "@/components/ui/button" import { Download, RefreshCw } from "lucide-react" import { exportTableToExcel } from "@/lib/export" @@ -20,6 +20,7 @@ import { SessionDetailDialog } from "./session-detail-dialog" import { DocumentsSheet } from "./documents-sheet" import { PrItemsDialog } from "./pr-items-dialog" import { EvaluationDialog } from "./evaluation-dialog" +import { toast } from "sonner" interface TbeLastTableProps { promises: Promise<[ @@ -30,21 +31,21 @@ interface TbeLastTableProps { export function TbeLastTable({ promises }: TbeLastTableProps) { const router = useRouter() const [{ data, pageCount }] = React.use(promises) - - console.log(data,"data") + + console.log(data, "data") // Dialog states const [sessionDetailOpen, setSessionDetailOpen] = React.useState(false) const [documentsOpen, setDocumentsOpen] = React.useState(false) const [prItemsOpen, setPrItemsOpen] = React.useState(false) const [evaluationOpen, setEvaluationOpen] = React.useState(false) - + const [selectedSessionId, setSelectedSessionId] = React.useState<number | null>(null) const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) const [selectedSession, setSelectedSession] = React.useState<TbeLastView | null>(null) const [sessionDetail, setSessionDetail] = React.useState<any>(null) const [isLoadingDetail, setIsLoadingDetail] = React.useState(false) - + // Load session detail when needed const loadSessionDetail = React.useCallback(async (sessionId: number) => { setIsLoadingDetail(true) @@ -57,37 +58,37 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { setIsLoadingDetail(false) } }, []) - + // Handlers const handleOpenSessionDetail = React.useCallback((sessionId: number) => { setSelectedSessionId(sessionId) setSessionDetailOpen(true) loadSessionDetail(sessionId) }, [loadSessionDetail]) - + const handleOpenDocuments = React.useCallback((sessionId: number) => { setSelectedSessionId(sessionId) setDocumentsOpen(true) loadSessionDetail(sessionId) }, [loadSessionDetail]) - + const handleOpenPrItems = React.useCallback((rfqId: number) => { setSelectedRfqId(rfqId) setPrItemsOpen(true) loadSessionDetail(rfqId) }, [loadSessionDetail]) - + const handleOpenEvaluation = React.useCallback((session: TbeLastView) => { setSelectedSession(session) setEvaluationOpen(true) loadSessionDetail(session.rfqId) }, []) - + const handleRefresh = React.useCallback(() => { router.refresh() }, [router]) - + // Table columns const columns = React.useMemo( () => @@ -99,7 +100,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { }), [handleOpenSessionDetail, handleOpenDocuments, handleOpenPrItems, handleOpenEvaluation] ) - + // Filter fields const filterFields: DataTableAdvancedFilterField<TbeLastView>[] = [ { @@ -125,7 +126,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { ], }, ] - + // Data table const { table } = useDataTable({ data, @@ -142,7 +143,64 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { shallow: false, clearOnDefault: true, }) - + + const handleBulkTBERequest = React.useCallback(async (rfqGroups: Map<string, TbeLastView[]>) => { + try { + const promises = Array.from(rfqGroups.entries()).map(async ([rfqCode, sessions]) => { + // 준비중 상태인 세션만 필터링 + const pendingSessions = sessions.filter(s => s.sessionStatus === "준비중"); + + if (pendingSessions.length === 0) { + toast.info(`RFQ ${rfqCode}: 이미 TBE가 요청되었습니다.`); + return null; + } + + const vendors = pendingSessions.map(session => ({ + sessionId: session.tbeSessionId, + vendorId: session.vendorId, // vendor ID 추가 + vendorCode: session.vendorCode, + vendorName: session.vendorName, + })); + + const rfqInfo = { + rfqId: sessions[0].rfqId, // rfqLastId 추가 + rfqCode: sessions[0].rfqCode, + rfqTitle: sessions[0].rfqTitle || "", + rfqDueDate: sessions[0].rfqDueDate, + projectCode: sessions[0].projectCode || "", + projectName: sessions[0].projectName || "", + packageNo: sessions[0].packageNo || "", + packageName: sessions[0].packageName || "", + picName: sessions[0].picName || "", + }; + + return requestTBEForRFQ(rfqInfo, vendors); + }); + + const results = await Promise.allSettled(promises); + + const successCount = results.filter(r => r.status === "fulfilled" && r.value?.success).length; + const failCount = results.filter(r => r.status === "rejected" || (r.status === "fulfilled" && !r.value?.success)).length; + + if (successCount > 0) { + toast.success(`${successCount}개 RFQ에 대한 TBE 요청이 완료되었습니다.`); + } + + if (failCount > 0) { + toast.error(`${failCount}개 RFQ에 대한 TBE 요청이 실패했습니다.`); + } + + // 테이블 새로고침 + router.refresh(); + table.resetRowSelection(); + + } catch (error) { + console.error("TBE 요청 처리 중 오류:", error); + toast.error("TBE 요청 처리 중 오류가 발생했습니다."); + } + }, [router, table]); + + return ( <> <DataTable table={table}> @@ -152,6 +210,30 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { shallow={false} > <div className="flex items-center gap-2"> + + {table.getFilteredSelectedRowModel().rows.length > 0 && ( + <Button + variant="default" + size="sm" + onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows; + const rfqGroups = new Map(); + + // RFQ별로 그룹핑 + selectedRows.forEach(row => { + const rfqCode = row.original.rfqCode; + if (!rfqGroups.has(rfqCode)) { + rfqGroups.set(rfqCode, []); + } + rfqGroups.get(rfqCode).push(row.original); + }); + + handleBulkTBERequest(rfqGroups); + }} + > + 선택된 항목 TBE 요청 ({table.getFilteredSelectedRowModel().rows.length}) + </Button> + )} <Button variant="outline" size="sm" @@ -178,7 +260,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { </div> </DataTableAdvancedToolbar> </DataTable> - + {/* Session Detail Dialog */} <SessionDetailDialog open={sessionDetailOpen} @@ -186,7 +268,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { sessionDetail={sessionDetail} isLoading={isLoadingDetail} /> - + {/* Documents Sheet */} <DocumentsSheet open={documentsOpen} @@ -194,7 +276,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { sessionDetail={sessionDetail} isLoading={isLoadingDetail} /> - + {/* PR Items Dialog */} <PrItemsDialog open={prItemsOpen} @@ -202,7 +284,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { sessionDetail={sessionDetail} isLoading={isLoadingDetail} /> - + {/* Evaluation Dialog */} <EvaluationDialog open={evaluationOpen} diff --git a/lib/tbe-last/vendor-tbe-service.ts b/lib/tbe-last/vendor-tbe-service.ts index 8335eb4f..858a5817 100644 --- a/lib/tbe-last/vendor-tbe-service.ts +++ b/lib/tbe-last/vendor-tbe-service.ts @@ -4,7 +4,7 @@ import { unstable_cache } from "next/cache" import db from "@/db/db" -import { and, desc, asc, eq, sql, or } from "drizzle-orm" +import { and, desc, asc, eq, sql, ne } from "drizzle-orm" import { tbeLastView, rfqLastTbeSessions } from "@/db/schema" import { rfqPrItems } from "@/db/schema/rfqLast" import { getServerSession } from "next-auth" @@ -42,7 +42,7 @@ export async function getTBEforVendor( const limit = input.perPage ?? 10 // 벤더 필터링 - const vendorWhere = eq(tbeLastView.vendorId, vendorId) + const vendorWhere =and(eq(tbeLastView.vendorId, vendorId),ne(tbeLastView.sessionStatus, "준비중")) // 데이터 조회 const [rows, total] = await db.transaction(async (tx) => { |
