summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq')
-rw-r--r--lib/techsales-rfq/service.ts365
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx140
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx4
-rw-r--r--lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx140
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>