diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/techsales-rfq/service.ts | 365 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx | 140 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table-column.tsx | 4 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx | 140 |
4 files changed, 451 insertions, 198 deletions
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 7be91092..f7a30b3b 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -582,6 +582,12 @@ export async function addVendorToTechSalesRfq(input: { }) .returning(); + // 캐시 무효화 + revalidateTag("techSalesVendorQuotations"); + revalidateTag(`techSalesRfq-${input.rfqId}`); + revalidateTag(`vendor-${input.vendorId}-quotations`); + revalidatePath("/evcp/budgetary-tech-sales-ship"); + return { data: newQuotation, error: null }; } catch (err) { console.error("Error adding vendor to RFQ:", err); @@ -675,6 +681,11 @@ export async function addVendorsToTechSalesRfq(input: { revalidateTag(`techSalesRfq-${input.rfqId}`); revalidatePath("/evcp/budgetary-tech-sales-ship"); + // 벤더별 캐시도 무효화 + for (const vendorId of input.vendorIds) { + revalidateTag(`vendor-${vendorId}-quotations`); + } + return { data: results, error: errors.length > 0 ? errors.join(", ") : null, @@ -737,6 +748,7 @@ export async function removeVendorFromTechSalesRfq(input: { // 캐시 무효화 추가 revalidateTag("techSalesVendorQuotations"); revalidateTag(`techSalesRfq-${input.rfqId}`); + revalidateTag(`vendor-${input.vendorId}-quotations`); revalidatePath("/evcp/budgetary-tech-sales-ship"); return { data: deletedQuotations[0], error: null }; @@ -811,6 +823,11 @@ export async function removeVendorsFromTechSalesRfq(input: { revalidateTag(`techSalesRfq-${input.rfqId}`); revalidatePath("/evcp/budgetary-tech-sales-ship"); + // 벤더별 캐시도 무효화 + for (const vendorId of input.vendorIds) { + revalidateTag(`vendor-${vendorId}-quotations`); + } + return { data: results, error: errors.length > 0 ? errors.join(", ") : null, @@ -969,26 +986,30 @@ export async function sendTechSalesRfqToVendors(input: { // 트랜잭션 시작 await db.transaction(async (tx) => { - // 1. RFQ 상태 업데이트 (첫 발송인 경우에만) - if (!isResend) { - await tx.update(techSalesRfqs) - .set({ - status: "RFQ Sent", - rfqSendDate: new Date(), - sentBy: Number(session.user.id), - updatedBy: Number(session.user.id), - updatedAt: new Date(), - }) - .where(eq(techSalesRfqs.id, input.rfqId)); + // 1. RFQ 상태 업데이트 (최초 발송인 경우 rfqSendDate 설정) + const updateData: Partial<typeof techSalesRfqs.$inferInsert> = { + status: "RFQ Sent", + sentBy: Number(session.user.id), + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }; + + // rfqSendDate가 null인 경우에만 최초 전송일 설정 + if (!rfq.rfqSendDate) { + updateData.rfqSendDate = new Date(); } + await tx.update(techSalesRfqs) + .set(updateData) + .where(eq(techSalesRfqs.id, input.rfqId)); + // 2. 각 벤더에 대해 이메일 발송 처리 for (const quotation of vendorQuotations) { if (!quotation.vendorId || !quotation.vendor) continue; // 벤더에 속한 모든 사용자 조회 const vendorUsers = await db.query.users.findMany({ - where: eq(users.companyId, quotation.vendorId), + where: eq(users.companyId, quotation.vendor.id), columns: { id: true, email: true, @@ -1156,11 +1177,12 @@ export async function updateTechSalesVendorQuotation(data: { updatedBy: number }) { try { - // 현재 견적서 상태 확인 + // 현재 견적서 상태 및 벤더 ID 확인 const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({ where: eq(techSalesVendorQuotations.id, data.id), columns: { status: true, + vendorId: true, } }); @@ -1213,6 +1235,7 @@ export async function submitTechSalesVendorQuotation(data: { where: eq(techSalesVendorQuotations.id, data.id), columns: { status: true, + vendorId: true, } }); @@ -1254,6 +1277,7 @@ export async function submitTechSalesVendorQuotation(data: { // 캐시 무효화 revalidateTag("techSalesVendorQuotations") + revalidateTag(`vendor-${currentQuotation.vendorId}-quotations`) revalidatePath(`/partners/techsales/rfq-ship/${data.id}`) return { data: result[0], error: null } @@ -1300,158 +1324,180 @@ export async function getVendorQuotations(input: { from?: string; to?: string; }, vendorId: string) { - unstable_noStore(); - try { - const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input; - const offset = (page - 1) * perPage; - const limit = perPage; - - // 기본 조건: 해당 벤더의 견적서만 조회 - const baseConditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))]; - - // 검색 조건 추가 - if (search) { - const s = `%${search}%`; - const searchCondition = or( - ilike(techSalesVendorQuotations.currency, s), - ilike(techSalesVendorQuotations.status, s) - ); - if (searchCondition) { - baseConditions.push(searchCondition); - } - } + return unstable_cache( + async () => { + try { + const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input; + const offset = (page - 1) * perPage; + const limit = perPage; - // 날짜 범위 필터 - if (from) { - baseConditions.push(sql`${techSalesVendorQuotations.createdAt} >= ${from}`); - } - if (to) { - baseConditions.push(sql`${techSalesVendorQuotations.createdAt} <= ${to}`); - } + // 기본 조건: 해당 벤더의 견적서만 조회 + const baseConditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))]; - // 고급 필터 처리 - if (filters.length > 0) { - const filterWhere = filterColumns({ - table: techSalesVendorQuotations, - filters: filters as Filter<typeof techSalesVendorQuotations>[], - joinOperator: input.joinOperator || "and", - }); - if (filterWhere) { - baseConditions.push(filterWhere); - } - } + // 검색 조건 추가 + if (search) { + const s = `%${search}%`; + const searchCondition = or( + ilike(techSalesVendorQuotations.currency, s), + ilike(techSalesVendorQuotations.status, s) + ); + if (searchCondition) { + baseConditions.push(searchCondition); + } + } - // 최종 WHERE 조건 - const finalWhere = baseConditions.length > 0 - ? and(...baseConditions) - : undefined; + // 날짜 범위 필터 + if (from) { + baseConditions.push(sql`${techSalesVendorQuotations.createdAt} >= ${from}`); + } + if (to) { + baseConditions.push(sql`${techSalesVendorQuotations.createdAt} <= ${to}`); + } - // 정렬 기준 설정 - let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.updatedAt)]; - - if (sort?.length) { - orderBy = sort.map(item => { - switch (item.id) { - case 'id': - return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id; - case 'status': - return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status; - case 'currency': - return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency; - case 'totalPrice': - return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice; - case 'validUntil': - return item.desc ? desc(techSalesVendorQuotations.validUntil) : techSalesVendorQuotations.validUntil; - case 'submittedAt': - return item.desc ? desc(techSalesVendorQuotations.submittedAt) : techSalesVendorQuotations.submittedAt; - case 'createdAt': - return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; - case 'updatedAt': - return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; - default: - return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; + // 고급 필터 처리 + if (filters.length > 0) { + const filterWhere = filterColumns({ + table: techSalesVendorQuotations, + filters: filters as Filter<typeof techSalesVendorQuotations>[], + joinOperator: input.joinOperator || "and", + }); + if (filterWhere) { + baseConditions.push(filterWhere); + } } - }); - } - // 조인을 포함한 데이터 조회 - const data = await db - .select({ - id: techSalesVendorQuotations.id, - rfqId: techSalesVendorQuotations.rfqId, - vendorId: techSalesVendorQuotations.vendorId, - status: techSalesVendorQuotations.status, - currency: techSalesVendorQuotations.currency, - totalPrice: techSalesVendorQuotations.totalPrice, - validUntil: techSalesVendorQuotations.validUntil, - submittedAt: techSalesVendorQuotations.submittedAt, - remark: techSalesVendorQuotations.remark, - createdAt: techSalesVendorQuotations.createdAt, - updatedAt: techSalesVendorQuotations.updatedAt, - createdBy: techSalesVendorQuotations.createdBy, - updatedBy: techSalesVendorQuotations.updatedBy, - // RFQ 정보 - rfqCode: techSalesRfqs.rfqCode, - materialCode: techSalesRfqs.materialCode, - dueDate: techSalesRfqs.dueDate, - rfqStatus: techSalesRfqs.status, - // 아이템 정보 - itemName: items.itemName, - // 프로젝트 정보 (JSON에서 추출) - projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`, - // 첨부파일 개수 - attachmentCount: sql<number>`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`, - }) - .from(techSalesVendorQuotations) - .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(items, eq(techSalesRfqs.itemId, items.id)) - .where(finalWhere) - .orderBy(...orderBy) - .limit(limit) - .offset(offset); - - // 총 개수 조회 - const totalResult = await db - .select({ count: sql<number>`count(*)` }) - .from(techSalesVendorQuotations) - .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) - .leftJoin(items, eq(techSalesRfqs.itemId, items.id)) - .where(finalWhere); + // 최종 WHERE 조건 + const finalWhere = baseConditions.length > 0 + ? and(...baseConditions) + : undefined; - const total = totalResult[0]?.count || 0; - const pageCount = Math.ceil(total / perPage); + // 정렬 기준 설정 + let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.updatedAt)]; + + if (sort?.length) { + orderBy = sort.map(item => { + switch (item.id) { + case 'id': + return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id; + case 'status': + return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status; + case 'currency': + return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency; + case 'totalPrice': + return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice; + case 'validUntil': + return item.desc ? desc(techSalesVendorQuotations.validUntil) : techSalesVendorQuotations.validUntil; + case 'submittedAt': + return item.desc ? desc(techSalesVendorQuotations.submittedAt) : techSalesVendorQuotations.submittedAt; + case 'createdAt': + return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt; + case 'updatedAt': + return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; + default: + return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt; + } + }); + } - return { data, pageCount, total }; - } catch (err) { - console.error("Error fetching vendor quotations:", err); - return { data: [], pageCount: 0, total: 0 }; - } + // 조인을 포함한 데이터 조회 + const data = await db + .select({ + id: techSalesVendorQuotations.id, + rfqId: techSalesVendorQuotations.rfqId, + vendorId: techSalesVendorQuotations.vendorId, + status: techSalesVendorQuotations.status, + currency: techSalesVendorQuotations.currency, + totalPrice: techSalesVendorQuotations.totalPrice, + validUntil: techSalesVendorQuotations.validUntil, + submittedAt: techSalesVendorQuotations.submittedAt, + remark: techSalesVendorQuotations.remark, + createdAt: techSalesVendorQuotations.createdAt, + updatedAt: techSalesVendorQuotations.updatedAt, + createdBy: techSalesVendorQuotations.createdBy, + updatedBy: techSalesVendorQuotations.updatedBy, + // RFQ 정보 + rfqCode: techSalesRfqs.rfqCode, + materialCode: techSalesRfqs.materialCode, + dueDate: techSalesRfqs.dueDate, + rfqStatus: techSalesRfqs.status, + // 아이템 정보 + itemName: items.itemName, + // 프로젝트 정보 (JSON에서 추출) + projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`, + // 첨부파일 개수 + attachmentCount: sql<number>`( + SELECT COUNT(*) + FROM tech_sales_attachments + WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} + )`, + }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(items, eq(techSalesRfqs.itemId, items.id)) + .where(finalWhere) + .orderBy(...orderBy) + .limit(limit) + .offset(offset); + + // 총 개수 조회 + const totalResult = await db + .select({ count: sql<number>`count(*)` }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(items, eq(techSalesRfqs.itemId, items.id)) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + const pageCount = Math.ceil(total / perPage); + + return { data, pageCount, total }; + } catch (err) { + console.error("Error fetching vendor quotations:", err); + return { data: [], pageCount: 0, total: 0 }; + } + }, + [JSON.stringify(input), vendorId], // 캐싱 키 + { + revalidate: 60, // 1분간 캐시 + tags: [ + "techSalesVendorQuotations", + `vendor-${vendorId}-quotations` + ], + } + )(); } /** * 벤더용 기술영업 견적서 상태별 개수 조회 */ export async function getQuotationStatusCounts(vendorId: string) { - unstable_noStore(); - try { - const result = await db - .select({ - status: techSalesVendorQuotations.status, - count: sql<number>`count(*)`, - }) - .from(techSalesVendorQuotations) - .where(eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))) - .groupBy(techSalesVendorQuotations.status); + return unstable_cache( + async () => { + try { + const result = await db + .select({ + status: techSalesVendorQuotations.status, + count: sql<number>`count(*)`, + }) + .from(techSalesVendorQuotations) + .where(eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))) + .groupBy(techSalesVendorQuotations.status); - return { data: result, error: null }; - } catch (err) { - console.error("Error fetching quotation status counts:", err); - return { data: null, error: getErrorMessage(err) }; - } + return { data: result, error: null }; + } catch (err) { + console.error("Error fetching quotation status counts:", err); + return { data: null, error: getErrorMessage(err) }; + } + }, + [vendorId], // 캐싱 키 + { + revalidate: 60, // 1분간 캐시 + tags: [ + "techSalesVendorQuotations", + `vendor-${vendorId}-quotations` + ], + } + )(); } /** @@ -1544,6 +1590,16 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { revalidateTag(`techSalesRfq-${result.rfqId}`) revalidateTag("techSalesRfqs") + // 해당 RFQ의 모든 벤더 캐시 무효화 (선택된 벤더와 거절된 벤더들) + const allVendorsInRfq = await db.query.techSalesVendorQuotations.findMany({ + where: eq(techSalesVendorQuotations.rfqId, result.rfqId), + columns: { vendorId: true } + }); + + for (const vendorQuotation of allVendorsInRfq) { + revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`); + } + return { success: true, data: result } } catch (error) { console.error("벤더 견적 승인 오류:", error) @@ -1581,6 +1637,7 @@ export async function rejectTechSalesVendorQuotation(quotationId: number, reject // 캐시 무효화 revalidateTag("techSalesVendorQuotations") revalidateTag(`techSalesRfq-${result[0].rfqId}`) + revalidateTag(`vendor-${result[0].vendorId}-quotations`) return { success: true, data: result[0] } } catch (error) { @@ -1944,7 +2001,7 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu } // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record<string, any>) || {}; + const projectInfo = (quotation.rfq.projectSnapshot as Record<string, unknown>) || {}; // 시리즈 정보 처리 const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ @@ -2063,7 +2120,7 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n } // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record<string, any>) || {}; + const projectInfo = (quotation.rfq.projectSnapshot as Record<string, unknown>) || {}; // 시리즈 정보 처리 const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ @@ -2197,7 +2254,7 @@ export async function sendQuotationAcceptedNotification(quotationId: number) { } // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record<string, any>) || {}; + const projectInfo = (quotation.rfq.projectSnapshot as Record<string, unknown>) || {}; // 시리즈 정보 처리 const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ @@ -2331,7 +2388,7 @@ export async function sendQuotationRejectedNotification(quotationId: number) { } // 프로젝트 정보 준비 - const projectInfo = (quotation.rfq.projectSnapshot as Record<string, any>) || {}; + const projectInfo = (quotation.rfq.projectSnapshot as Record<string, unknown>) || {}; // 시리즈 정보 처리 const seriesInfo = quotation.rfq.seriesSnapshot ? quotation.rfq.seriesSnapshot.map((series: SeriesSnapshot) => ({ diff --git a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx index 958cc8d1..4172ccd7 100644 --- a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx +++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx @@ -15,7 +15,6 @@ import { DrawerHeader, DrawerTitle, } from "@/components/ui/drawer" -import { ScrollArea } from "@/components/ui/scroll-area" import { Badge } from "@/components/ui/badge" import { toast } from "sonner" import { @@ -143,6 +142,11 @@ export function VendorCommunicationDrawer({ const fileInputRef = useRef<HTMLInputElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null); + // 자동 새로고침 관련 상태 + const [autoRefresh, setAutoRefresh] = useState(true); + const [lastMessageCount, setLastMessageCount] = useState(0); + const intervalRef = useRef<NodeJS.Timeout | null>(null); + // 첨부파일 관련 상태 const [previewDialogOpen, setPreviewDialogOpen] = useState(false); const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null); @@ -151,8 +155,20 @@ export function VendorCommunicationDrawer({ useEffect(() => { if (open && selectedRfq && selectedVendor) { loadComments(); + // 자동 새로고침 시작 + if (autoRefresh) { + startAutoRefresh(); + } + } else { + // 드로어가 닫히면 자동 새로고침 중지 + stopAutoRefresh(); } - }, [open, selectedRfq, selectedVendor]); + + // 컴포넌트 언마운트 시 정리 + return () => { + stopAutoRefresh(); + }; + }, [open, selectedRfq, selectedVendor, autoRefresh]); // 스크롤 최하단으로 이동 useEffect(() => { @@ -160,25 +176,79 @@ export function VendorCommunicationDrawer({ messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); } }, [comments]); + + // 자동 새로고침 시작 + const startAutoRefresh = () => { + stopAutoRefresh(); // 기존 interval 정리 + intervalRef.current = setInterval(() => { + if (open && selectedRfq && selectedVendor && !isSubmitting) { + loadComments(true); // 자동 새로고침임을 표시 + } + }, 60000); // 60초마다 새로고침 + }; + + // 자동 새로고침 중지 + const stopAutoRefresh = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + + // 자동 새로고침 토글 + const toggleAutoRefresh = () => { + setAutoRefresh(prev => { + const newValue = !prev; + if (newValue && open) { + startAutoRefresh(); + } else { + stopAutoRefresh(); + } + return newValue; + }); + }; - // 코멘트 로드 함수 - const loadComments = async () => { + // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가) + const loadComments = async (isAutoRefresh = false) => { if (!selectedRfq || !selectedVendor) return; try { - setIsLoading(true); + // 자동 새로고침일 때는 로딩 표시하지 않음 + if (!isAutoRefresh) { + setIsLoading(true); + } // Server Action을 사용하여 코멘트 데이터 가져오기 const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0); + + // 새 메시지가 있는지 확인 (자동 새로고침일 때만) + if (isAutoRefresh) { + const newMessageCount = commentsData.length; + if (newMessageCount > lastMessageCount && lastMessageCount > 0) { + // 새 메시지 알림 (선택사항) + toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`); + } + setLastMessageCount(newMessageCount); + } else { + setLastMessageCount(commentsData.length); + } + setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅 // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경 await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0); } catch (error) { console.error("코멘트 로드 오류:", error); - toast.error("메시지를 불러오는 중 오류가 발생했습니다"); + if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음 + toast.error("메시지를 불러오는 중 오류가 발생했습니다"); + } } finally { - setIsLoading(false); + // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지 + if (!isAutoRefresh) { + setTimeout(() => { + setIsLoading(false); + }, 200); + } } }; @@ -323,8 +393,8 @@ export function VendorCommunicationDrawer({ return ( <Drawer open={open} onOpenChange={onOpenChange}> - <DrawerContent className="max-h-[85vh]"> - <DrawerHeader className="border-b"> + <DrawerContent className="max-h-[80vh] flex flex-col"> + <DrawerHeader className="border-b flex-shrink-0"> <DrawerTitle className="flex items-center gap-2"> <Avatar className="h-8 w-8"> <AvatarFallback className="bg-primary/10"> @@ -341,10 +411,10 @@ export function VendorCommunicationDrawer({ </DrawerDescription> </DrawerHeader> - <div className="p-0 flex flex-col h-[60vh]"> + <div className="flex flex-col flex-1 min-h-0"> {/* 메시지 목록 */} - <ScrollArea className="flex-1 p-4"> - {isLoading ? ( + <div className="flex-1 p-4 overflow-y-auto min-h-[300px]"> + {isLoading && comments.length === 0 ? ( <div className="flex h-full items-center justify-center"> <p className="text-muted-foreground">메시지 로딩 중...</p> </div> @@ -356,7 +426,15 @@ export function VendorCommunicationDrawer({ </div> </div> ) : ( - <div className="space-y-4"> + <div className="space-y-4 relative"> + {isLoading && ( + <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1"> + <div className="flex items-center gap-2"> + <div className="w-2 h-2 bg-primary rounded-full animate-pulse" /> + <span className="text-xs text-muted-foreground">새로고침 중...</span> + </div> + </div> + )} {comments.map(comment => ( <div key={comment.id} @@ -436,11 +514,11 @@ export function VendorCommunicationDrawer({ <div ref={messagesEndRef} /> </div> )} - </ScrollArea> + </div> {/* 선택된 첨부파일 표시 */} {attachments.length > 0 && ( - <div className="p-2 bg-muted mx-4 rounded-md mb-2"> + <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0"> <div className="text-xs font-medium mb-1">첨부파일</div> <div className="flex flex-wrap gap-2"> {attachments.map((file, index) => ( @@ -466,7 +544,7 @@ export function VendorCommunicationDrawer({ )} {/* 메시지 입력 영역 */} - <div className="p-4 border-t"> + <div className="p-4 border-t flex-shrink-0"> <div className="flex gap-2 items-end"> <div className="flex-1"> <Textarea @@ -503,11 +581,31 @@ export function VendorCommunicationDrawer({ </div> </div> - <DrawerFooter className="border-t"> - <div className="flex justify-between"> - <Button variant="outline" onClick={() => loadComments()}> - 새로고침 - </Button> + <DrawerFooter className="border-t flex-shrink-0"> + <div className="flex justify-between items-center"> + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={() => loadComments()}> + 새로고침 + </Button> + <Button + variant={autoRefresh ? "default" : "outline"} + size="sm" + onClick={toggleAutoRefresh} + className="gap-2" + > + {autoRefresh ? ( + <> + <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> + 자동 새로고침 ON + </> + ) : ( + <> + <div className="w-2 h-2 bg-gray-400 rounded-full" /> + 자동 새로고침 OFF + </> + )} + </Button> + </div> <DrawerClose asChild> <Button variant="outline">닫기</Button> </DrawerClose> diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx index 125e800b..e1047fd1 100644 --- a/lib/techsales-rfq/table/rfq-table-column.tsx +++ b/lib/techsales-rfq/table/rfq-table-column.tsx @@ -250,14 +250,14 @@ export function getColumns({ { accessorKey: "rfqSendDate", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 전송일" /> + <DataTableColumnHeaderSimple column={column} title="최초 전송일" /> ), cell: ({ cell }) => { const value = cell.getValue(); return value ? formatDate(value as Date, "KR") : ""; }, meta: { - excelHeader: "RFQ 전송일" + excelHeader: "최초 전송일" }, enableResizing: true, size: 120, diff --git a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx index c8a0efc2..4422a32c 100644 --- a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx +++ b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx @@ -28,7 +28,6 @@ import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" import { Dialog, DialogContent, @@ -237,6 +236,11 @@ export function BuyerCommunicationDrawer({ const fileInputRef = useRef<HTMLInputElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null); + // 자동 새로고침 관련 상태 + const [autoRefresh, setAutoRefresh] = useState(true); + const [lastMessageCount, setLastMessageCount] = useState(0); + const intervalRef = useRef<NodeJS.Timeout | null>(null); + // 첨부파일 관련 상태 const [previewDialogOpen, setPreviewDialogOpen] = useState(false); const [selectedAttachment, setSelectedAttachment] = useState<TechSalesAttachment | null>(null); @@ -245,8 +249,20 @@ export function BuyerCommunicationDrawer({ useEffect(() => { if (open && quotation) { loadComments(); + // 자동 새로고침 시작 + if (autoRefresh) { + startAutoRefresh(); + } + } else { + // 드로어가 닫히면 자동 새로고침 중지 + stopAutoRefresh(); } - }, [open, quotation]); + + // 컴포넌트 언마운트 시 정리 + return () => { + stopAutoRefresh(); + }; + }, [open, quotation, autoRefresh]); // 스크롤 최하단으로 이동 useEffect(() => { @@ -255,23 +271,77 @@ export function BuyerCommunicationDrawer({ } }, [comments]); - // 코멘트 로드 함수 - const loadComments = async () => { + // 자동 새로고침 시작 + const startAutoRefresh = () => { + stopAutoRefresh(); // 기존 interval 정리 + intervalRef.current = setInterval(() => { + if (open && quotation && !isSubmitting) { + loadComments(true); // 자동 새로고침임을 표시 + } + }, 60000); // 60초마다 새로고침 + }; + + // 자동 새로고침 중지 + const stopAutoRefresh = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + + // 자동 새로고침 토글 + const toggleAutoRefresh = () => { + setAutoRefresh(prev => { + const newValue = !prev; + if (newValue && open) { + startAutoRefresh(); + } else { + stopAutoRefresh(); + } + return newValue; + }); + }; + + // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가) + const loadComments = async (isAutoRefresh = false) => { if (!quotation) return; try { - setIsLoading(true); + // 자동 새로고침일 때는 로딩 표시하지 않음 + if (!isAutoRefresh) { + setIsLoading(true); + } // API를 사용하여 코멘트 데이터 가져오기 const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId); + + // 새 메시지가 있는지 확인 (자동 새로고침일 때만) + if (isAutoRefresh) { + const newMessageCount = commentsData.length; + if (newMessageCount > lastMessageCount && lastMessageCount > 0) { + // 새 메시지 알림 + toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`); + } + setLastMessageCount(newMessageCount); + } else { + setLastMessageCount(commentsData.length); + } + setComments(commentsData); // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정 } catch (error) { console.error("코멘트 로드 오류:", error); - toast.error("메시지를 불러오는 중 오류가 발생했습니다"); + if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음 + toast.error("메시지를 불러오는 중 오류가 발생했습니다"); + } } finally { - setIsLoading(false); + // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지 + if (!isAutoRefresh) { + setTimeout(() => { + setIsLoading(false); + }, 200); + } } }; @@ -413,8 +483,8 @@ export function BuyerCommunicationDrawer({ return ( <Drawer open={open} onOpenChange={onOpenChange}> - <DrawerContent className="max-h-[85vh]"> - <DrawerHeader className="border-b"> + <DrawerContent className="max-h-[80vh] flex flex-col"> + <DrawerHeader className="border-b flex-shrink-0"> <DrawerTitle className="flex items-center gap-2"> <Avatar className="h-8 w-8"> <AvatarFallback className="bg-primary/10"> @@ -431,10 +501,10 @@ export function BuyerCommunicationDrawer({ </DrawerDescription> </DrawerHeader> - <div className="p-0 flex flex-col h-[60vh]"> + <div className="flex flex-col flex-1 min-h-0"> {/* 메시지 목록 */} - <ScrollArea className="flex-1 p-4"> - {isLoading ? ( + <div className="flex-1 p-4 overflow-y-auto min-h-[300px]"> + {isLoading && comments.length === 0 ? ( <div className="flex h-full items-center justify-center"> <p className="text-muted-foreground">메시지 로딩 중...</p> </div> @@ -446,7 +516,15 @@ export function BuyerCommunicationDrawer({ </div> </div> ) : ( - <div className="space-y-4"> + <div className="space-y-4 relative"> + {isLoading && ( + <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1"> + <div className="flex items-center gap-2"> + <div className="w-2 h-2 bg-primary rounded-full animate-pulse" /> + <span className="text-xs text-muted-foreground">새로고침 중...</span> + </div> + </div> + )} {comments.map(comment => ( <div key={comment.id} @@ -528,11 +606,11 @@ export function BuyerCommunicationDrawer({ <div ref={messagesEndRef} /> </div> )} - </ScrollArea> + </div> {/* 선택된 첨부파일 표시 */} {attachments.length > 0 && ( - <div className="p-2 bg-muted mx-4 rounded-md mb-2"> + <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0"> <div className="text-xs font-medium mb-1">첨부파일</div> <div className="flex flex-wrap gap-2"> {attachments.map((file, index) => ( @@ -558,7 +636,7 @@ export function BuyerCommunicationDrawer({ )} {/* 메시지 입력 영역 */} - <div className="p-4 border-t"> + <div className="p-4 border-t flex-shrink-0"> <div className="flex gap-2 items-end"> <div className="flex-1"> <Textarea @@ -595,11 +673,31 @@ export function BuyerCommunicationDrawer({ </div> </div> - <DrawerFooter className="border-t"> - <div className="flex justify-between"> - <Button variant="outline" onClick={() => loadComments()}> - 새로고침 - </Button> + <DrawerFooter className="border-t flex-shrink-0"> + <div className="flex justify-between items-center"> + <div className="flex items-center gap-2"> + <Button variant="outline" onClick={() => loadComments()}> + 새로고침 + </Button> + <Button + variant={autoRefresh ? "default" : "outline"} + size="sm" + onClick={toggleAutoRefresh} + className="gap-2" + > + {autoRefresh ? ( + <> + <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> + 자동 새로고침 ON + </> + ) : ( + <> + <div className="w-2 h-2 bg-gray-400 rounded-full" /> + 자동 새로고침 OFF + </> + )} + </Button> + </div> <DrawerClose asChild> <Button variant="outline">닫기</Button> </DrawerClose> |
