summaryrefslogtreecommitdiff
path: root/lib/vendors
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
commitef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch)
tree345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib/vendors
parent9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff)
~20250428 작업사항
Diffstat (limited to 'lib/vendors')
-rw-r--r--lib/vendors/repository.ts34
-rw-r--r--lib/vendors/service.ts731
-rw-r--r--lib/vendors/table/approve-vendor-dialog.tsx11
-rw-r--r--lib/vendors/table/request-additional-Info-dialog.tsx22
-rw-r--r--lib/vendors/table/request-basicContract-dialog.tsx548
-rw-r--r--lib/vendors/table/request-project-pq-dialog.tsx24
-rw-r--r--lib/vendors/table/request-vendor-investigate-dialog.tsx243
-rw-r--r--lib/vendors/table/request-vendor-pg-dialog.tsx11
-rw-r--r--lib/vendors/table/update-vendor-sheet.tsx710
-rw-r--r--lib/vendors/table/vendor-all-export.ts486
-rw-r--r--lib/vendors/table/vendors-table-columns.tsx393
-rw-r--r--lib/vendors/table/vendors-table-toolbar-actions.tsx154
-rw-r--r--lib/vendors/table/vendors-table.tsx99
-rw-r--r--lib/vendors/table/view-vendors_logs-dialog.tsx244
-rw-r--r--lib/vendors/validations.ts79
15 files changed, 3163 insertions, 626 deletions
diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts
index ff195932..1f59aac0 100644
--- a/lib/vendors/repository.ts
+++ b/lib/vendors/repository.ts
@@ -2,7 +2,7 @@
import { and, eq, inArray, count, gt, AnyColumn, SQLWrapper, SQL} from "drizzle-orm";
import { PgTransaction } from "drizzle-orm/pg-core";
-import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
+import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, type Vendor } from "@/db/schema/vendors";
import db from '@/db/db';
import { items } from "@/db/schema/items";
import { rfqs,rfqItems, rfqEvaluations, vendorResponses } from "@/db/schema/rfq";
@@ -47,8 +47,33 @@ export async function countVendors(
}
+ export async function selectVendorsWithTypes (
+ tx: PgTransaction<any, any, any>,
+ { where, orderBy, offset, limit }: SelectVendorsOptions
+) {
+ return tx
+ .select()
+ .from(vendorsWithTypesView)
+ .where(where ?? undefined)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset ?? 0)
+ .limit(limit ?? 20);
+}
+
/**
- * 3) INSERT (단일 벤더 생성)
+ * 2) COUNT
+ */
+export async function countVendorsWithTypes(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+ ) {
+ const res = await tx.select({ count: count() }).from(vendorsWithTypesView).where(where);
+ return res[0]?.count ?? 0;
+ }
+
+
+/**
+ * 3) INSERT (단일 협력업체 생성)
* - id/createdAt/updatedAt은 DB default 사용
* - 반환값은 "생성된 레코드" 배열 ([newVendor])
*/
@@ -60,7 +85,7 @@ export async function insertVendor(
}
/**
- * 4) UPDATE (단일 벤더)
+ * 4) UPDATE (단일 협력업체)
*/
export async function updateVendor(
tx: PgTransaction<any, any, any>,
@@ -75,7 +100,7 @@ export async function updateVendor(
}
/**
- * 5) UPDATE (복수 벤더)
+ * 5) UPDATE (복수 협력업체)
* - 여러 개의 id를 받아 일괄 업데이트
*/
export async function updateVendors(
@@ -280,3 +305,4 @@ export async function countRfqHistory(
return count;
}
+
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 8f095c0e..87a8336d 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -2,12 +2,13 @@
import { revalidateTag, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
+import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema/vendors";
import logger from '@/lib/logger';
import { filterColumns } from "@/lib/filter-columns";
import { unstable_cache } from "@/lib/unstable-cache";
import { getErrorMessage } from "@/lib/handle-error";
+import { headers } from 'next/headers';
import {
selectVendors,
@@ -24,7 +25,10 @@ import {
countVendorItems,
insertVendorItem,
countRfqHistory,
- selectRfqHistory
+ selectRfqHistory,
+ selectVendorsWithTypes,
+ countVendorsWithTypes,
+
} from "./repository";
import type {
@@ -51,7 +55,8 @@ import { items } from "@/db/schema/items";
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";
+import { contracts, contractsDetailView, projects, vendorProjectPQs, vendorsLogs } from "@/db/schema";
+import { Hospital } from "lucide-react";
/* -----------------------------------------------------
@@ -68,61 +73,63 @@ export async function getVendors(input: GetVendorsSchema) {
async () => {
try {
const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터
+
+ // 1) 고급 필터 - vendors 대신 vendorsWithTypesView 사용
const advancedWhere = filterColumns({
- table: vendors,
+ table: vendorsWithTypesView,
filters: input.filters,
joinOperator: input.joinOperator,
});
-
+
// 2) 글로벌 검색
let globalWhere;
if (input.search) {
const s = `%${input.search}%`;
globalWhere = or(
- ilike(vendors.vendorName, s),
- ilike(vendors.vendorCode, s),
- ilike(vendors.email, s),
- ilike(vendors.status, s)
+ ilike(vendorsWithTypesView.vendorName, s),
+ ilike(vendorsWithTypesView.vendorCode, s),
+ ilike(vendorsWithTypesView.email, s),
+ ilike(vendorsWithTypesView.status, s),
+ // 추가: 업체 유형 검색
+ ilike(vendorsWithTypesView.vendorTypeName, s)
);
}
-
+
// 최종 where 결합
const finalWhere = and(advancedWhere, globalWhere);
-
+
// 간단 검색 (advancedTable=false) 시 예시
const simpleWhere = and(
input.vendorName
- ? ilike(vendors.vendorName, `%${input.vendorName}%`)
+ ? ilike(vendorsWithTypesView.vendorName, `%${input.vendorName}%`)
: undefined,
- input.status ? ilike(vendors.status, input.status) : undefined,
+ input.status ? ilike(vendorsWithTypesView.status, input.status) : undefined,
input.country
- ? ilike(vendors.country, `%${input.country}%`)
+ ? ilike(vendorsWithTypesView.country, `%${input.country}%`)
: undefined
);
-
+
// 실제 사용될 where
const where = finalWhere;
-
+
// 정렬
const orderBy =
input.sort.length > 0
? input.sort.map((item) =>
- item.desc ? desc(vendors[item.id]) : asc(vendors[item.id])
+ item.desc ? desc(vendorsWithTypesView[item.id]) : asc(vendorsWithTypesView[item.id])
)
- : [asc(vendors.createdAt)];
-
+ : [asc(vendorsWithTypesView.createdAt)];
+
// 트랜잭션 내에서 데이터 조회
const { data, total } = await db.transaction(async (tx) => {
- // 1) vendor 목록 조회
- const vendorsData = await selectVendors(tx, {
+ // 1) vendor 목록 조회 (view 사용)
+ const vendorsData = await selectVendorsWithTypes(tx, {
where,
orderBy,
offset,
limit: input.perPage,
});
-
+
// 2) 각 vendor의 attachments 조회
const vendorsWithAttachments = await Promise.all(
vendorsData.map(async (vendor) => {
@@ -134,7 +141,7 @@ export async function getVendors(input: GetVendorsSchema) {
})
.from(vendorAttachments)
.where(eq(vendorAttachments.vendorId, vendor.id));
-
+
return {
...vendor,
hasAttachments: attachments.length > 0,
@@ -142,17 +149,18 @@ export async function getVendors(input: GetVendorsSchema) {
};
})
);
-
+
// 3) 전체 개수
- const total = await countVendors(tx, where);
+ const total = await countVendorsWithTypes(tx, where);
return { data: vendorsWithAttachments, total };
});
-
+
// 페이지 수
const pageCount = Math.ceil(total / input.perPage);
-
+
return { data, pageCount };
} catch (err) {
+ console.error("Error fetching vendors:", err);
// 에러 발생 시
return { data: [], pageCount: 0 };
}
@@ -165,7 +173,6 @@ export async function getVendors(input: GetVendorsSchema) {
)();
}
-
export async function getVendorStatusCounts() {
return unstable_cache(
async () => {
@@ -252,34 +259,58 @@ async function storeVendorFiles(
}
}
+
+export async function getVendorTypes() {
+ unstable_noStore(); // Next.js server action caching prevention
+
+ try {
+ const types = await db
+ .select({
+ id: vendorTypes.id,
+ code: vendorTypes.code,
+ nameKo: vendorTypes.nameKo,
+ nameEn: vendorTypes.nameEn,
+ })
+ .from(vendorTypes)
+ .orderBy(vendorTypes.nameKo);
+
+ return { data: types, error: null };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
export type CreateVendorData = {
vendorName: string
+ vendorTypeId: number
vendorCode?: string
+ items?: string
website?: string
taxId: string
address?: string
email: string
phone?: string
-
+
representativeName?: string
representativeBirth?: string
representativeEmail?: string
representativePhone?: string
-
+
creditAgency?: string
creditRating?: string
cashFlowRating?: string
corporateRegistrationNumber?: string
-
+
country?: string
status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED"
}
+// Updated createVendor function with taxId duplicate check
export async function createVendor(params: {
vendorData: CreateVendorData
// 기존의 일반 첨부파일
files?: File[]
-
+
// 신용평가 / 현금흐름 등급 첨부
creditRatingFiles?: File[]
cashFlowRatingFiles?: File[]
@@ -292,17 +323,17 @@ 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 {
@@ -310,7 +341,22 @@ export async function createVendor(params: {
error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)`
};
}
-
+
+ // taxId 중복 검사 추가
+ const existingVendor = await db
+ .select({ id: vendors.id })
+ .from(vendors)
+ .where(eq(vendors.taxId, vendorData.taxId))
+ .limit(1);
+
+ // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환
+ if (existingVendor.length > 0) {
+ return {
+ data: null,
+ error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)`
+ };
+ }
+
await db.transaction(async (tx) => {
// 1) Insert the vendor (확장 필드도 함께)
const [newVendor] = await insertVendor(tx, {
@@ -323,36 +369,38 @@ export async function createVendor(params: {
website: vendorData.website || null,
status: vendorData.status ?? "PENDING_REVIEW",
taxId: vendorData.taxId,
-
+ vendorTypeId: vendorData.vendorTypeId,
+ items: vendorData.items || 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,
})
-
+
// 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,
@@ -364,7 +412,7 @@ export async function createVendor(params: {
})
}
})
-
+
revalidateTag("vendors")
return { data: null, error: null }
} catch (error) {
@@ -377,12 +425,26 @@ export async function createVendor(params: {
/** 단건 업데이트 */
export async function modifyVendor(
- input: UpdateVendorSchema & { id: string }
+ input: UpdateVendorSchema & { id: string; userId: number; comment:string; } // userId 추가
) {
unstable_noStore();
try {
const updated = await db.transaction(async (tx) => {
- // 특정 ID 벤더를 업데이트
+ // 1. 업데이트 전에 기존 벤더 정보를 가져옴
+ const existingVendor = await tx.query.vendors.findFirst({
+ where: eq(vendors.id, parseInt(input.id)),
+ columns: {
+ status: true, // 상태 변경 로깅에 필요한 현재 상태만 가져옴
+ },
+ });
+
+ if (!existingVendor) {
+ throw new Error(`Vendor with ID ${input.id} not found`);
+ }
+
+ const oldStatus = existingVendor.status;
+
+ // 2. 벤더 정보 업데이트
const [res] = await updateVendor(tx, input.id, {
vendorName: input.vendorName,
vendorCode: input.vendorCode,
@@ -391,8 +453,32 @@ export async function modifyVendor(
phone: input.phone,
email: input.email,
website: input.website,
+ creditAgency: input.creditAgency,
+ creditRating: input.creditRating,
+ cashFlowRating: input.cashFlowRating,
status: input.status,
});
+
+ // 3. 상태가 변경되었다면 로그 기록
+ if (oldStatus !== input.status) {
+ await tx.insert(vendorsLogs).values({
+ vendorId: parseInt(input.id),
+ userId: input.userId,
+ action: "status_change",
+ oldStatus,
+ newStatus: input.status,
+ comment: input.comment || `Status changed from ${oldStatus} to ${input.status}`,
+ });
+ } else if (input.comment) {
+ // 상태 변경이 없더라도 코멘트가 있으면 로그 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: parseInt(input.id),
+ userId: input.userId,
+ action: "vendor_updated",
+ comment: input.comment,
+ });
+ }
+
return res;
});
@@ -414,7 +500,7 @@ export async function modifyVendors(input: {
unstable_noStore();
try {
const data = await db.transaction(async (tx) => {
- // 여러 벤더 일괄 업데이트
+ // 여러 협력업체 일괄 업데이트
const [updated] = await updateVendors(tx, input.ids, {
// 예: 상태만 일괄 변경
status: input.status,
@@ -560,7 +646,7 @@ export async function createVendorContact(input: CreateVendorContactSchema) {
return newContact;
});
- // 캐시 무효화 (벤더 연락처 목록 등)
+ // 캐시 무효화 (협력업체 연락처 목록 등)
revalidateTag(`vendor-contacts-${input.vendorId}`);
return { data: null, error: null };
@@ -723,7 +809,7 @@ export async function createVendorItem(input: CreateVendorItemSchema) {
return newContact;
});
- // 캐시 무효화 (벤더 연락처 목록 등)
+ // 캐시 무효화 (협력업체 연락처 목록 등)
revalidateTag(`vendor-items-${input.vendorId}`);
return { data: null, error: null };
@@ -885,98 +971,55 @@ interface CreateCompanyInput {
/**
- * 벤더 첨부파일 다운로드를 위한 서버 액션
- * @param vendorId 벤더 ID
+ * 협력업체 첨부파일 다운로드를 위한 서버 액션
+ * @param vendorId 협력업체 ID
* @param fileId 특정 파일 ID (단일 파일 다운로드시)
* @returns 다운로드할 수 있는 임시 URL
*/
-export async function downloadVendorAttachments(vendorId: number, fileId?: number) {
+export async function downloadVendorAttachments(vendorId:number, fileId?:number) {
try {
- // 벤더 정보 조회
- const vendor = await db.query.vendors.findFirst({
- where: eq(vendors.id, vendorId)
+ // API 경로 생성 (단일 파일 또는 모든 파일)
+ const url = fileId
+ ? `/api/vendors/attachments/download?id=${fileId}&vendorId=${vendorId}`
+ : `/api/vendors/attachments/download-all?vendorId=${vendorId}`;
+
+ // fetch 요청 (기본적으로 Blob으로 응답 받기)
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
});
-
- if (!vendor) {
- throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`);
- }
-
- // 첨부파일 조회 (특정 파일 또는 모든 파일)
- const attachments = fileId
- ? await db.select()
- .from(vendorAttachments)
- .where(eq(vendorAttachments.id, fileId))
- : await db.select()
- .from(vendorAttachments)
- .where(eq(vendorAttachments.vendorId, vendorId));
-
- if (!attachments.length) {
- throw new Error('다운로드할 첨부파일이 없습니다.');
+
+ if (!response.ok) {
+ throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
-
- // 업로드 기본 경로
- const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
-
- // 단일 파일인 경우 직접 URL 반환
- if (attachments.length === 1) {
- const attachment = attachments[0];
- const filePath = `/api/vendors/attachments/download?id=${attachment.id}`;
- return { url: filePath, fileName: attachment.fileName };
+
+ // 파일명 가져오기 (Content-Disposition 헤더에서)
+ const contentDisposition = response.headers.get('content-disposition');
+ let fileName = fileId ? `file-${fileId}.zip` : `vendor-${vendorId}-files.zip`;
+
+ if (contentDisposition) {
+ const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
+ if (matches && matches[1]) {
+ fileName = matches[1].replace(/['"]/g, '');
+ }
}
-
- // 다중 파일: 임시 ZIP 생성 후 URL 반환
- // 임시 디렉토리 생성
- const tempDir = path.join(process.cwd(), 'tmp');
- await fsPromises.mkdir(tempDir, { recursive: true });
-
- // 고유 ID로 임시 ZIP 파일명 생성
- const tempId = randomUUID();
- const zipFileName = `${vendor.vendorName || `vendor-${vendorId}`}-attachments-${tempId}.zip`;
- const zipFilePath = path.join(tempDir, zipFileName);
-
- // JSZip을 사용하여 ZIP 파일 생성
- const zip = new JSZip();
-
- // 파일 읽기 및 추가 작업을 병렬로 처리
- await Promise.all(
- attachments.map(async (attachment) => {
- const filePath = path.join(basePath, attachment.filePath);
-
- try {
- // 파일 존재 확인 (fsPromises.access 사용)
- try {
- await fsPromises.access(filePath, fs.constants.F_OK);
- } catch (e) {
- console.warn(`파일이 존재하지 않습니다: ${filePath}`);
- return; // 파일이 없으면 건너뜀
- }
-
- // 파일 읽기 (fsPromises.readFile 사용)
- const fileData = await fsPromises.readFile(filePath);
-
- // ZIP에 파일 추가
- zip.file(attachment.fileName, fileData);
- } catch (error) {
- console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error);
- // 오류가 있더라도 계속 진행
- }
- })
- );
-
- // ZIP 생성 및 저장
- const zipContent = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 9 } });
- await fsPromises.writeFile(zipFilePath, zipContent);
-
- // 임시 ZIP 파일에 접근할 수 있는 URL 생성
- const downloadUrl = `/api/vendors/attachments/download-temp?file=${encodeURIComponent(zipFileName)}`;
-
- return {
- url: downloadUrl,
- fileName: `${vendor.vendorName || `vendor-${vendorId}`}-attachments.zip`
+
+ // Blob으로 응답 변환
+ const blob = await response.blob();
+
+ // Blob URL 생성
+ const blobUrl = window.URL.createObjectURL(blob);
+
+ return {
+ url: blobUrl,
+ fileName,
+ blob
};
} catch (error) {
- console.error('첨부파일 다운로드 서버 액션 오류:', error);
- throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.');
+ console.error('Download API error:', error);
+ throw error;
}
}
@@ -1016,13 +1059,22 @@ interface ApproveVendorsInput {
/**
* 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션
*/
-export async function approveVendors(input: ApproveVendorsInput) {
+export async function approveVendors(input: ApproveVendorsInput & { userId: number }) {
unstable_noStore();
try {
- // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송
+ // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송
const result = await db.transaction(async (tx) => {
- // 1. 벤더 상태 업데이트
+ // 0. 업데이트 전 협력업체 상태 조회
+ const vendorsBeforeUpdate = await tx
+ .select({
+ id: vendors.id,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 1. 협력업체 상태 업데이트
const [updated] = await tx
.update(vendors)
.set({
@@ -1032,7 +1084,7 @@ export async function approveVendors(input: ApproveVendorsInput) {
.where(inArray(vendors.id, input.ids))
.returning();
- // 2. 업데이트된 벤더 정보 조회
+ // 2. 업데이트된 협력업체 정보 조회
const updatedVendors = await tx
.select({
id: vendors.id,
@@ -1067,18 +1119,35 @@ export async function approveVendors(input: ApproveVendorsInput) {
})
);
- // 4. 각 벤더에게 이메일 발송
+ // 4. 로그 기록
+ await Promise.all(
+ vendorsBeforeUpdate.map(async (vendorBefore) => {
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendorBefore.id,
+ userId: input.userId,
+ action: "status_change",
+ oldStatus: vendorBefore.status,
+ newStatus: "IN_REVIEW",
+ comment: "Vendor approved for review",
+ });
+ })
+ );
+
+ // 5. 각 벤더에게 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
try {
- const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+ const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기
const subject =
"[eVCP] Admin Account Created";
- const loginUrl = "http://3.36.56.124:3000/en/login";
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const baseUrl = `http://${host}`
+ const loginUrl = `${baseUrl}/en/login`;
await sendEmail({
to: vendor.email,
@@ -1112,7 +1181,7 @@ export async function approveVendors(input: ApproveVendorsInput) {
}
}
-export async function requestPQVendors(input: ApproveVendorsInput) {
+export async function requestPQVendors(input: ApproveVendorsInput & { userId: number }) {
unstable_noStore();
try {
@@ -1134,9 +1203,18 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
}
}
- // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송
+ // 트랜잭션 내에서 협력업체 상태 업데이트 및 이메일 발송
const result = await db.transaction(async (tx) => {
- // 1. 벤더 상태 업데이트
+ // 0. 업데이트 전 협력업체 상태 조회
+ const vendorsBeforeUpdate = await tx
+ .select({
+ id: vendors.id,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 1. 협력업체 상태 업데이트
const [updated] = await tx
.update(vendors)
.set({
@@ -1146,7 +1224,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
.where(inArray(vendors.id, input.ids))
.returning();
- // 2. 업데이트된 벤더 정보 조회
+ // 2. 업데이트된 협력업체 정보 조회
const updatedVendors = await tx
.select({
id: vendors.id,
@@ -1169,14 +1247,33 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
await tx.insert(vendorProjectPQs).values(vendorProjectPQsData);
}
-
- // 4. 각 벤더에게 이메일 발송
+
+ // 4. 로그 기록
+ await Promise.all(
+ vendorsBeforeUpdate.map(async (vendorBefore) => {
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendorBefore.id,
+ userId: input.userId,
+ action: "status_change",
+ oldStatus: vendorBefore.status,
+ newStatus: "IN_PQ",
+ comment: input.projectId
+ ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})`
+ : "PQ requested",
+ });
+ })
+ );
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 5. 각 벤더에게 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
try {
- const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+ const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기
// 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경
const subject = input.projectId
@@ -1184,7 +1281,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
: "[eVCP] You are invited to submit PQ";
// 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우)
- const baseLoginUrl = "http://3.36.56.124:3000/en/login";
+ const baseLoginUrl = `${host}/partners/pq`;
const loginUrl = input.projectId
? `${baseLoginUrl}?projectId=${input.projectId}`
: baseLoginUrl;
@@ -1192,7 +1289,8 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
await sendEmail({
to: vendor.email,
subject,
- template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용
+ template:input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용
+ // template: "vendor-pq-status", // 프로젝트별 템플릿 사용
context: {
vendorName: vendor.vendorName,
loginUrl,
@@ -1225,21 +1323,20 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
return { data: null, error: getErrorMessage(err) };
}
}
-
interface SendVendorsInput {
ids: number[];
}
/**
- * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션
+ * APPROVED 상태인 협력업체 정보를 기간계 시스템에 전송하고 협력업체 코드를 업데이트하는 서버 액션
*/
-export async function sendVendors(input: SendVendorsInput) {
+export async function sendVendors(input: SendVendorsInput & { userId: number }) {
unstable_noStore();
try {
// 트랜잭션 내에서 진행
const result = await db.transaction(async (tx) => {
- // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링
+ // 1. 선택된 협력업체 중 APPROVED 상태인 벤더만 필터링
const approvedVendors = await db.query.vendors.findMany({
where: and(
inArray(vendors.id, input.ids),
@@ -1255,16 +1352,16 @@ export async function sendVendors(input: SendVendorsInput) {
// 2. 각 벤더에 대해 처리
for (const vendor of approvedVendors) {
- // 2-1. 벤더 연락처 정보 조회
+ // 2-1. 협력업체 연락처 정보 조회
const contacts = await db.query.vendorContacts.findMany({
where: eq(vendorContacts.vendorId, vendor.id)
});
- // 2-2. 벤더 가능 아이템 조회
+ // 2-2. 협력업체 가능 아이템 조회
const possibleItems = await db.query.vendorPossibleItems.findMany({
where: eq(vendorPossibleItems.vendorId, vendor.id)
});
- // 2-3. 벤더 첨부파일 조회
+ // 2-3. 협력업체 첨부파일 조회
const attachments = await db.query.vendorAttachments.findMany({
where: eq(vendorAttachments.vendorId, vendor.id),
columns: {
@@ -1274,8 +1371,7 @@ export async function sendVendors(input: SendVendorsInput) {
}
});
-
- // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
+ // 2-4. 협력업체 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
const vendorData = {
id: vendor.id,
vendorName: vendor.vendorName,
@@ -1311,7 +1407,7 @@ export async function sendVendors(input: SendVendorsInput) {
throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`);
}
- // 2-5. 벤더 코드 및 상태 업데이트
+ // 2-5. 협력업체 코드 및 상태 업데이트
const vendorCode = responseData.vendorCode;
const [updated] = await tx
@@ -1324,16 +1420,27 @@ export async function sendVendors(input: SendVendorsInput) {
.where(eq(vendors.id, vendor.id))
.returning();
- // 2-6. 벤더에게 알림 이메일 발송
+ // 2-6. 로그 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendor.id,
+ userId: input.userId,
+ action: "status_change",
+ oldStatus: "APPROVED",
+ newStatus: "ACTIVE",
+ comment: `Sent to ERP system. Vendor code assigned: ${vendorCode}`,
+ });
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 2-7. 벤더에게 알림 이메일 발송
if (vendor.email) {
- const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+ const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기
const subject =
"[eVCP] Vendor Registration Completed";
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
-
- const portalUrl = `${baseUrl}/en/partners`;
+ const portalUrl = `http://${host}/en/partners`;
await sendEmail({
to: vendor.email,
@@ -1355,12 +1462,20 @@ export async function sendVendors(input: SendVendorsInput) {
message: "Successfully sent to ERP system",
});
} catch (vendorError) {
- // 개별 벤더 처리 오류 기록
+ // 개별 협력업체 처리 오류 기록
results.push({
id: vendor.id,
success: false,
error: getErrorMessage(vendorError),
});
+
+ // 에러가 발생해도 로그는 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendor.id,
+ userId: input.userId,
+ action: "erp_send_failed",
+ comment: `Failed to send to ERP: ${getErrorMessage(vendorError)}`,
+ });
}
}
@@ -1387,55 +1502,68 @@ export async function sendVendors(input: SendVendorsInput) {
}
}
-
interface RequestInfoProps {
ids: number[];
+ userId: number; // 추가: 어떤 사용자가 요청했는지 로깅하기 위함
}
-export async function requestInfo({ ids }: RequestInfoProps) {
+export async function requestInfo({ ids, userId }: RequestInfoProps) {
try {
- // 1. 벤더 정보 가져오기
- const vendorList = await db.query.vendors.findMany({
- where: inArray(vendors.id, ids),
- });
-
- if (!vendorList.length) {
- return { error: "벤더 정보를 찾을 수 없습니다." };
- }
+ return await db.transaction(async (tx) => {
+ // 1. 협력업체 정보 가져오기
+ const vendorList = await tx.query.vendors.findMany({
+ where: inArray(vendors.id, ids),
+ });
- // 2. 각 벤더에게 이메일 보내기
- for (const vendor of vendorList) {
- // 이메일이 없는 경우 스킵
- if (!vendor.email) continue;
+ if (!vendorList.length) {
+ return { error: "협력업체 정보를 찾을 수 없습니다." };
+ }
- // 벤더 정보 페이지 URL 생성
- const vendorInfoUrl = `${process.env.NEXT_PUBLIC_APP_URL}/partners/info?vendorId=${vendor.id}`;
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 2. 각 벤더에 대한 로그 기록 및 이메일 발송
+ for (const vendor of vendorList) {
+ // 로그 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendor.id,
+ userId: userId,
+ action: "info_requested",
+ comment: "추가 정보 요청됨",
+ });
- // 벤더에게 이메일 보내기
- await sendEmail({
- to: vendor.email,
- subject: "[EVCP] 추가 정보 요청 / Additional Information Request",
- template: "vendor-additional-info",
- context: {
- vendorName: vendor.vendorName,
- vendorInfoUrl: vendorInfoUrl,
- language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음
- },
- });
- }
+ // 이메일이 없는 경우 스킵
+ if (!vendor.email) continue;
+
+ // 협력업체 정보 페이지 URL 생성
+ const vendorInfoUrl = `http://${host}/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 };
+ // 3. 성공적으로 처리됨
+ return { success: true };
+ });
} catch (error) {
- console.error("벤더 정보 요청 중 오류 발생:", error);
- return { error: "벤더 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." };
+ console.error("협력업체 정보 요청 중 오류 발생:", error);
+ return { error: "협력업체 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." };
}
}
export async function getVendorDetailById(id: number) {
try {
- // View를 통해 벤더 정보 조회
+ // View를 통해 협력업체 정보 조회
const vendor = await db
.select()
.from(vendorDetailView)
@@ -1496,7 +1624,7 @@ export type ContactInfo = {
}
/**
- * 벤더 정보를 업데이트하는 함수
+ * 협력업체 정보를 업데이트하는 함수
*/
export async function updateVendorInfo(params: {
vendorData: UpdateVendorInfoData
@@ -1533,7 +1661,7 @@ export async function updateVendorInfo(params: {
// 트랜잭션으로 업데이트 수행
await db.transaction(async (tx) => {
- // 1. 벤더 정보 업데이트
+ // 1. 협력업체 정보 업데이트
await tx.update(vendors).set({
vendorName: vendorData.vendorName,
address: vendorData.address || null,
@@ -1672,7 +1800,7 @@ export async function updateVendorInfo(params: {
return {
data: {
success: true,
- message: '벤더 정보가 성공적으로 업데이트되었습니다.',
+ message: '협력업체 정보가 성공적으로 업데이트되었습니다.',
vendorId: vendorData.id
},
error: null
@@ -1681,4 +1809,205 @@ export async function updateVendorInfo(params: {
console.error("Vendor info update error:", error);
return { data: null, error: getErrorMessage(error) }
}
+}
+
+
+
+export interface VendorsLogWithUser {
+ id: number
+ vendorCandidateId: number
+ userId: number
+ userName: string | null
+ userEmail: string | null
+ action: string
+ oldStatus: string | null
+ newStatus: string | null
+ comment: string | null
+ createdAt: Date
+}
+
+export async function getVendorLogs(vendorId: number): Promise<VendorsLogWithUser[]> {
+ try {
+ const logs = await db
+ .select({
+ id: vendorsLogs.id,
+ vendorCandidateId: vendorsLogs.vendorId,
+ userId: vendorsLogs.userId,
+ action: vendorsLogs.action,
+ oldStatus: vendorsLogs.oldStatus,
+ newStatus: vendorsLogs.newStatus,
+ comment: vendorsLogs.comment,
+ createdAt: vendorsLogs.createdAt,
+
+ // 조인한 users 테이블 필드
+ userName: users.name,
+ userEmail: users.email,
+ })
+ .from(vendorsLogs)
+ .leftJoin(users, eq(vendorsLogs.userId, users.id))
+ .where(eq(vendorsLogs.vendorId, vendorId))
+ .orderBy(desc(vendorsLogs.createdAt))
+
+ return logs
+ } catch (error) {
+ console.error("Failed to fetch candidate logs with user info:", error)
+ throw error
+ }
+}
+
+
+
+/**
+ * 엑셀 내보내기용 벤더 연락처 목록 조회
+ * - 페이지네이션 없이 모든 연락처 반환
+ */
+export async function exportVendorContacts(vendorId: number) {
+ try {
+ const contacts = await db
+ .select()
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorId))
+ .orderBy(vendorContacts.isPrimary, vendorContacts.contactName);
+
+ return contacts;
+ } catch (error) {
+ console.error("Failed to export vendor contacts:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 아이템 목록 조회
+ * - 페이지네이션 없이 모든 아이템 정보 반환
+ */
+export async function exportVendorItems(vendorId: number) {
+ try {
+ const vendorItems = await db
+ .select({
+ id: vendorItemsView.vendorItemId,
+ vendorId: vendorItemsView.vendorId,
+ itemName: vendorItemsView.itemName,
+ itemCode: vendorItemsView.itemCode,
+ description: vendorItemsView.description,
+ createdAt: vendorItemsView.createdAt,
+ updatedAt: vendorItemsView.updatedAt,
+ })
+ .from(vendorItemsView)
+ .where(eq(vendorItemsView.vendorId, vendorId))
+ .orderBy(vendorItemsView.itemName);
+
+ return vendorItems;
+ } catch (error) {
+ console.error("Failed to export vendor items:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 RFQ 목록 조회
+ * - 페이지네이션 없이 모든 RFQ 정보 반환
+ */
+export async function exportVendorRFQs(vendorId: number) {
+ try {
+ const rfqs = await db
+ .select()
+ .from(vendorRfqView)
+ .where(eq(vendorRfqView.vendorId, vendorId))
+ .orderBy(vendorRfqView.rfqVendorUpdated);
+
+ return rfqs;
+ } catch (error) {
+ console.error("Failed to export vendor RFQs:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 계약 목록 조회
+ * - 페이지네이션 없이 모든 계약 정보 반환
+ */
+export async function exportVendorContracts(vendorId: number) {
+ try {
+ const contracts = await db
+ .select()
+ .from(contractsDetailView)
+ .where(eq(contractsDetailView.vendorId, vendorId))
+ .orderBy(contractsDetailView.createdAt);
+
+ return contracts;
+ } catch (error) {
+ console.error("Failed to export vendor contracts:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 정보 조회
+ * - 페이지네이션 없이 모든 벤더 정보 반환
+ */
+export async function exportVendorDetails(vendorIds: number[]) {
+ try {
+ if (!vendorIds.length) return [];
+
+ // 벤더 기본 정보 조회
+ const vendorsData = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ taxId: vendors.taxId,
+ address: vendors.address,
+ country: vendors.country,
+ phone: vendors.phone,
+ email: vendors.email,
+ website: vendors.website,
+ status: vendors.status,
+ representativeName: vendors.representativeName,
+ representativeBirth: vendors.representativeBirth,
+ representativeEmail: vendors.representativeEmail,
+ representativePhone: vendors.representativePhone,
+ corporateRegistrationNumber: vendors.corporateRegistrationNumber,
+ creditAgency: vendors.creditAgency,
+ creditRating: vendors.creditRating,
+ cashFlowRating: vendors.cashFlowRating,
+ createdAt: vendors.createdAt,
+ updatedAt: vendors.updatedAt,
+ })
+ .from(vendors)
+ .where(
+ vendorIds.length === 1
+ ? eq(vendors.id, vendorIds[0])
+ : inArray(vendors.id, vendorIds)
+ );
+
+ // 벤더별 상세 정보를 포함하여 반환
+ const vendorsWithDetails = await Promise.all(
+ vendorsData.map(async (vendor) => {
+ // 연락처 조회
+ const contacts = await exportVendorContacts(vendor.id);
+
+ // 아이템 조회
+ const items = await exportVendorItems(vendor.id);
+
+ // RFQ 조회
+ const rfqs = await exportVendorRFQs(vendor.id);
+
+ // 계약 조회
+ const contracts = await exportVendorContracts(vendor.id);
+
+ return {
+ ...vendor,
+ vendorContacts: contacts,
+ vendorItems: items,
+ vendorRfqs: rfqs,
+ vendorContracts: contracts,
+ };
+ })
+ );
+
+ return vendorsWithDetails;
+ } catch (error) {
+ console.error("Failed to export vendor details:", error);
+ return [];
+ }
} \ No newline at end of file
diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx
index 253c2830..9c175dc5 100644
--- a/lib/vendors/table/approve-vendor-dialog.tsx
+++ b/lib/vendors/table/approve-vendor-dialog.tsx
@@ -29,6 +29,7 @@ import {
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
import { approveVendors } from "../service"
+import { useSession } from "next-auth/react"
interface ApprovalVendorDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -45,11 +46,19 @@ export function ApproveVendorsDialog({
}: ApprovalVendorDialogProps) {
const [isApprovePending, startApproveTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
function onApprove() {
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
startApproveTransition(async () => {
const { error } = await approveVendors({
ids: vendors.map((vendor) => vendor.id),
+ userId: Number(session.user.id)
+
})
if (error) {
@@ -70,7 +79,7 @@ export function ApproveVendorsDialog({
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Check className="size-4" aria-hidden="true" />
- Approve ({vendors.length})
+ 가입 Approve ({vendors.length})
</Button>
</DialogTrigger>
) : null}
diff --git a/lib/vendors/table/request-additional-Info-dialog.tsx b/lib/vendors/table/request-additional-Info-dialog.tsx
index 872162dd..2e39a527 100644
--- a/lib/vendors/table/request-additional-Info-dialog.tsx
+++ b/lib/vendors/table/request-additional-Info-dialog.tsx
@@ -29,6 +29,7 @@ import {
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
import { requestInfo } from "../service"
+import { useSession } from "next-auth/react"
interface RequestInfoDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -45,11 +46,18 @@ export function RequestInfoDialog({
}: RequestInfoDialogProps) {
const [isRequestPending, startRequestTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
function onApprove() {
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
startRequestTransition(async () => {
const { error, success } = await requestInfo({
ids: vendors.map((vendor) => vendor.id),
+ userId: Number(session.user.id)
+
})
if (error) {
@@ -58,7 +66,7 @@ export function RequestInfoDialog({
}
props.onOpenChange?.(false)
- toast.success("추가 정보 요청이 성공적으로 벤더에게 발송되었습니다.")
+ toast.success("추가 정보 요청이 성공적으로 협력업체에게 발송되었습니다.")
onSuccess?.()
})
}
@@ -76,12 +84,12 @@ export function RequestInfoDialog({
) : null}
<DialogContent>
<DialogHeader>
- <DialogTitle>벤더 추가 정보 요청 확인</DialogTitle>
+ <DialogTitle>협력업체 추가 정보 요청 확인</DialogTitle>
<DialogDescription>
<span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까?
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 추가 정보를 요청하시겠습니까?
<br /><br />
- 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
+ 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
추가 정보를 입력하게 됩니다.
</DialogDescription>
</DialogHeader>
@@ -121,12 +129,12 @@ export function RequestInfoDialog({
) : null}
<DrawerContent>
<DrawerHeader>
- <DrawerTitle>벤더 추가 정보 요청 확인</DrawerTitle>
+ <DrawerTitle>협력업체 추가 정보 요청 확인</DrawerTitle>
<DrawerDescription>
<span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까?
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 추가 정보를 요청하시겠습니까?
<br /><br />
- 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
+ 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
추가 정보를 입력하게 됩니다.
</DrawerDescription>
</DrawerHeader>
diff --git a/lib/vendors/table/request-basicContract-dialog.tsx b/lib/vendors/table/request-basicContract-dialog.tsx
new file mode 100644
index 00000000..8d05fbbe
--- /dev/null
+++ b/lib/vendors/table/request-basicContract-dialog.tsx
@@ -0,0 +1,548 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Send, AlertCircle, Clock, RefreshCw } 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 { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+import { Vendor } from "@/db/schema/vendors"
+import { useSession } from "next-auth/react"
+import { getAllTemplates } from "@/lib/basic-contract/service"
+import { useState, useEffect } from "react"
+import { requestBasicContractInfo } from "@/lib/basic-contract/service"
+import { checkContractRequestStatus } from "@/lib/basic-contract/service"
+import { BasicContractTemplate } from "@/db/schema"
+
+interface RequestInfoDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+// 계약 요청 상태 인터페이스
+interface VendorTemplateStatus {
+ vendorId: number;
+ vendorName: string;
+ templateId: number;
+ templateName: string;
+ status: string;
+ createdAt: Date;
+ completedAt?: Date; // 계약 체결 날짜 추가
+ isExpired: boolean; // 요청 만료 (30일)
+ isUpdated: boolean; // 템플릿 업데이트 여부
+ isContractExpired: boolean; // 계약 유효기간 만료 여부 (1년) 추가
+}
+export function RequestContractDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: RequestInfoDialogProps) {
+ const [isRequestPending, startRequestTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
+ const [templates, setTemplates] = useState<BasicContractTemplate[]>([])
+ const [selectedTemplateIds, setSelectedTemplateIds] = useState<number[]>([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [statusLoading, setStatusLoading] = useState(false)
+ const [statusData, setStatusData] = useState<VendorTemplateStatus[]>([])
+ const [forceResend, setForceResend] = useState<Set<string>>(new Set())
+
+ // 템플릿 및 상태 로드
+ useEffect(() => {
+ loadTemplatesAndStatus();
+ }, [vendors]);
+
+ // 템플릿과 현재 요청 상태를 로드하는 함수
+ const loadTemplatesAndStatus = async () => {
+ console.log("loadTemplatesAndStatus")
+ setIsLoading(true);
+ setStatusLoading(true);
+
+ try {
+ // 1. 템플릿 로드
+ const allTemplates = await getAllTemplates();
+ const activeTemplates = allTemplates.filter(t => t.status === 'ACTIVE');
+ setTemplates(activeTemplates);
+
+ // 기본 템플릿 선택 설정
+ const allActiveTemplateIds = activeTemplates.map(t => t.id);
+ setSelectedTemplateIds(allActiveTemplateIds);
+
+ // 2. 현재 계약 요청 상태 확인
+ if (vendors.length > 0 && allActiveTemplateIds.length > 0) {
+ const vendorIds = vendors.map(v => v.id);
+ const { data } = await checkContractRequestStatus(vendorIds, allActiveTemplateIds);
+ setStatusData(data || []);
+ }
+ } catch (error) {
+ console.error("데이터 로딩 오류:", error);
+ toast.error("템플릿 또는 상태 정보를 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoading(false);
+ setStatusLoading(false);
+ }
+ };
+
+ // 체크박스 상태 변경 핸들러
+ const handleTemplateToggle = (templateId: number, checked: boolean) => {
+ if (checked) {
+ setSelectedTemplateIds(prev => [...prev, templateId]);
+ } else {
+ setSelectedTemplateIds(prev => prev.filter(id => id !== templateId));
+ }
+ };
+
+ // 강제 재전송 토글
+ const toggleForceResend = (vendorId: number, templateId: number) => {
+ const key = `${vendorId}-${templateId}`;
+ const newForceResend = new Set(forceResend);
+
+ if (newForceResend.has(key)) {
+ newForceResend.delete(key);
+ } else {
+ newForceResend.add(key);
+ }
+
+ setForceResend(newForceResend);
+ };
+
+ const renderStatusBadge = (vendorId: number, templateId: number) => {
+ const status = statusData.find(
+ s => s.vendorId === vendorId && s.templateId === templateId
+ );
+
+ if (!status || status.status === "NONE") return null;
+
+ // 상태에 따른 배지 스타일 설정
+ let badgeVariant = "outline";
+ let badgeLabel = "";
+ let icon = null;
+ let tooltip = "";
+
+ switch (status.status) {
+ case "PENDING":
+ badgeVariant = "secondary";
+ badgeLabel = "대기중";
+
+ if (status.isExpired) {
+ icon = <Clock className="h-3 w-3 mr-1" />;
+ tooltip = "요청이 만료되었습니다. 재전송이 필요합니다.";
+ } else if (status.isUpdated) {
+ icon = <RefreshCw className="h-3 w-3 mr-1" />;
+ tooltip = "템플릿이 업데이트되었습니다. 재전송이 필요합니다.";
+ } else {
+ tooltip = "서명 요청이 진행 중입니다.";
+ }
+ break;
+
+ case "COMPLETED":
+ // 계약 유효기간 만료 확인
+ if (status.isContractExpired) {
+ badgeVariant = "warning"; // 경고 스타일 적용
+ badgeLabel = "재계약 필요";
+ icon = <Clock className="h-3 w-3 mr-1" />;
+ tooltip = "계약 유효기간이 만료되었습니다. 재계약이 필요합니다.";
+ } else {
+ badgeVariant = "success";
+ badgeLabel = "완료됨";
+ tooltip = "이미 서명이 완료되었습니다.";
+ }
+ break;
+
+ case "REJECTED":
+ badgeVariant = "destructive";
+ badgeLabel = "거부됨";
+ tooltip = "협력업체가 서명을 거부했습니다.";
+ break;
+ }
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Badge variant={badgeVariant as any} className="ml-2 text-xs">
+ {icon}
+ {badgeLabel}
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltip}</p>
+
+ {/* 재전송 조건에 계약 유효기간 만료 추가 */}
+ {(status.isExpired || status.isUpdated || status.status === "REJECTED" ||
+ (status.status === "COMPLETED" && status.isContractExpired)) && (
+ <p className="text-xs mt-1">
+ <Button
+ variant="link"
+ size="sm"
+ className="h-4 p-0"
+ onClick={() => toggleForceResend(vendorId, templateId)}
+ >
+ {forceResend.has(`${vendorId}-${templateId}`) ? "재전송 취소" : "재전송 하기"}
+ </Button>
+ </p>
+ )}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+ };
+
+ // 유효한 요청인지 확인 함수 개선
+ const isValidRequest = (vendorId: number, templateId: number) => {
+ const status = statusData.find(
+ s => s.vendorId === vendorId && s.templateId === templateId
+ );
+
+ if (!status || status.status === "NONE") return true;
+
+ // 만료되었거나 템플릿이 업데이트되었거나 거부된 경우 재전송 가능
+ // 계약 유효기간 만료도 조건에 추가
+ if (status.isExpired ||
+ status.isUpdated ||
+ status.status === "REJECTED" ||
+ (status.status === "COMPLETED" && status.isContractExpired)) {
+ return forceResend.has(`${vendorId}-${templateId}`);
+ }
+
+ // PENDING(비만료) 또는 COMPLETED(유효기간 내)는 재전송 불가
+ return false;
+ };
+
+
+ // 요청 발송 처리
+ function onApprove() {
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ if (selectedTemplateIds.length === 0) {
+ toast.error("최소 하나 이상의 계약서 템플릿을 선택해주세요.")
+ return
+ }
+
+ // 모든 협력업체-템플릿 조합 생성
+ const validRequests: { vendorId: number, templateId: number }[] = [];
+ const skippedRequests: { vendorId: number, templateId: number, reason: string }[] = [];
+
+ vendors.forEach(vendor => {
+ selectedTemplateIds.forEach(templateId => {
+ if (isValidRequest(vendor.id, templateId)) {
+ validRequests.push({
+ vendorId: vendor.id,
+ templateId
+ });
+ } else {
+ // 유효하지 않은 요청은 건너뜀
+ const status = statusData.find(
+ s => s.vendorId === vendor.id && s.templateId === templateId
+ );
+
+ let reason = "알 수 없음";
+ if (status) {
+ if (status.status === "PENDING") reason = "이미 대기 중";
+ if (status.status === "COMPLETED") reason = "이미 완료됨";
+ }
+
+ skippedRequests.push({
+ vendorId: vendor.id,
+ templateId,
+ reason
+ });
+ }
+ });
+ });
+
+ if (validRequests.length === 0) {
+ toast.error("전송 가능한 요청이 없습니다. 재전송이 필요한 항목을 '재전송 하기' 버튼으로 활성화하세요.");
+ return;
+ }
+
+ startRequestTransition(async () => {
+ // 유효한 요청만 처리
+ const requests = validRequests.map(req =>
+ requestBasicContractInfo({
+ vendorIds: [req.vendorId],
+ requestedBy: Number(session.user.id),
+ templateId: req.templateId
+ })
+ );
+
+ try {
+ const results = await Promise.all(requests);
+
+ // 오류 확인
+ const errors = results.filter(r => r.error);
+ if (errors.length > 0) {
+ toast.error(`${errors.length}개의 요청에서 오류가 발생했습니다.`);
+ return;
+ }
+
+ // 상태 메시지 생성
+ let successMessage = "기본계약서 서명 요청이 성공적으로 발송되었습니다.";
+ if (skippedRequests.length > 0) {
+ successMessage += ` (${skippedRequests.length}개 요청 건너뜀)`;
+ }
+
+ props.onOpenChange?.(false);
+ toast.success(successMessage);
+ onSuccess?.();
+ } catch (error) {
+ console.error("요청 처리 중 오류:", error);
+ toast.error("서명 요청 처리 중 오류가 발생했습니다.");
+ }
+ });
+ }
+
+ // 선택된 템플릿 수 표시
+ const selectedCount = selectedTemplateIds.length;
+ const totalCount = templates.length;
+
+ // UI 렌더링
+ const renderTemplateList = () => (
+ <div className="space-y-3">
+ {templates.map((template) => (
+ <div key={template.id} className="pb-2 border-b last:border-b-0">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`template-${template.id}`}
+ checked={selectedTemplateIds.includes(template.id)}
+ onCheckedChange={(checked) => handleTemplateToggle(template.id, checked as boolean)}
+ />
+ <label
+ htmlFor={`template-${template.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
+ >
+ {template.templateName}
+ </label>
+
+ {/* 상태 배지를 템플릿 이름 옆에 나란히 배치 */}
+ {vendors.length === 1 && renderStatusBadge(vendors[0].id, template.id)}
+ </div>
+
+
+ {vendors.length === 1 && (() => {
+ const status = statusData.find(
+ s => s.vendorId === vendors[0].id && s.templateId === template.id
+ );
+
+ // 계약 유효기간 만료 조건 추가
+ if (status && (status.isExpired ||
+ status.isUpdated ||
+ status.status === "REJECTED" ||
+ (status.status === "COMPLETED" && status.isContractExpired))) {
+ const key = `${vendors[0].id}-${template.id}`;
+
+ // 계약 유효기간 만료인 경우 다른 텍스트 표시
+ const buttonText = status.status === "COMPLETED" && status.isContractExpired
+ ? (forceResend.has(key) ? "재계약 취소" : "재계약하기")
+ : (forceResend.has(key) ? "재전송 취소" : "재전송하기");
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-7 px-2 text-xs"
+ onClick={() => toggleForceResend(vendors[0].id, template.id)}
+ >
+ {buttonText}
+ </Button>
+ );
+ }
+ return null;
+ })()}
+
+ </div>
+
+ {/* 추가 정보 표시 (파일명 등) */}
+ <div className="mt-1 pl-6 text-xs text-muted-foreground">
+ {template.fileName}
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+
+ // 내용 영역 렌더링
+ const renderContentArea = () => (
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <div className="flex justify-between items-center">
+ <h3 className="text-sm font-medium">계약서 템플릿 선택</h3>
+ <span className="text-xs text-muted-foreground">
+ {selectedCount}/{totalCount} 선택됨
+ </span>
+ </div>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader className="size-4 animate-spin mr-2" />
+ <span>템플릿 로딩 중...</span>
+ </div>
+ ) : templates.length === 0 ? (
+ <div className="text-sm text-muted-foreground p-2 border rounded-md">
+ 활성 상태의 템플릿이 없습니다. 템플릿을 먼저 등록해주세요.
+ </div>
+ ) : (
+ // ScrollArea 대신 네이티브 스크롤 사용
+ <div className="border rounded-md p-3 overflow-y-auto h-[200px]">
+ {renderTemplateList()}
+ </div>
+ )}
+ </div>
+
+ {statusLoading && (
+ <div className="flex items-center text-sm text-muted-foreground">
+ <Loader className="size-3 animate-spin mr-2" />
+ <span>계약 상태 확인 중...</span>
+ </div>
+ )}
+
+ {/* 선택된 템플릿 정보 (ScrollArea 대신 네이티브 스크롤 사용) */}
+ {selectedTemplateIds.length > 0 && (
+ <div className="space-y-2 text-sm">
+ <h3 className="font-medium">선택된 템플릿 정보</h3>
+ <div className="overflow-y-auto max-h-[150px] border rounded-md p-2">
+ <div className="space-y-2">
+ {selectedTemplateIds.map(id => {
+ const template = templates.find(t => t.id === id);
+ if (!template) return null;
+
+ return (
+ <div key={id} className="p-2 border rounded-md bg-muted/50">
+ <p><span className="font-medium">이름:</span> {template.templateName}</p>
+ <p><span className="font-medium">파일:</span> {template.fileName}</p>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ </div>
+ )}
+
+ <div className="text-sm text-muted-foreground mt-4">
+ 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 기본계약서와 기타 관련 서류들에 대해서 서명을 하게 됩니다.
+ </div>
+ </div>
+ );
+
+ 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 className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>협력업체 기본계약서 요청 확인</DialogTitle>
+ <DialogDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 기본계약서 서명을 요청하시겠습니까?
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="py-4">
+ {renderContentArea()}
+ </div>
+
+ <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 || isLoading || selectedTemplateIds.length === 0}
+ >
+ {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 ? "개의 협력업체" : "개의 협력업체들"}에게 기본계약서 서명을 요청하시겠습니까?
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="px-4">
+ {renderContentArea()}
+ </div>
+
+ <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 || isLoading || selectedTemplateIds.length === 0}
+ >
+ {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
index c590d7ec..a9fe0e1a 100644
--- a/lib/vendors/table/request-project-pq-dialog.tsx
+++ b/lib/vendors/table/request-project-pq-dialog.tsx
@@ -44,6 +44,7 @@ import { Label } from "@/components/ui/label"
import { Vendor } from "@/db/schema/vendors"
import { requestPQVendors } from "../service"
import { getProjects, type Project } from "@/lib/rfqs/service"
+import { useSession } from "next-auth/react"
interface RequestProjectPQDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -63,6 +64,7 @@ export function RequestProjectPQDialog({
const [projects, setProjects] = React.useState<Project[]>([])
const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null)
const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
+ const { data: session } = useSession()
// 프로젝트 목록 로드
React.useEffect(() => {
@@ -95,15 +97,23 @@ export function RequestProjectPQDialog({
}
function onApprove() {
+
if (!selectedProjectId) {
toast.error("프로젝트를 선택해주세요.")
return
}
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
startApproveTransition(async () => {
const { error } = await requestPQVendors({
ids: vendors.map((vendor) => vendor.id),
projectId: selectedProjectId,
+ userId: Number(session.user.id)
+
})
if (error) {
@@ -113,7 +123,7 @@ export function RequestProjectPQDialog({
props.onOpenChange?.(false)
- toast.success(`벤더에게 프로젝트 PQ가 성공적으로 요청되었습니다.`)
+ toast.success(`협력업체에게 프로젝트 PQ가 성공적으로 요청되었습니다.`)
onSuccess?.()
})
}
@@ -165,8 +175,8 @@ export function RequestProjectPQDialog({
<DialogTitle>프로젝트 PQ 요청 확인</DialogTitle>
<DialogDescription>
<span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
- 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
+ 요청을 보내면 협력업체에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
</DialogDescription>
</DialogHeader>
@@ -177,7 +187,7 @@ export function RequestProjectPQDialog({
<Button variant="outline">취소</Button>
</DialogClose>
<Button
- aria-label="선택한 벤더에게 요청하기"
+ aria-label="선택한 협력업체에게 요청하기"
variant="default"
onClick={onApprove}
disabled={isApprovePending || !selectedProjectId}
@@ -211,8 +221,8 @@ export function RequestProjectPQDialog({
<DrawerTitle>프로젝트 PQ 요청 확인</DrawerTitle>
<DrawerDescription>
<span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
- 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
+ 요청을 보내면 협력업체에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
</DrawerDescription>
</DrawerHeader>
@@ -225,7 +235,7 @@ export function RequestProjectPQDialog({
<Button variant="outline">취소</Button>
</DrawerClose>
<Button
- aria-label="선택한 벤더에게 요청하기"
+ aria-label="선택한 협력업체에게 요청하기"
variant="default"
onClick={onApprove}
disabled={isApprovePending || !selectedProjectId}
diff --git a/lib/vendors/table/request-vendor-investigate-dialog.tsx b/lib/vendors/table/request-vendor-investigate-dialog.tsx
index 0309ee4a..b3deafce 100644
--- a/lib/vendors/table/request-vendor-investigate-dialog.tsx
+++ b/lib/vendors/table/request-vendor-investigate-dialog.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Row } from "@tanstack/react-table"
-import { Loader, Check, SendHorizonal } from "lucide-react"
+import { Loader, Check, SendHorizonal, AlertCircle, AlertTriangle } from "lucide-react"
import { toast } from "sonner"
import { useMediaQuery } from "@/hooks/use-media-query"
@@ -27,8 +27,29 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion"
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+
import { Vendor } from "@/db/schema/vendors"
-import { requestInvestigateVendors } from "@/lib/vendor-investigation/service"
+import { requestInvestigateVendors, getExistingInvestigationsForVendors } from "@/lib/vendor-investigation/service"
+import { useSession } from "next-auth/react"
+import { formatDate } from "@/lib/utils"
interface ApprovalVendorDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -37,21 +58,98 @@ interface ApprovalVendorDialogProps
onSuccess?: () => void
}
+// Helper function to get status badge variant and text
+function getStatusBadge(status: string) {
+ switch (status) {
+ case "REQUESTED":
+ return { variant: "secondary", text: "Requested" }
+ case "SCHEDULED":
+ return { variant: "warning", text: "Scheduled" }
+ case "IN_PROGRESS":
+ return { variant: "default", text: "In Progress" }
+ case "COMPLETED":
+ return { variant: "success", text: "Completed" }
+ case "CANCELLED":
+ return { variant: "destructive", text: "Cancelled" }
+ default:
+ return { variant: "outline", text: status }
+ }
+}
+
export function RequestVendorsInvestigateDialog({
vendors,
showTrigger = true,
onSuccess,
...props
}: ApprovalVendorDialogProps) {
-
- console.log(vendors)
const [isApprovePending, startApproveTransition] = React.useTransition()
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [existingInvestigations, setExistingInvestigations] = React.useState<any[]>([])
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
+
+ // Fetch existing investigations when dialog opens
+ React.useEffect(() => {
+ if (vendors.length > 0) {
+ setIsLoading(true)
+ const fetchExistingInvestigations = async () => {
+ try {
+ const vendorIds = vendors.map(vendor => vendor.id)
+ const result = await getExistingInvestigationsForVendors(vendorIds)
+ setExistingInvestigations(result)
+ } catch (error) {
+ console.error("Failed to fetch existing investigations:", error)
+ toast.error("Failed to fetch existing investigations")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ fetchExistingInvestigations()
+ }
+ }, [vendors])
+
+ // Group vendors by investigation status
+ const vendorsWithInvestigations = React.useMemo(() => {
+ if (!existingInvestigations.length) return { withInvestigations: [], withoutInvestigations: vendors }
+
+ const vendorMap = new Map(vendors.map(v => [v.id, v]))
+ const withInvestigations: Array<{ vendor: typeof vendors[0], investigation: any }> = []
+
+ // Find vendors with existing investigations
+ existingInvestigations.forEach(inv => {
+ const vendor = vendorMap.get(inv.vendorId)
+ if (vendor) {
+ withInvestigations.push({ vendor, investigation: inv })
+ vendorMap.delete(inv.vendorId)
+ }
+ })
+
+ // Remaining vendors don't have investigations
+ const withoutInvestigations = Array.from(vendorMap.values())
+
+ return { withInvestigations, withoutInvestigations }
+ }, [vendors, existingInvestigations])
function onApprove() {
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ // Only request investigations for vendors without existing ones
+ const vendorsToRequest = vendorsWithInvestigations.withoutInvestigations
+
+ if (vendorsToRequest.length === 0) {
+ toast.info("모든 선택된 업체에 이미 실사 요청이 있습니다.")
+ props.onOpenChange?.(false)
+ return
+ }
+
startApproveTransition(async () => {
const { error } = await requestInvestigateVendors({
- ids: vendors.map((vendor) => vendor.id),
+ ids: vendorsToRequest.map((vendor) => vendor.id),
+ userId: Number(session.user.id)
})
if (error) {
@@ -60,11 +158,102 @@ export function RequestVendorsInvestigateDialog({
}
props.onOpenChange?.(false)
- toast.success("Vendor Investigation successfully sent to 벤더실사담당자")
+ toast.success(`${vendorsToRequest.length}개 업체에 대한 실사 요청을 보냈습니다.`)
onSuccess?.()
})
}
+ const renderContent = () => {
+ return (
+ <>
+ <div className="space-y-4">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader className="size-6 animate-spin text-muted-foreground" />
+ </div>
+ ) : (
+ <>
+ {vendorsWithInvestigations.withInvestigations.length > 0 && (
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertTitle>기존 실사 요청 정보가 있습니다</AlertTitle>
+ <AlertDescription>
+ 선택한 {vendors.length}개 업체 중 {vendorsWithInvestigations.withInvestigations.length}개 업체에 대한
+ 기존 실사 요청이 있습니다. 새로운 요청은 기존 데이터가 없는 업체에만 적용됩니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {vendorsWithInvestigations.withInvestigations.length > 0 && (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="existing-investigations">
+ <AccordionTrigger className="font-medium">
+ 기존 실사 요청 ({vendorsWithInvestigations.withInvestigations.length})
+ </AccordionTrigger>
+ <AccordionContent>
+ <ScrollArea className="max-h-[200px]">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>업체명</TableHead>
+ <TableHead>상태</TableHead>
+ <TableHead>요청일</TableHead>
+ <TableHead>예정 일정</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {vendorsWithInvestigations.withInvestigations.map(({ vendor, investigation }) => {
+ const status = getStatusBadge(investigation.investigationStatus)
+ return (
+ <TableRow key={investigation.investigationId}>
+ <TableCell className="font-medium">{vendor.vendorName}</TableCell>
+ <TableCell>
+ <Badge variant={status.variant as any}>{status.text}</Badge>
+ </TableCell>
+ <TableCell>{formatDate(investigation.createdAt)}</TableCell>
+ <TableCell>
+ {investigation.scheduledStartAt
+ ? formatDate(investigation.scheduledStartAt)
+ : "미정"}
+ </TableCell>
+ </TableRow>
+ )
+ })}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ )}
+
+ <div>
+ <h3 className="text-sm font-medium mb-2">
+ 새로운 실사가 요청될 업체 ({vendorsWithInvestigations.withoutInvestigations.length})
+ </h3>
+ {vendorsWithInvestigations.withoutInvestigations.length > 0 ? (
+ <ScrollArea className="max-h-[200px]">
+ <ul className="space-y-1">
+ {vendorsWithInvestigations.withoutInvestigations.map((vendor) => (
+ <li key={vendor.id} className="text-sm py-1 px-2 border-b">
+ {vendor.vendorName} ({vendor.vendorCode || "코드 없음"})
+ </li>
+ ))}
+ </ul>
+ </ScrollArea>
+ ) : (
+ <p className="text-sm text-muted-foreground py-2">
+ 모든 선택된 업체에 이미 실사 요청이 있습니다.
+ </p>
+ )}
+ </div>
+ </>
+ )}
+ </div>
+ </>
+ )
+ }
+
if (isDesktop) {
return (
<Dialog {...props}>
@@ -72,29 +261,30 @@ export function RequestVendorsInvestigateDialog({
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<SendHorizonal className="size-4" aria-hidden="true" />
- Vendor Investigation Request ({vendors.length})
+ 실사 요청 ({vendors.length})
</Button>
</DialogTrigger>
) : null}
- <DialogContent>
+ <DialogContent className="max-w-md">
<DialogHeader>
- <DialogTitle>Confirm Vendor Investigation Requst</DialogTitle>
+ <DialogTitle>Confirm Vendor Investigation Request</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.
+ 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다.
+ 요청 후 협력업체실사담당자에게 알림이 전송됩니다.
</DialogDescription>
</DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
+
+ {renderContent()}
+
+ <DialogFooter className="gap-2 sm:space-x-0 mt-4">
<DialogClose asChild>
- <Button variant="outline">Cancel</Button>
+ <Button variant="outline">취소</Button>
</DialogClose>
<Button
aria-label="Request selected vendors"
variant="default"
onClick={onApprove}
- disabled={isApprovePending}
+ disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0}
>
{isApprovePending && (
<Loader
@@ -102,7 +292,7 @@ export function RequestVendorsInvestigateDialog({
aria-hidden="true"
/>
)}
- Request
+ 요청하기
</Button>
</DialogFooter>
</DialogContent>
@@ -124,26 +314,29 @@ export function RequestVendorsInvestigateDialog({
<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.
+ 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다.
+ 요청 후 협력업체실사담당자에게 알림이 전송됩니다.
</DrawerDescription>
</DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
+
+ <div className="px-4">
+ {renderContent()}
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0 mt-4">
<DrawerClose asChild>
- <Button variant="outline">Cancel</Button>
+ <Button variant="outline">취소</Button>
</DrawerClose>
<Button
aria-label="Request selected vendors"
variant="default"
onClick={onApprove}
- disabled={isApprovePending}
+ disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0}
>
{isApprovePending && (
<Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
)}
- Request
+ 요청하기
</Button>
</DrawerFooter>
</DrawerContent>
diff --git a/lib/vendors/table/request-vendor-pg-dialog.tsx b/lib/vendors/table/request-vendor-pg-dialog.tsx
index de23ad9b..4bc4e909 100644
--- a/lib/vendors/table/request-vendor-pg-dialog.tsx
+++ b/lib/vendors/table/request-vendor-pg-dialog.tsx
@@ -29,6 +29,7 @@ import {
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
import { requestPQVendors } from "../service"
+import { useSession } from "next-auth/react"
interface ApprovalVendorDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -45,11 +46,21 @@ export function RequestPQVendorsDialog({
}: ApprovalVendorDialogProps) {
const [isApprovePending, startApproveTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
function onApprove() {
+
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
+
startApproveTransition(async () => {
const { error } = await requestPQVendors({
ids: vendors.map((vendor) => vendor.id),
+ userId: Number(session.user.id)
+
})
if (error) {
diff --git a/lib/vendors/table/update-vendor-sheet.tsx b/lib/vendors/table/update-vendor-sheet.tsx
index e65c4b1c..08994b6a 100644
--- a/lib/vendors/table/update-vendor-sheet.tsx
+++ b/lib/vendors/table/update-vendor-sheet.tsx
@@ -3,7 +3,25 @@
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
-import { Loader } from "lucide-react"
+import {
+ Loader,
+ Activity,
+ AlertCircle,
+ AlertTriangle,
+ ClipboardList,
+ FilePenLine,
+ XCircle,
+ ClipboardCheck,
+ FileCheck2,
+ FileX2,
+ BadgeCheck,
+ CheckCircle2,
+ Circle as CircleIcon,
+ User,
+ Building,
+ AlignLeft,
+ Calendar
+} from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
@@ -14,6 +32,7 @@ import {
FormItem,
FormLabel,
FormMessage,
+ FormDescription
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
@@ -33,27 +52,143 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import { useSession } from "next-auth/react" // Import useSession
-import { Vendor } from "@/db/schema/vendors"
+import { VendorWithType, vendors } from "@/db/schema/vendors"
import { updateVendorSchema, type UpdateVendorSchema } from "../validations"
import { modifyVendor } from "../service"
-// 예: import { modifyVendor } from "@/lib/vendors/service"
interface UpdateVendorSheetProps
extends React.ComponentPropsWithRef<typeof Sheet> {
- vendor: Vendor | null
+ vendor: VendorWithType | null
+}
+type StatusType = (typeof vendors.status.enumValues)[number];
+
+type StatusConfig = {
+ Icon: React.ElementType;
+ className: string;
+ label: string;
+};
+
+// 상태 표시 유틸리티 함수
+const getStatusConfig = (status: StatusType): StatusConfig => {
+ switch(status) {
+ case "PENDING_REVIEW":
+ return {
+ Icon: ClipboardList,
+ className: "text-yellow-600",
+ label: "가입 신청 중"
+ };
+ case "IN_REVIEW":
+ return {
+ Icon: FilePenLine,
+ className: "text-blue-600",
+ label: "심사 중"
+ };
+ case "REJECTED":
+ return {
+ Icon: XCircle,
+ className: "text-red-600",
+ label: "심사 거부됨"
+ };
+ case "IN_PQ":
+ return {
+ Icon: ClipboardCheck,
+ className: "text-purple-600",
+ label: "PQ 진행 중"
+ };
+ case "PQ_SUBMITTED":
+ return {
+ Icon: FileCheck2,
+ className: "text-indigo-600",
+ label: "PQ 제출"
+ };
+ case "PQ_FAILED":
+ return {
+ Icon: FileX2,
+ className: "text-red-600",
+ label: "PQ 실패"
+ };
+ case "PQ_APPROVED":
+ return {
+ Icon: BadgeCheck,
+ className: "text-green-600",
+ label: "PQ 통과"
+ };
+ case "APPROVED":
+ return {
+ Icon: CheckCircle2,
+ className: "text-green-600",
+ label: "승인됨"
+ };
+ case "READY_TO_SEND":
+ return {
+ Icon: CheckCircle2,
+ className: "text-emerald-600",
+ label: "MDG 송부대기"
+ };
+ case "ACTIVE":
+ return {
+ Icon: Activity,
+ className: "text-emerald-600",
+ label: "활성 상태"
+ };
+ case "INACTIVE":
+ return {
+ Icon: AlertCircle,
+ className: "text-gray-600",
+ label: "비활성 상태"
+ };
+ case "BLACKLISTED":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "거래 금지"
+ };
+ default:
+ return {
+ Icon: CircleIcon,
+ className: "text-gray-600",
+ label: status
+ };
+ }
+};
+
+// 신용평가기관 목록
+const creditAgencies = [
+ { value: "NICE", label: "NICE평가정보" },
+ { value: "KIS", label: "KIS (한국신용평가)" },
+ { value: "KED", label: "KED (한국기업데이터)" },
+ { value: "SCI", label: "SCI평가정보" },
+]
+
+// 신용등급 스케일
+const creditRatingScaleMap: Record<string, string[]> = {
+ NICE: ["AAA", "AA", "A", "BBB", "BB", "B", "C", "D"],
+ KIS: ["AAA", "AA+", "AA", "A+", "A", "BBB+", "BBB", "BB", "B", "C"],
+ KED: ["AAA", "AA", "A", "BBB", "BB", "B", "CCC", "CC", "C", "D"],
+ SCI: ["AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB-", "B"],
+}
+
+// 현금흐름등급 스케일
+const cashFlowRatingScaleMap: Record<string, string[]> = {
+ NICE: ["우수", "양호", "보통", "미흡", "불량"],
+ KIS: ["A+", "A", "B+", "B", "C", "D"],
+ KED: ["1등급", "2등급", "3등급", "4등급", "5등급"],
+ SCI: ["Level 1", "Level 2", "Level 3", "Level 4"],
}
// 폼 컴포넌트
export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) {
const [isPending, startTransition] = React.useTransition()
+ const [selectedAgency, setSelectedAgency] = React.useState<string>(vendor?.creditAgency || "NICE")
- console.log(vendor)
-
- // RHF + Zod
+ // 폼 정의 - UpdateVendorSchema 타입을 직접 사용
const form = useForm<UpdateVendorSchema>({
resolver: zodResolver(updateVendorSchema),
defaultValues: {
+ // 업체 기본 정보
vendorName: vendor?.vendorName ?? "",
vendorCode: vendor?.vendorCode ?? "",
address: vendor?.address ?? "",
@@ -61,7 +196,18 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps)
phone: vendor?.phone ?? "",
email: vendor?.email ?? "",
website: vendor?.website ?? "",
+ creditRating: vendor?.creditRating ?? "",
+ cashFlowRating: vendor?.cashFlowRating ?? "",
status: vendor?.status ?? "ACTIVE",
+ vendorTypeId: vendor?.vendorTypeId ?? undefined,
+
+ // 구매담당자 정보 (기본값은 비어있음)
+ buyerName: "",
+ buyerDepartment: "",
+ contractStartDate: undefined,
+ contractEndDate: undefined,
+ internalNotes: "",
+ // evaluationScore: "",
},
})
@@ -75,191 +221,439 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps)
phone: vendor?.phone ?? "",
email: vendor?.email ?? "",
website: vendor?.website ?? "",
+ creditRating: vendor?.creditRating ?? "",
+ cashFlowRating: vendor?.cashFlowRating ?? "",
status: vendor?.status ?? "ACTIVE",
+ vendorTypeId: vendor?.vendorTypeId ?? undefined,
+
+ // 구매담당자 필드는 유지
+ buyerName: form.getValues("buyerName"),
+ buyerDepartment: form.getValues("buyerDepartment"),
+ contractStartDate: form.getValues("contractStartDate"),
+ contractEndDate: form.getValues("contractEndDate"),
+ internalNotes: form.getValues("internalNotes"),
+ // evaluationScore: form.getValues("evaluationScore"),
});
}
}, [vendor, form]);
- console.log(form.getValues())
+ // 신용평가기관 변경 시 등급 필드를 초기화하는 효과
+ React.useEffect(() => {
+ // 선택된 평가기관에 따라 현재 선택된 등급이 유효한지 확인
+ const currentCreditRating = form.getValues("creditRating");
+ const currentCashFlowRating = form.getValues("cashFlowRating");
+
+ // 선택된 기관에 따른 유효한 등급 목록
+ const validCreditRatings = creditRatingScaleMap[selectedAgency] || [];
+ const validCashFlowRatings = cashFlowRatingScaleMap[selectedAgency] || [];
+
+ // 현재 등급이 유효하지 않으면 초기화
+ if (currentCreditRating && !validCreditRatings.includes(currentCreditRating)) {
+ form.setValue("creditRating", "");
+ }
+
+ if (currentCashFlowRating && !validCashFlowRatings.includes(currentCashFlowRating)) {
+ form.setValue("cashFlowRating", "");
+ }
+
+ // 신용평가기관 필드 업데이트
+ if(selectedAgency){
+ form.setValue("creditAgency", selectedAgency as "NICE" | "KIS" | "KED" | "SCI");
+ }
+
+ }, [selectedAgency, form]);
+
// 제출 핸들러
async function onSubmit(data: UpdateVendorSchema) {
if (!vendor) return
+ const { data: session } = useSession()
- startTransition(async () => {
- // 서버 액션 or API
- // const { error } = await modifyVendor({ id: vendor.id, ...data })
- // 여기선 간단 예시
- try {
- // 예시:
- const { error } = await modifyVendor({ id: String(vendor.id), ...data })
- if (error) throw new Error(error)
-
- toast.success("Vendor updated!")
- form.reset()
- props.onOpenChange?.(false)
- } catch (err: any) {
- toast.error(String(err))
- }
- })
- }
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+ startTransition(async () => {
+ try {
+ // Add status change comment if status has changed
+ const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined
+ const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined
+
+ const statusComment =
+ oldStatus !== newStatus
+ ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}`
+ : "" // Empty string instead of undefined
+
+ // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가
+ const { error } = await modifyVendor({
+ id: String(vendor.id),
+ userId: Number(session.user.id), // Add user ID from session
+ comment: statusComment, // Add comment for status changes
+ ...data // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리
+ })
+
+ if (error) throw new Error(error)
+
+ toast.success("업체 정보가 업데이트되었습니다!")
+ form.reset()
+ props.onOpenChange?.(false)
+ } catch (err: any) {
+ toast.error(String(err))
+ }
+ })
+}
return (
<Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto">
<SheetHeader className="text-left">
- <SheetTitle>Update Vendor</SheetTitle>
+ <SheetTitle>업체 정보 수정</SheetTitle>
<SheetDescription>
- Update the vendor details and save the changes
+ 업체 세부 정보를 수정하고 변경 사항을 저장하세요
</SheetDescription>
</SheetHeader>
<Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
- {/* vendorName */}
- <FormField
- control={form.control}
- name="vendorName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Vendor Name</FormLabel>
- <FormControl>
- <Input placeholder="Vendor Name" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
+ {/* 업체 기본 정보 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center">
+ <Building className="mr-2 h-5 w-5 text-muted-foreground" />
+ <h3 className="text-sm font-medium">업체 기본 정보</h3>
+ </div>
+ <FormDescription>
+ 업체가 제공한 기본 정보입니다. 필요시 수정하세요.
+ </FormDescription>
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
+ {/* vendorName */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체명</FormLabel>
+ <FormControl>
+ <Input placeholder="업체명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* vendorCode */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체 코드</FormLabel>
+ <FormControl>
+ <Input placeholder="예: ABC123" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* address */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem className="md:col-span-2">
+ <FormLabel>주소</FormLabel>
+ <FormControl>
+ <Input placeholder="주소 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* country */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 대한민국" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* phone */}
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 010-1234-5678" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* email */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일</FormLabel>
+ <FormControl>
+ <Input placeholder="예: info@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* website */}
+ <FormField
+ control={form.control}
+ name="website"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>웹사이트</FormLabel>
+ <FormControl>
+ <Input placeholder="예: https://www.company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
- {/* vendorCode */}
- <FormField
- control={form.control}
- name="vendorCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Vendor Code</FormLabel>
- <FormControl>
- <Input placeholder="Code123" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ {/* status with icons */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => {
+ // 현재 선택된 상태의 구성 정보 가져오기
+ const selectedConfig = getStatusConfig(field.value ?? "ACTIVE");
+ const SelectedIcon = selectedConfig?.Icon || CircleIcon;
- {/* address */}
- <FormField
- control={form.control}
- name="address"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Address</FormLabel>
- <FormControl>
- <Input placeholder="123 Main St" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ return (
+ <FormItem>
+ <FormLabel>업체승인상태</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue>
+ {field.value && (
+ <div className="flex items-center">
+ <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} />
+ <span>{selectedConfig.label}</span>
+ </div>
+ )}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {vendors.status.enumValues.map((status) => {
+ const config = getStatusConfig(status);
+ const StatusIcon = config.Icon;
+ return (
+ <SelectItem key={status} value={status}>
+ <div className="flex items-center">
+ <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ );
+ })}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
- {/* country */}
- <FormField
- control={form.control}
- name="country"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Country</FormLabel>
- <FormControl>
- <Input placeholder="USA" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+
+ {/* 신용평가기관 선택 */}
+ <FormField
+ control={form.control}
+ name="creditAgency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>신용평가기관</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value || ""}
+ onValueChange={(value) => {
+ field.onChange(value);
+ setSelectedAgency(value);
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="평가기관 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {creditAgencies.map((agency) => (
+ <SelectItem key={agency.value} value={agency.value}>
+ {agency.label}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가년도 - 나중에 추가 가능 */}
+
+ {/* 신용등급 - 선택된 기관에 따라 옵션 변경 */}
+ <FormField
+ control={form.control}
+ name="creditRating"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>신용등급</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ disabled={!selectedAgency}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="신용등급 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {(creditRatingScaleMap[selectedAgency] || []).map((rating) => (
+ <SelectItem key={rating} value={rating}>
+ {rating}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 현금흐름등급 - 선택된 기관에 따라 옵션 변경 */}
+ <FormField
+ control={form.control}
+ name="cashFlowRating"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>현금흐름등급</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ disabled={!selectedAgency}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="현금흐름등급 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {(cashFlowRatingScaleMap[selectedAgency] || []).map((rating) => (
+ <SelectItem key={rating} value={rating}>
+ {rating}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
- {/* phone */}
- <FormField
- control={form.control}
- name="phone"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Phone</FormLabel>
- <FormControl>
- <Input placeholder="+1 555-1234" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ </div>
+ </div>
- {/* email */}
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Email</FormLabel>
- <FormControl>
- <Input placeholder="vendor@example.com" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ {/* 구분선 */}
+ <Separator className="my-2" />
- {/* website */}
- <FormField
- control={form.control}
- name="website"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Website</FormLabel>
- <FormControl>
- <Input placeholder="https://www.vendor.com" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ {/* 구매담당자 입력 섹션 */}
+ <div className="space-y-4 bg-slate-50 p-4 rounded-md border border-slate-200">
+ <div className="flex items-center">
+ <User className="mr-2 h-5 w-5 text-blue-600" />
+ <h3 className="text-sm font-medium text-blue-800">구매담당자 정보</h3>
+ </div>
+ <FormDescription>
+ 구매담당자가 관리하는 추가 정보입니다. 이 정보는 내부용으로만 사용됩니다.
+ </FormDescription>
+
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
+ {/* 여기에 구매담당자 필드 추가 */}
+ <FormField
+ control={form.control}
+ name="buyerName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자 이름</FormLabel>
+ <FormControl>
+ <Input placeholder="담당자 이름" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="buyerDepartment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당 부서</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 구매부" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
- {/* status */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Status</FormLabel>
- <FormControl>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <SelectTrigger className="capitalize">
- <SelectValue placeholder="Select a status" />
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- {/* enum ["ACTIVE","INACTIVE","BLACKLISTED"] */}
- <SelectItem value="ACTIVE">ACTIVE</SelectItem>
- <SelectItem value="INACTIVE">INACTIVE</SelectItem>
- <SelectItem value="BLACKLISTED">BLACKLISTED</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+
+ <FormField
+ control={form.control}
+ name="internalNotes"
+ render={({ field }) => (
+ <FormItem className="md:col-span-2">
+ <FormLabel>내부 메모</FormLabel>
+ <FormControl>
+ <Input placeholder="내부 참고사항을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
<SheetFooter className="gap-2 pt-2 sm:space-x-0">
<SheetClose asChild>
<Button type="button" variant="outline">
- Cancel
+ 취소
</Button>
</SheetClose>
<Button disabled={isPending}>
{isPending && (
<Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
- Save
+ 저장
</Button>
</SheetFooter>
</form>
diff --git a/lib/vendors/table/vendor-all-export.ts b/lib/vendors/table/vendor-all-export.ts
new file mode 100644
index 00000000..cef801fd
--- /dev/null
+++ b/lib/vendors/table/vendor-all-export.ts
@@ -0,0 +1,486 @@
+// /lib/vendor-export.ts
+import ExcelJS from "exceljs"
+import { VendorWithType } from "@/db/schema/vendors"
+import { exportVendorDetails } from "../service";
+
+// 연락처 인터페이스 정의
+interface VendorContact {
+ contactName: string;
+ contactPosition?: string | null;
+ contactEmail: string;
+ contactPhone?: string | null;
+ isPrimary: boolean;
+}
+
+// 아이템 인터페이스 정의
+interface VendorItem {
+ itemCode: string;
+ itemName: string;
+ description?: string | null;
+ createdAt?: Date | string;
+}
+
+// RFQ 인터페이스 정의
+interface VendorRFQ {
+ rfqNumber: string;
+ title: string;
+ status: string;
+ requestDate?: Date | string | null;
+ dueDate?: Date | string | null;
+ description?: string | null;
+}
+
+// 계약 인터페이스 정의
+interface VendorContract {
+ projectCode: string;
+ projectName: string;
+ contractNo: string;
+ contractName: string;
+ status: string;
+ paymentTerms: string;
+ deliveryTerms: string;
+ deliveryDate: Date | string;
+ deliveryLocation: string;
+ startDate?: Date | string | null;
+ endDate?: Date | string | null;
+ currency: string;
+ totalAmount?: number | null;
+}
+
+// 서비스에서 반환하는 실제 데이터 구조
+interface VendorData {
+ id: number;
+ vendorName: string;
+ vendorCode: string | null;
+ taxId: string;
+ address: string | null;
+ country: string | null;
+ phone: string | null;
+ email: string | null;
+ website: string | null;
+ status: string;
+ representativeName: string | null;
+ representativeBirth: string | null;
+ representativeEmail: string | null;
+ representativePhone: string | null;
+ corporateRegistrationNumber: string | null;
+ creditAgency: string | null;
+ creditRating: string | null;
+ cashFlowRating: string | null;
+// items: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ vendorContacts: VendorContact[];
+ vendorItems: VendorItem[];
+ vendorRfqs: VendorRFQ[];
+ vendorContracts: VendorContract[];
+}
+
+/**
+ * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수
+ * - 기본정보 시트
+ * - 연락처 시트
+ * - 아이템 시트
+ * - RFQ 시트
+ * - 계약 시트
+ * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨
+ */
+export async function exportVendorsWithRelatedData(
+ vendors: VendorWithType[],
+ filename = "vendors-detailed"
+): Promise<void> {
+ if (!vendors.length) return;
+
+ // 선택된 벤더 ID 목록
+ const vendorIds = vendors.map(vendor => vendor.id);
+
+ try {
+ // 서버로부터 모든 관련 데이터 가져오기
+ const vendorsWithDetails = await exportVendorDetails(vendorIds);
+
+ if (!vendorsWithDetails.length) {
+ throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다.");
+ }
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+
+ // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태)
+ const vendorData = vendorsWithDetails as unknown as any[];
+
+ // ===== 1. 기본 정보 시트 =====
+ createBasicInfoSheet(workbook, vendorData);
+
+ // ===== 2. 연락처 시트 =====
+ createContactsSheet(workbook, vendorData);
+
+ // ===== 3. 아이템 시트 =====
+ createItemsSheet(workbook, vendorData);
+
+ // ===== 4. RFQ 시트 =====
+ createRFQsSheet(workbook, vendorData);
+
+ // ===== 5. 계약 시트 =====
+ createContractsSheet(workbook, vendorData);
+
+ // 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`;
+ link.click();
+ URL.revokeObjectURL(url);
+
+ return;
+ } catch (error) {
+ console.error("Export error:", error);
+ throw error;
+ }
+}
+
+// 기본 정보 시트 생성 함수
+function createBasicInfoSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: VendorData[]
+): void {
+ const basicInfoSheet = workbook.addWorksheet("기본정보");
+
+ // 기본 정보 시트 헤더 설정
+ basicInfoSheet.columns = [
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ { header: "국가", key: "country", width: 10 },
+ { header: "상태", key: "status", width: 15 },
+ { header: "이메일", key: "email", width: 20 },
+ { header: "전화번호", key: "phone", width: 15 },
+ { header: "웹사이트", key: "website", width: 20 },
+ { header: "주소", key: "address", width: 30 },
+ { header: "대표자명", key: "representativeName", width: 15 },
+ { header: "신용등급", key: "creditRating", width: 10 },
+ { header: "현금흐름등급", key: "cashFlowRating", width: 10 },
+ { header: "생성일", key: "createdAt", width: 15 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(basicInfoSheet);
+
+ // 벤더 데이터 추가
+ vendors.forEach((vendor: VendorData) => {
+ basicInfoSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ country: vendor.country,
+ status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환
+ email: vendor.email,
+ phone: vendor.phone,
+ website: vendor.website,
+ address: vendor.address,
+ representativeName: vendor.representativeName,
+ creditRating: vendor.creditRating,
+ cashFlowRating: vendor.cashFlowRating,
+ createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "",
+ });
+ });
+}
+
+// 연락처 시트 생성 함수
+function createContactsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: VendorData[]
+): void {
+ const contactsSheet = workbook.addWorksheet("연락처");
+
+ contactsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 연락처 정보
+ { header: "이름", key: "contactName", width: 15 },
+ { header: "직책", key: "contactPosition", width: 15 },
+ { header: "이메일", key: "contactEmail", width: 25 },
+ { header: "전화번호", key: "contactPhone", width: 15 },
+ { header: "주요 연락처", key: "isPrimary", width: 10 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(contactsSheet);
+
+ // 벤더별 연락처 데이터 추가
+ vendors.forEach((vendor: VendorData) => {
+ if (vendor.vendorContacts && vendor.vendorContacts.length > 0) {
+ vendor.vendorContacts.forEach((contact: VendorContact) => {
+ contactsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 연락처 정보
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || "",
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || "",
+ isPrimary: contact.isPrimary ? "예" : "아니오",
+ });
+ });
+ } else {
+ // 연락처가 없는 경우에도 벤더 정보만 추가
+ contactsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ isPrimary: "",
+ });
+ }
+ });
+}
+
+// 아이템 시트 생성 함수
+function createItemsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: VendorData[]
+): void {
+ const itemsSheet = workbook.addWorksheet("아이템");
+
+ itemsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 아이템 정보
+ { header: "아이템 코드", key: "itemCode", width: 15 },
+ { header: "아이템명", key: "itemName", width: 25 },
+ { header: "설명", key: "description", width: 30 },
+ { header: "등록일", key: "createdAt", width: 15 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(itemsSheet);
+
+ // 벤더별 아이템 데이터 추가
+ vendors.forEach((vendor: VendorData) => {
+ if (vendor.vendorItems && vendor.vendorItems.length > 0) {
+ vendor.vendorItems.forEach((item: VendorItem) => {
+ itemsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 아이템 정보
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ description: item.description || "",
+ createdAt: item.createdAt ? formatDate(item.createdAt) : "",
+ });
+ });
+ } else {
+ // 아이템이 없는 경우에도 벤더 정보만 추가
+ itemsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ itemCode: "",
+ itemName: "",
+ description: "",
+ createdAt: "",
+ });
+ }
+ });
+}
+
+// RFQ 시트 생성 함수
+function createRFQsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: VendorData[]
+): void {
+ const rfqsSheet = workbook.addWorksheet("RFQ");
+
+ rfqsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // RFQ 정보
+ { header: "RFQ 번호", key: "rfqNumber", width: 15 },
+ { header: "제목", key: "title", width: 25 },
+ { header: "상태", key: "status", width: 15 },
+ { header: "요청일", key: "requestDate", width: 15 },
+ { header: "마감일", key: "dueDate", width: 15 },
+ { header: "설명", key: "description", width: 30 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(rfqsSheet);
+
+ // 벤더별 RFQ 데이터 추가
+ vendors.forEach((vendor: VendorData) => {
+ if (vendor.vendorRfqs && vendor.vendorRfqs.length > 0) {
+ vendor.vendorRfqs.forEach((rfq: VendorRFQ) => {
+ rfqsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // RFQ 정보
+ rfqNumber: rfq.rfqNumber,
+ title: rfq.title,
+ status: rfq.status,
+ requestDate: rfq.requestDate ? formatDate(rfq.requestDate) : "",
+ dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : "",
+ description: rfq.description || "",
+ });
+ });
+ } else {
+ // RFQ가 없는 경우에도 벤더 정보만 추가
+ rfqsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ rfqNumber: "",
+ title: "",
+ status: "",
+ requestDate: "",
+ dueDate: "",
+ description: "",
+ });
+ }
+ });
+}
+
+// 계약 시트 생성 함수
+function createContractsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: VendorData[]
+): void {
+ const contractsSheet = workbook.addWorksheet("계약");
+
+ contractsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 계약 정보
+ { header: "프로젝트 코드", key: "projectCode", width: 15 },
+ { header: "프로젝트명", key: "projectName", width: 20 },
+ { header: "계약 번호", key: "contractNo", width: 15 },
+ { header: "계약명", key: "contractName", width: 25 },
+ { header: "상태", key: "status", width: 15 },
+ { header: "지급 조건", key: "paymentTerms", width: 15 },
+ { header: "납품 조건", key: "deliveryTerms", width: 15 },
+ { header: "납품 일자", key: "deliveryDate", width: 15 },
+ { header: "납품 위치", key: "deliveryLocation", width: 20 },
+ { header: "계약 시작일", key: "startDate", width: 15 },
+ { header: "계약 종료일", key: "endDate", width: 15 },
+ { header: "통화", key: "currency", width: 10 },
+ { header: "총액", key: "totalAmount", width: 15 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(contractsSheet);
+
+ // 벤더별 계약 데이터 추가
+ vendors.forEach((vendor: VendorData) => {
+ if (vendor.vendorContracts && vendor.vendorContracts.length > 0) {
+ vendor.vendorContracts.forEach((contract: VendorContract) => {
+ contractsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 계약 정보
+ projectCode: contract.projectCode,
+ projectName: contract.projectName,
+ contractNo: contract.contractNo,
+ contractName: contract.contractName,
+ status: contract.status,
+ paymentTerms: contract.paymentTerms,
+ deliveryTerms: contract.deliveryTerms,
+ deliveryDate: contract.deliveryDate ? formatDate(contract.deliveryDate) : "",
+ deliveryLocation: contract.deliveryLocation,
+ startDate: contract.startDate ? formatDate(contract.startDate) : "",
+ endDate: contract.endDate ? formatDate(contract.endDate) : "",
+ currency: contract.currency,
+ totalAmount: contract.totalAmount ? formatAmount(contract.totalAmount) : "",
+ });
+ });
+ } else {
+ // 계약이 없는 경우에도 벤더 정보만 추가
+ contractsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ projectCode: "",
+ projectName: "",
+ contractNo: "",
+ contractName: "",
+ status: "",
+ paymentTerms: "",
+ deliveryTerms: "",
+ deliveryDate: "",
+ deliveryLocation: "",
+ startDate: "",
+ endDate: "",
+ currency: "",
+ totalAmount: "",
+ });
+ }
+ });
+}
+
+// 헤더 스타일 적용 함수
+function applyHeaderStyle(sheet: ExcelJS.Worksheet): void {
+ const headerRow = sheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.alignment = { horizontal: "center" };
+ headerRow.eachCell((cell: ExcelJS.Cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ };
+ });
+}
+
+// 날짜 포맷 함수
+function formatDate(date: Date | string): string {
+ if (!date) return "";
+ if (typeof date === 'string') {
+ date = new Date(date);
+ }
+ return date.toISOString().split('T')[0];
+}
+
+// 금액 포맷 함수
+function formatAmount(amount: number): string {
+ return amount.toLocaleString();
+}
+
+// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수
+function getStatusText(status: string): string {
+ const statusMap: Record<string, string> = {
+ "PENDING_REVIEW": "검토 대기중",
+ "IN_REVIEW": "검토 중",
+ "REJECTED": "거부됨",
+ "IN_PQ": "PQ 진행 중",
+ "PQ_SUBMITTED": "PQ 제출됨",
+ "PQ_FAILED": "PQ 실패",
+ "PQ_APPROVED": "PQ 승인됨",
+ "APPROVED": "승인됨",
+ "READY_TO_SEND": "발송 준비됨",
+ "ACTIVE": "활성",
+ "INACTIVE": "비활성",
+ "BLACKLISTED": "거래 금지"
+ };
+
+ return statusMap[status] || status;
+} \ No newline at end of file
diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx
index 77750c47..c768b587 100644
--- a/lib/vendors/table/vendors-table-columns.tsx
+++ b/lib/vendors/table/vendors-table-columns.tsx
@@ -27,30 +27,41 @@ import {
import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
import { useRouter } from "next/navigation"
-import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors"
+import { VendorWithType, vendors, VendorWithAttachments } from "@/db/schema/vendors"
import { modifyVendor } from "../service"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { vendorColumnsConfig } from "@/config/vendorColumnsConfig"
import { Separator } from "@/components/ui/separator"
import { AttachmentsButton } from "./attachmentButton"
+import { getVendorStatusIcon } from "../utils"
+// 타입 정의 추가
+type StatusType = (typeof vendors.status.enumValues)[number];
+type BadgeVariantType = "default" | "secondary" | "destructive" | "outline";
+type StatusConfig = {
+ variant: BadgeVariantType;
+ className: string;
+};
+type StatusDisplayMap = {
+ [key in StatusType]: string;
+};
type NextRouter = ReturnType<typeof useRouter>;
-
interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>;
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorWithType> | null>>;
router: NextRouter;
+ userId: number;
}
/**
* tanstack table 컬럼 정의 (중첩 헤더 버전)
*/
-export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] {
+export function getColumns({ setRowAction, router, userId }: GetColumnsProps): ColumnDef<VendorWithType>[] {
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
// ----------------------------------------------------------------
- const selectColumn: ColumnDef<Vendor> = {
+ const selectColumn: ColumnDef<VendorWithType> = {
id: "select",
header: ({ table }) => (
<Checkbox
@@ -79,102 +90,103 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
// ----------------------------------------------------------------
// 2) actions 컬럼 (Dropdown 메뉴)
// ----------------------------------------------------------------
-// ----------------------------------------------------------------
-// 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-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`);
- }}
- >
- Details
- </DropdownMenuItem>
-
- {/* APPROVED 상태일 때만 추가 정보 기입 메뉴 표시 */}
- {isApproved && (
+ const actionsColumn: ColumnDef<VendorWithType> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const isApproved = row.original.status === "PQ_APPROVED";
+ const afterApproved = row.original.status === "ACTIVE";
+
+ 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">
+ {(isApproved ||afterApproved) && (
<DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "requestInfo" })}
- className="text-blue-600 font-medium"
+ onSelect={() => setRowAction({ row, type: "update" })}
>
- 추가 정보 기입
+ 레코드 편집
</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,
-}
+ )}
+
+ <DropdownMenuItem
+ onSelect={() => {
+ // 1) 만약 rowAction을 열고 싶다면
+ // setRowAction({ row, type: "update" })
+
+ // 2) 자세히 보기 페이지로 클라이언트 라우팅
+ router.push(`/evcp/vendors/${row.original.id}/info`);
+ }}
+ >
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "log" })}
+ >
+ 감사 로그 보기
+ </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 VendorWithType["status"],
+ userId,
+ vendorName: row.original.vendorName, // Required field from UpdateVendorSchema
+ comment: `Status changed to ${value}`
+ }),
+ {
+ 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 생성
// ----------------------------------------------------------------
- // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] }
- const groupMap: Record<string, ColumnDef<Vendor>[]> = {}
+ // 3-1) groupMap: { [groupName]: ColumnDef<VendorWithType>[] }
+ const groupMap: Record<string, ColumnDef<VendorWithType>[]> = {}
vendorColumnsConfig.forEach((cfg) => {
// 만약 group가 없으면 "_noGroup" 처리
@@ -185,7 +197,7 @@ const actionsColumn: ColumnDef<Vendor> = {
}
// child column 정의
- const childCol: ColumnDef<Vendor> = {
+ const childCol: ColumnDef<VendorWithType> = {
accessorKey: cfg.id,
enableResizing: true,
header: ({ column }) => (
@@ -197,20 +209,158 @@ const actionsColumn: ColumnDef<Vendor> = {
type: cfg.type,
},
cell: ({ row, cell }) => {
+ // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용
+ if (cfg.id === "status") {
+ const statusVal = row.original.status as StatusType;
+ if (!statusVal) return null;
+ // Status badge variant mapping - 더 뚜렷한 색상으로 변경
+ const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => {
+ switch (status) {
+ case "PENDING_REVIEW":
+ return {
+ variant: "outline",
+ className: "bg-yellow-100 text-yellow-800 border-yellow-300",
+ iconColor: "text-yellow-600"
+ };
+ case "IN_REVIEW":
+ return {
+ variant: "outline",
+ className: "bg-blue-100 text-blue-800 border-blue-300",
+ iconColor: "text-blue-600"
+ };
+ case "REJECTED":
+ return {
+ variant: "outline",
+ className: "bg-red-100 text-red-800 border-red-300",
+ iconColor: "text-red-600"
+ };
+ case "IN_PQ":
+ return {
+ variant: "outline",
+ className: "bg-purple-100 text-purple-800 border-purple-300",
+ iconColor: "text-purple-600"
+ };
+ case "PQ_SUBMITTED":
+ return {
+ variant: "outline",
+ className: "bg-indigo-100 text-indigo-800 border-indigo-300",
+ iconColor: "text-indigo-600"
+ };
+ case "PQ_FAILED":
+ return {
+ variant: "outline",
+ className: "bg-red-100 text-red-800 border-red-300",
+ iconColor: "text-red-600"
+ };
+ case "PQ_APPROVED":
+ return {
+ variant: "outline",
+ className: "bg-green-100 text-green-800 border-green-300",
+ iconColor: "text-green-600"
+ };
+ case "APPROVED":
+ return {
+ variant: "outline",
+ className: "bg-green-100 text-green-800 border-green-300",
+ iconColor: "text-green-600"
+ };
+ case "READY_TO_SEND":
+ return {
+ variant: "outline",
+ className: "bg-emerald-100 text-emerald-800 border-emerald-300",
+ iconColor: "text-emerald-600"
+ };
+ case "ACTIVE":
+ return {
+ variant: "outline",
+ className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold",
+ iconColor: "text-emerald-600"
+ };
+ case "INACTIVE":
+ return {
+ variant: "outline",
+ className: "bg-gray-100 text-gray-800 border-gray-300",
+ iconColor: "text-gray-600"
+ };
+ case "BLACKLISTED":
+ return {
+ variant: "outline",
+ className: "bg-slate-800 text-white border-slate-900",
+ iconColor: "text-white"
+ };
+ default:
+ return {
+ variant: "outline",
+ className: "bg-gray-100 text-gray-800 border-gray-300",
+ iconColor: "text-gray-600"
+ };
+ }
+ };
+
+ // Translate status for display
+ const getStatusDisplay = (status: StatusType): string => {
+ const statusMap: StatusDisplayMap = {
+ "PENDING_REVIEW": "가입 신청 중",
+ "IN_REVIEW": "심사 중",
+ "REJECTED": "심사 거부됨",
+ "IN_PQ": "PQ 진행 중",
+ "PQ_SUBMITTED": "PQ 제출",
+ "PQ_FAILED": "PQ 실패",
+ "PQ_APPROVED": "PQ 통과",
+ "APPROVED": "승인됨",
+ "READY_TO_SEND": "MDG 송부대기",
+ "ACTIVE": "활성 상태",
+ "INACTIVE": "비활성 상태",
+ "BLACKLISTED": "거래 금지"
+ };
+
+ return statusMap[status] || status;
+ };
+
+ const config = getStatusConfig(statusVal);
+ const displayText = getStatusDisplay(statusVal);
+ const StatusIcon = getVendorStatusIcon(statusVal);
- if (cfg.id === "status") {
- const statusVal = row.original.status
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
return (
- <div className="flex w-[6.25rem] items-center">
- {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */}
- <span className="capitalize">{statusVal}</span>
- </div>
- )
+ <Badge variant={config.variant} className={`flex items-center px-2 py-1 ${config.className}`}>
+ <StatusIcon className={`mr-1 h-3.5 w-3.5 ${config.iconColor}`} />
+ <span>{displayText}</span>
+ </Badge>
+ );
}
+ // 업체 유형 컬럼 처리
+ if (cfg.id === "vendorTypeName") {
+ const typeVal = row.original.vendorTypeName as string | null;
+ return typeVal ? (
+ <span className="text-sm font-medium">
+ {typeVal}
+ </span>
+ ) : (
+ <span className="text-sm text-gray-400">미지정</span>
+ );
+ }
+
+ // 업체 분류 컬럼 처리 (별도로 표시하고 싶은 경우)
+ if (cfg.id === "vendorCategory") {
+ const categoryVal = row.original.vendorCategory as string | null;
+ if (!categoryVal) return null;
+
+ let badgeClass = "";
+
+ if (categoryVal === "정규업체") {
+ badgeClass = "bg-green-50 text-green-700 border-green-200";
+ } else if (categoryVal === "잠재업체") {
+ badgeClass = "bg-blue-50 text-blue-700 border-blue-200";
+ }
+
+ return (
+ <Badge variant="outline" className={badgeClass}>
+ {categoryVal}
+ </Badge>
+ );
+ }
if (cfg.id === "createdAt") {
const dateVal = cell.getValue() as Date
@@ -222,10 +372,10 @@ const actionsColumn: ColumnDef<Vendor> = {
return formatDate(dateVal)
}
-
// code etc...
return row.getValue(cfg.id) ?? ""
},
+ minSize: 150
}
groupMap[groupName].push(childCol)
@@ -234,7 +384,7 @@ const actionsColumn: ColumnDef<Vendor> = {
// ----------------------------------------------------------------
// 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
// ----------------------------------------------------------------
- const nestedColumns: ColumnDef<Vendor>[] = []
+ const nestedColumns: ColumnDef<VendorWithType>[] = []
// 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
// 여기서는 그냥 Object.entries 순서
@@ -252,34 +402,35 @@ const actionsColumn: ColumnDef<Vendor> = {
}
})
- const attachmentsColumn: ColumnDef<VendorWithAttachments> = {
+ // attachments 컬럼 타입 문제 해결을 위한 타입 단언
+ const attachmentsColumn: ColumnDef<VendorWithType> = {
id: "attachments",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="" />
),
cell: ({ row }) => {
// hasAttachments 및 attachmentsList 속성이 추가되었다고 가정
- const hasAttachments = row.original.hasAttachments;
- const attachmentsList = row.original.attachmentsList || [];
-
- if(hasAttachments){
+ const hasAttachments = (row.original as VendorWithAttachments).hasAttachments;
+ const attachmentsList = (row.original as VendorWithAttachments).attachmentsList || [];
- // 서버 액션을 사용하는 컴포넌트로 교체
- return (
- <AttachmentsButton
- vendorId={row.original.id}
- hasAttachments={hasAttachments}
- attachmentsList={attachmentsList}
- />
- );}{
- return null
+ if (hasAttachments) {
+ // 서버 액션을 사용하는 컴포넌트로 교체
+ return (
+ <AttachmentsButton
+ vendorId={row.original.id}
+ hasAttachments={hasAttachments}
+ attachmentsList={attachmentsList}
+ />
+ );
+ } else {
+ return null;
}
},
enableSorting: false,
enableHiding: false,
minSize: 45,
};
-
+
// ----------------------------------------------------------------
// 4) 최종 컬럼 배열: select, nestedColumns, actions
diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx
index 3cb2c552..1c788911 100644
--- a/lib/vendors/table/vendors-table-toolbar-actions.tsx
+++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, Upload, Check, BuildingIcon } from "lucide-react"
+import { Download, FileSpreadsheet, Upload, Check, BuildingIcon, FileText } from "lucide-react"
import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
@@ -11,25 +11,29 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
-import { Vendor } from "@/db/schema/vendors"
+import { VendorWithType } 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"
+import { RequestContractDialog } from "./request-basicContract-dialog"
+import { exportVendorsWithRelatedData } from "./vendor-all-export"
interface VendorsTableToolbarActionsProps {
- table: Table<Vendor>
+ table: Table<VendorWithType>
}
export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
+ const [isExporting, setIsExporting] = React.useState(false);
// 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
const fileInputRef = React.useRef<HTMLInputElement>(null)
- // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링
+ // 선택된 협력업체 중 PENDING_REVIEW 상태인 협력업체만 필터링
const pendingReviewVendors = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -38,7 +42,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
.filter(vendor => vendor.status === "PENDING_REVIEW");
}, [table.getFilteredSelectedRowModel().rows]);
- // 선택된 벤더 중 IN_REVIEW 상태인 벤더만 필터링
+ // 선택된 협력업체 중 IN_REVIEW 상태인 협력업체만 필터링
const inReviewVendors = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -71,7 +75,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
.filter(vendor => vendor.status === "PQ_APPROVED");
}, [table.getFilteredSelectedRowModel().rows]);
- // 프로젝트 PQ를 보낼 수 있는 벤더 상태 필터링
+ // 프로젝트 PQ를 보낼 수 있는 협력업체 상태 필터링
const projectPQEligibleVendors = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -81,10 +85,66 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
["PENDING_REVIEW", "IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status)
);
}, [table.getFilteredSelectedRowModel().rows]);
+
+ // 선택된 모든 벤더 가져오기
+ const selectedVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original);
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ // 테이블의 모든 벤더 가져오기 (필터링된 결과)
+ const allFilteredVendors = React.useMemo(() => {
+ return table
+ .getFilteredRowModel()
+ .rows
+ .map(row => row.original);
+ }, [table.getFilteredRowModel().rows]);
+
+ // 선택된 벤더 통합 내보내기 함수 실행
+ const handleSelectedExport = async () => {
+ if (selectedVendors.length === 0) {
+ toast.warning("내보낼 협력업체를 선택해주세요.");
+ return;
+ }
+
+ try {
+ setIsExporting(true);
+ toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`);
+ await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed");
+ toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
+ } catch (error) {
+ console.error("상세 정보 내보내기 오류:", error);
+ toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ // 모든 벤더 통합 내보내기 함수 실행
+ const handleAllFilteredExport = async () => {
+ if (allFilteredVendors.length === 0) {
+ toast.warning("내보낼 협력업체가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsExporting(true);
+ toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`);
+ await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed");
+ toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
+ } catch (error) {
+ console.error("상세 정보 내보내기 오류:", error);
+ toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
+ } finally {
+ setIsExporting(false);
+ }
+ };
return (
<div className="flex items-center gap-2">
- {/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */}
+ {/* 승인 다이얼로그: PENDING_REVIEW 상태인 협력업체가 있을 때만 표시 */}
{pendingReviewVendors.length > 0 && (
<ApproveVendorsDialog
vendors={pendingReviewVendors}
@@ -92,7 +152,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
/>
)}
- {/* 일반 PQ 요청: IN_REVIEW 상태인 벤더가 있을 때만 표시 */}
+ {/* 일반 PQ 요청: IN_REVIEW 상태인 협력업체가 있을 때만 표시 */}
{inReviewVendors.length > 0 && (
<RequestPQVendorsDialog
vendors={inReviewVendors}
@@ -100,7 +160,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
/>
)}
- {/* 프로젝트 PQ 요청: 적격 상태의 벤더가 있을 때만 표시 */}
+ {/* 프로젝트 PQ 요청: 적격 상태의 협력업체가 있을 때만 표시 */}
{projectPQEligibleVendors.length > 0 && (
<RequestProjectPQDialog
vendors={projectPQEligibleVendors}
@@ -109,13 +169,13 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
)}
{approvedVendors.length > 0 && (
- <RequestInfoDialog
+ <RequestContractDialog
vendors={approvedVendors}
onSuccess={() => table.toggleAllRowsSelected(false)}
/>
)}
- {sendVendors.length > 0 && (
+ {pqApprovedVendors.length > 0 && (
<RequestInfoDialog
vendors={sendVendors}
onSuccess={() => table.toggleAllRowsSelected(false)}
@@ -129,21 +189,63 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
/>
)}
- {/** 4) Export 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "vendors",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
+ {/* Export 드롭다운 메뉴로 변경 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ disabled={isExporting}
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isExporting ? "내보내는 중..." : "Export"}
+ </span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */}
+ <DropdownMenuItem
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "vendors",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ disabled={isExporting}
+ >
+ <FileText className="mr-2 size-4" />
+ <span>현재 테이블 데이터 내보내기</span>
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ {/* 선택된 벤더만 상세 내보내기 */}
+ <DropdownMenuItem
+ onClick={handleSelectedExport}
+ disabled={selectedVendors.length === 0 || isExporting}
+ >
+ <FileSpreadsheet className="mr-2 size-4" />
+ <span>선택한 업체 상세 정보 내보내기</span>
+ {selectedVendors.length > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span>
+ )}
+ </DropdownMenuItem>
+
+ {/* 모든 필터링된 벤더 상세 내보내기 */}
+ <DropdownMenuItem
+ onClick={handleAllFilteredExport}
+ disabled={allFilteredVendors.length === 0 || isExporting}
+ >
+ <Download className="mr-2 size-4" />
+ <span>모든 업체 상세 정보 내보내기</span>
+ {allFilteredVendors.length > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span>
+ )}
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
</div>
)
} \ No newline at end of file
diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx
index 36fd45bd..02768f32 100644
--- a/lib/vendors/table/vendors-table.tsx
+++ b/lib/vendors/table/vendors-table.tsx
@@ -8,19 +8,18 @@ import type {
DataTableRowAction,
} from "@/types/table"
-import { toSentenceCase } from "@/lib/utils"
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { useFeatureFlags } from "./feature-flags-provider"
import { getColumns } from "./vendors-table-columns"
import { getVendors, getVendorStatusCounts } from "../service"
-import { Vendor, vendors } from "@/db/schema/vendors"
+import { VendorWithType, vendors } from "@/db/schema/vendors"
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"
+import { ViewVendorLogsDialog } from "./view-vendors_logs-dialog"
+import { useSession } from "next-auth/react"
interface VendorsTableProps {
promises: Promise<
@@ -32,58 +31,83 @@ interface VendorsTableProps {
}
export function VendorsTable({ promises }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
-
+ const { data: session } = useSession()
+ const userId = Number(session?.user.id)
+
// Suspense로 받아온 데이터
const [{ data, pageCount }, statusCounts] = React.use(promises)
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null)
-
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithType> | null>(null)
+
// **router** 획득
const router = useRouter()
-
+
// getColumns() 호출 시, router를 주입
const columns = React.useMemo(
- () => getColumns({ setRowAction, router }),
- [setRowAction, router]
+ () => getColumns({ setRowAction, router , userId}),
+ [setRowAction, router, userId]
)
-
- const filterFields: DataTableFilterField<Vendor>[] = [
+
+ // 상태 한글 변환 유틸리티 함수
+ const getStatusDisplay = (status: string): string => {
+ const statusMap: Record<string, string> = {
+ "PENDING_REVIEW": "가입 신청 중",
+ "IN_REVIEW": "심사 중",
+ "REJECTED": "심사 거부됨",
+ "IN_PQ": "PQ 진행 중",
+ "PQ_SUBMITTED": "PQ 제출",
+ "PQ_FAILED": "PQ 실패",
+ "PQ_APPROVED": "PQ 통과",
+ "APPROVED": "승인됨",
+ "READY_TO_SEND": "MDG 송부대기",
+ "ACTIVE": "활성 상태",
+ "INACTIVE": "비활성 상태",
+ "BLACKLISTED": "거래 금지"
+ };
+
+ return statusMap[status] || status;
+ };
+
+ const filterFields: DataTableFilterField<VendorWithType>[] = [
{
id: "status",
- label: "Status",
+ label: "상태",
options: vendors.status.enumValues.map((status) => ({
- label: toSentenceCase(status),
+ label: getStatusDisplay(status),
value: status,
count: statusCounts[status],
})),
},
-
- { id: "vendorCode", label: "Vendor Code" },
-
+
+ { id: "vendorCode", label: "업체 코드" },
]
-
- const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [
- { id: "vendorName", label: "Vendor Name", type: "text" },
- { id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "email", label: "Email", type: "text" },
- { id: "country", label: "Country", type: "text" },
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorWithType>[] = [
+ { id: "vendorName", label: "업체명", type: "text" },
+ { id: "vendorCode", label: "업체코드", type: "text" },
+ { id: "email", label: "이메일", type: "text" },
+ { id: "country", label: "국가", type: "text" },
+ { id: "vendorTypeName", label: "업체 유형", type: "text" },
+ { id: "vendorCategory", label: "업체 분류", type: "select", options: [
+ { label: "정규업체", value: "정규업체" },
+ { label: "잠재업체", value: "잠재업체" },
+ ]},
{
id: "status",
- label: "Status",
+ label: "업체승인상태",
type: "multi-select",
options: vendors.status.enumValues.map((status) => ({
- label: (status),
+ label: getStatusDisplay(status),
value: status,
count: statusCounts[status],
icon: getVendorStatusIcon(status),
-
})),
},
- { id: "createdAt", label: "Created at", type: "date" },
- { id: "updatedAt", label: "Updated at", type: "date" },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
]
-
+
const { table } = useDataTable({
data,
columns,
@@ -100,16 +124,25 @@ export function VendorsTable({ promises }: VendorsTableProps) {
clearOnDefault: true,
})
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+
return (
<>
<DataTable
table={table}
+ compact={isCompact}
// floatingBar={<VendorsTableFloatingBar table={table} />}
>
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="vendorsTableCompact"
+ onCompactChange={handleCompactChange}
>
<VendorsTableToolbarActions table={table} />
</DataTableAdvancedToolbar>
@@ -119,6 +152,12 @@ export function VendorsTable({ promises }: VendorsTableProps) {
onOpenChange={() => setRowAction(null)}
vendor={rowAction?.row.original ?? null}
/>
+
+ <ViewVendorLogsDialog
+ open={rowAction?.type === "log"}
+ onOpenChange={() => setRowAction(null)}
+ vendorId={rowAction?.row.original?.id ?? null}
+ />
</>
)
} \ No newline at end of file
diff --git a/lib/vendors/table/view-vendors_logs-dialog.tsx b/lib/vendors/table/view-vendors_logs-dialog.tsx
new file mode 100644
index 00000000..7402ae55
--- /dev/null
+++ b/lib/vendors/table/view-vendors_logs-dialog.tsx
@@ -0,0 +1,244 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { formatDateTime } from "@/lib/utils"
+import { useToast } from "@/hooks/use-toast"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Badge } from "@/components/ui/badge"
+import { Download, Search, User } from "lucide-react"
+import { VendorsLogWithUser, getVendorLogs } from "../service"
+
+interface VendorLogsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorId: number | null
+}
+
+export function ViewVendorLogsDialog({
+ open,
+ onOpenChange,
+ vendorId,
+}: VendorLogsDialogProps) {
+ const [logs, setLogs] = React.useState<VendorsLogWithUser[]>([])
+ const [filteredLogs, setFilteredLogs] = React.useState<VendorsLogWithUser[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [error, setError] = React.useState<string | null>(null)
+ const [searchQuery, setSearchQuery] = React.useState("")
+ const [actionFilter, setActionFilter] = React.useState<string>("all")
+ const { toast } = useToast()
+
+ // Get unique action types for filter dropdown
+ const actionTypes = React.useMemo(() => {
+ if (!logs.length) return []
+ return Array.from(new Set(logs.map(log => log.action)))
+ }, [logs])
+
+ // Fetch logs when dialog opens
+ React.useEffect(() => {
+ if (open && vendorId) {
+ setLoading(true)
+ setError(null)
+ getVendorLogs(vendorId)
+ .then((res) => {
+ setLogs(res)
+ setFilteredLogs(res)
+ })
+ .catch((err) => {
+ console.error(err)
+ setError("Failed to load logs. Please try again.")
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to load candidate logs",
+ })
+ })
+ .finally(() => setLoading(false))
+ } else {
+ // Reset state when dialog closes
+ setSearchQuery("")
+ setActionFilter("all")
+ }
+ }, [open, vendorId, toast])
+
+ // Filter logs based on search query and action filter
+ React.useEffect(() => {
+ if (!logs.length) return
+
+ let result = [...logs]
+
+ // Apply action filter
+ if (actionFilter !== "all") {
+ result = result.filter(log => log.action === actionFilter)
+ }
+
+ // Apply search filter (case insensitive)
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase()
+ result = result.filter(log =>
+ log.action.toLowerCase().includes(query) ||
+ (log.comment && log.comment.toLowerCase().includes(query)) ||
+ (log.oldStatus && log.oldStatus.toLowerCase().includes(query)) ||
+ (log.newStatus && log.newStatus.toLowerCase().includes(query)) ||
+ (log.userName && log.userName.toLowerCase().includes(query)) ||
+ (log.userEmail && log.userEmail.toLowerCase().includes(query))
+ )
+ }
+
+ setFilteredLogs(result)
+ }, [logs, searchQuery, actionFilter])
+
+ // Export logs as CSV
+ const exportLogs = () => {
+ if (!filteredLogs.length) return
+
+ const headers = ["Action", "Old Status", "New Status", "Comment", "User", "Email", "Date"]
+ const csvContent = [
+ headers.join(","),
+ ...filteredLogs.map(log => [
+ `"${log.action}"`,
+ `"${log.oldStatus || ''}"`,
+ `"${log.newStatus || ''}"`,
+ `"${log.comment?.replace(/"/g, '""') || ''}"`,
+ `"${log.userName || ''}"`,
+ `"${log.userEmail || ''}"`,
+ `"${formatDateTime(log.createdAt)}"`
+ ].join(","))
+ ].join("\n")
+
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.setAttribute("href", url)
+ link.setAttribute("download", `vendor-logs-${vendorId}-${new Date().toISOString().split('T')[0]}.csv`)
+ link.style.visibility = "hidden"
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ }
+
+ // Render status change with appropriate badge
+ const renderStatusChange = (oldStatus: string, newStatus: string) => {
+ return (
+ <div className="text-sm flex flex-wrap gap-2 items-center">
+ <strong>Status:</strong>
+ <Badge variant="outline" className="text-xs">{oldStatus}</Badge>
+ <span>→</span>
+ <Badge variant="outline" className="bg-primary/10 text-xs">{newStatus}</Badge>
+ </div>
+ )
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[700px]">
+ <DialogHeader>
+ <DialogTitle>Audit Logs</DialogTitle>
+ </DialogHeader>
+
+ {/* Filters and search */}
+ <div className="flex items-center gap-2 mb-4">
+ <div className="relative flex-1">
+ <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
+ <Search className="h-4 w-4 text-muted-foreground" />
+ </div>
+ <Input
+ placeholder="Search logs..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+
+ <Select
+ value={actionFilter}
+ onValueChange={setActionFilter}
+ >
+ <SelectTrigger className="w-[180px]">
+ <SelectValue placeholder="Filter by action" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All actions</SelectItem>
+ {actionTypes.map(action => (
+ <SelectItem key={action} value={action}>{action}</SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ <Button
+ size="icon"
+ variant="outline"
+ onClick={exportLogs}
+ disabled={filteredLogs.length === 0}
+ title="Export to CSV"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+
+ <div className="space-y-2">
+ {loading && (
+ <div className="flex justify-center py-8">
+ <p className="text-muted-foreground">Loading logs...</p>
+ </div>
+ )}
+
+ {error && !loading && (
+ <div className="bg-destructive/10 text-destructive p-3 rounded-md">
+ {error}
+ </div>
+ )}
+
+ {!loading && !error && filteredLogs.length === 0 && (
+ <p className="text-muted-foreground text-center py-8">
+ {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."}
+ </p>
+ )}
+
+ {!loading && !error && filteredLogs.length > 0 && (
+ <>
+ <div className="text-xs text-muted-foreground mb-2">
+ Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'}
+ {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`}
+ </div>
+ <div className="max-h-96 space-y-4 pr-4 overflow-y-auto">
+ {filteredLogs.map((log) => (
+ <div key={log.id} className="rounded-md border p-4 mb-3 hover:bg-muted/50 transition-colors">
+ <div className="flex justify-between items-start mb-2">
+ <Badge className="text-xs">{log.action}</Badge>
+ <div className="text-xs text-muted-foreground">
+ {formatDateTime(log.createdAt)}
+ </div>
+ </div>
+
+ {log.oldStatus && log.newStatus && (
+ <div className="my-2">
+ {renderStatusChange(log.oldStatus, log.newStatus)}
+ </div>
+ )}
+
+ {log.comment && (
+ <div className="my-2 text-sm bg-muted/50 p-2 rounded-md">
+ <strong>Comment:</strong> {log.comment}
+ </div>
+ )}
+
+ {(log.userName || log.userEmail) && (
+ <div className="mt-3 pt-2 border-t flex items-center text-xs text-muted-foreground">
+ <User className="h-3 w-3 mr-1" />
+ {log.userName || "Unknown"}
+ {log.userEmail && <span className="ml-1">({log.userEmail})</span>}
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ </>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts
index 1c08f8ff..e01fa8b9 100644
--- a/lib/vendors/validations.ts
+++ b/lib/vendors/validations.ts
@@ -8,7 +8,7 @@ import {
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors } from "@/db/schema/vendors";
+import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors, VendorWithType } from "@/db/schema/vendors";
import { rfqs } from "@/db/schema/rfq"
@@ -24,7 +24,7 @@ export const searchParamsCache = createSearchParamsCache({
perPage: parseAsInteger.withDefault(10),
// 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정)
- sort: getSortingStateParser<Vendor>().withDefault([
+ sort: getSortingStateParser<VendorWithType>().withDefault([
{ id: "createdAt", desc: true }, // createdAt 기준 내림차순
]),
@@ -36,12 +36,12 @@ export const searchParamsCache = createSearchParamsCache({
search: parseAsString.withDefault(""),
// -----------------------------------------------------------------
- // 여기부터는 "벤더"에 특화된 검색 필드 예시
+ // 여기부터는 "협력업체"에 특화된 검색 필드 예시
// -----------------------------------------------------------------
// 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택
status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED"]),
- // 벤더명 검색
+ // 협력업체명 검색
vendorName: parseAsString.withDefault(""),
// 국가 검색
@@ -114,21 +114,32 @@ export const searchParamsItemCache = createSearchParamsCache({
description: parseAsString.withDefault(""),
});
+const creditAgencyEnum = z.enum(["NICE", "KIS", "KED", "SCI"]);
+export type CreditAgencyType = z.infer<typeof creditAgencyEnum>;
+
export const updateVendorSchema = z.object({
- vendorName: z.string().min(1, "Vendor name is required").max(255, "Max length 255").optional(),
- vendorCode: z.string().max(100, "Max length 100").optional(),
+ vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"),
+ vendorCode: z.string().optional(),
address: z.string().optional(),
- country: z.string().max(100, "Max length 100").optional(),
- phone: z.string().max(50, "Max length 50").optional(),
- email: z.string().email("Invalid email").max(255).optional(),
- website: z.string().url("Invalid URL").max(255).optional(),
-
- // status는 특정 값만 허용하도록 enum 사용 예시
- // 필요 시 'SUSPENDED', 'BLACKLISTED' 등 추가하거나 제거 가능
- status: z.enum(vendors.status.enumValues)
- .optional()
- .default("ACTIVE"),
+ country: z.string().optional(),
+ phone: z.string().optional(),
+ email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(),
+ website: z.string().url("유효한 URL을 입력해주세요").optional(),
+ status: z.enum(vendors.status.enumValues).optional(),
+ vendorTypeId: z.number().optional(),
+
+ // Optional fields for buyer information
+ buyerName: z.string().optional(),
+ buyerDepartment: z.string().optional(),
+ contractStartDate: z.date().optional(),
+ contractEndDate: z.date().optional(),
+ internalNotes: z.string().optional(),
+ creditRating: z.string().optional(),
+ cashFlowRating: z.string().optional(),
+ creditAgency: creditAgencyEnum.optional(),
+
+ // evaluationScore: z.string().optional(),
});
@@ -151,9 +162,9 @@ export const createVendorSchema = z
.string()
.min(1, "Vendor name is required")
.max(255, "Max length 255"),
- email: z.string().email("Invalid email").max(255),
- taxId: z.string().max(100, "Max length 100"),
+ vendorTypeId: z.number({ required_error: "업체유형을 선택해주세요" }),
+ email: z.string().email("Invalid email").max(255),
// 나머지 optional
vendorCode: z.string().max(100, "Max length 100").optional(),
address: z.string().optional(),
@@ -163,8 +174,6 @@ export const createVendorSchema = z
phone: z.string().max(50, "Max length 50").optional(),
website: z.string().url("Invalid URL").max(255).optional(),
- creditRatingAttachment: z.any().optional(), // 신용평가 첨부
- cashFlowRatingAttachment: z.any().optional(), // 현금흐름 첨부
attachedFiles: z.any()
.refine(
val => {
@@ -183,10 +192,9 @@ export const createVendorSchema = z
representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(),
representativePhone: z.union([z.string().max(50), z.literal("")]).optional(),
corporateRegistrationNumber: z.union([z.string().max(100), z.literal("")]).optional(),
+ taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }),
- creditAgency: z.string().max(50).optional(),
- creditRating: z.string().max(50).optional(),
- cashFlowRating: z.string().max(50).optional(),
+ items: z.string().min(1, { message: "공급품목을 입력해주세요" }),
contacts: z
.array(contactSchema)
@@ -233,28 +241,7 @@ export const createVendorSchema = z
})
}
- // 2) 신용/현금흐름 등급도 필수라면
- if (!data.creditAgency) {
- ctx.addIssue({
- code: "custom",
- path: ["creditAgency"],
- message: "신용평가사 선택은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
- if (!data.creditRating) {
- ctx.addIssue({
- code: "custom",
- path: ["creditRating"],
- message: "신용평가등급은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
- if (!data.cashFlowRating) {
- ctx.addIssue({
- code: "custom",
- path: ["cashFlowRating"],
- message: "현금흐름등급은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
+
}
}
)
@@ -349,7 +336,7 @@ export const updateVendorInfoSchema = z.object({
phone: z.string().optional(),
email: z.string().email("유효한 이메일을 입력해 주세요."),
website: z.string().optional(),
-
+
// 한국 사업자 정보 (KR일 경우 필수 항목들)
representativeName: z.string().optional(),
representativeBirth: z.string().optional(),