summaryrefslogtreecommitdiff
path: root/lib/vendors
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendors')
-rw-r--r--lib/vendors/service.ts551
-rw-r--r--lib/vendors/table/attachmentButton.tsx45
-rw-r--r--lib/vendors/table/request-additional-Info-dialog.tsx152
-rw-r--r--lib/vendors/table/request-project-pq-dialog.tsx242
-rw-r--r--lib/vendors/table/request-vendor-investigate-dialog.tsx152
-rw-r--r--lib/vendors/table/send-vendor-dialog.tsx4
-rw-r--r--lib/vendors/table/vendors-table-columns.tsx158
-rw-r--r--lib/vendors/table/vendors-table-toolbar-actions.tsx86
-rw-r--r--lib/vendors/table/vendors-table.tsx5
-rw-r--r--lib/vendors/utils.ts48
-rw-r--r--lib/vendors/validations.ts103
11 files changed, 1327 insertions, 219 deletions
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 2da16888..8f095c0e 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -2,7 +2,7 @@
import { revalidateTag, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import { vendorAttachments, VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
+import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
import logger from '@/lib/logger';
import { filterColumns } from "@/lib/filter-columns";
@@ -38,7 +38,7 @@ import type {
GetRfqHistorySchema,
} from "./validations";
-import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull } from "drizzle-orm";
+import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm";
import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq";
import path from "path";
import fs from "fs/promises";
@@ -48,8 +48,10 @@ import { promises as fsPromises } from 'fs';
import { sendEmail } from "../mail/sendEmail";
import { PgTransaction } from "drizzle-orm/pg-core";
import { items } from "@/db/schema/items";
-import { id_ID } from "@faker-js/faker";
import { users } from "@/db/schema/users";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { projects, vendorProjectPQs } from "@/db/schema";
/* -----------------------------------------------------
@@ -178,7 +180,9 @@ export async function getVendorStatusCounts() {
"REJECTED": 0,
"IN_PQ": 0,
"PQ_FAILED": 0,
+ "PQ_APPROVED": 0,
"APPROVED": 0,
+ "READY_TO_SEND": 0,
"PQ_SUBMITTED": 0
};
@@ -275,7 +279,7 @@ export async function createVendor(params: {
vendorData: CreateVendorData
// 기존의 일반 첨부파일
files?: File[]
-
+
// 신용평가 / 현금흐름 등급 첨부
creditRatingFiles?: File[]
cashFlowRatingFiles?: File[]
@@ -288,25 +292,25 @@ export async function createVendor(params: {
}[]
}) {
unstable_noStore() // Next.js 서버 액션 캐싱 방지
-
+
try {
const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params
-
+
// 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인
const existingUser = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, vendorData.email))
.limit(1);
-
+
// 이미 사용자가 존재하면 에러 반환
if (existingUser.length > 0) {
- return {
- data: null,
- error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)`
+ return {
+ data: null,
+ error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)`
};
}
-
+
await db.transaction(async (tx) => {
// 1) Insert the vendor (확장 필드도 함께)
const [newVendor] = await insertVendor(tx, {
@@ -319,36 +323,36 @@ export async function createVendor(params: {
website: vendorData.website || null,
status: vendorData.status ?? "PENDING_REVIEW",
taxId: vendorData.taxId,
-
+
// 대표자 정보
representativeName: vendorData.representativeName || null,
representativeBirth: vendorData.representativeBirth || null,
representativeEmail: vendorData.representativeEmail || null,
representativePhone: vendorData.representativePhone || null,
corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null,
-
+
// 신용/현금흐름
creditAgency: vendorData.creditAgency || null,
creditRating: vendorData.creditRating || null,
cashFlowRating: vendorData.cashFlowRating || null,
})
-
+
// 2) If there are attached files, store them
// (2-1) 일반 첨부
if (files.length > 0) {
await storeVendorFiles(tx, newVendor.id, files, "GENERAL")
}
-
+
// (2-2) 신용평가 파일
if (creditRatingFiles.length > 0) {
await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING")
}
-
+
// (2-3) 현금흐름 파일
if (cashFlowRatingFiles.length > 0) {
await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING")
}
-
+
for (const contact of contacts) {
await tx.insert(vendorContacts).values({
vendorId: newVendor.id,
@@ -360,7 +364,7 @@ export async function createVendor(params: {
})
}
})
-
+
revalidateTag("vendors")
return { data: null, error: null }
} catch (error) {
@@ -665,21 +669,21 @@ export async function getItemsForVendor(vendorId: number) {
// 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함
// 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회
const itemsData = await db
- .select({
- itemCode: items.itemCode,
- itemName: items.itemName,
- description: items.description,
- })
- .from(items)
- .leftJoin(
- vendorPossibleItems,
- eq(items.itemCode, vendorPossibleItems.itemCode)
- )
- // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만
- .where(
- isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode)
- )
- .orderBy(asc(items.itemName))
+ .select({
+ itemCode: items.itemCode,
+ itemName: items.itemName,
+ description: items.description,
+ })
+ .from(items)
+ .leftJoin(
+ vendorPossibleItems,
+ eq(items.itemCode, vendorPossibleItems.itemCode)
+ )
+ // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만
+ .where(
+ isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode)
+ )
+ .orderBy(asc(items.itemName))
return {
data: itemsData.map((item) => ({
@@ -843,14 +847,15 @@ export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number
export async function checkJoinPortal(taxID: string) {
try {
// 이미 등록된 회사가 있는지 검색
- const result = await db.select().from(vendors).where(eq(vendors.taxId, taxID)).limit(1)
+ const result = await db.query.vendors.findFirst({
+ where: eq(vendors.taxId, taxID)
+ });
- if (result.length > 0) {
+ if (result) {
// 이미 가입되어 있음
- // data에 예시로 vendorName이나 다른 정보를 담아 반환
return {
success: false,
- data: result[0].vendorName ?? "Already joined",
+ data: result.vendorName ?? "Already joined",
}
}
@@ -888,11 +893,9 @@ interface CreateCompanyInput {
export async function downloadVendorAttachments(vendorId: number, fileId?: number) {
try {
// 벤더 정보 조회
- const vendor = await db.select()
- .from(vendors)
- .where(eq(vendors.id, vendorId))
- .limit(1)
- .then(rows => rows[0]);
+ const vendor = await db.query.vendors.findFirst({
+ where: eq(vendors.id, vendorId)
+ });
if (!vendor) {
throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`);
@@ -1007,6 +1010,7 @@ export async function cleanupTempFiles(fileName: string) {
interface ApproveVendorsInput {
ids: number[];
+ projectId?: number | null
}
/**
@@ -1014,7 +1018,7 @@ interface ApproveVendorsInput {
*/
export async function approveVendors(input: ApproveVendorsInput) {
unstable_noStore();
-
+
try {
// 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송
const result = await db.transaction(async (tx) => {
@@ -1027,7 +1031,7 @@ export async function approveVendors(input: ApproveVendorsInput) {
})
.where(inArray(vendors.id, input.ids))
.returning();
-
+
// 2. 업데이트된 벤더 정보 조회
const updatedVendors = await tx
.select({
@@ -1037,21 +1041,22 @@ export async function approveVendors(input: ApproveVendorsInput) {
})
.from(vendors)
.where(inArray(vendors.id, input.ids));
-
+
// 3. 각 벤더에 대한 유저 계정 생성
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
-
+
// 이미 존재하는 유저인지 확인
- const existingUser = await tx
- .select({ id: users.id })
- .from(users)
- .where(eq(users.email, vendor.email))
- .limit(1);
-
+ const existingUser = await db.query.users.findFirst({
+ where: eq(users.email, vendor.email),
+ columns: {
+ id: true
+ }
+ });
+
// 유저가 존재하지 않는 경우에만 생성
- if (existingUser.length === 0) {
+ if (!existingUser) {
await tx.insert(users).values({
name: vendor.vendorName,
email: vendor.email,
@@ -1061,20 +1066,20 @@ export async function approveVendors(input: ApproveVendorsInput) {
}
})
);
-
+
// 4. 각 벤더에게 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
-
+
try {
const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
-
- const subject =
+
+ const subject =
"[eVCP] Admin Account Created";
-
+
const loginUrl = "http://3.36.56.124:3000/en/login";
-
+
await sendEmail({
to: vendor.email,
subject,
@@ -1091,25 +1096,44 @@ export async function approveVendors(input: ApproveVendorsInput) {
}
})
);
-
+
return updated;
});
-
+
// 캐시 무효화
revalidateTag("vendors");
revalidateTag("vendor-status-counts");
revalidateTag("users"); // 유저 캐시도 무효화
-
+
return { data: result, error: null };
} catch (err) {
console.error("Error approving vendors:", err);
return { data: null, error: getErrorMessage(err) };
}
}
+
export async function requestPQVendors(input: ApproveVendorsInput) {
unstable_noStore();
-
+
try {
+ // 프로젝트 정보 가져오기 (projectId가 있는 경우)
+ let projectInfo = null;
+ if (input.projectId) {
+ const project = await db
+ .select({
+ id: projects.id,
+ projectCode: projects.code,
+ projectName: projects.name,
+ })
+ .from(projects)
+ .where(eq(projects.id, input.projectId))
+ .limit(1);
+
+ if (project.length > 0) {
+ projectInfo = project[0];
+ }
+ }
+
// 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송
const result = await db.transaction(async (tx) => {
// 1. 벤더 상태 업데이트
@@ -1121,7 +1145,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
})
.where(inArray(vendors.id, input.ids))
.returning();
-
+
// 2. 업데이트된 벤더 정보 조회
const updatedVendors = await tx
.select({
@@ -1131,28 +1155,51 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
})
.from(vendors)
.where(inArray(vendors.id, input.ids));
-
- // 3. 각 벤더에게 이메일 발송
+
+ // 3. 프로젝트 PQ인 경우, vendorProjectPQs 테이블에 레코드 추가
+ if (input.projectId && projectInfo) {
+ // 각 벤더에 대해 프로젝트 PQ 연결 생성
+ const vendorProjectPQsData = input.ids.map(vendorId => ({
+ vendorId,
+ projectId: input.projectId!,
+ status: "REQUESTED",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }));
+
+ await tx.insert(vendorProjectPQs).values(vendorProjectPQsData);
+ }
+
+ // 4. 각 벤더에게 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
-
+
try {
const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
-
- const subject =
- "[eVCP] You are invited to submit PQ";
-
- const loginUrl = "http://3.36.56.124:3000/en/login";
-
+
+ // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경
+ const subject = input.projectId
+ ? `[eVCP] You are invited to submit Project PQ for ${projectInfo?.projectCode || 'a project'}`
+ : "[eVCP] You are invited to submit PQ";
+
+ // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우)
+ const baseLoginUrl = "http://3.36.56.124:3000/en/login";
+ const loginUrl = input.projectId
+ ? `${baseLoginUrl}?projectId=${input.projectId}`
+ : baseLoginUrl;
+
await sendEmail({
to: vendor.email,
subject,
- template: "pq", // 이메일 템플릿 이름
+ template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용
context: {
vendorName: vendor.vendorName,
loginUrl,
language: userLang,
+ projectCode: projectInfo?.projectCode || '',
+ projectName: projectInfo?.projectName || '',
+ hasProject: !!input.projectId,
},
});
} catch (emailError) {
@@ -1161,17 +1208,20 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
}
})
);
-
+
return updated;
});
-
+
// 캐시 무효화
revalidateTag("vendors");
revalidateTag("vendor-status-counts");
-
+ if (input.projectId) {
+ revalidateTag(`project-${input.projectId}`);
+ }
+
return { data: result, error: null };
} catch (err) {
- console.error("Error approving vendors:", err);
+ console.error("Error requesting PQ from vendors:", err);
return { data: null, error: getErrorMessage(err) };
}
}
@@ -1190,46 +1240,40 @@ export async function sendVendors(input: SendVendorsInput) {
// 트랜잭션 내에서 진행
const result = await db.transaction(async (tx) => {
// 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링
- const approvedVendors = await tx
- .select()
- .from(vendors)
- .where(
- and(
- inArray(vendors.id, input.ids),
- eq(vendors.status, "APPROVED")
- )
- );
+ const approvedVendors = await db.query.vendors.findMany({
+ where: and(
+ inArray(vendors.id, input.ids),
+ eq(vendors.status, "APPROVED")
+ )
+ });
if (!approvedVendors.length) {
throw new Error("No approved vendors found in the selection");
}
-
// 벤더별 처리 결과를 저장할 배열
const results = [];
// 2. 각 벤더에 대해 처리
for (const vendor of approvedVendors) {
// 2-1. 벤더 연락처 정보 조회
- const contacts = await tx
- .select()
- .from(vendorContacts)
- .where(eq(vendorContacts.vendorId, vendor.id));
+ const contacts = await db.query.vendorContacts.findMany({
+ where: eq(vendorContacts.vendorId, vendor.id)
+ });
// 2-2. 벤더 가능 아이템 조회
- const possibleItems = await tx
- .select()
- .from(vendorPossibleItems)
- .where(eq(vendorPossibleItems.vendorId, vendor.id));
-
+ const possibleItems = await db.query.vendorPossibleItems.findMany({
+ where: eq(vendorPossibleItems.vendorId, vendor.id)
+ });
// 2-3. 벤더 첨부파일 조회
- const attachments = await tx
- .select({
- id: vendorAttachments.id,
- fileName: vendorAttachments.fileName,
- filePath: vendorAttachments.filePath,
- })
- .from(vendorAttachments)
- .where(eq(vendorAttachments.vendorId, vendor.id));
+ const attachments = await db.query.vendorAttachments.findMany({
+ where: eq(vendorAttachments.vendorId, vendor.id),
+ columns: {
+ id: true,
+ fileName: true,
+ filePath: true
+ }
+ });
+
// 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
const vendorData = {
@@ -1287,7 +1331,7 @@ export async function sendVendors(input: SendVendorsInput) {
const subject =
"[eVCP] Vendor Registration Completed";
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
const portalUrl = `${baseUrl}/en/partners`;
@@ -1343,3 +1387,298 @@ export async function sendVendors(input: SendVendorsInput) {
}
}
+
+interface RequestInfoProps {
+ ids: number[];
+}
+
+export async function requestInfo({ ids }: RequestInfoProps) {
+ try {
+ // 1. 벤더 정보 가져오기
+ const vendorList = await db.query.vendors.findMany({
+ where: inArray(vendors.id, ids),
+ });
+
+ if (!vendorList.length) {
+ return { error: "벤더 정보를 찾을 수 없습니다." };
+ }
+
+ // 2. 각 벤더에게 이메일 보내기
+ for (const vendor of vendorList) {
+ // 이메일이 없는 경우 스킵
+ if (!vendor.email) continue;
+
+ // 벤더 정보 페이지 URL 생성
+ const vendorInfoUrl = `${process.env.NEXT_PUBLIC_APP_URL}/partners/info?vendorId=${vendor.id}`;
+
+ // 벤더에게 이메일 보내기
+ await sendEmail({
+ to: vendor.email,
+ subject: "[EVCP] 추가 정보 요청 / Additional Information Request",
+ template: "vendor-additional-info",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorInfoUrl: vendorInfoUrl,
+ language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음
+ },
+ });
+ }
+
+ // 3. 성공적으로 처리됨
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 정보 요청 중 오류 발생:", error);
+ return { error: "벤더 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." };
+ }
+}
+
+
+export async function getVendorDetailById(id: number) {
+ try {
+ // View를 통해 벤더 정보 조회
+ const vendor = await db
+ .select()
+ .from(vendorDetailView)
+ .where(eq(vendorDetailView.id, id))
+ .limit(1)
+ .then(rows => rows[0] || null);
+
+ if (!vendor) {
+ return null;
+ }
+
+ // JSON 문자열로 반환된 contacts와 attachments를 JavaScript 객체로 파싱
+ const contacts = typeof vendor.contacts === 'string'
+ ? JSON.parse(vendor.contacts)
+ : vendor.contacts;
+
+ const attachments = typeof vendor.attachments === 'string'
+ ? JSON.parse(vendor.attachments)
+ : vendor.attachments;
+
+ // 파싱된 데이터로 반환
+ return {
+ ...vendor,
+ contacts,
+ attachments
+ };
+ } catch (error) {
+ console.error("Error fetching vendor detail:", error);
+ throw new Error("Failed to fetch vendor detail");
+ }
+}
+
+export type UpdateVendorInfoData = {
+ id: number
+ vendorName: string
+ website?: string
+ address?: string
+ email: string
+ phone?: string
+ country?: string
+ representativeName?: string
+ representativeBirth?: string
+ representativeEmail?: string
+ representativePhone?: string
+ corporateRegistrationNumber?: string
+ creditAgency?: string
+ creditRating?: string
+ cashFlowRating?: string
+}
+
+export type ContactInfo = {
+ id?: number
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ isPrimary?: boolean
+}
+
+/**
+ * 벤더 정보를 업데이트하는 함수
+ */
+export async function updateVendorInfo(params: {
+ vendorData: UpdateVendorInfoData
+ files?: File[]
+ creditRatingFiles?: File[]
+ cashFlowRatingFiles?: File[]
+ contacts: ContactInfo[]
+ filesToDelete?: number[] // 삭제할 파일 ID 목록
+}) {
+ try {
+ const {
+ vendorData,
+ files = [],
+ creditRatingFiles = [],
+ cashFlowRatingFiles = [],
+ contacts,
+ filesToDelete = []
+ } = params
+
+ // 세션 및 권한 확인
+ const session = await getServerSession(authOptions)
+ if (!session?.user || !session.user.companyId) {
+ return { data: null, error: "권한이 없습니다. 로그인이 필요합니다." };
+ }
+
+ const companyId = Number(session.user.companyId);
+
+ // 자신의 회사 정보만 수정 가능 (관리자는 모든 회사 정보 수정 가능)
+ if (
+ // !session.user.isAdmin &&
+ vendorData.id !== companyId) {
+ return { data: null, error: "자신의 회사 정보만 수정할 수 있습니다." };
+ }
+
+ // 트랜잭션으로 업데이트 수행
+ await db.transaction(async (tx) => {
+ // 1. 벤더 정보 업데이트
+ await tx.update(vendors).set({
+ vendorName: vendorData.vendorName,
+ address: vendorData.address || null,
+ email: vendorData.email,
+ phone: vendorData.phone || null,
+ website: vendorData.website || null,
+ country: vendorData.country || null,
+ representativeName: vendorData.representativeName || null,
+ representativeBirth: vendorData.representativeBirth || null,
+ representativeEmail: vendorData.representativeEmail || null,
+ representativePhone: vendorData.representativePhone || null,
+ corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null,
+ creditAgency: vendorData.creditAgency || null,
+ creditRating: vendorData.creditRating || null,
+ cashFlowRating: vendorData.cashFlowRating || null,
+ updatedAt: new Date(),
+ }).where(eq(vendors.id, vendorData.id))
+
+ // 2. 연락처 정보 관리
+ // 2-1. 기존 연락처 가져오기
+ const existingContacts = await tx
+ .select()
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorData.id))
+
+ // 2-2. 기존 연락처 ID 목록
+ const existingContactIds = existingContacts.map(c => c.id)
+
+ // 2-3. 업데이트할 연락처와 새로 추가할 연락처 분류
+ const contactsToUpdate = contacts.filter(c => c.id && existingContactIds.includes(c.id))
+ const contactsToAdd = contacts.filter(c => !c.id)
+
+ // 2-4. 삭제할 연락처 (기존에 있지만 새 목록에 없는 것)
+ const contactIdsToKeep = contactsToUpdate.map(c => c.id)
+ .filter((id): id is number => id !== undefined)
+ const contactIdsToDelete = existingContactIds.filter(id => !contactIdsToKeep.includes(id))
+
+ // 2-5. 연락처 삭제
+ if (contactIdsToDelete.length > 0) {
+ await tx
+ .delete(vendorContacts)
+ .where(and(
+ eq(vendorContacts.vendorId, vendorData.id),
+ inArray(vendorContacts.id, contactIdsToDelete)
+ ))
+ }
+
+ // 2-6. 연락처 업데이트
+ for (const contact of contactsToUpdate) {
+ if (contact.id !== undefined) {
+ await tx
+ .update(vendorContacts)
+ .set({
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: contact.isPrimary || false,
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(vendorContacts.id, contact.id),
+ eq(vendorContacts.vendorId, vendorData.id)
+ ))
+ }
+ }
+
+ // 2-7. 연락처 추가
+ for (const contact of contactsToAdd) {
+ await tx
+ .insert(vendorContacts)
+ .values({
+ vendorId: vendorData.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: contact.isPrimary || false,
+ })
+ }
+
+ // 3. 파일 삭제 처리
+ if (filesToDelete.length > 0) {
+ // 3-1. 삭제할 파일 정보 가져오기
+ const attachmentsToDelete = await tx
+ .select()
+ .from(vendorAttachments)
+ .where(and(
+ eq(vendorAttachments.vendorId, vendorData.id),
+ inArray(vendorAttachments.id, filesToDelete)
+ ))
+
+ // 3-2. 파일 시스템에서 파일 삭제
+ for (const attachment of attachmentsToDelete) {
+ try {
+ // 파일 경로는 /public 기준이므로 process.cwd()/public을 앞에 붙임
+ const filePath = path.join(process.cwd(), 'public', attachment.filePath.replace(/^\//, ''))
+ await fs.access(filePath, fs.constants.F_OK) // 파일 존재 확인
+ await fs.unlink(filePath) // 파일 삭제
+ } catch (error) {
+ console.warn(`Failed to delete file for attachment ${attachment.id}:`, error)
+ // 파일 삭제 실패해도 DB에서는 삭제 진행
+ }
+ }
+
+ // 3-3. DB에서 파일 기록 삭제
+ await tx
+ .delete(vendorAttachments)
+ .where(and(
+ eq(vendorAttachments.vendorId, vendorData.id),
+ inArray(vendorAttachments.id, filesToDelete)
+ ))
+ }
+
+ // 4. 새 파일 저장 (제공된 storeVendorFiles 함수 활용)
+ // 4-1. 일반 파일 저장
+ if (files.length > 0) {
+ await storeVendorFiles(tx, vendorData.id, files, "GENERAL");
+ }
+
+ // 4-2. 신용평가 파일 저장
+ if (creditRatingFiles.length > 0) {
+ await storeVendorFiles(tx, vendorData.id, creditRatingFiles, "CREDIT_RATING");
+ }
+
+ // 4-3. 현금흐름 파일 저장
+ if (cashFlowRatingFiles.length > 0) {
+ await storeVendorFiles(tx, vendorData.id, cashFlowRatingFiles, "CASH_FLOW_RATING");
+ }
+ })
+
+ // 캐시 무효화
+ revalidateTag("vendors")
+ revalidateTag(`vendor-${vendorData.id}`)
+
+ return {
+ data: {
+ success: true,
+ message: '벤더 정보가 성공적으로 업데이트되었습니다.',
+ vendorId: vendorData.id
+ },
+ error: null
+ }
+ } catch (error) {
+ console.error("Vendor info update error:", error);
+ return { data: null, error: getErrorMessage(error) }
+ }
+} \ No newline at end of file
diff --git a/lib/vendors/table/attachmentButton.tsx b/lib/vendors/table/attachmentButton.tsx
index a82f59e1..3ffa9c5f 100644
--- a/lib/vendors/table/attachmentButton.tsx
+++ b/lib/vendors/table/attachmentButton.tsx
@@ -16,25 +16,25 @@ interface AttachmentsButtonProps {
export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) {
if (!hasAttachments) return null;
-
+
const handleDownload = async () => {
try {
toast.loading('첨부파일을 준비하는 중...');
-
+
// 서버 액션 호출
const result = await downloadVendorAttachments(vendorId);
-
+
// 로딩 토스트 닫기
toast.dismiss();
-
+
if (!result || !result.url) {
toast.error('다운로드 준비 중 오류가 발생했습니다.');
return;
}
-
+
// 파일 다운로드 트리거
toast.success('첨부파일 다운로드가 시작되었습니다.');
-
+
// 다운로드 링크 열기
const a = document.createElement('a');
a.href = result.url;
@@ -43,27 +43,34 @@ export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList =
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
-
+
} catch (error) {
toast.dismiss();
toast.error('첨부파일 다운로드에 실패했습니다.');
console.error('첨부파일 다운로드 오류:', error);
}
};
-
+
return (
- <Button
- variant="ghost"
- size="icon"
- onClick={handleDownload}
- title={`${attachmentsList.length}개 파일 다운로드`}
- >
- <PaperclipIcon className="h-4 w-4" />
- {attachmentsList.length > 1 && (
- <Badge variant="outline" className="ml-1 h-5 min-w-5 px-1">
+ <>
+ {attachmentsList && attachmentsList.length > 0 &&
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={handleDownload}
+ title={`${attachmentsList.length}개 파일 다운로드`}
+ >
+ <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {/* {attachmentsList.length > 1 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center"
+ >
{attachmentsList.length}
</Badge>
- )}
- </Button>
+ )} */}
+ </Button>
+ }
+ </>
);
}
diff --git a/lib/vendors/table/request-additional-Info-dialog.tsx b/lib/vendors/table/request-additional-Info-dialog.tsx
new file mode 100644
index 00000000..872162dd
--- /dev/null
+++ b/lib/vendors/table/request-additional-Info-dialog.tsx
@@ -0,0 +1,152 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Send } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Vendor } from "@/db/schema/vendors"
+import { requestInfo } from "../service"
+
+interface RequestInfoDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function RequestInfoDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: RequestInfoDialogProps) {
+ const [isRequestPending, startRequestTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onApprove() {
+ startRequestTransition(async () => {
+ const { error, success } = await requestInfo({
+ ids: vendors.map((vendor) => vendor.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("추가 정보 요청이 성공적으로 벤더에게 발송되었습니다.")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Send className="size-4" aria-hidden="true" />
+ 추가 정보 요청 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>벤더 추가 정보 요청 확인</DialogTitle>
+ <DialogDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까?
+ <br /><br />
+ 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
+ 추가 정보를 입력하게 됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="Send request to selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isRequestPending}
+ >
+ {isRequestPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 요청 발송
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Send className="size-4" aria-hidden="true" />
+ 추가 정보 요청 ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>벤더 추가 정보 요청 확인</DrawerTitle>
+ <DrawerDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까?
+ <br /><br />
+ 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
+ 추가 정보를 입력하게 됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Send request to selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isRequestPending}
+ >
+ {isRequestPending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 요청 발송
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/request-project-pq-dialog.tsx b/lib/vendors/table/request-project-pq-dialog.tsx
new file mode 100644
index 00000000..c590d7ec
--- /dev/null
+++ b/lib/vendors/table/request-project-pq-dialog.tsx
@@ -0,0 +1,242 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, ChevronDown, BuildingIcon } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Label } from "@/components/ui/label"
+import { Vendor } from "@/db/schema/vendors"
+import { requestPQVendors } from "../service"
+import { getProjects, type Project } from "@/lib/rfqs/service"
+
+interface RequestProjectPQDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function RequestProjectPQDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: RequestProjectPQDialogProps) {
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const [projects, setProjects] = React.useState<Project[]>([])
+ const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null)
+ const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
+
+ // 프로젝트 목록 로드
+ React.useEffect(() => {
+ async function loadProjects() {
+ setIsLoadingProjects(true)
+ try {
+ const projectsList = await getProjects()
+ setProjects(projectsList)
+ } catch (error) {
+ console.error("프로젝트 목록 로드 오류:", error)
+ toast.error("프로젝트 목록을 불러오는 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoadingProjects(false)
+ }
+ }
+
+ loadProjects()
+ }, [])
+
+ // 다이얼로그가 닫힐 때 선택된 프로젝트 초기화
+ React.useEffect(() => {
+ if (!props.open) {
+ setSelectedProjectId(null)
+ }
+ }, [props.open])
+
+ // 프로젝트 선택 처리
+ const handleProjectChange = (value: string) => {
+ setSelectedProjectId(Number(value))
+ }
+
+ function onApprove() {
+ if (!selectedProjectId) {
+ toast.error("프로젝트를 선택해주세요.")
+ return
+ }
+
+ startApproveTransition(async () => {
+ const { error } = await requestPQVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ projectId: selectedProjectId,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+
+ toast.success(`벤더에게 프로젝트 PQ가 성공적으로 요청되었습니다.`)
+ onSuccess?.()
+ })
+ }
+
+ const dialogContent = (
+ <>
+ <div className="space-y-4 py-2">
+ <div className="space-y-2">
+ <Label htmlFor="project-selection">프로젝트 선택</Label>
+ <Select
+ onValueChange={handleProjectChange}
+ disabled={isLoadingProjects || isApprovePending}
+ >
+ <SelectTrigger id="project-selection" className="w-full">
+ <SelectValue placeholder="프로젝트를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {isLoadingProjects ? (
+ <SelectItem value="loading" disabled>프로젝트 로딩 중...</SelectItem>
+ ) : projects.length === 0 ? (
+ <SelectItem value="empty" disabled>등록된 프로젝트가 없습니다</SelectItem>
+ ) : (
+ projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.projectCode} - {project.projectName}
+ </SelectItem>
+ ))
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <BuildingIcon className="size-4" aria-hidden="true" />
+ 프로젝트 PQ 요청 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>프로젝트 PQ 요청 확인</DialogTitle>
+ <DialogDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
+ 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {dialogContent}
+
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 벤더에게 요청하기"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending || !selectedProjectId}
+ >
+ {isApprovePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 요청하기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <BuildingIcon className="size-4" aria-hidden="true" />
+ 프로젝트 PQ 요청 ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>프로젝트 PQ 요청 확인</DrawerTitle>
+ <DrawerDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
+ 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="px-4">
+ {dialogContent}
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 벤더에게 요청하기"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending || !selectedProjectId}
+ >
+ {isApprovePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 요청하기
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/request-vendor-investigate-dialog.tsx b/lib/vendors/table/request-vendor-investigate-dialog.tsx
new file mode 100644
index 00000000..0309ee4a
--- /dev/null
+++ b/lib/vendors/table/request-vendor-investigate-dialog.tsx
@@ -0,0 +1,152 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Check, SendHorizonal } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Vendor } from "@/db/schema/vendors"
+import { requestInvestigateVendors } from "@/lib/vendor-investigation/service"
+
+interface ApprovalVendorDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function RequestVendorsInvestigateDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: ApprovalVendorDialogProps) {
+
+ console.log(vendors)
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onApprove() {
+ startApproveTransition(async () => {
+ const { error } = await requestInvestigateVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Vendor Investigation successfully sent to 벤더실사담당자")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <SendHorizonal className="size-4" aria-hidden="true" />
+ Vendor Investigation Request ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Vendor Investigation Requst</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to request{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After sent, 벤더실사담당자 will be notified and can manage it.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Request selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Request
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Check className="size-4" aria-hidden="true" />
+ Investigation Request ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Confirm Vendor Investigation</DrawerTitle>
+ <DrawerDescription>
+ Are you sure you want to request{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After sent, 벤더실사담당자 will be notified and can manage it.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Request selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Request
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/send-vendor-dialog.tsx b/lib/vendors/table/send-vendor-dialog.tsx
index a34abb77..1f93bd7f 100644
--- a/lib/vendors/table/send-vendor-dialog.tsx
+++ b/lib/vendors/table/send-vendor-dialog.tsx
@@ -28,7 +28,7 @@ import {
DrawerTrigger,
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
-import { requestPQVendors, sendVendors } from "../service"
+import { sendVendors } from "../service"
interface ApprovalVendorDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -58,7 +58,7 @@ export function SendVendorsDialog({
}
props.onOpenChange?.(false)
- toast.success("PQ successfully sent to vendors")
+ toast.success("Vendor Information successfully sent to MDG")
onSuccess?.()
})
}
diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx
index c503e369..77750c47 100644
--- a/lib/vendors/table/vendors-table-columns.tsx
+++ b/lib/vendors/table/vendors-table-columns.tsx
@@ -79,82 +79,96 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
// ----------------------------------------------------------------
// 2) actions 컬럼 (Dropdown 메뉴)
// ----------------------------------------------------------------
- const actionsColumn: ColumnDef<Vendor> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
+// ----------------------------------------------------------------
+// 2) actions 컬럼 (Dropdown 메뉴)
+// ----------------------------------------------------------------
+const actionsColumn: ColumnDef<Vendor> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const isApproved = row.original.status === "APPROVED";
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- Edit
- </DropdownMenuItem>
- <DropdownMenuItem
- onSelect={() => {
- // 1) 만약 rowAction을 열고 싶다면
- // setRowAction({ row, type: "update" })
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ // 1) 만약 rowAction을 열고 싶다면
+ // setRowAction({ row, type: "update" })
- // 2) 자세히 보기 페이지로 클라이언트 라우팅
- router.push(`/evcp/vendors/${row.original.id}/info`);
- }}
+ // 2) 자세히 보기 페이지로 클라이언트 라우팅
+ router.push(`/evcp/vendors/${row.original.id}/info`);
+ }}
+ >
+ Details
+ </DropdownMenuItem>
+
+ {/* APPROVED 상태일 때만 추가 정보 기입 메뉴 표시 */}
+ {isApproved && (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "requestInfo" })}
+ className="text-blue-600 font-medium"
>
- Details
+ 추가 정보 기입
</DropdownMenuItem>
- <Separator />
- <DropdownMenuSub>
- <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
- <DropdownMenuSubContent>
- <DropdownMenuRadioGroup
- value={row.original.status}
- onValueChange={(value) => {
- startUpdateTransition(() => {
- toast.promise(
- modifyVendor({
- id: String(row.original.id),
- status: value as Vendor["status"],
- }),
- {
- loading: "Updating...",
- success: "Label updated",
- error: (err) => getErrorMessage(err),
- }
- )
- })
- }}
- >
- {vendors.status.enumValues.map((status) => (
- <DropdownMenuRadioItem
- key={status}
- value={status}
- className="capitalize"
- disabled={isUpdatePending}
- >
- {status}
- </DropdownMenuRadioItem>
- ))}
- </DropdownMenuRadioGroup>
- </DropdownMenuSubContent>
- </DropdownMenuSub>
-
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
+ )}
+
+ <Separator />
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.status}
+ onValueChange={(value) => {
+ startUpdateTransition(() => {
+ toast.promise(
+ modifyVendor({
+ id: String(row.original.id),
+ status: value as Vendor["status"],
+ }),
+ {
+ loading: "Updating...",
+ success: "Label updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {vendors.status.enumValues.map((status) => (
+ <DropdownMenuRadioItem
+ key={status}
+ value={status}
+ className="capitalize"
+ disabled={isUpdatePending}
+ >
+ {status}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+}
// ----------------------------------------------------------------
// 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx
index c0605191..3cb2c552 100644
--- a/lib/vendors/table/vendors-table-toolbar-actions.tsx
+++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx
@@ -2,15 +2,24 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, Upload, Check } from "lucide-react"
+import { Download, Upload, Check, BuildingIcon } from "lucide-react"
import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
import { Vendor } from "@/db/schema/vendors"
import { ApproveVendorsDialog } from "./approve-vendor-dialog"
import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog"
+import { RequestProjectPQDialog } from "./request-project-pq-dialog"
import { SendVendorsDialog } from "./send-vendor-dialog"
+import { RequestVendorsInvestigateDialog } from "./request-vendor-investigate-dialog"
+import { RequestInfoDialog } from "./request-additional-Info-dialog"
interface VendorsTableToolbarActionsProps {
table: Table<Vendor>
@@ -19,7 +28,7 @@ interface VendorsTableToolbarActionsProps {
export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
// 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
const fileInputRef = React.useRef<HTMLInputElement>(null)
-
+
// 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링
const pendingReviewVendors = React.useMemo(() => {
return table
@@ -28,9 +37,8 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
.map(row => row.original)
.filter(vendor => vendor.status === "PENDING_REVIEW");
}, [table.getFilteredSelectedRowModel().rows]);
-
-
- // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링
+
+ // 선택된 벤더 중 IN_REVIEW 상태인 벤더만 필터링
const inReviewVendors = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -38,7 +46,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
.map(row => row.original)
.filter(vendor => vendor.status === "IN_REVIEW");
}, [table.getFilteredSelectedRowModel().rows]);
-
+
const approvedVendors = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -46,14 +54,36 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
.map(row => row.original)
.filter(vendor => vendor.status === "APPROVED");
}, [table.getFilteredSelectedRowModel().rows]);
-
-
-
+
+ const sendVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.status === "READY_TO_SEND");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ const pqApprovedVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.status === "PQ_APPROVED");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ // 프로젝트 PQ를 보낼 수 있는 벤더 상태 필터링
+ const projectPQEligibleVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor =>
+ ["PENDING_REVIEW", "IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status)
+ );
+ }, [table.getFilteredSelectedRowModel().rows]);
+
return (
<div className="flex items-center gap-2">
-
-
-
{/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */}
{pendingReviewVendors.length > 0 && (
<ApproveVendorsDialog
@@ -61,22 +91,44 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
onSuccess={() => table.toggleAllRowsSelected(false)}
/>
)}
-
+
+ {/* 일반 PQ 요청: IN_REVIEW 상태인 벤더가 있을 때만 표시 */}
{inReviewVendors.length > 0 && (
<RequestPQVendorsDialog
vendors={inReviewVendors}
onSuccess={() => table.toggleAllRowsSelected(false)}
/>
)}
-
+
+ {/* 프로젝트 PQ 요청: 적격 상태의 벤더가 있을 때만 표시 */}
+ {projectPQEligibleVendors.length > 0 && (
+ <RequestProjectPQDialog
+ vendors={projectPQEligibleVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
{approvedVendors.length > 0 && (
- <SendVendorsDialog
+ <RequestInfoDialog
vendors={approvedVendors}
onSuccess={() => table.toggleAllRowsSelected(false)}
/>
)}
-
-
+
+ {sendVendors.length > 0 && (
+ <RequestInfoDialog
+ vendors={sendVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
+ {pqApprovedVendors.length > 0 && (
+ <RequestVendorsInvestigateDialog
+ vendors={pqApprovedVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
{/** 4) Export 버튼 */}
<Button
variant="outline"
diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx
index c04d57a9..36fd45bd 100644
--- a/lib/vendors/table/vendors-table.tsx
+++ b/lib/vendors/table/vendors-table.tsx
@@ -20,6 +20,7 @@ import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
import { VendorsTableFloatingBar } from "./vendors-table-floating-bar"
import { UpdateTaskSheet } from "@/lib/tasks/table/update-task-sheet"
import { UpdateVendorSheet } from "./update-vendor-sheet"
+import { getVendorStatusIcon } from "@/lib/vendors/utils"
interface VendorsTableProps {
promises: Promise<
@@ -72,9 +73,11 @@ export function VendorsTable({ promises }: VendorsTableProps) {
label: "Status",
type: "multi-select",
options: vendors.status.enumValues.map((status) => ({
- label: toSentenceCase(status),
+ label: (status),
value: status,
count: statusCounts[status],
+ icon: getVendorStatusIcon(status),
+
})),
},
{ id: "createdAt", label: "Created at", type: "date" },
diff --git a/lib/vendors/utils.ts b/lib/vendors/utils.ts
new file mode 100644
index 00000000..305d772d
--- /dev/null
+++ b/lib/vendors/utils.ts
@@ -0,0 +1,48 @@
+import {
+ Activity,
+ AlertCircle,
+ AlertTriangle,
+ ArrowDownIcon,
+ ArrowRightIcon,
+ ArrowUpIcon,
+ AwardIcon,
+ BadgeCheck,
+ CheckCircle2,
+ CircleHelp,
+ CircleIcon,
+ CircleX,
+ ClipboardCheck,
+ ClipboardList,
+ FileCheck2,
+ FilePenLine,
+ FileX2,
+ MailCheck,
+ PencilIcon,
+ SearchIcon,
+ SendIcon,
+ Timer,
+ Trash2,
+ XCircle,
+} from "lucide-react"
+
+import { Vendor } from "@/db/schema/vendors"
+
+export function getVendorStatusIcon(status: Vendor["status"]) {
+ const statusIcons = {
+ PENDING_REVIEW: ClipboardList, // 가입 신청 중 (초기 신청)
+ IN_REVIEW: FilePenLine, // 심사 중
+ REJECTED: XCircle, // 심사 거부됨
+ IN_PQ: ClipboardCheck, // PQ 진행 중
+ PQ_SUBMITTED: FileCheck2, // PQ 제출
+ PQ_FAILED: FileX2, // PQ 실패
+ PQ_APPROVED: BadgeCheck, // PQ 통과, 승인됨
+ APPROVED: CheckCircle2, // PQ 통과, 승인됨
+ READY_TO_SEND: CheckCircle2, // PQ 통과, 승인됨
+ ACTIVE: Activity, // 활성 상태 (실제 거래 중)
+ INACTIVE: AlertCircle, // 비활성 상태 (일시적)
+ BLACKLISTED: AlertTriangle, // 거래 금지 상태
+ }
+
+ return statusIcons[status] || CircleIcon
+}
+
diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts
index 14efc8dc..1c08f8ff 100644
--- a/lib/vendors/validations.ts
+++ b/lib/vendors/validations.ts
@@ -1,4 +1,3 @@
-import { tasks, type Task } from "@/db/schema/tasks";
import {
createSearchParamsCache,
parseAsArrayOf,
@@ -9,7 +8,7 @@ import {
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { Vendor, VendorContact, VendorItemsView, vendors } from "@/db/schema/vendors";
+import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors } from "@/db/schema/vendors";
import { rfqs } from "@/db/schema/rfq"
@@ -339,3 +338,103 @@ export type UpdateVendorContactSchema = z.infer<typeof updateVendorContactSchema
export type CreateVendorItemSchema = z.infer<typeof createVendorItemSchema>
export type UpdateVendorItemSchema = z.infer<typeof updateVendorItemSchema>
export type GetRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>>
+
+
+
+export const updateVendorInfoSchema = z.object({
+ vendorName: z.string().min(1, "업체명은 필수 입력사항입니다."),
+ taxId: z.string(),
+ address: z.string().optional(),
+ country: z.string().min(1, "국가를 선택해 주세요."),
+ phone: z.string().optional(),
+ email: z.string().email("유효한 이메일을 입력해 주세요."),
+ website: z.string().optional(),
+
+ // 한국 사업자 정보 (KR일 경우 필수 항목들)
+ representativeName: z.string().optional(),
+ representativeBirth: z.string().optional(),
+ representativeEmail: z.string().optional(),
+ representativePhone: z.string().optional(),
+ corporateRegistrationNumber: z.string().optional(),
+
+ // 신용평가 정보
+ creditAgency: z.string().optional(),
+ creditRating: z.string().optional(),
+ cashFlowRating: z.string().optional(),
+
+ // 첨부파일
+ attachedFiles: z.any().optional(),
+ creditRatingAttachment: z.any().optional(),
+ cashFlowRatingAttachment: z.any().optional(),
+
+ // 연락처 정보
+ contacts: z.array(contactSchema).min(1, "최소 1명의 담당자가 필요합니다."),
+})
+
+export const updateVendorSchemaWithConditions = updateVendorInfoSchema.superRefine(
+ (data, ctx) => {
+ // 국가가 한국(KR)인 경우, 한국 사업자 정보 필수
+ if (data.country === "KR") {
+ if (!data.representativeName) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "대표자 이름은 필수 입력사항입니다.",
+ path: ["representativeName"],
+ })
+ }
+
+ if (!data.representativeBirth) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "대표자 생년월일은 필수 입력사항입니다.",
+ path: ["representativeBirth"],
+ })
+ }
+
+ if (!data.representativeEmail) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "대표자 이메일은 필수 입력사항입니다.",
+ path: ["representativeEmail"],
+ })
+ }
+
+ if (!data.representativePhone) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "대표자 전화번호는 필수 입력사항입니다.",
+ path: ["representativePhone"],
+ })
+ }
+
+ if (!data.corporateRegistrationNumber) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "법인등록번호는 필수 입력사항입니다.",
+ path: ["corporateRegistrationNumber"],
+ })
+ }
+
+ // 신용평가사가 선택된 경우, 등급 정보 필수
+ if (data.creditAgency) {
+ if (!data.creditRating) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "신용평가등급은 필수 입력사항입니다.",
+ path: ["creditRating"],
+ })
+ }
+
+ if (!data.cashFlowRating) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "현금흐름등급은 필수 입력사항입니다.",
+ path: ["cashFlowRating"],
+ })
+ }
+ }
+ }
+ }
+)
+
+export type UpdateVendorInfoSchema = z.infer<typeof updateVendorInfoSchema> \ No newline at end of file