summaryrefslogtreecommitdiff
path: root/lib/tbe-last
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-18 00:23:40 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-18 00:23:40 +0000
commitcf8dac0c6490469dab88a560004b0c07dbd48612 (patch)
treeb9e76061e80d868331e6b4277deecb9086f845f3 /lib/tbe-last
parente5745fc0268bbb5770bc14a55fd58a0ec30b466e (diff)
(대표님) rfq, 계약, 서명 등
Diffstat (limited to 'lib/tbe-last')
-rw-r--r--lib/tbe-last/service.ts181
-rw-r--r--lib/tbe-last/table/tbe-last-table-columns.tsx49
-rw-r--r--lib/tbe-last/table/tbe-last-table.tsx118
-rw-r--r--lib/tbe-last/vendor-tbe-service.ts4
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) => {