summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/forms/services.ts4
-rw-r--r--lib/mail/templates/vendor-additional-info.hbs76
-rw-r--r--lib/mail/templates/vendor-invitation.hbs86
-rw-r--r--lib/pq/service.ts1172
-rw-r--r--lib/pq/table/add-pq-dialog.tsx431
-rw-r--r--lib/pq/table/import-pq-button.tsx258
-rw-r--r--lib/pq/table/import-pq-handler.tsx146
-rw-r--r--lib/pq/table/pq-excel-template.tsx205
-rw-r--r--lib/pq/table/pq-table-toolbar-actions.tsx86
-rw-r--r--lib/pq/table/pq-table.tsx4
-rw-r--r--lib/rfqs/table/ItemsDialog.tsx112
-rw-r--r--lib/rfqs/table/add-rfq-dialog.tsx6
-rw-r--r--lib/tags/service.ts153
-rw-r--r--lib/tags/table/add-tag-dialog.tsx5
-rw-r--r--lib/tasks/utils.ts13
-rw-r--r--lib/utils.ts7
-rw-r--r--lib/vendor-candidates/service.ts360
-rw-r--r--lib/vendor-candidates/table/add-candidates-dialog.tsx327
-rw-r--r--lib/vendor-candidates/table/candidates-table-columns.tsx193
-rw-r--r--lib/vendor-candidates/table/candidates-table-floating-bar.tsx337
-rw-r--r--lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx93
-rw-r--r--lib/vendor-candidates/table/candidates-table.tsx173
-rw-r--r--lib/vendor-candidates/table/delete-candidates-dialog.tsx149
-rw-r--r--lib/vendor-candidates/table/excel-template-download.tsx94
-rw-r--r--lib/vendor-candidates/table/feature-flags-provider.tsx108
-rw-r--r--lib/vendor-candidates/table/feature-flags.tsx96
-rw-r--r--lib/vendor-candidates/table/import-button.tsx211
-rw-r--r--lib/vendor-candidates/table/invite-candidates-dialog.tsx150
-rw-r--r--lib/vendor-candidates/table/update-candidate-sheet.tsx339
-rw-r--r--lib/vendor-candidates/utils.ts40
-rw-r--r--lib/vendor-candidates/validations.ts84
-rw-r--r--lib/vendor-document/service.ts1
-rw-r--r--lib/vendor-investigation/service.ts229
-rw-r--r--lib/vendor-investigation/table/feature-flags-provider.tsx108
-rw-r--r--lib/vendor-investigation/table/investigation-table-columns.tsx251
-rw-r--r--lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx41
-rw-r--r--lib/vendor-investigation/table/investigation-table.tsx133
-rw-r--r--lib/vendor-investigation/table/update-investigation-sheet.tsx324
-rw-r--r--lib/vendor-investigation/validations.ts93
-rw-r--r--lib/vendors/service.ts551
-rw-r--r--lib/vendors/table/attachmentButton.tsx45
-rw-r--r--lib/vendors/table/request-additional-Info-dialog.tsx152
-rw-r--r--lib/vendors/table/request-project-pq-dialog.tsx242
-rw-r--r--lib/vendors/table/request-vendor-investigate-dialog.tsx152
-rw-r--r--lib/vendors/table/send-vendor-dialog.tsx4
-rw-r--r--lib/vendors/table/vendors-table-columns.tsx158
-rw-r--r--lib/vendors/table/vendors-table-toolbar-actions.tsx86
-rw-r--r--lib/vendors/table/vendors-table.tsx5
-rw-r--r--lib/vendors/utils.ts48
-rw-r--r--lib/vendors/validations.ts103
50 files changed, 7505 insertions, 739 deletions
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
index 22f10466..e3a8b2b2 100644
--- a/lib/forms/services.ts
+++ b/lib/forms/services.ts
@@ -29,6 +29,7 @@ export interface FormInfo {
}
export async function getFormsByContractItemId(contractItemId: number | null) {
+
// 유효성 검사
if (!contractItemId || contractItemId <= 0) {
console.warn(`Invalid contractItemId: ${contractItemId}`);
@@ -40,7 +41,10 @@ export async function getFormsByContractItemId(contractItemId: number | null) {
try {
return unstable_cache(
+
async () => {
+ console.log(contractItemId,"contractItemId")
+
console.log(
`[Forms Service] Fetching forms for contractItemId: ${contractItemId}`
);
diff --git a/lib/mail/templates/vendor-additional-info.hbs b/lib/mail/templates/vendor-additional-info.hbs
new file mode 100644
index 00000000..9d93bb7b
--- /dev/null
+++ b/lib/mail/templates/vendor-additional-info.hbs
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>{{t "email.additionalInfo.title"}}</title>
+ <style>
+ body {
+ font-family: Arial, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+ .header {
+ background-color: #0056b3;
+ color: white;
+ padding: 20px;
+ text-align: center;
+ border-radius: 5px 5px 0 0;
+ }
+ .content {
+ padding: 20px;
+ border: 1px solid #ddd;
+ border-top: none;
+ border-radius: 0 0 5px 5px;
+ }
+ .button {
+ background-color: #0056b3;
+ color: white;
+ padding: 12px 20px;
+ text-decoration: none;
+ border-radius: 5px;
+ display: inline-block;
+ margin-top: 15px;
+ font-weight: bold;
+ }
+ .footer {
+ margin-top: 30px;
+ text-align: center;
+ font-size: 0.8em;
+ color: #777;
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <h1>{{t "email.additionalInfo.header"}}</h1>
+ </div>
+
+ <div class="content">
+ <p>{{t "email.additionalInfo.greeting" vendorName=vendorName}}</p>
+
+ <p>{{t "email.additionalInfo.messageP1"}}</p>
+
+ <p>{{t "email.additionalInfo.messageP2"}}</p>
+
+ <p>{{t "email.additionalInfo.messageP3"}}</p>
+
+ <div style="text-align: center;">
+ <a href="{{vendorInfoUrl}}" class="button">{{t "email.additionalInfo.buttonText"}}</a>
+ </div>
+
+ <p>{{t "email.additionalInfo.messageP4"}}</p>
+
+ <p>{{t "email.additionalInfo.closing"}}</p>
+
+ <p>EVCP Team</p>
+ </div>
+
+ <div class="footer">
+ <p>© {{currentYear}} EVCP. {{t "email.additionalInfo.footerText"}}</p>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/vendor-invitation.hbs b/lib/mail/templates/vendor-invitation.hbs
new file mode 100644
index 00000000..d85067f4
--- /dev/null
+++ b/lib/mail/templates/vendor-invitation.hbs
@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Vendor Registration Invitation</title>
+ <style>
+ body {
+ font-family: Arial, sans-serif;
+ line-height: 1.6;
+ color: #333333;
+ margin: 0;
+ padding: 0;
+ }
+ .container {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+ .header {
+ background-color: #2563EB;
+ padding: 20px;
+ text-align: center;
+ color: white;
+ }
+ .content {
+ padding: 20px;
+ background-color: #ffffff;
+ }
+ .footer {
+ padding: 20px;
+ text-align: center;
+ font-size: 12px;
+ color: #666666;
+ background-color: #f5f5f5;
+ }
+ .button {
+ display: inline-block;
+ background-color: #2563EB;
+ color: white;
+ padding: 12px 24px;
+ text-decoration: none;
+ border-radius: 4px;
+ margin: 20px 0;
+ font-weight: bold;
+ }
+ .highlight {
+ background-color: #f8f9fa;
+ padding: 15px;
+ border-left: 4px solid #2563EB;
+ margin: 20px 0;
+ }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <div class="header">
+ <h1>{{t "email.vendor.invitation.title"}}</h1>
+ </div>
+ <div class="content">
+ <p>{{t "email.vendor.invitation.greeting"}} {{companyName}},</p>
+
+ <p>{{t "email.vendor.invitation.message"}}</p>
+
+ <div class="highlight">
+ <p>{{t "email.vendor.invitation.details"}}</p>
+ </div>
+
+ <div style="text-align: center;">
+ <a href="{{registrationLink}}" class="button">{{t "email.vendor.invitation.register_now"}}</a>
+ </div>
+
+ <p>{{t "email.vendor.invitation.expire_notice"}}</p>
+
+ <p>{{t "email.vendor.invitation.footer"}}</p>
+
+ <p>{{t "email.vendor.invitation.signature"}}<br>
+ EVCP {{t "email.vendor.invitation.team"}}</p>
+ </div>
+ <div class="footer">
+ <p>© {{currentYear}} EVCP. {{t "email.vendor.invitation.copyright"}}</p>
+ <p>{{t "email.vendor.invitation.no_reply"}}</p>
+ </div>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index a1373dae..6906ff52 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -5,10 +5,10 @@ import { GetPQSchema } from "./validations"
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
import { getErrorMessage } from "@/lib/handle-error";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count} from "drizzle-orm";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count,isNull,SQL} from "drizzle-orm";
import { z } from "zod"
import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache";
-import { pqCriterias, vendorCriteriaAttachments, vendorPqCriteriaAnswers, vendorPqReviewLogs } from "@/db/schema/pq"
+import { pqCriterias, pqCriteriasExtension, vendorCriteriaAttachments, vendorPqCriteriaAnswers, vendorPqReviewLogs, vendorProjectPQs } from "@/db/schema/pq"
import { countPqs, selectPqs } from "./repository";
import { sendEmail } from "../mail/sendEmail";
import { vendorAttachments, vendors } from "@/db/schema/vendors";
@@ -18,63 +18,126 @@ import { randomUUID } from 'crypto';
import { writeFile, mkdir } from 'fs/promises';
import { GetVendorsSchema } from "../vendors/validations";
import { countVendors, selectVendors } from "../vendors/repository";
+import { projects } from "@/db/schema";
/**
* PQ 목록 조회
*/
-export async function getPQs(input: GetPQSchema) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // advancedTable 모드면 filterColumns()로 where 절 구성
- const advancedWhere = filterColumns({
- table: pqCriterias,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(ilike(pqCriterias.code, s), ilike(pqCriterias.groupName, s), ilike(pqCriterias.remarks, s), ilike(pqCriterias.checkPoint, s), ilike(pqCriterias.description, s)
- )
- }
-
- const finalWhere = and(advancedWhere, globalWhere);
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(pqCriterias[item.id]) : asc(pqCriterias[item.id])
- )
- : [asc(pqCriterias.createdAt)];
-
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectPqs(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
- const total = await countPqs(tx, finalWhere);
- return { data, total };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
-
- return { data, pageCount };
- } catch (err) {
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)],
- {
- revalidate: 3600,
- tags: [`pq`],
- }
- )();
+export async function getPQs(
+ input: GetPQSchema,
+ projectId?: number | null,
+ onlyGeneral?: boolean
+) {
+ return unstable_cache(
+ async () => {
+ try {
+ // Common query building logic extracted to a helper function
+ const buildBaseQuery = (queryBuilder: any) => {
+ let query = queryBuilder.from(pqCriterias);
+
+ // Handle join conditions based on parameters
+ if (projectId) {
+ query = query
+ .innerJoin(
+ pqCriteriasExtension,
+ eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId)
+ )
+ .where(eq(pqCriteriasExtension.projectId, projectId));
+ } else if (onlyGeneral) {
+ query = query
+ .leftJoin(
+ pqCriteriasExtension,
+ eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId)
+ )
+ .where(isNull(pqCriteriasExtension.id));
+ }
+
+ // Apply filters
+ const advancedWhere = filterColumns({
+ table: pqCriterias,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // Handle search
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(pqCriterias.code, s),
+ ilike(pqCriterias.groupName, s),
+ ilike(pqCriterias.remarks, s),
+ ilike(pqCriterias.checkPoint, s),
+ ilike(pqCriterias.description, s)
+ );
+ }
+
+ // Combine where clauses
+ const finalWhere = and(advancedWhere, globalWhere);
+ if (finalWhere) {
+ query = query.where(finalWhere);
+ }
+
+ return { query, finalWhere };
+ };
+
+ const offset = (input.page - 1) * input.perPage;
+
+ // Build sort order configuration
+ const orderBy = input.sort?.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(pqCriterias[item.id])
+ : asc(pqCriterias[item.id])
+ )
+ : [asc(pqCriterias.createdAt)];
+
+ // Execute in a transaction
+ const { data, total } = await db.transaction(async (tx) => {
+ // 변경: 쿼리 결과 형태를 변경하여 데이터가 평탄화되도록 수정
+ // Data query
+ const { query: baseQuery } = buildBaseQuery(tx.select({
+ id: pqCriterias.id,
+ code: pqCriterias.code,
+ checkPoint: pqCriterias.checkPoint,
+ description: pqCriterias.description,
+ remarks: pqCriterias.remarks,
+ groupName: pqCriterias.groupName,
+ createdAt: pqCriterias.createdAt,
+ updatedAt: pqCriterias.updatedAt,
+ // 필요한 경우 pqCriteriasExtension의 필드도 여기에 추가
+ }));
+
+ const data = await baseQuery
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(input.perPage);
+
+ // Count query - reusing the same base query logic
+ const { query: countQuery } = buildBaseQuery(tx.select({ count: count() }));
+ const countRes = await countQuery;
+ const total = countRes[0]?.count ?? 0;
+
+ return { data, total };
+ });
+
+ // Calculate page count
+ const pageCount = Math.ceil(total / input.perPage);
+
+ // 이미 평탄화된 객체 배열 형태로 반환됨
+ return { data, pageCount };
+ } catch (err) {
+ console.log('Error in getPQs:', err);
+ console.error('Error in getPQs:', err);
+ throw new Error('Failed to fetch PQ criteria');
+ }
+ },
+ [JSON.stringify(input), projectId?.toString() ?? 'undefined', onlyGeneral?.toString() ?? 'undefined'],
+ {
+ revalidate: 3600,
+ tags: ["pq"],
+ }
+ )();
}
// PQ 생성을 위한 입력 스키마 정의
@@ -86,19 +149,26 @@ const createPqSchema = z.object({
groupName: z.string().optional()
});
-export type CreatePqInputType = z.infer<typeof createPqSchema>;
+export interface CreatePqInputType extends z.infer<typeof createPqSchema> {
+ projectId?: number | null;
+ contractInfo?: string | null;
+ additionalRequirement?: string | null;
+}
/**
* PQ 기준 생성
*/
export async function createPq(input: CreatePqInputType) {
try {
- // 입력 유효성 검증
+ // 기본 데이터 유효성 검증
const validatedData = createPqSchema.parse(input);
-
+
+ // 프로젝트 정보 및 확장 필드 확인
+ const isProjectSpecific = !!input.projectId;
+
// 트랜잭션 사용하여 PQ 기준 생성
return await db.transaction(async (tx) => {
- // PQ 기준 생성
+ // 1. 기본 PQ 기준 생성
const [newPqCriteria] = await tx
.insert(pqCriterias)
.values({
@@ -109,12 +179,27 @@ export async function createPq(input: CreatePqInputType) {
groupName: validatedData.groupName || null,
})
.returning({ id: pqCriterias.id });
-
+
+ // 2. 프로젝트별 PQ인 경우 확장 테이블에도 데이터 추가
+ if (isProjectSpecific && input.projectId) {
+ await tx
+ .insert(pqCriteriasExtension)
+ .values({
+ pqCriteriaId: newPqCriteria.id,
+ projectId: input.projectId,
+ contractInfo: input.contractInfo || null,
+ additionalRequirement: input.additionalRequirement || null,
+ });
+ }
+
// 성공 결과 반환
- return {
- success: true,
+ return {
+ success: true,
pqId: newPqCriteria.id,
- message: "PQ criteria created successfully"
+ isProjectSpecific,
+ message: isProjectSpecific
+ ? "Project-specific PQ criteria created successfully"
+ : "General PQ criteria created successfully"
};
});
} catch (error) {
@@ -122,21 +207,20 @@ export async function createPq(input: CreatePqInputType) {
// Zod 유효성 검사 에러 처리
if (error instanceof z.ZodError) {
- return {
- success: false,
- message: "Validation failed",
- errors: error.errors
+ return {
+ success: false,
+ message: "Validation failed",
+ errors: error.errors
};
}
// 기타 에러 처리
- return {
- success: false,
- message: "Failed to create PQ criteria"
+ return {
+ success: false,
+ message: "Failed to create PQ criteria"
};
}
}
-
// PQ 캐시 무효화 함수
export async function invalidatePqCache() {
revalidatePath(`/evcp/pq-criteria`);
@@ -259,12 +343,16 @@ export interface PQAttachment {
}
export interface PQItem {
- answerId: number | null; // null도 허용하도록 변경
+ answerId: number | null
criteriaId: number
code: string
checkPoint: string
description: string | null
- answer: string // or null
+ remarks?: string | null
+ // 프로젝트 PQ 전용 필드
+ contractInfo?: string | null
+ additionalRequirement?: string | null
+ answer: string
attachments: PQAttachment[]
}
@@ -273,89 +361,176 @@ export interface PQGroupData {
items: PQItem[]
}
-
-export async function getPQDataByVendorId(vendorId: number): Promise<PQGroupData[]> {
- // 1) Query: pqCriterias
- // LEFT JOIN vendorPqCriteriaAnswers (to get `answer`)
- // LEFT JOIN vendorCriteriaAttachments (to get each attachment row)
- const rows = await db
+export interface ProjectPQ {
+ id: number;
+ projectId: number;
+ status: string;
+ submittedAt: Date | null;
+ projectCode: string;
+ projectName: string;
+}
+
+export async function getPQProjectsByVendorId(vendorId: number): Promise<ProjectPQ[]> {
+ const result = await db
.select({
+ id: vendorProjectPQs.id,
+ projectId: vendorProjectPQs.projectId,
+ status: vendorProjectPQs.status,
+ submittedAt: vendorProjectPQs.submittedAt,
+ projectCode: projects.code,
+ projectName: projects.name,
+ })
+ .from(vendorProjectPQs)
+ .innerJoin(
+ projects,
+ eq(vendorProjectPQs.projectId, projects.id)
+ )
+ .where(eq(vendorProjectPQs.vendorId, vendorId))
+ .orderBy(projects.code);
+
+ return result;
+}
+
+export async function getPQDataByVendorId(
+ vendorId: number,
+ projectId?: number
+): Promise<PQGroupData[]> {
+ try {
+ // 기본 쿼리 구성
+ const selectObj = {
criteriaId: pqCriterias.id,
groupName: pqCriterias.groupName,
code: pqCriterias.code,
checkPoint: pqCriterias.checkPoint,
description: pqCriterias.description,
-
- // From vendorPqCriteriaAnswers
- answer: vendorPqCriteriaAnswers.answer, // can be null if no row exists
- answerId: vendorPqCriteriaAnswers.id, // internal PK if needed
-
- // From vendorCriteriaAttachments
+ remarks: pqCriterias.remarks,
+
+ // 프로젝트 PQ 추가 필드
+ contractInfo: pqCriteriasExtension.contractInfo,
+ additionalRequirement: pqCriteriasExtension.additionalRequirement,
+
+ // 벤더 응답 필드
+ answer: vendorPqCriteriaAnswers.answer,
+ answerId: vendorPqCriteriaAnswers.id,
+
+ // 첨부 파일 필드
attachId: vendorCriteriaAttachments.id,
fileName: vendorCriteriaAttachments.fileName,
filePath: vendorCriteriaAttachments.filePath,
fileSize: vendorCriteriaAttachments.fileSize,
- })
- .from(pqCriterias)
- .leftJoin(
- vendorPqCriteriaAnswers,
- and(
- eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
- eq(vendorPqCriteriaAnswers.vendorId, vendorId)
- )
- )
- .leftJoin(
- vendorCriteriaAttachments,
- eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
- )
- .orderBy(pqCriterias.groupName, pqCriterias.code)
-
- // 2) Group by groupName => each group has a map of criteriaId => PQItem
- // so we can gather attachments properly.
- const groupMap = new Map<string, Record<number, PQItem>>()
-
- for (const row of rows) {
- const g = row.groupName || "Others"
-
- // Ensure we have an object for this group
- if (!groupMap.has(g)) {
- groupMap.set(g, {})
- }
-
- const groupItems = groupMap.get(g)!
- // If we haven't seen this criteriaId yet, create a PQItem
- if (!groupItems[row.criteriaId]) {
- groupItems[row.criteriaId] = {
- answerId: row.answerId,
- criteriaId: row.criteriaId,
- code: row.code,
- checkPoint: row.checkPoint,
- description: row.description,
- answer: row.answer || "", // if row.answer is null, just empty string
- attachments: [],
- }
- }
+ };
- // If there's an attachment row (attachId not null), push it onto `attachments`
- if (row.attachId) {
- groupItems[row.criteriaId].attachments.push({
- attachId: row.attachId,
- fileName: row.fileName || "",
- filePath: row.filePath || "",
- fileSize: row.fileSize || undefined,
- })
+ // Create separate queries for each case instead of modifying the same query variable
+ if (projectId) {
+ // 프로젝트별 PQ 쿼리
+ const rows = await db
+ .select(selectObj)
+ .from(pqCriterias)
+ .innerJoin(
+ pqCriteriasExtension,
+ and(
+ eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId),
+ eq(pqCriteriasExtension.projectId, projectId)
+ )
+ )
+ .leftJoin(
+ vendorPqCriteriaAnswers,
+ and(
+ eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ eq(vendorPqCriteriaAnswers.projectId, projectId)
+ )
+ )
+ .leftJoin(
+ vendorCriteriaAttachments,
+ eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
+ )
+ .orderBy(pqCriterias.groupName, pqCriterias.code);
+
+ return processQueryResults(rows);
+ } else {
+ // 일반 PQ 쿼리
+ const rows = await db
+ .select(selectObj)
+ .from(pqCriterias)
+ .leftJoin(
+ pqCriteriasExtension,
+ eq(pqCriterias.id, pqCriteriasExtension.pqCriteriaId)
+ )
+ .where(isNull(pqCriteriasExtension.id))
+ .leftJoin(
+ vendorPqCriteriaAnswers,
+ and(
+ eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ isNull(vendorPqCriteriaAnswers.projectId)
+ )
+ )
+ .leftJoin(
+ vendorCriteriaAttachments,
+ eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
+ )
+ .orderBy(pqCriterias.groupName, pqCriterias.code);
+
+ return processQueryResults(rows);
}
+ } catch (error) {
+ console.error("Error fetching PQ data:", error);
+ return [];
}
- // 3) Convert groupMap into an array of { groupName, items[] }
- const data: PQGroupData[] = []
- for (const [groupName, itemsMap] of groupMap.entries()) {
- // Convert the itemsMap (key=criteriaId => PQItem) into an array
- const items = Object.values(itemsMap)
- data.push({ groupName, items })
+ // Helper function to process query results
+ function processQueryResults(rows: any[]) {
+ // 그룹별로 데이터 구성
+ const groupMap = new Map<string, Record<number, PQItem>>();
+
+ for (const row of rows) {
+ const g = row.groupName || "Others";
+
+ // 그룹 확인
+ if (!groupMap.has(g)) {
+ groupMap.set(g, {});
+ }
+
+ const groupItems = groupMap.get(g)!;
+
+ // 아직 이 기준을 처리하지 않았으면 PQItem 생성
+ if (!groupItems[row.criteriaId]) {
+ groupItems[row.criteriaId] = {
+ answerId: row.answerId,
+ criteriaId: row.criteriaId,
+ code: row.code,
+ checkPoint: row.checkPoint,
+ description: row.description,
+ remarks: row.remarks,
+ // 프로젝트 PQ 전용 필드
+ contractInfo: row.contractInfo,
+ additionalRequirement: row.additionalRequirement,
+ answer: row.answer || "",
+ attachments: [],
+ };
+ }
+
+ // 첨부 파일이 있으면 추가
+ if (row.attachId) {
+ groupItems[row.criteriaId].attachments.push({
+ attachId: row.attachId,
+ fileName: row.fileName || "",
+ filePath: row.filePath || "",
+ fileSize: row.fileSize || undefined,
+ });
+ }
+ }
+
+ // 최종 데이터 구성
+ const data: PQGroupData[] = [];
+ for (const [groupName, itemsMap] of groupMap.entries()) {
+ const items = Object.values(itemsMap);
+ data.push({ groupName, items });
+ }
+
+ return data;
}
-
- return data
}
@@ -373,6 +548,7 @@ interface SavePQAnswer {
interface SavePQInput {
vendorId: number
+ projectId?: number
answers: SavePQAnswer[]
}
@@ -380,20 +556,27 @@ interface SavePQInput {
* 여러 항목을 한 번에 Upsert
*/
export async function savePQAnswersAction(input: SavePQInput) {
- const { vendorId, answers } = input
+ const { vendorId, projectId, answers } = input
try {
for (const ans of answers) {
- // 1) Check if a row already exists for (vendorId, criteriaId)
+ // 1) Check if a row already exists for (vendorId, criteriaId, projectId)
+ const queryConditions = [
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ eq(vendorPqCriteriaAnswers.criteriaId, ans.criteriaId)
+ ];
+
+ // Add projectId condition when it exists
+ if (projectId !== undefined) {
+ queryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId));
+ } else {
+ queryConditions.push(isNull(vendorPqCriteriaAnswers.projectId));
+ }
+
const existing = await db
.select()
.from(vendorPqCriteriaAnswers)
- .where(
- and(
- eq(vendorPqCriteriaAnswers.vendorId, vendorId),
- eq(vendorPqCriteriaAnswers.criteriaId, ans.criteriaId)
- )
- )
+ .where(and(...queryConditions));
let answerId: number
@@ -405,11 +588,11 @@ export async function savePQAnswersAction(input: SavePQInput) {
.values({
vendorId,
criteriaId: ans.criteriaId,
+ projectId: projectId || null, // Include projectId when provided
answer: ans.answer,
- // no attachmentPaths column anymore
})
.returning({ id: vendorPqCriteriaAnswers.id })
-
+
answerId = inserted[0].id
} else {
// Update existing
@@ -425,8 +608,6 @@ export async function savePQAnswersAction(input: SavePQInput) {
}
// 3) Now manage attachments in vendorCriteriaAttachments
- // We'll do a "diff": remove old ones not in the new list, insert new ones not in DB.
-
// 3a) Load old attachments from DB
const oldAttachments = await db
.select({
@@ -448,17 +629,16 @@ export async function savePQAnswersAction(input: SavePQInput) {
.where(inArray(vendorCriteriaAttachments.id, removeIds))
}
- // 3d) Insert new attachments that aren’t in DB
+ // 3d) Insert new attachments that aren't in DB
const oldPaths = oldAttachments.map(o => o.filePath)
const toAdd = ans.attachments.filter(a => !oldPaths.includes(a.url))
for (const attach of toAdd) {
await db.insert(vendorCriteriaAttachments).values({
vendorCriteriaAnswerId: answerId,
- fileName: attach.fileName, // original filename
- filePath: attach.url, // random/UUID path on server
+ fileName: attach.fileName,
+ filePath: attach.url,
fileSize: attach.size ?? null,
- // fileType if you have it, etc.
})
}
}
@@ -476,23 +656,40 @@ export async function savePQAnswersAction(input: SavePQInput) {
* PQ 제출 서버 액션 - 벤더 상태를 PQ_SUBMITTED로 업데이트
* @param vendorId 벤더 ID
*/
-export async function submitPQAction(vendorId: number) {
+export async function submitPQAction({
+ vendorId,
+ projectId
+}: {
+ vendorId: number;
+ projectId?: number;
+}) {
unstable_noStore();
try {
// 1. 모든 PQ 항목에 대한 응답이 있는지 검증
+ const queryConditions = [
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId)
+ ];
+
+ // Add projectId condition when it exists
+ if (projectId !== undefined) {
+ queryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId));
+ } else {
+ queryConditions.push(isNull(vendorPqCriteriaAnswers.projectId));
+ }
+
const pqCriteriaCount = await db
.select({ count: count() })
.from(vendorPqCriteriaAnswers)
- .where(eq(vendorPqCriteriaAnswers.vendorId, vendorId));
-
+ .where(and(...queryConditions));
+
const totalPqCriteriaCount = pqCriteriaCount[0]?.count || 0;
-
+
// 응답 데이터 검증
if (totalPqCriteriaCount === 0) {
return { ok: false, error: "No PQ answers found" };
}
-
+
// 2. 벤더 정보 조회
const vendor = await db
.select({
@@ -504,41 +701,118 @@ export async function submitPQAction(vendorId: number) {
.from(vendors)
.where(eq(vendors.id, vendorId))
.then(rows => rows[0]);
-
+
if (!vendor) {
return { ok: false, error: "Vendor not found" };
}
- // 3. 벤더 상태가 제출 가능한 상태인지 확인
- const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"];
- if (!allowedStatuses.includes(vendor.status)) {
- return {
- ok: false,
- error: `Cannot submit PQ in current status: ${vendor.status}`
- };
+ // Project 정보 조회 (projectId가 있는 경우)
+ let projectName = '';
+ if (projectId) {
+ const projectData = await db
+ .select({
+ projectName: projects.name
+ })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .then(rows => rows[0]);
+
+ projectName = projectData?.projectName || 'Unknown Project';
}
- // 4. 벤더 상태 업데이트
- await db
- .update(vendors)
- .set({
- status: "PQ_SUBMITTED",
- updatedAt: new Date(),
- })
- .where(eq(vendors.id, vendorId));
+ // 3. 상태 업데이트
+ const currentDate = new Date();
- // 5. 관리자에게 이메일 알림 발송
+ if (projectId) {
+ // 프로젝트별 PQ인 경우 vendorProjectPQs 테이블 업데이트
+ const existingProjectPQ = await db
+ .select({ id: vendorProjectPQs.id, status: vendorProjectPQs.status })
+ .from(vendorProjectPQs)
+ .where(
+ and(
+ eq(vendorProjectPQs.vendorId, vendorId),
+ eq(vendorProjectPQs.projectId, projectId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (existingProjectPQ) {
+ // 프로젝트 PQ 상태가 제출 가능한 상태인지 확인
+ const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "REJECTED"];
+
+ if (!allowedStatuses.includes(existingProjectPQ.status)) {
+ return {
+ ok: false,
+ error: `Cannot submit Project PQ in current status: ${existingProjectPQ.status}`
+ };
+ }
+
+ // Update existing project PQ status
+ await db
+ .update(vendorProjectPQs)
+ .set({
+ status: "SUBMITTED",
+ submittedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorProjectPQs.id, existingProjectPQ.id));
+ } else {
+ // Project PQ entry doesn't exist, create one
+ await db
+ .insert(vendorProjectPQs)
+ .values({
+ vendorId,
+ projectId,
+ status: "SUBMITTED",
+ submittedAt: currentDate,
+ createdAt: currentDate,
+ updatedAt: currentDate,
+ });
+ }
+ } else {
+ // 일반 PQ인 경우 벤더 상태 검증 및 업데이트
+ const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"];
+
+ if (!allowedStatuses.includes(vendor.status)) {
+ return {
+ ok: false,
+ error: `Cannot submit PQ in current status: ${vendor.status}`
+ };
+ }
+
+ // Update vendor status
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_SUBMITTED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+
+ // 4. 관리자에게 이메일 알림 발송
if (process.env.ADMIN_EMAIL) {
try {
+ const emailSubject = projectId
+ ? `[eVCP] Project PQ Submitted: ${vendor.vendorName} for ${projectName}`
+ : `[eVCP] PQ Submitted: ${vendor.vendorName}`;
+
+ const adminUrl = projectId
+ ? `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/projects/${projectId}/pq`
+ : `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/pq`;
+
await sendEmail({
to: process.env.ADMIN_EMAIL,
- subject: `[eVCP] PQ Submitted: ${vendor.vendorName}`,
+ subject: emailSubject,
template: "pq-submitted-admin",
context: {
vendorName: vendor.vendorName,
vendorId: vendor.id,
- submittedDate: new Date().toLocaleString(),
- adminUrl: `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/pq`,
+ projectId: projectId,
+ projectName: projectName,
+ isProjectPQ: !!projectId,
+ submittedDate: currentDate.toLocaleString(),
+ adminUrl,
}
});
} catch (emailError) {
@@ -546,18 +820,29 @@ export async function submitPQAction(vendorId: number) {
// 이메일 실패는 전체 프로세스를 중단하지 않음
}
}
-
- // 6. 벤더에게 확인 이메일 발송
+
+ // 5. 벤더에게 확인 이메일 발송
if (vendor.email) {
try {
+ const emailSubject = projectId
+ ? `[eVCP] Project PQ Submission Confirmation for ${projectName}`
+ : "[eVCP] PQ Submission Confirmation";
+
+ const portalUrl = projectId
+ ? `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/projects/${projectId}`
+ : `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`;
+
await sendEmail({
to: vendor.email,
- subject: "[eVCP] PQ Submission Confirmation",
+ subject: emailSubject,
template: "pq-submitted-vendor",
context: {
vendorName: vendor.vendorName,
- submittedDate: new Date().toLocaleString(),
- portalUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
+ projectId: projectId,
+ projectName: projectName,
+ isProjectPQ: !!projectId,
+ submittedDate: currentDate.toLocaleString(),
+ portalUrl,
}
});
} catch (emailError) {
@@ -565,11 +850,17 @@ export async function submitPQAction(vendorId: number) {
// 이메일 실패는 전체 프로세스를 중단하지 않음
}
}
-
- // 7. 캐시 무효화
+
+ // 6. 캐시 무효화
revalidateTag("vendors");
revalidateTag("vendor-status-counts");
+ if (projectId) {
+ revalidateTag(`vendor-project-pqs-${vendorId}`);
+ revalidateTag(`project-vendors-${projectId}`);
+ revalidateTag(`project-pq-${projectId}`);
+ }
+
return { ok: true };
} catch (error) {
console.error("PQ submit error:", error);
@@ -697,7 +988,7 @@ export async function getVendorsInPQ(input: GetVendorsSchema) {
});
// 2) 글로벌 검색
- let globalWhere;
+ let globalWhere: SQL<unknown> | undefined = undefined;
if (input.search) {
const s = `%${input.search}%`;
globalWhere = or(
@@ -708,44 +999,80 @@ export async function getVendorsInPQ(input: GetVendorsSchema) {
);
}
- // 최종 where 결합
- const finalWhere = and(advancedWhere, globalWhere, eq(vendors.status ,"PQ_SUBMITTED"));
-
- // 간단 검색 (advancedTable=false) 시 예시
- const simpleWhere = and(
- input.vendorName
- ? ilike(vendors.vendorName, `%${input.vendorName}%`)
- : undefined,
- input.status ? ilike(vendors.status, input.status) : undefined,
- input.country
- ? ilike(vendors.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])
- )
- : [asc(vendors.createdAt)];
-
// 트랜잭션 내에서 데이터 조회
const { data, total } = await db.transaction(async (tx) => {
- // 1) vendor 목록 조회
+ // 벤더 ID 모음 (중복 제거용)
+ const vendorIds = new Set<number>();
+
+ // 1-A) 일반 PQ 답변이 있는 벤더 찾기 (status와 상관없이)
+ const generalPqVendors = await tx
+ .select({
+ vendorId: vendorPqCriteriaAnswers.vendorId
+ })
+ .from(vendorPqCriteriaAnswers)
+ .innerJoin(
+ vendors,
+ eq(vendorPqCriteriaAnswers.vendorId, vendors.id)
+ )
+ .where(
+ and(
+ isNull(vendorPqCriteriaAnswers.projectId), // 일반 PQ만 (프로젝트 PQ 아님)
+ advancedWhere,
+ globalWhere
+ )
+ )
+ .groupBy(vendorPqCriteriaAnswers.vendorId); // 각 벤더당 한 번만 카운트
+
+ generalPqVendors.forEach(v => vendorIds.add(v.vendorId));
+
+ // 1-B) 프로젝트 PQ 답변이 있는 벤더 ID 조회 (status와 상관없이)
+ const projectPqVendors = await tx
+ .select({
+ vendorId: vendorProjectPQs.vendorId
+ })
+ .from(vendorProjectPQs)
+ .innerJoin(
+ vendors,
+ eq(vendorProjectPQs.vendorId, vendors.id)
+ )
+ .where(
+ and(
+ // 최소한 IN_PROGRESS부터는 작업이 시작된 상태이므로 포함
+ not(eq(vendorProjectPQs.status, "REQUESTED")), // REQUESTED 상태는 제외
+ advancedWhere,
+ globalWhere
+ )
+ );
+
+ projectPqVendors.forEach(v => vendorIds.add(v.vendorId));
+
+ // 중복 제거된 벤더 ID 배열
+ const uniqueVendorIds = Array.from(vendorIds);
+
+ // 총 개수 (중복 제거 후)
+ const total = uniqueVendorIds.length;
+
+ if (total === 0) {
+ return { data: [], total: 0 };
+ }
+
+ // 페이징 처리 (정렬 후 limit/offset 적용)
+ const paginatedIds = uniqueVendorIds.slice(offset, offset + input.perPage);
+
+ // 2) 페이징된 벤더 상세 정보 조회
const vendorsData = await selectVendors(tx, {
- where,
- orderBy,
- offset,
- limit: input.perPage,
+ where: inArray(vendors.id, paginatedIds),
+ orderBy: input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendors[item.id]) : asc(vendors[item.id])
+ )
+ : [asc(vendors.createdAt)],
});
-
- // 2) 각 vendor의 attachments 조회
- const vendorsWithAttachments = await Promise.all(
+
+ // 3) 각 벤더별 PQ 상태 정보 추가
+ const vendorsWithPqInfo = await Promise.all(
vendorsData.map(async (vendor) => {
+ // 3-A) 첨부 파일 조회
const attachments = await tx
.select({
id: vendorAttachments.id,
@@ -754,18 +1081,71 @@ export async function getVendorsInPQ(input: GetVendorsSchema) {
})
.from(vendorAttachments)
.where(eq(vendorAttachments.vendorId, vendor.id));
-
+
+ // 3-B) 일반 PQ 제출 여부 확인 (PQ 답변이 있는지)
+ const generalPqAnswers = await tx
+ .select({ count: count() })
+ .from(vendorPqCriteriaAnswers)
+ .where(
+ and(
+ eq(vendorPqCriteriaAnswers.vendorId, vendor.id),
+ isNull(vendorPqCriteriaAnswers.projectId)
+ )
+ );
+
+ const hasGeneralPq = generalPqAnswers[0]?.count > 0;
+
+ // 3-C) 프로젝트 PQ 정보 조회 (모든 상태 포함)
+ const projectPqs = await tx
+ .select({
+ projectId: vendorProjectPQs.projectId,
+ projectName: projects.name,
+ status: vendorProjectPQs.status,
+ submittedAt: vendorProjectPQs.submittedAt,
+ approvedAt: vendorProjectPQs.approvedAt,
+ rejectedAt: vendorProjectPQs.rejectedAt
+ })
+ .from(vendorProjectPQs)
+ .innerJoin(
+ projects,
+ eq(vendorProjectPQs.projectId, projects.id)
+ )
+ .where(
+ and(
+ eq(vendorProjectPQs.vendorId, vendor.id),
+ not(eq(vendorProjectPQs.status, "REQUESTED")) // REQUESTED 상태는 제외
+ )
+ );
+
+ const hasProjectPq = projectPqs.length > 0;
+
+ // 프로젝트 PQ 상태별 카운트
+ const projectPqStatusCounts = {
+ inProgress: projectPqs.filter(p => p.status === "IN_PROGRESS").length,
+ submitted: projectPqs.filter(p => p.status === "SUBMITTED").length,
+ approved: projectPqs.filter(p => p.status === "APPROVED").length,
+ rejected: projectPqs.filter(p => p.status === "REJECTED").length,
+ total: projectPqs.length
+ };
+
+ // 3-D) PQ 상태 정보 추가
return {
...vendor,
hasAttachments: attachments.length > 0,
attachmentsList: attachments,
+ pqInfo: {
+ hasGeneralPq,
+ hasProjectPq,
+ projectPqs,
+ projectPqStatusCounts,
+ // 현재 PQ 상태 (UI에 표시 용도)
+ pqStatus: getPqStatusDisplay(vendor.status, hasGeneralPq, hasProjectPq, projectPqStatusCounts)
+ }
};
})
);
-
- // 3) 전체 개수
- const total = await countVendors(tx, where);
- return { data: vendorsWithAttachments, total };
+
+ return { data: vendorsWithPqInfo, total };
});
// 페이지 수
@@ -773,6 +1153,7 @@ export async function getVendorsInPQ(input: GetVendorsSchema) {
return { data, pageCount };
} catch (err) {
+ console.error("Error in getVendorsInPQ:", err);
// 에러 발생 시
return { data: [], pageCount: 0 };
}
@@ -780,11 +1161,65 @@ export async function getVendorsInPQ(input: GetVendorsSchema) {
[JSON.stringify(input)], // 캐싱 키
{
revalidate: 3600,
- tags: ["vendors-in-pq"], // revalidateTag("vendors") 호출 시 무효화
+ tags: ["vendors-in-pq", "project-pqs"], // revalidateTag 호출 시 무효화
}
)();
}
+// PQ 상태 표시 함수
+function getPqStatusDisplay(
+ vendorStatus: string,
+ hasGeneralPq: boolean,
+ hasProjectPq: boolean,
+ projectPqCounts: { inProgress: number, submitted: number, approved: number, rejected: number, total: number }
+): string {
+ // 프로젝트 PQ 상태 문자열 생성
+ let projectPqStatus = "";
+ if (hasProjectPq) {
+ const parts = [];
+ if (projectPqCounts.inProgress > 0) {
+ parts.push(`진행중: ${projectPqCounts.inProgress}`);
+ }
+ if (projectPqCounts.submitted > 0) {
+ parts.push(`제출: ${projectPqCounts.submitted}`);
+ }
+ if (projectPqCounts.approved > 0) {
+ parts.push(`승인: ${projectPqCounts.approved}`);
+ }
+ if (projectPqCounts.rejected > 0) {
+ parts.push(`거부: ${projectPqCounts.rejected}`);
+ }
+ projectPqStatus = parts.join(", ");
+ }
+
+ // 일반 PQ + 프로젝트 PQ 조합 상태
+ if (hasGeneralPq && hasProjectPq) {
+ return `일반 PQ (${getPqVendorStatusText(vendorStatus)}) + 프로젝트 PQ (${projectPqStatus})`;
+ } else if (hasGeneralPq) {
+ return `일반 PQ (${getPqVendorStatusText(vendorStatus)})`;
+ } else if (hasProjectPq) {
+ return `프로젝트 PQ (${projectPqStatus})`;
+ }
+
+ return "PQ 정보 없음";
+}
+
+// 벤더 상태 텍스트 변환
+function getPqVendorStatusText(status: string): string {
+ switch (status) {
+ case "IN_PQ": return "진행중";
+ case "PQ_SUBMITTED": return "제출됨";
+ case "PQ_FAILED": return "실패";
+ case "PQ_APPROVED":
+ case "APPROVED": return "승인됨";
+ case "READY_TO_SEND": return "거래 준비";
+ case "ACTIVE": return "활성";
+ case "INACTIVE": return "비활성";
+ case "BLACKLISTED": return "거래금지";
+ default: return status;
+ }
+}
+
export type VendorStatus =
| "PENDING_REVIEW"
@@ -797,6 +1232,7 @@ export type VendorStatus =
| "ACTIVE"
| "INACTIVE"
| "BLACKLISTED"
+ | "PQ_APPROVED"
export async function updateVendorStatusAction(
vendorId: number,
@@ -833,6 +1269,111 @@ export type VendorStatus =
return { ok: false, error: String(error) }
}
}
+
+ type ProjectPQStatus = "REQUESTED" | "IN_PROGRESS" | "SUBMITTED" | "APPROVED" | "REJECTED";
+
+/**
+ * Update the status of a project-specific PQ for a vendor
+ */
+export async function updateProjectPQStatusAction({
+ vendorId,
+ projectId,
+ status,
+ comment
+}: {
+ vendorId: number;
+ projectId: number;
+ status: ProjectPQStatus;
+ comment?: string;
+}) {
+ try {
+ const currentDate = new Date();
+
+ // 1) Prepare update data with appropriate timestamps
+ const updateData: any = {
+ status,
+ updatedAt: currentDate,
+ };
+
+ // Add status-specific fields
+ if (status === "APPROVED") {
+ updateData.approvedAt = currentDate;
+ } else if (status === "REJECTED") {
+ updateData.rejectedAt = currentDate;
+ updateData.rejectReason = comment || null;
+ } else if (status === "SUBMITTED") {
+ updateData.submittedAt = currentDate;
+ }
+
+ // 2) Update the project PQ record
+ await db
+ .update(vendorProjectPQs)
+ .set(updateData)
+ .where(
+ and(
+ eq(vendorProjectPQs.vendorId, vendorId),
+ eq(vendorProjectPQs.projectId, projectId)
+ )
+ );
+
+ // 3) Load vendor and project details for email
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ email: vendors.email,
+ vendorName: vendors.vendorName
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ const project = await db
+ .select({
+ name: projects.name
+ })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .then(rows => rows[0]);
+
+ if (!project) {
+ return { ok: false, error: "Project not found" };
+ }
+
+ // 4) Send email notification
+ await sendEmail({
+ to: vendor.email || "",
+ subject: `Your Project PQ for ${project.name} is now ${status}`,
+ template: "vendor-project-pq-status", // matches .hbs file (you might need to create this)
+ context: {
+ name: vendor.vendorName,
+ status,
+ projectName: project.name,
+ rejectionReason: status === "REJECTED" ? comment : undefined,
+ hasRejectionReason: status === "REJECTED" && !!comment,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/projects/${projectId}/pq`,
+ approvalDate: status === "APPROVED" ? currentDate.toLocaleDateString() : undefined,
+ rejectionDate: status === "REJECTED" ? currentDate.toLocaleDateString() : undefined,
+ },
+ });
+
+ // 5) Revalidate cache tags
+ revalidateTag("vendors");
+ revalidateTag("vendors-in-pq");
+ revalidateTag(`vendor-project-pqs-${vendorId}`);
+ revalidateTag(`project-pq-${projectId}`);
+ revalidateTag(`project-vendors-${projectId}`);
+
+ return { ok: true };
+ } catch (error) {
+ console.error("updateProjectPQStatusAction error:", error);
+ return { ok: false, error: String(error) };
+ }
+}
+
// 코멘트 타입 정의
interface ItemComment {
answerId: number;
@@ -850,24 +1391,60 @@ interface ItemComment {
*/
export async function requestPqChangesAction({
vendorId,
+ projectId,
comment,
generalComment,
}: {
vendorId: number;
+ projectId?: number; // Optional project ID for project-specific PQs
comment: ItemComment[];
generalComment?: string;
}) {
try {
- // 1) 벤더 상태 업데이트
- await db.update(vendors)
- .set({
- status: "IN_PQ", // 변경 요청 상태로 설정
- updatedAt: new Date(),
- })
- .where(eq(vendors.id, vendorId));
+ // 1) 상태 업데이트 (PQ 타입에 따라 다르게 처리)
+ if (projectId) {
+ // 프로젝트 PQ인 경우 vendorProjectPQs 테이블 업데이트
+ const projectPq = await db
+ .select()
+ .from(vendorProjectPQs)
+ .where(
+ and(
+ eq(vendorProjectPQs.vendorId, vendorId),
+ eq(vendorProjectPQs.projectId, projectId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!projectPq) {
+ return { ok: false, error: "Project PQ record not found" };
+ }
+
+ await db
+ .update(vendorProjectPQs)
+ .set({
+ status: "IN_PROGRESS", // 변경 요청 상태로 설정
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(vendorProjectPQs.vendorId, vendorId),
+ eq(vendorProjectPQs.projectId, projectId)
+ )
+ );
+ } else {
+ // 일반 PQ인 경우 vendors 테이블 업데이트
+ await db
+ .update(vendors)
+ .set({
+ status: "IN_PQ", // 변경 요청 상태로 설정
+ updatedAt: new Date(),
+ })
+ .where(eq(vendors.id, vendorId));
+ }
// 2) 벤더 정보 가져오기
- const vendor = await db.select()
+ const vendor = await db
+ .select()
.from(vendors)
.where(eq(vendors.id, vendorId))
.then(r => r[0]);
@@ -876,6 +1453,20 @@ export async function requestPqChangesAction({
return { ok: false, error: "Vendor not found" };
}
+ // 프로젝트 정보 가져오기 (프로젝트 PQ인 경우)
+ let projectName = "";
+ if (projectId) {
+ const project = await db
+ .select({
+ name: projects.name
+ })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .then(rows => rows[0]);
+
+ projectName = project?.name || "Unknown Project";
+ }
+
// 3) 각 항목별 코멘트 저장
const currentDate = new Date();
const reviewerId = 1; // 관리자 ID (실제 구현에서는 세션에서 가져옵니다)
@@ -883,7 +1474,7 @@ export async function requestPqChangesAction({
// 병렬로 모든 코멘트 저장
if (comment && comment.length > 0) {
- const insertPromises = comment.map(item =>
+ const insertPromises = comment.map(item =>
db.insert(vendorPqReviewLogs)
.values({
vendorPqCriteriaAnswerId: item.answerId,
@@ -910,23 +1501,43 @@ export async function requestPqChangesAction({
text: item.comment
}));
+ // PQ 유형에 따라 이메일 제목 및 내용 조정
+ const emailSubject = projectId
+ ? `[IMPORTANT] Your Project PQ (${projectName}) requires changes`
+ : `[IMPORTANT] Your PQ submission requires changes`;
+
+ // 로그인 URL - 프로젝트 PQ인 경우 다른 경로로 안내
+ const loginUrl = projectId
+ ? `${process.env.NEXT_PUBLIC_URL}/partners/projects/${projectId}/pq`
+ : `${process.env.NEXT_PUBLIC_URL}/partners/pq`;
+
await sendEmail({
to: vendor.email || "",
- subject: `[IMPORTANT] Your PQ submission requires changes`,
+ subject: emailSubject,
template: "vendor-pq-comment", // matches .hbs file
context: {
name: vendor.vendorName,
vendorCode: vendor.vendorCode,
- loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq`,
+ loginUrl,
comments: commentItems,
generalComment: generalComment || "",
hasGeneralComment: !!generalComment,
commentCount: commentItems.length,
+ projectId,
+ projectName,
+ isProjPQ: !!projectId,
},
});
- revalidateTag("vendors")
- revalidateTag("vendors-in-pq")
+ // 5) 캐시 무효화 - PQ 유형에 따라 적절한 태그 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendors-in-pq");
+
+ if (projectId) {
+ revalidateTag(`vendor-project-pqs-${vendorId}`);
+ revalidateTag(`project-pq-${projectId}`);
+ revalidateTag(`project-vendors-${projectId}`);
+ }
return { ok: true };
} catch (error) {
@@ -934,6 +1545,7 @@ export async function requestPqChangesAction({
return { ok: false, error: String(error) };
}
}
+
interface AddReviewCommentInput {
answerId: number // vendorPqCriteriaAnswers.id
comment: string
@@ -984,4 +1596,80 @@ export async function getItemReviewLogsAction(input: GetItemReviewLogsInput) {
console.error("getItemReviewLogsAction error:", error);
return { ok: false, error: String(error) };
}
+}
+
+export interface VendorPQListItem {
+ projectId: number;
+ projectName: string;
+ status: string;
+ submittedAt?: Date | null; // Change to accept both undefined and null
+}
+
+export interface VendorPQsList {
+ hasGeneralPq: boolean;
+ generalPqStatus?: string; // vendor.status for general PQ
+ projectPQs: VendorPQListItem[];
+}
+
+export async function getVendorPQsList(vendorId: number): Promise<VendorPQsList> {
+ try {
+ // 1. Check if vendor has general PQ answers
+ const generalPqAnswers = await db
+ .select({ count: count() })
+ .from(vendorPqCriteriaAnswers)
+ .where(
+ and(
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ isNull(vendorPqCriteriaAnswers.projectId)
+ )
+ );
+
+ const hasGeneralPq = (generalPqAnswers[0]?.count || 0) > 0;
+
+ // 2. Get vendor status for general PQ
+ let generalPqStatus;
+ if (hasGeneralPq) {
+ const vendor = await db
+ .select({ status: vendors.status })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ generalPqStatus = vendor?.status;
+ }
+
+ // 3. Get project PQs
+ const projectPQs = await db
+ .select({
+ projectId: vendorProjectPQs.projectId,
+ projectName: projects.name,
+ status: vendorProjectPQs.status,
+ submittedAt: vendorProjectPQs.submittedAt
+ })
+ .from(vendorProjectPQs)
+ .innerJoin(
+ projects,
+ eq(vendorProjectPQs.projectId, projects.id)
+ )
+ .where(
+ and(
+ eq(vendorProjectPQs.vendorId, vendorId),
+ not(eq(vendorProjectPQs.status, "REQUESTED")) // Exclude requests that haven't been started
+ )
+ )
+ .orderBy(vendorProjectPQs.updatedAt);
+
+ return {
+ hasGeneralPq,
+ generalPqStatus,
+ projectPQs: projectPQs
+ };
+
+ } catch (error) {
+ console.error("Error fetching vendor PQs list:", error);
+ return {
+ hasGeneralPq: false,
+ projectPQs: []
+ };
+ }
} \ No newline at end of file
diff --git a/lib/pq/table/add-pq-dialog.tsx b/lib/pq/table/add-pq-dialog.tsx
index 8164dbaf..1f374cd0 100644
--- a/lib/pq/table/add-pq-dialog.tsx
+++ b/lib/pq/table/add-pq-dialog.tsx
@@ -27,8 +27,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
+import { Checkbox } from "@/components/ui/checkbox"
import { useToast } from "@/hooks/use-toast"
import { createPq, invalidatePqCache } from "../service"
+import { ProjectSelector } from "@/components/ProjectSelector"
+import { type Project } from "@/lib/rfqs/service"
+import { ScrollArea } from "@/components/ui/scroll-area"
// PQ 생성을 위한 Zod 스키마 정의
const createPqSchema = z.object({
@@ -36,10 +40,15 @@ const createPqSchema = z.object({
checkPoint: z.string().min(1, "Check point is required"),
groupName: z.string().min(1, "Group is required"),
description: z.string().optional(),
- remarks: z.string().optional()
+ remarks: z.string().optional(),
+ // 프로젝트별 PQ 여부 체크박스
+ isProjectSpecific: z.boolean().default(false),
+ // 프로젝트 관련 추가 필드는 isProjectSpecific가 true일 때만 필수
+ contractInfo: z.string().optional(),
+ additionalRequirement: z.string().optional(),
});
-type CreatePqInputType = z.infer<typeof createPqSchema>;
+type CreatePqFormType = z.infer<typeof createPqSchema>;
// 그룹 이름 옵션
const groupOptions = [
@@ -54,36 +63,71 @@ const descriptionExample = `Address :
Tel. / Fax :
e-mail :`;
-export function AddPqDialog() {
+interface AddPqDialogProps {
+ currentProjectId?: number | null; // 현재 선택된 프로젝트 ID (옵션)
+}
+
+export function AddPqDialog({ currentProjectId }: AddPqDialogProps) {
const [open, setOpen] = React.useState(false)
const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
const router = useRouter()
const { toast } = useToast()
// react-hook-form 설정
- const form = useForm<CreatePqInputType>({
+ const form = useForm<CreatePqFormType>({
resolver: zodResolver(createPqSchema),
defaultValues: {
code: "",
checkPoint: "",
groupName: groupOptions[0],
description: "",
- remarks: ""
+ remarks: "",
+ isProjectSpecific: !!currentProjectId, // 현재 프로젝트 ID가 있으면 기본값 true
+ contractInfo: "",
+ additionalRequirement: "",
},
})
+ // 프로젝트별 PQ 여부 상태 감시
+ const isProjectSpecific = form.watch("isProjectSpecific")
+
+ // 현재 프로젝트 ID가 있으면 선택된 프로젝트 설정
+ React.useEffect(() => {
+ if (currentProjectId) {
+ form.setValue("isProjectSpecific", true)
+ }
+ }, [currentProjectId, form])
+
// 예시 텍스트를 description 필드에 채우는 함수
const fillExampleText = () => {
form.setValue("description", descriptionExample);
};
- async function onSubmit(data: CreatePqInputType) {
+ async function onSubmit(data: CreatePqFormType) {
try {
setIsSubmitting(true)
-
+
+ // 서버 액션 호출용 데이터 준비
+ const submitData = {
+ ...data,
+ projectId: data.isProjectSpecific ? selectedProject?.id || currentProjectId : null,
+ }
+
+ // 프로젝트별 PQ인데 프로젝트가 선택되지 않은 경우 검증
+ if (data.isProjectSpecific && !submitData.projectId) {
+ toast({
+ title: "Error",
+ description: "Please select a project",
+ variant: "destructive",
+ })
+ setIsSubmitting(false)
+ return
+ }
+
// 서버 액션 호출
- const result = await createPq(data)
-
+ const result = await createPq(submitData)
+
if (!result.success) {
toast({
title: "Error",
@@ -94,20 +138,21 @@ export function AddPqDialog() {
}
await invalidatePqCache();
-
+
// 성공 시 처리
toast({
title: "Success",
- description: "PQ criteria created successfully",
+ description: result.message || "PQ criteria created successfully",
})
-
+
// 모달 닫고 폼 리셋
form.reset()
+ setSelectedProject(null)
setOpen(false)
-
+
// 페이지 새로고침
router.refresh()
-
+
} catch (error) {
console.error('Error creating PQ criteria:', error)
toast({
@@ -123,10 +168,24 @@ export function AddPqDialog() {
function handleDialogOpenChange(nextOpen: boolean) {
if (!nextOpen) {
form.reset()
+ setSelectedProject(null)
}
setOpen(nextOpen)
}
+ // 프로젝트 선택 핸들러
+ const handleProjectSelect = (project: Project | null) => {
+ // project가 null인 경우 선택 해제를 의미
+ if (project === null) {
+ setSelectedProject(null);
+ // 필요한 경우 추가 처리
+ return;
+ }
+
+ // 기존 처리 - 프로젝트가 선택된 경우
+ setSelectedProject(project);
+ }
+
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
{/* 모달을 열기 위한 버튼 */}
@@ -137,7 +196,7 @@ export function AddPqDialog() {
</Button>
</DialogTrigger>
- <DialogContent className="sm:max-w-[550px]">
+ <DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Create New PQ Criteria</DialogTitle>
<DialogDescription>
@@ -147,145 +206,241 @@ export function AddPqDialog() {
{/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
<Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2">
- {/* Code 필드 */}
- <FormField
- control={form.control}
- name="code"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Code <span className="text-destructive">*</span></FormLabel>
- <FormControl>
- <Input
- placeholder="예: 1-1, A.2.3"
- {...field}
- />
- </FormControl>
- <FormDescription>
- PQ 항목의 고유 코드를 입력하세요 (예: "1-1", "A.2.3")
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Check Point 필드 */}
- <FormField
- control={form.control}
- name="checkPoint"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel>
- <FormControl>
- <Input
- placeholder="검증 항목을 입력하세요"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Group Name 필드 (Select) */}
- <FormField
- control={form.control}
- name="groupName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Group <span className="text-destructive">*</span></FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- value={field.value}
- >
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2 flex flex-col">
+ {/* 프로젝트별 PQ 여부 체크박스 */}
+
+ <div className="flex-1 overflow-auto px-4 space-y-4">
+ <FormField
+ control={form.control}
+ name="isProjectSpecific"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
- <SelectTrigger>
- <SelectValue placeholder="그룹을 선택하세요" />
- </SelectTrigger>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
</FormControl>
- <SelectContent>
- {groupOptions.map((group) => (
- <SelectItem key={group} value={group}>
- {group}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <div className="space-y-1 leading-none">
+ <FormLabel>프로젝트별 PQ 생성</FormLabel>
+ <FormDescription>
+ 특정 프로젝트에만 적용되는 PQ 항목을 생성합니다
+ </FormDescription>
+ </div>
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 필드 (프로젝트별 PQ 선택 시에만 표시) */}
+ {isProjectSpecific && (
+ <div className="space-y-2">
+ <FormLabel>Project <span className="text-destructive">*</span></FormLabel>
+ <ProjectSelector
+ selectedProjectId={currentProjectId || selectedProject?.id}
+ onProjectSelect={handleProjectSelect}
+ placeholder="프로젝트를 선택하세요"
+ />
<FormDescription>
- PQ 항목의 분류 그룹을 선택하세요
+ PQ 항목을 적용할 프로젝트를 선택하세요
</FormDescription>
- <FormMessage />
- </FormItem>
+ </div>
)}
- />
-
- {/* Description 필드 - 예시 템플릿 버튼 추가 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <div className="flex items-center justify-between">
- <FormLabel>Description</FormLabel>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={fillExampleText}
- >
- 예시 채우기
- </Button>
- </div>
- <FormControl>
- <Textarea
- placeholder={`줄바꿈을 포함한 상세 설명을 입력하세요\n예:\n${descriptionExample}`}
- className="min-h-[120px] font-mono"
- {...field}
- value={field.value || ""}
+
+ <div className="flex-1 overflow-auto px-2 py-2 space-y-4" style={{maxHeight:420}}>
+
+
+ {/* Code 필드 */}
+ <FormField
+ control={form.control}
+ name="code"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Code <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1-1, A.2.3"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ PQ 항목의 고유 코드를 입력하세요 (예: "1-1", "A.2.3")
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Check Point 필드 */}
+ <FormField
+ control={form.control}
+ name="checkPoint"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="검증 항목을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Group Name 필드 (Select) */}
+ <FormField
+ control={form.control}
+ name="groupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Group <span className="text-destructive">*</span></FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ value={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="그룹을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {groupOptions.map((group) => (
+ <SelectItem key={group} value={group}>
+ {group}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ PQ 항목의 분류 그룹을 선택하세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Description 필드 - 예시 템플릿 버튼 추가 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <div className="flex items-center justify-between">
+ <FormLabel>Description</FormLabel>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={fillExampleText}
+ >
+ 예시 채우기
+ </Button>
+ </div>
+ <FormControl>
+ <Textarea
+ placeholder={`줄바꿈을 포함한 상세 설명을 입력하세요\n예:\n${descriptionExample}`}
+ className="min-h-[120px] font-mono"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormDescription>
+ 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Remarks 필드 */}
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Remarks</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고 사항을 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트별 PQ일 경우 추가 필드 */}
+ {isProjectSpecific && (
+ <>
+ {/* 계약 정보 필드 */}
+ <FormField
+ control={form.control}
+ name="contractInfo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contract Info</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="계약 관련 정보를 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormDescription>
+ 해당 프로젝트의 계약 관련 특이사항
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
/>
- </FormControl>
- <FormDescription>
- 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다.
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Remarks 필드 */}
- <FormField
- control={form.control}
- name="remarks"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Remarks</FormLabel>
- <FormControl>
- <Textarea
- placeholder="비고 사항을 입력하세요"
- className="min-h-[80px]"
- {...field}
- value={field.value || ""}
+
+ {/* 추가 요구사항 필드 */}
+ <FormField
+ control={form.control}
+ name="additionalRequirement"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Additional Requirements</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 요구사항을 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormDescription>
+ 프로젝트별 추가 요구사항
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
/>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ </>
+ )}
+ </div>
+ </div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
- form.reset();
- setOpen(false);
- }}
+ form.reset();
+ setSelectedProject(null);
+ setOpen(false);
+ }}
>
Cancel
</Button>
- <Button
- type="submit"
+ <Button
+ type="submit"
disabled={isSubmitting}
>
{isSubmitting ? "Creating..." : "Create"}
diff --git a/lib/pq/table/import-pq-button.tsx b/lib/pq/table/import-pq-button.tsx
new file mode 100644
index 00000000..e4e0147f
--- /dev/null
+++ b/lib/pq/table/import-pq-button.tsx
@@ -0,0 +1,258 @@
+"use client"
+
+import * as React from "react"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+import { processFileImport } from "./import-pq-handler" // 별도 파일로 분리
+
+interface ImportPqButtonProps {
+ projectId?: number | null
+ onSuccess?: () => void
+}
+
+export function ImportPqButton({ projectId, onSuccess }: ImportPqButtonProps) {
+ const [open, setOpen] = React.useState(false)
+ const [file, setFile] = React.useState<File | null>(null)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [progress, setProgress] = React.useState(0)
+ const [error, setError] = React.useState<string | null>(null)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0]
+ if (!selectedFile) return
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.")
+ return
+ }
+
+ setFile(selectedFile)
+ setError(null)
+ }
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.")
+ return
+ }
+
+ try {
+ setIsUploading(true)
+ setProgress(0)
+ setError(null)
+
+ // 파일을 ArrayBuffer로 읽기
+ const arrayBuffer = await file.arrayBuffer();
+
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(arrayBuffer);
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.");
+ }
+
+ // 헤더 행 번호 찾기 (보통 지침 행이 있으므로 헤더는 뒤에 위치)
+ let headerRowIndex = 1;
+ let headerRow: ExcelJS.Row | undefined;
+ let headerValues: (string | null)[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[];
+ if (!headerRow && values.some(v => v === "Code" || v === "Check Point") && rowNumber > 1) {
+ headerRowIndex = rowNumber;
+ headerRow = row;
+ headerValues = [...values];
+ }
+ });
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
+ }
+
+ // 헤더를 기반으로 인덱스 매핑 생성
+ const headerMapping: Record<string, number> = {};
+ headerValues.forEach((value, index) => {
+ if (typeof value === 'string') {
+ headerMapping[value] = index;
+ }
+ });
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["Code", "Check Point", "Group Name"];
+ const missingHeaders = requiredHeaders.filter(header => !(header in headerMapping));
+
+ if (missingHeaders.length > 0) {
+ throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
+ }
+
+ // 데이터 행 추출 (헤더 이후 행부터)
+ const dataRows: Record<string, any>[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > headerRowIndex) {
+ const rowData: Record<string, any> = {};
+ const values = row.values as (string | null | undefined)[];
+
+ Object.entries(headerMapping).forEach(([header, index]) => {
+ rowData[header] = values[index] || "";
+ });
+
+ // 빈 행이 아닌 경우만 추가
+ if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
+ dataRows.push(rowData);
+ }
+ }
+ });
+
+ if (dataRows.length === 0) {
+ throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
+ }
+
+ // 진행 상황 업데이트를 위한 콜백
+ const updateProgress = (current: number, total: number) => {
+ const percentage = Math.round((current / total) * 100);
+ setProgress(percentage);
+ };
+
+ // 실제 데이터 처리는 별도 함수에서 수행
+ const result = await processFileImport(
+ dataRows,
+ projectId,
+ updateProgress
+ );
+
+ // 처리 완료
+ toast.success(`${result.successCount}개의 PQ 항목이 성공적으로 가져와졌습니다.`);
+
+ if (result.errorCount > 0) {
+ toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`);
+ }
+
+ // 상태 초기화 및 다이얼로그 닫기
+ setFile(null);
+ setOpen(false);
+
+ // 성공 콜백 호출
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Excel 파일 처리 중 오류 발생:", error);
+ setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ // 다이얼로그 열기/닫기 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // 닫을 때 상태 초기화
+ setFile(null)
+ setError(null)
+ setProgress(0)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+ }
+ setOpen(newOpen)
+ }
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => setOpen(true)}
+ disabled={isUploading}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>PQ 항목 가져오기</DialogTitle>
+ <DialogDescription>
+ {projectId
+ ? "프로젝트별 PQ 항목을 Excel 파일에서 가져옵니다."
+ : "일반 PQ 항목을 Excel 파일에서 가져옵니다."}
+ <br />
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="flex items-center gap-4">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ disabled={isUploading}
+ />
+ </div>
+
+ {file && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
+ </div>
+ )}
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} />
+ <p className="text-sm text-muted-foreground text-center">
+ {progress}% 완료
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <div className="text-sm font-medium text-destructive">
+ {error}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "처리 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/table/import-pq-handler.tsx b/lib/pq/table/import-pq-handler.tsx
new file mode 100644
index 00000000..aa5e6c47
--- /dev/null
+++ b/lib/pq/table/import-pq-handler.tsx
@@ -0,0 +1,146 @@
+"use client"
+
+import { z } from "zod"
+import { createPq } from "../service" // PQ 생성 서버 액션
+
+// PQ 데이터 검증을 위한 Zod 스키마
+const pqItemSchema = z.object({
+ code: z.string().min(1, "Code is required"),
+ checkPoint: z.string().min(1, "Check point is required"),
+ groupName: z.string().min(1, "Group is required"),
+ description: z.string().optional().nullable(),
+ remarks: z.string().optional().nullable(),
+ contractInfo: z.string().optional().nullable(),
+ additionalRequirement: z.string().optional().nullable(),
+});
+
+// 지원하는 그룹 이름 목록
+const validGroupNames = [
+ "GENERAL",
+ "Quality Management System",
+ "Workshop & Environment",
+ "Warranty",
+];
+
+type ImportPqItem = z.infer<typeof pqItemSchema>;
+
+interface ProcessResult {
+ successCount: number;
+ errorCount: number;
+ errors?: Array<{ row: number; message: string }>;
+}
+
+/**
+ * Excel 파일에서 가져온 PQ 데이터를 처리하는 함수
+ */
+export async function processFileImport(
+ jsonData: any[],
+ projectId: number | null | undefined,
+ progressCallback?: (current: number, total: number) => void
+): Promise<ProcessResult> {
+ // 결과 카운터 초기화
+ let successCount = 0;
+ let errorCount = 0;
+ const errors: Array<{ row: number; message: string }> = [];
+
+ // 헤더 행과 지침 행 건너뛰기
+ const dataRows = jsonData.filter(row => {
+ // 행이 문자열로만 구성된 경우 지침 행으로 간주
+ if (Object.values(row).every(val => typeof val === 'string' && !val.includes(':'))) {
+ return false;
+ }
+ // 빈 행 건너뛰기
+ if (Object.values(row).every(val => !val)) {
+ return false;
+ }
+ return true;
+ });
+
+ // 데이터 행이 없으면 빈 결과 반환
+ if (dataRows.length === 0) {
+ return { successCount: 0, errorCount: 0 };
+ }
+
+ // 각 행에 대해 처리
+ for (let i = 0; i < dataRows.length; i++) {
+ const row = dataRows[i];
+ const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작
+
+ // 진행 상황 콜백 호출
+ if (progressCallback) {
+ progressCallback(i + 1, dataRows.length);
+ }
+
+ try {
+ // 데이터 정제
+ const cleanedRow: ImportPqItem = {
+ code: row.Code?.toString().trim() ?? "",
+ checkPoint: row["Check Point"]?.toString().trim() ?? "",
+ groupName: row["Group Name"]?.toString().trim() ?? "",
+ description: row.Description?.toString() ?? null,
+ remarks: row.Remarks?.toString() ?? null,
+ contractInfo: row["Contract Info"]?.toString() ?? null,
+ additionalRequirement: row["Additional Requirements"]?.toString() ?? null,
+ };
+
+ // 데이터 유효성 검사
+ const validationResult = pqItemSchema.safeParse(cleanedRow);
+
+ if (!validationResult.success) {
+ const errorMessage = validationResult.error.errors.map(
+ err => `${err.path.join('.')}: ${err.message}`
+ ).join(', ');
+
+ errors.push({ row: rowIndex, message: errorMessage });
+ errorCount++;
+ continue;
+ }
+
+ // 그룹 이름 유효성 검사
+ if (!validGroupNames.includes(cleanedRow.groupName)) {
+ errors.push({
+ row: rowIndex,
+ message: `Invalid group name: ${cleanedRow.groupName}. Must be one of: ${validGroupNames.join(', ')}`
+ });
+ errorCount++;
+ continue;
+ }
+
+ // PQ 생성 서버 액션 호출
+ const createResult = await createPq({
+ ...cleanedRow,
+ projectId: projectId,
+ isProjectSpecific: !!projectId,
+ });
+
+ if (createResult.success) {
+ successCount++;
+ } else {
+ errors.push({
+ row: rowIndex,
+ message: createResult.message || "Unknown error"
+ });
+ errorCount++;
+ }
+ } catch (error) {
+ console.error(`Row ${rowIndex} processing error:`, error);
+ errors.push({
+ row: rowIndex,
+ message: error instanceof Error ? error.message : "Unknown error"
+ });
+ errorCount++;
+ }
+
+ // 비동기 작업 쓰로틀링
+ if (i % 5 === 0) {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ }
+
+ // 처리 결과 반환
+ return {
+ successCount,
+ errorCount,
+ errors: errors.length > 0 ? errors : undefined
+ };
+} \ No newline at end of file
diff --git a/lib/pq/table/pq-excel-template.tsx b/lib/pq/table/pq-excel-template.tsx
new file mode 100644
index 00000000..aa8c1b3a
--- /dev/null
+++ b/lib/pq/table/pq-excel-template.tsx
@@ -0,0 +1,205 @@
+"use client"
+
+import * as ExcelJS from 'exceljs';
+import { saveAs } from 'file-saver';
+import { toast } from 'sonner';
+
+/**
+ * PQ 기준 Excel 템플릿을 다운로드하는 함수 (exceljs 사용)
+ * @param isProjectSpecific 프로젝트별 PQ 템플릿 여부
+ */
+export async function exportPqTemplate(isProjectSpecific: boolean = false) {
+ try {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+
+ // 워크시트 생성
+ const sheetName = isProjectSpecific ? "Project PQ Template" : "General PQ Template";
+ const worksheet = workbook.addWorksheet(sheetName);
+
+ // 그룹 옵션 정의 - 드롭다운 목록에 사용
+ const groupOptions = [
+ "GENERAL",
+ "Quality Management System",
+ "Workshop & Environment",
+ "Warranty",
+ ];
+
+ // 일반 PQ 필드 (기본 필드)
+ const basicFields = [
+ { header: "Code", key: "code", width: 90 },
+ { header: "Check Point", key: "checkPoint", width: 180 },
+ { header: "Group Name", key: "groupName", width: 150 },
+ { header: "Description", key: "description", width: 240 },
+ { header: "Remarks", key: "remarks", width: 180 },
+ ];
+
+ // 프로젝트별 PQ 추가 필드
+ const projectFields = isProjectSpecific
+ ? [
+ { header: "Contract Info", key: "contractInfo", width: 180 },
+ { header: "Additional Requirements", key: "additionalRequirement", width: 240 },
+ ]
+ : [];
+
+ // 모든 필드 합치기
+ const fields = [...basicFields, ...projectFields];
+
+ // 지침 행 추가
+ const instructionTitle = worksheet.addRow(["Instructions:"]);
+ instructionTitle.font = { bold: true, size: 12 };
+ worksheet.mergeCells(1, 1, 1, fields.length);
+
+ const instructions = [
+ "1. 'Code' 필드는 고유해야 합니다 (예: 1-1, A.2.3).",
+ "2. 'Check Point'는 필수 항목입니다.",
+ "3. 'Group Name'은 드롭다운 목록에서 선택하세요: GENERAL, Quality Management System, Workshop & Environment, Warranty",
+ "4. 여러 줄 텍스트는 \\n으로 줄바꿈을 표시합니다.",
+ "5. 아래 회색 배경의 예시 행은 참고용입니다. 실제 데이터 입력 전에 이 행을 수정하거나 삭제해야 합니다.",
+ ];
+
+ // 프로젝트별 PQ일 경우 추가 지침
+ if (isProjectSpecific) {
+ instructions.push(
+ "6. 'Contract Info'와 'Additional Requirements'는 프로젝트별 세부 정보를 위한 필드입니다."
+ );
+ }
+
+ // 지침 행 추가
+ instructions.forEach((instruction, idx) => {
+ const row = worksheet.addRow([instruction]);
+ worksheet.mergeCells(idx + 2, 1, idx + 2, fields.length);
+ row.font = { color: { argb: '00808080' } };
+ });
+
+ // 빈 행 추가
+ worksheet.addRow([]);
+
+ // 헤더 행 추가
+ const headerRow = worksheet.addRow(fields.map(field => field.header));
+ headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FF4472C4' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 예시 행 표시를 위한 첫 번째 열 값 수정
+ const exampleData: Record<string, string> = {
+ code: "[예시 - 수정/삭제 필요] 1-1",
+ checkPoint: "Selling / 1 year Property",
+ groupName: "GENERAL",
+ description: "Address :\nTel. / Fax :\ne-mail :",
+ remarks: "Optional remarks",
+ };
+
+ // 프로젝트별 PQ인 경우 예시 데이터에 추가 필드 추가
+ if (isProjectSpecific) {
+ exampleData.contractInfo = "Contract details for this project";
+ exampleData.additionalRequirement = "Additional technical requirements";
+ }
+
+ const exampleRow = worksheet.addRow(fields.map(field => exampleData[field.key] || ""));
+ exampleRow.font = { italic: true };
+ exampleRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFEDEDED' }
+ };
+ // 예시 행 첫 번째 셀에 코멘트 추가
+ const codeCell = worksheet.getCell(exampleRow.number, 1);
+ codeCell.note = '이 예시 행은 참고용입니다. 실제 데이터 입력 전에 수정하거나 삭제하세요.';
+
+ // Group Name 열 인덱스 찾기 (0-based)
+ const groupNameIndex = fields.findIndex(field => field.key === "groupName");
+
+ // 열 너비 설정
+ fields.forEach((field, index) => {
+ const column = worksheet.getColumn(index + 1);
+ column.width = field.width / 6.5; // ExcelJS에서는 픽셀과 다른 단위 사용
+ });
+
+ // 각 셀에 테두리 추가
+ const headerRowNum = instructions.length + 3;
+ const exampleRowNum = headerRowNum + 1;
+
+ for (let i = 1; i <= fields.length; i++) {
+ // 헤더 행에 테두리 추가
+ worksheet.getCell(headerRowNum, i).border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+
+ // 예시 행에 테두리 추가
+ worksheet.getCell(exampleRowNum, i).border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ }
+
+ // 사용자 입력용 빈 행 추가 (10개)
+ for (let rowIdx = 0; rowIdx < 10; rowIdx++) {
+ // 빈 행 추가
+ const emptyRow = worksheet.addRow(Array(fields.length).fill(''));
+ const currentRowNum = exampleRowNum + 1 + rowIdx;
+
+ // 각 셀에 테두리 추가
+ for (let colIdx = 1; colIdx <= fields.length; colIdx++) {
+ const cell = worksheet.getCell(currentRowNum, colIdx);
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+
+ // Group Name 열에 데이터 유효성 검사 (드롭다운) 추가
+ if (colIdx === groupNameIndex + 1) {
+ cell.dataValidation = {
+ type: 'list',
+ allowBlank: true,
+ formulae: [`"${groupOptions.join(',')}"`],
+ showErrorMessage: true,
+ errorStyle: 'error',
+ error: '유효하지 않은 그룹입니다',
+ errorTitle: '입력 오류',
+ prompt: '목록에서 선택하세요',
+ promptTitle: '그룹 선택'
+ };
+ }
+ }
+ }
+
+ // 예시 행이 있는 열에도 Group Name 드롭다운 적용
+ const exampleGroupCell = worksheet.getCell(exampleRowNum, groupNameIndex + 1);
+ exampleGroupCell.dataValidation = {
+ type: 'list',
+ allowBlank: true,
+ formulae: [`"${groupOptions.join(',')}"`],
+ showErrorMessage: true,
+ errorStyle: 'error',
+ error: '유효하지 않은 그룹입니다',
+ errorTitle: '입력 오류',
+ prompt: '목록에서 선택하세요',
+ promptTitle: '그룹 선택'
+ };
+
+ // 워크북을 Excel 파일로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+
+ // 파일명 설정 및 저장
+ const fileName = isProjectSpecific ? "project-pq-template.xlsx" : "general-pq-template.xlsx";
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, fileName);
+
+ toast.success(`${isProjectSpecific ? '프로젝트별' : '일반'} PQ 템플릿이 다운로드되었습니다.`);
+ } catch (error) {
+ console.error("템플릿 다운로드 중 오류 발생:", error);
+ toast.error("템플릿 다운로드 중 오류가 발생했습니다.");
+ }
+} \ No newline at end of file
diff --git a/lib/pq/table/pq-table-toolbar-actions.tsx b/lib/pq/table/pq-table-toolbar-actions.tsx
index 1d151520..1790caf8 100644
--- a/lib/pq/table/pq-table-toolbar-actions.tsx
+++ b/lib/pq/table/pq-table-toolbar-actions.tsx
@@ -2,23 +2,41 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, Send, Upload } from "lucide-react"
+import { Download, FileDown, Upload } from "lucide-react"
import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
import { DeletePqsDialog } from "./delete-pqs-dialog"
import { AddPqDialog } from "./add-pq-dialog"
import { PqCriterias } from "@/db/schema/pq"
+import { ImportPqButton } from "./import-pq-button"
+import { exportPqTemplate } from "./pq-excel-template"
-
-interface DocTableToolbarActionsProps {
+interface PqTableToolbarActionsProps {
table: Table<PqCriterias>
+ currentProjectId?: number
}
-export function PqTableToolbarActions({ table}: DocTableToolbarActionsProps) {
-
-
+export function PqTableToolbarActions({
+ table,
+ currentProjectId
+}: PqTableToolbarActionsProps) {
+ const [refreshKey, setRefreshKey] = React.useState(0)
+ const isProjectSpecific = !!currentProjectId
+
+ // Import 성공 후 테이블 갱신
+ const handleImportSuccess = () => {
+ setRefreshKey(prev => prev + 1)
+ }
+
return (
<div className="flex items-center gap-2">
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
@@ -29,27 +47,41 @@ export function PqTableToolbarActions({ table}: DocTableToolbarActionsProps) {
onSuccess={() => table.toggleAllRowsSelected(false)}
/>
) : null}
-
-
- <AddPqDialog />
-
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "Document-list",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
-
-
-
+
+ <AddPqDialog currentProjectId={currentProjectId} />
+
+ {/* Import 버튼 */}
+ <ImportPqButton
+ projectId={currentProjectId}
+ onSuccess={handleImportSuccess}
+ />
+
+ {/* Export 드롭다운 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: isProjectSpecific ? `project-${currentProjectId}-pq-criteria` : "general-pq-criteria",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ >
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>현재 데이터 내보내기</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => exportPqTemplate(isProjectSpecific)}>
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>{isProjectSpecific ? '프로젝트용' : '일반'} 템플릿 다운로드</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
</div>
)
} \ No newline at end of file
diff --git a/lib/pq/table/pq-table.tsx b/lib/pq/table/pq-table.tsx
index 73876c72..99365ad5 100644
--- a/lib/pq/table/pq-table.tsx
+++ b/lib/pq/table/pq-table.tsx
@@ -19,10 +19,12 @@ import { UpdatePqSheet } from "./update-pq-sheet"
interface DocumentListTableProps {
promises: Promise<[Awaited<ReturnType<typeof getPQs>>]>
+ currentProjectId?: number
}
export function PqsTable({
promises,
+ currentProjectId
}: DocumentListTableProps) {
// 1) 데이터를 가져옴 (server component -> use(...) pattern)
const [{ data, pageCount }] = React.use(promises)
@@ -103,7 +105,7 @@ export function PqsTable({
filterFields={advancedFilterFields}
shallow={false}
>
- <PqTableToolbarActions table={table} />
+ <PqTableToolbarActions table={table} currentProjectId={currentProjectId}/>
</DataTableAdvancedToolbar>
</DataTable>
diff --git a/lib/rfqs/table/ItemsDialog.tsx b/lib/rfqs/table/ItemsDialog.tsx
index f1dbf90e..3d822499 100644
--- a/lib/rfqs/table/ItemsDialog.tsx
+++ b/lib/rfqs/table/ItemsDialog.tsx
@@ -96,16 +96,16 @@ export function RfqsItemsDialog({
rfqType
}: RfqsItemsDialogProps) {
const rfqId = rfq?.rfqId ?? 0;
-
+
// 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능
const isEditable = rfq?.status === "DRAFT";
-
+
// 초기 아이템 ID 목록을 추적하기 위한 상태 추가
const [initialItemIds, setInitialItemIds] = React.useState<(number | undefined)[]>([]);
-
+
// 삭제된 아이템 ID를 저장하는 상태 추가
const [deletedItemIds, setDeletedItemIds] = React.useState<number[]>([]);
-
+
// 1) form
const form = useForm<ItemsFormSchema>({
resolver: zodResolver(itemsFormSchema),
@@ -125,24 +125,24 @@ export function RfqsItemsDialog({
// 다이얼로그가 열릴 때마다 폼 초기화 및 초기 아이템 ID 저장
React.useEffect(() => {
if (open) {
- const initialItems = defaultItems.length > 0
+ const initialItems = defaultItems.length > 0
? defaultItems.map((it) => ({
- id: it.id,
- quantity: it.quantity ?? 1,
- uom: it.uom ?? "each",
- itemCode: it.itemCode ?? "",
- description: it.description ?? "",
- }))
+ id: it.id,
+ quantity: it.quantity ?? 1,
+ uom: it.uom ?? "each",
+ itemCode: it.itemCode ?? "",
+ description: it.description ?? "",
+ }))
: [{ itemCode: "", description: "", quantity: 1, uom: "each" }];
-
+
form.reset({
rfqId,
items: initialItems,
});
-
+
// 초기 아이템 ID 목록 저장
setInitialItemIds(defaultItems.map(item => item.id));
-
+
// 삭제된 아이템 목록 초기화
setDeletedItemIds([]);
setHasUnsavedChanges(false);
@@ -158,7 +158,7 @@ export function RfqsItemsDialog({
// 폼 변경 감지 - 편집 가능한 경우에만 변경 감지
React.useEffect(() => {
if (!isEditable) return;
-
+
const subscription = form.watch(() => {
setHasUnsavedChanges(true);
});
@@ -177,16 +177,16 @@ export function RfqsItemsDialog({
// 4) Add item row with auto-focus
function handleAddItem() {
if (!isEditable) return;
-
+
// 명시적으로 숫자 타입으로 지정
- append({
- itemCode: "",
- description: "",
- quantity: 1,
- uom: "each"
+ append({
+ itemCode: "",
+ description: "",
+ quantity: 1,
+ uom: "each"
});
setHasUnsavedChanges(true);
-
+
// 다음 렌더링 사이클에서 새로 추가된 항목에 포커스
setTimeout(() => {
const newIndex = fields.length;
@@ -200,17 +200,17 @@ export function RfqsItemsDialog({
// 항목 직접 삭제 - 기존 ID가 있을 경우 삭제 목록에 추가
const handleRemoveItem = (index: number) => {
if (!isEditable) return;
-
+
const itemToRemove = form.getValues().items[index];
-
+
// 기존 ID가 있는 아이템이라면 삭제 목록에 추가
if (itemToRemove.id !== undefined) {
setDeletedItemIds(prev => [...prev, itemToRemove.id as number]);
}
-
+
remove(index);
setHasUnsavedChanges(true);
-
+
// 포커스 처리: 다음 항목이 있으면 다음 항목으로, 없으면 마지막 항목으로
setTimeout(() => {
const nextIndex = Math.min(index, fields.length - 1);
@@ -232,7 +232,7 @@ export function RfqsItemsDialog({
// 필드 포커스 유틸리티 함수
const focusField = (selector: string) => {
if (!isEditable) return;
-
+
setTimeout(() => {
const element = document.querySelector(selector) as HTMLInputElement | null;
if (element) {
@@ -244,28 +244,28 @@ export function RfqsItemsDialog({
// 5) Submit - 업데이트된 제출 로직 (생성/수정 + 삭제 처리)
async function onSubmit(data: ItemsFormSchema) {
if (!isEditable) return;
-
+
try {
setIsSubmitting(true);
-
+
// 각 아이템이 유효한지 확인
const anyInvalidItems = data.items.some(item => !item.itemCode || item.quantity < 1);
-
+
if (anyInvalidItems) {
toast.error("유효하지 않은 아이템이 있습니다. 모든 필드를 확인해주세요.");
setIsSubmitting(false);
return;
}
-
+
// 1. 삭제 처리 - 삭제된 아이템 ID가 있으면 삭제 요청
- const deletePromises = deletedItemIds.map(id =>
+ const deletePromises = deletedItemIds.map(id =>
deleteRfqItem({
id: id,
rfqId: rfqId,
rfqType: rfqType ?? RfqType.PURCHASE
})
);
-
+
// 2. 생성/수정 처리 - 폼에 남아있는 아이템들
const upsertPromises = data.items.map((item) =>
createRfqItem({
@@ -273,13 +273,13 @@ export function RfqsItemsDialog({
itemCode: item.itemCode,
description: item.description,
// 명시적으로 숫자로 변환
- quantity: Number(item.quantity),
+ quantity: Number(item.quantity),
uom: item.uom,
rfqType: rfqType ?? RfqType.PURCHASE,
id: item.id // 기존 ID가 있으면 업데이트, 없으면 생성
})
);
-
+
// 모든 요청 병렬 처리
await Promise.all([...deletePromises, ...upsertPromises]);
@@ -296,7 +296,7 @@ export function RfqsItemsDialog({
// 단축키 처리 - 편집 가능한 경우에만 단축키 활성화
React.useEffect(() => {
if (!isEditable) return;
-
+
const handleKeyDown = (e: KeyboardEvent) => {
// Alt+N: 새 항목 추가
if (e.altKey && e.key === 'n') {
@@ -336,8 +336,8 @@ export function RfqsItemsDialog({
</Badge>
)}
{rfq?.status && (
- <Badge
- variant={rfq.status === "DRAFT" ? "outline" : "secondary"}
+ <Badge
+ variant={rfq.status === "DRAFT" ? "outline" : "secondary"}
className="ml-1"
>
{rfq.status}
@@ -345,8 +345,8 @@ export function RfqsItemsDialog({
)}
</DialogTitle>
<DialogDescription>
- {isEditable
- ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.')
+ {isEditable
+ ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.')
: '드래프트 상태가 아닌 RFQ는 아이템을 편집할 수 없습니다.'}
</DialogDescription>
</DialogHeader>
@@ -393,6 +393,7 @@ export function RfqsItemsDialog({
<div key={field.id} className="flex items-center gap-2 group hover:bg-gray-50 p-1 rounded-md transition-colors">
{/* -- itemCode + Popover(Select) -- */}
{isEditable ? (
+ // 전체 FormField 컴포넌트와 아이템 선택 로직 개선
<FormField
control={form.control}
name={`items.${index}.itemCode`}
@@ -401,7 +402,7 @@ export function RfqsItemsDialog({
const selected = filteredItems.find(it => it.code === field.value);
return (
- <FormItem className="flex items-center gap-2 w-[250px]">
+ <FormItem className="flex items-center gap-2 w-[250px]" style={{width:250}}>
<FormControl>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
@@ -413,12 +414,17 @@ export function RfqsItemsDialog({
variant="outline"
role="combobox"
aria-expanded={popoverOpen}
- className="w-full justify-between"
+ className="flex items-center"
data-error={!!form.formState.errors.items?.[index]?.itemCode}
data-state={selected ? "filled" : "empty"}
+ style={{width:250}}
>
- {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ <div className="flex-1 overflow-hidden mr-2 text-left">
+ <span className="block truncate" style={{width:200}}>
+ {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."}
+ </span>
+ </div>
+ <ChevronsUpDown className="h-4 w-4 flex-shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
@@ -440,7 +446,9 @@ export function RfqsItemsDialog({
focusField(`input[name="items.${index}.description"]`);
}}
>
- {label}
+ <div className="flex-1 overflow-hidden">
+ <span className="block truncate">{label}</span>
+ </div>
<Check
className={
"ml-auto h-4 w-4" +
@@ -486,9 +494,9 @@ export function RfqsItemsDialog({
render={({ field }) => (
<FormItem className="w-[400px]">
<FormControl>
- <Input
- className="w-full"
- placeholder="아이템 상세 정보"
+ <Input
+ className="w-full"
+ placeholder="아이템 상세 정보"
{...field}
onKeyDown={(e) => {
if (e.key === 'Enter') {
@@ -650,7 +658,7 @@ export function RfqsItemsDialog({
</span>
)}
</div>
-
+
{isEditable && (
<div className="text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 mr-2">
@@ -680,12 +688,12 @@ export function RfqsItemsDialog({
<TooltipContent>변경사항을 저장하지 않고 나가기</TooltipContent>
</Tooltip>
</TooltipProvider>
-
+
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
- <Button
- type="submit"
+ <Button
+ type="submit"
disabled={isSubmitting || (!form.formState.isDirty && deletedItemIds.length === 0) || !form.formState.isValid}
>
{isSubmitting ? (
diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx
index 1d824bc0..45390cd0 100644
--- a/lib/rfqs/table/add-rfq-dialog.tsx
+++ b/lib/rfqs/table/add-rfq-dialog.tsx
@@ -128,7 +128,11 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
}, [budgetaryRfqs, budgetarySearchTerm]);
// 프로젝트 선택 처리
- const handleProjectSelect = (project: Project) => {
+ const handleProjectSelect = (project: Project | null) => {
+ if (project === null) {
+ return;
+ }
+
form.setValue("projectId", project.id);
};
diff --git a/lib/tags/service.ts b/lib/tags/service.ts
index efba2fd5..034c106f 100644
--- a/lib/tags/service.ts
+++ b/lib/tags/service.ts
@@ -7,7 +7,7 @@ import { createTagSchema, GetTagsSchema, updateTagSchema, UpdateTagSchema, type
import { revalidateTag, unstable_noStore } from "next/cache";
import { filterColumns } from "@/lib/filter-columns";
import { unstable_cache } from "@/lib/unstable-cache";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne } from "drizzle-orm";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne ,count,isNull} from "drizzle-orm";
import { countTags, deleteTagById, deleteTagsByIds, insertTag, selectTags } from "./repository";
import { getErrorMessage } from "../handle-error";
import { getFormMappingsByTagType } from './form-mapping-service';
@@ -158,6 +158,7 @@ export async function createTag(
const createdOrExistingForms: CreatedOrExistingForm[] = []
if (formMappings && formMappings.length > 0) {
+ console.log(selectedPackageId, formMappings)
for (const formMapping of formMappings) {
// 4-1) 이미 존재하는 폼인지 확인
const existingForm = await tx
@@ -236,6 +237,8 @@ export async function createTag(
}
})
} catch (err: any) {
+ console.log("createTag error:", err)
+
console.error("createTag error:", err)
return { error: getErrorMessage(err) }
}
@@ -540,12 +543,12 @@ function removeTagFromDataJson(
export async function removeTags(input: RemoveTagsInput) {
unstable_noStore() // React 서버 액션 무상태 함수
-
+
const { ids, selectedPackageId } = input
-
+
try {
await db.transaction(async (tx) => {
- // 1) 삭제 대상 tag들을 미리 조회 (tagNo, tagType, class 등을 얻기 위함)
+ // 1) 삭제 대상 tag들을 미리 조회
const tagsToDelete = await tx
.select({
id: tags.id,
@@ -555,72 +558,112 @@ export async function removeTags(input: RemoveTagsInput) {
})
.from(tags)
.where(inArray(tags.id, ids))
-
- // 2) 각 tag마다 관련된 formCode를 찾고, forms & formEntries 처리를 수행
- for (const tagInfo of tagsToDelete) {
- const { tagNo, tagType, class: tagClass } = tagInfo
-
- // 2-1) tagTypeClassFormMappings(혹은 대응되는 로직)에서 formCode 목록 가져오기
- const formMappings = await getFormMappingsByTagType(tagType, tagClass)
- if (!formMappings) continue
-
- // 2-2) 얻어온 formCode 리스트를 순회하면서, forms 테이블과 formEntries 테이블 처리
- for (const fm of formMappings) {
- // (A) forms 테이블 삭제
- // - 조건: contractItemId=selectedPackageId, formCode=fm.formCode
- await tx
- .delete(forms)
- .where(
- and(
- eq(forms.contractItemId, selectedPackageId),
- eq(forms.formCode, fm.formCode)
- )
+
+ // 2) 태그 타입과 클래스의 고유 조합 추출
+ const uniqueTypeClassCombinations = [...new Set(
+ tagsToDelete.map(tag => `${tag.tagType}|${tag.class || ''}`)
+ )].map(combo => {
+ const [tagType, classValue] = combo.split('|');
+ return { tagType, class: classValue || undefined };
+ });
+
+ // 3) 각 태그 타입/클래스 조합에 대해 처리
+ for (const { tagType, class: classValue } of uniqueTypeClassCombinations) {
+ // 3-1) 삭제 중인 태그들 외에, 동일한 태그 타입/클래스를 가진 다른 태그가 있는지 확인
+ const otherTagsWithSameTypeClass = await tx
+ .select({ count: count() })
+ .from(tags)
+ .where(
+ and(
+ eq(tags.tagType, tagType),
+ classValue ? eq(tags.class, classValue) : isNull(tags.class),
+ not(inArray(tags.id, ids)), // 현재 삭제 중인 태그들은 제외
+ eq(tags.contractItemId, selectedPackageId) // 같은 contractItemId 내에서만 확인
)
-
- // (B) formEntries 테이블 JSON에서 tagNo 제거 → 업데이트
- // - 예: formEntries 안에 (id, contractItemId, formCode, data(=json)) 칼럼 존재 가정
- const formEntryRecords = await tx
- .select({
- id: formEntries.id,
- data: formEntries.data,
- })
- .from(formEntries)
- .where(
- and(
- eq(formEntries.contractItemId, selectedPackageId),
- eq(formEntries.formCode, fm.formCode)
+ )
+
+ // 3-2) 이 태그 타입/클래스에 연결된 폼 매핑 가져오기
+ const formMappings = await getFormMappingsByTagType(tagType, classValue);
+
+ if (!formMappings.length) continue;
+
+ // 3-3) 이 태그 타입/클래스와 관련된 태그 번호 추출
+ const relevantTagNos = tagsToDelete
+ .filter(tag => tag.tagType === tagType &&
+ (classValue ? tag.class === classValue : !tag.class))
+ .map(tag => tag.tagNo);
+
+ // 3-4) 각 폼 코드에 대해 처리
+ for (const formMapping of formMappings) {
+ // 다른 태그가 없다면 폼 삭제
+ if (otherTagsWithSameTypeClass[0].count === 0) {
+ // 폼 삭제
+ await tx
+ .delete(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, selectedPackageId),
+ eq(forms.formCode, formMapping.formCode)
+ )
)
- )
-
- // 여러 formEntries 레코드가 있을 수도 있어서 모두 처리
- for (const entry of formEntryRecords) {
- const updatedJson = removeTagFromDataJson(entry.data, tagNo)
-
- // 변경이 있다면 업데이트
+
+ // formEntries 테이블에서도 해당 formCode 관련 데이터 삭제
await tx
- .update(formEntries)
- .set({ data: updatedJson })
- .where(eq(formEntries.id, entry.id))
+ .delete(formEntries)
+ .where(
+ and(
+ eq(formEntries.contractItemId, selectedPackageId),
+ eq(formEntries.formCode, formMapping.formCode)
+ )
+ )
+ }
+ // 다른 태그가 있다면 formEntries 데이터에서 해당 태그 정보만 제거
+ else if (relevantTagNos.length > 0) {
+ const formEntryRecords = await tx
+ .select({
+ id: formEntries.id,
+ data: formEntries.data,
+ })
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.contractItemId, selectedPackageId),
+ eq(formEntries.formCode, formMapping.formCode)
+ )
+ )
+
+ // 각 formEntry에 대해 처리
+ for (const entry of formEntryRecords) {
+ let updatedJson = entry.data;
+
+ // 각 tagNo에 대해 JSON 데이터에서 제거
+ for (const tagNo of relevantTagNos) {
+ updatedJson = removeTagFromDataJson(updatedJson, tagNo);
+ }
+
+ // 변경이 있다면 업데이트
+ await tx
+ .update(formEntries)
+ .set({ data: updatedJson })
+ .where(eq(formEntries.id, entry.id))
+ }
}
}
}
-
- // 3) 마지막으로 실제로 tags 테이블에서 Tag들을 삭제
- // (Tag → forms → formEntries 순서대로 처리)
+
+ // 4) 마지막으로 tags 테이블에서 태그들 삭제
await tx.delete(tags).where(inArray(tags.id, ids))
})
-
- // 4) 캐시 무효화
- // revalidateTag("tags")
+
+ // 5) 캐시 무효화
revalidateTag(`tags-${selectedPackageId}`)
revalidateTag(`forms-${selectedPackageId}`)
-
+
return { data: null, error: null }
} catch (err) {
return { data: null, error: getErrorMessage(err) }
}
}
-
// Updated service functions to support the new schema
// 업데이트된 ClassOption 타입
diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx
index 3814761d..e1e176cf 100644
--- a/lib/tags/table/add-tag-dialog.tsx
+++ b/lib/tags/table/add-tag-dialog.tsx
@@ -112,7 +112,6 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
const fieldIdsRef = React.useRef<Record<string, string>>({})
const classOptionIdsRef = React.useRef<Record<string, string>>({})
- console.log(subFields)
// ---------------
// Load Class Options
@@ -296,6 +295,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
try {
const res = await createTag(tagData, selectedPackageId);
if ("error" in res) {
+ console.log(res.error )
failedTags.push({ tag: row.tagNo, error: res.error });
} else {
successfulTags.push(row.tagNo);
@@ -311,8 +311,9 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
}
if (failedTags.length > 0) {
+ console.log("Failed tags:", failedTags);
+
toast.error(`${failedTags.length}개의 태그 생성에 실패했습니다.`);
- console.error("Failed tags:", failedTags);
}
// Refresh the page
diff --git a/lib/tasks/utils.ts b/lib/tasks/utils.ts
index ea4425de..aaa1184c 100644
--- a/lib/tasks/utils.ts
+++ b/lib/tasks/utils.ts
@@ -1,18 +1,30 @@
import { tasks, type Task } from "@/db/schema/tasks"
import { faker } from "@faker-js/faker"
import {
+ Activity,
+ AlertCircle,
+ AlertTriangle,
ArrowDownIcon,
ArrowRightIcon,
ArrowUpIcon,
AwardIcon,
+ BadgeCheck,
CheckCircle2,
CircleHelp,
CircleIcon,
CircleX,
+ ClipboardCheck,
+ ClipboardList,
+ FileCheck2,
+ FilePenLine,
+ FileX2,
+ MailCheck,
PencilIcon,
SearchIcon,
SendIcon,
Timer,
+ Trash2,
+ XCircle,
} from "lucide-react"
import { customAlphabet } from "nanoid"
@@ -51,6 +63,7 @@ export function getStatusIcon(status: Task["status"]) {
return statusIcons[status] || CircleIcon
}
+
export function getRFQStatusIcon(status: Rfq["status"]) {
const statusIcons = {
DRAFT: PencilIcon,
diff --git a/lib/utils.ts b/lib/utils.ts
index 2eca9285..af9df057 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -28,10 +28,15 @@ export function formatDate(
// Alternative: Create a separate function for date and time
export function formatDateTime(
- date: Date | string | number,
+ date: Date | string | number| null | undefined,
locale: string = "en-US",
opts: Intl.DateTimeFormatOptions = {}
) {
+
+ if (date === null || date === undefined || date === '') {
+ return ''; // 또는 '-', 'N/A' 등 원하는 기본값 반환
+ }
+
return new Intl.DateTimeFormat(locale, {
month: opts.month ?? "long",
day: opts.day ?? "numeric",
diff --git a/lib/vendor-candidates/service.ts b/lib/vendor-candidates/service.ts
new file mode 100644
index 00000000..68971f18
--- /dev/null
+++ b/lib/vendor-candidates/service.ts
@@ -0,0 +1,360 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { vendorCandidates} from "@/db/schema/vendors"
+import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm";
+import { revalidateTag, unstable_noStore } from "next/cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+import db from "@/db/db";
+import { sendEmail } from "../mail/sendEmail";
+import { CreateVendorCandidateSchema, createVendorCandidateSchema, GetVendorsCandidateSchema, RemoveCandidatesInput, removeCandidatesSchema, updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "./validations";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+export async function getVendorCandidates(input: GetVendorsCandidateSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 1) Advanced filters
+ const advancedWhere = filterColumns({
+ table: vendorCandidates,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
+
+ // 2) Global search
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(vendorCandidates.companyName, s),
+ ilike(vendorCandidates.contactEmail, s),
+ ilike(vendorCandidates.contactPhone, s),
+ ilike(vendorCandidates.country, s),
+ ilike(vendorCandidates.source, s),
+ ilike(vendorCandidates.status, s),
+ // etc.
+ )
+ }
+
+ // 3) Combine finalWhere
+ // Example: Only show vendorStatus = "PQ_SUBMITTED"
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere,
+ )
+
+
+
+ // 5) Sorting
+ const orderBy =
+ input.sort && input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(vendorCandidates[item.id])
+ : asc(vendorCandidates[item.id])
+ )
+ : [desc(vendorCandidates.createdAt)]
+
+ // 6) Query & count
+ const { data, total } = await db.transaction(async (tx) => {
+ // a) Select from the view
+ const candidatesData = await tx
+ .select()
+ .from(vendorCandidates)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(input.perPage)
+
+ // b) Count total
+ const resCount = await tx
+ .select({ count: count() })
+ .from(vendorCandidates)
+ .where(finalWhere)
+
+ return { data: candidatesData, total: resCount[0]?.count }
+ })
+
+ // 7) Calculate pageCount
+ const pageCount = Math.ceil(total / input.perPage)
+
+ // Now 'data' already contains JSON arrays of contacts & items
+ // thanks to the subqueries in the view definition!
+ return { data, pageCount }
+ } catch (err) {
+ console.error(err)
+ return { data: [], pageCount: 0 }
+ }
+ },
+ // Cache key
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["vendor-candidates"],
+ }
+ )()
+}
+
+export async function createVendorCandidate(input: CreateVendorCandidateSchema) {
+ try {
+ // Validate input
+ const validated = createVendorCandidateSchema.parse(input);
+
+ // Insert into database
+ const [newCandidate] = await db
+ .insert(vendorCandidates)
+ .values({
+ companyName: validated.companyName,
+ contactEmail: validated.contactEmail,
+ contactPhone: validated.contactPhone || null,
+ country: validated.country || null,
+ source: validated.source || null,
+ status: validated.status,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+
+ return { success: true, data: newCandidate };
+ } catch (error) {
+ console.error("Failed to create vendor candidate:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+
+// Helper function to group vendor candidates by status
+async function groupVendorCandidatesByStatus( tx: PgTransaction<any, any, any>,) {
+ return tx
+ .select({
+ status: vendorCandidates.status,
+ count: count(),
+ })
+ .from(vendorCandidates)
+ .groupBy(vendorCandidates.status);
+}
+
+/**
+ * Get count of vendor candidates grouped by status
+ */
+export async function getVendorCandidateCounts() {
+ return unstable_cache(
+ async () => {
+ try {
+ // Initialize counts object with all possible statuses set to 0
+ const initial: Record<"COLLECTED" | "INVITED" | "DISCARDED", number> = {
+ COLLECTED: 0,
+ INVITED: 0,
+ DISCARDED: 0,
+ };
+
+ // Execute query within transaction and transform results
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupVendorCandidatesByStatus(tx);
+ return rows.reduce<Record<string, number>>((acc, { status, count }) => {
+ if (status in acc) {
+ acc[status] = count;
+ }
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ console.error("Failed to get vendor candidate counts:", err);
+ return {
+ COLLECTED: 0,
+ INVITED: 0,
+ DISCARDED: 0,
+ };
+ }
+ },
+ ["vendor-candidate-status-counts"], // Cache key
+ {
+ revalidate: 3600, // Revalidate every hour
+ // tags: ["vendor-candidates"], // Use the same tag as other vendor candidate functions
+ }
+ )();
+}
+
+
+/**
+ * Update a vendor candidate
+ */
+export async function updateVendorCandidate(input: UpdateVendorCandidateSchema) {
+ try {
+ // Validate input
+ const validated = updateVendorCandidateSchema.parse(input);
+
+ // Prepare update data (excluding id)
+ const { id, ...updateData } = validated;
+
+ // Add updatedAt timestamp
+ const dataToUpdate = {
+ ...updateData,
+ updatedAt: new Date(),
+ };
+
+ // Update database
+ const [updatedCandidate] = await db
+ .update(vendorCandidates)
+ .set(dataToUpdate)
+ .where(eq(vendorCandidates.id, id))
+ .returning();
+
+ // If status was updated to "INVITED", send email
+ if (validated.status === "INVITED" && updatedCandidate.contactEmail) {
+ await sendEmail({
+ to: updatedCandidate.contactEmail,
+ subject: "Invitation to Register as a Vendor",
+ template: "vendor-invitation",
+ context: {
+ companyName: updatedCandidate.companyName,
+ language: "en",
+ registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`,
+ }
+ });
+ }
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+
+ return { success: true, data: updatedCandidate };
+ } catch (error) {
+ console.error("Failed to update vendor candidate:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * Update status of multiple vendor candidates at once
+ */
+export async function bulkUpdateVendorCandidateStatus({
+ ids,
+ status
+}: {
+ ids: number[],
+ status: "COLLECTED" | "INVITED" | "DISCARDED"
+}) {
+ try {
+ // Validate inputs
+ if (!ids.length) {
+ return { success: false, error: "No IDs provided" };
+ }
+
+ if (!["COLLECTED", "INVITED", "DISCARDED"].includes(status)) {
+ return { success: false, error: "Invalid status" };
+ }
+
+ // Get current data of candidates (needed for email sending)
+ const candidatesBeforeUpdate = await db
+ .select()
+ .from(vendorCandidates)
+ .where(inArray(vendorCandidates.id, ids));
+
+ // Update all records
+ const updatedCandidates = await db
+ .update(vendorCandidates)
+ .set({
+ status,
+ updatedAt: new Date(),
+ })
+ .where(inArray(vendorCandidates.id, ids))
+ .returning();
+
+ // If status is "INVITED", send emails to all updated candidates
+ if (status === "INVITED") {
+ const emailPromises = updatedCandidates
+ .filter(candidate => candidate.contactEmail)
+ .map(candidate =>
+ sendEmail({
+ to: candidate.contactEmail!,
+ subject: "Invitation to Register as a Vendor",
+ template: "vendor-invitation",
+ context: {
+ companyName: candidate.companyName,
+ language: "en",
+ registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`,
+ }
+ })
+ );
+
+ // Wait for all emails to be sent
+ await Promise.all(emailPromises);
+ }
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+
+ return {
+ success: true,
+ data: updatedCandidates,
+ count: updatedCandidates.length
+ };
+ } catch (error) {
+ console.error("Failed to bulk update vendor candidates:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+
+
+
+/**
+ * Remove multiple vendor candidates by their IDs
+ */
+export async function removeCandidates(input: RemoveCandidatesInput) {
+ try {
+ // Validate input
+ const validated = removeCandidatesSchema.parse(input);
+
+ // Get candidates before deletion (for logging purposes)
+ const candidatesBeforeDelete = await db
+ .select({
+ id: vendorCandidates.id,
+ companyName: vendorCandidates.companyName,
+ })
+ .from(vendorCandidates)
+ .where(inArray(vendorCandidates.id, validated.ids));
+
+ // Delete the candidates
+ const deletedCandidates = await db
+ .delete(vendorCandidates)
+ .where(inArray(vendorCandidates.id, validated.ids))
+ .returning({ id: vendorCandidates.id });
+
+ // If no candidates were deleted, return an error
+ if (!deletedCandidates.length) {
+ return {
+ success: false,
+ error: "No candidates were found with the provided IDs",
+ };
+ }
+
+ // Log deletion for audit purposes
+ console.log(
+ `Deleted ${deletedCandidates.length} vendor candidates:`,
+ candidatesBeforeDelete.map(c => `${c.id} (${c.companyName})`)
+ );
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+ revalidateTag("vendor-candidate-status-counts");
+ revalidateTag("vendor-candidate-total-count");
+
+ return {
+ success: true,
+ count: deletedCandidates.length,
+ deletedIds: deletedCandidates.map(c => c.id),
+ };
+ } catch (error) {
+ console.error("Failed to remove vendor candidates:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+} \ No newline at end of file
diff --git a/lib/vendor-candidates/table/add-candidates-dialog.tsx b/lib/vendor-candidates/table/add-candidates-dialog.tsx
new file mode 100644
index 00000000..db475064
--- /dev/null
+++ b/lib/vendor-candidates/table/add-candidates-dialog.tsx
@@ -0,0 +1,327 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown } from "lucide-react"
+import i18nIsoCountries from "i18n-iso-countries"
+import enLocale from "i18n-iso-countries/langs/en.json"
+import koLocale from "i18n-iso-countries/langs/ko.json"
+import { cn } from "@/lib/utils"
+
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+
+// react-hook-form + shadcn/ui Form
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+
+// shadcn/ui Select
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+import { createVendorCandidateSchema, CreateVendorCandidateSchema } from "../validations"
+import { createVendorCandidate } from "../service"
+import { vendorCandidates } from "@/db/schema/vendors"
+
+// Register locales for countries
+i18nIsoCountries.registerLocale(enLocale)
+i18nIsoCountries.registerLocale(koLocale)
+
+// Generate country array
+const locale = "ko"
+const countryMap = i18nIsoCountries.getNames(locale, { select: "official" })
+const countryArray = Object.entries(countryMap).map(([code, label]) => ({
+ code,
+ label,
+}))
+
+export function AddCandidateDialog() {
+ const [open, setOpen] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // react-hook-form 세팅
+ const form = useForm<CreateVendorCandidateSchema>({
+ resolver: zodResolver(createVendorCandidateSchema),
+ defaultValues: {
+ companyName: "",
+ contactEmail: "",
+ contactPhone: "",
+ country: "",
+ source: "",
+ status: "COLLECTED", // Default status set to COLLECTED
+ },
+ })
+
+ async function onSubmit(data: CreateVendorCandidateSchema) {
+ setIsSubmitting(true)
+ try {
+ const result = await createVendorCandidate(data)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ setOpen(false)
+ } catch (error) {
+ console.error("Failed to create vendor candidate:", error)
+ alert("An unexpected error occurred")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달을 열기 위한 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Vendor Candidate
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>Create New Vendor Candidate</DialogTitle>
+ <DialogDescription>
+ 새 Vendor Candidate 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ {/* Company Name 필드 */}
+ <FormField
+ control={form.control}
+ name="companyName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ Company Name
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Enter company name"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Contact Email 필드 */}
+ <FormField
+ control={form.control}
+ name="contactEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ Contact Email
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="email@example.com"
+ type="email"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Contact Phone 필드 */}
+ <FormField
+ control={form.control}
+ name="contactPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Phone</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="+82-10-1234-5678"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Country 필드 */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => {
+ const selectedCountry = countryArray.find(
+ (c) => c.code === field.value
+ )
+ return (
+ <FormItem>
+ <FormLabel>Country</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ disabled={isSubmitting}
+ >
+ {selectedCountry
+ ? selectedCountry.label
+ : "Select a country"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[300px] p-0">
+ <Command>
+ <CommandInput placeholder="Search country..." />
+ <CommandList>
+ <CommandEmpty>No country found.</CommandEmpty>
+ <CommandGroup className="max-h-[300px] overflow-y-auto">
+ {countryArray.map((country) => (
+ <CommandItem
+ key={country.code}
+ value={country.label}
+ onSelect={() =>
+ field.onChange(country.code)
+ }
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ country.code === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {country.label}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+
+ {/* Source 필드 */}
+ <FormField
+ control={form.control}
+ name="source"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Source</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Where this candidate was found"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Status 필드 */}
+ {/* <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Status</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={isSubmitting}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select status" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ {vendorCandidates.status.enumValues.map((status) => (
+ <SelectItem key={status} value={status}>
+ {status}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ /> */}
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "Creating..." : "Create"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-candidates/table/candidates-table-columns.tsx b/lib/vendor-candidates/table/candidates-table-columns.tsx
new file mode 100644
index 00000000..dc014d4e
--- /dev/null
+++ b/lib/vendor-candidates/table/candidates-table-columns.tsx
@@ -0,0 +1,193 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors"
+import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils"
+import { candidateColumnsConfig } from "@/config/candidatesColumnsConfig"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidates> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidates>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorCandidates> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<VendorCandidates> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidates>[] }
+ const groupMap: Record<string, ColumnDef<VendorCandidates>[]> = {}
+
+ candidateColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<VendorCandidates> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+
+ if (cfg.id === "status") {
+ const statusVal = row.original.status
+ if (!statusVal) return null
+ const Icon = getCandidateStatusIcon(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>
+ )
+ }
+
+
+ if (cfg.id === "createdAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ // code etc...
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<VendorCandidates>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-candidates/table/candidates-table-floating-bar.tsx b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx
new file mode 100644
index 00000000..2696292d
--- /dev/null
+++ b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx
@@ -0,0 +1,337 @@
+"use client"
+
+import * as React from "react"
+import { SelectTrigger } from "@radix-ui/react-select"
+import { type Table } from "@tanstack/react-table"
+import {
+ ArrowUp,
+ CheckCircle2,
+ Download,
+ Loader,
+ Trash2,
+ X,
+ Mail,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors"
+import { bulkUpdateVendorCandidateStatus, removeCandidates, updateVendorCandidate } from "../service"
+
+interface CandidatesTableFloatingBarProps {
+ table: Table<VendorCandidates>
+}
+
+export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+
+ const [isPending, startTransition] = React.useTransition()
+ const [action, setAction] = React.useState<
+ "update-status" | "export" | "delete" | "invite"
+ >()
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ // Clear selection on Escape key press
+ React.useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ table.toggleAllRowsSelected(false)
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [table])
+
+ // 공용 confirm dialog state
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => { },
+ })
+
+ // 1) "삭제" Confirm 열기
+ function handleDeleteConfirm() {
+ setAction("delete")
+ setConfirmProps({
+ title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`,
+ description: "This action cannot be undone.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await removeCandidates({
+ ids: rows.map((row) => row.original.id),
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Users deleted")
+ table.toggleAllRowsSelected(false)
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ // 2) 상태 업데이트
+ function handleSelectStatus(newStatus: VendorCandidates["status"]) {
+ setAction("update-status")
+
+ setConfirmProps({
+ title: `Update ${rows.length} candidate${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`,
+ description: "This action will override their current status.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await bulkUpdateVendorCandidateStatus({
+ ids: rows.map((row) => row.original.id),
+ status: newStatus,
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Candidates updated")
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ // 3) 초대하기 (INVITED 상태로 바꾸고 이메일 전송)
+ function handleInvite() {
+ setAction("invite")
+ setConfirmProps({
+ title: `Invite ${rows.length} candidate${rows.length > 1 ? "s" : ""}?`,
+ description: "This will change their status to INVITED and send invitation emails.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await bulkUpdateVendorCandidateStatus({
+ ids: rows.map((row) => row.original.id),
+ status: "INVITED",
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Invitation emails sent successfully")
+ table.toggleAllRowsSelected(false)
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ return (
+ <Portal >
+ <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}>
+ <div className="w-full overflow-x-auto">
+ <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
+ <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
+ <span className="whitespace-nowrap text-xs">
+ {rows.length} selected
+ </span>
+ <Separator orientation="vertical" className="ml-2 mr-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-5 hover:border"
+ onClick={() => table.toggleAllRowsSelected(false)}
+ >
+ <X className="size-3.5 shrink-0" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
+ <p className="mr-2">Clear selection</p>
+ <Kbd abbrTitle="Escape" variant="outline">
+ Esc
+ </Kbd>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ <Separator orientation="vertical" className="hidden h-5 sm:block" />
+ <div className="flex items-center gap-1.5">
+ {/* 초대하기 버튼 (새로 추가) */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="sm"
+ className="h-7 border"
+ onClick={handleInvite}
+ disabled={isPending}
+ >
+ {isPending && action === "invite" ? (
+ <Loader
+ className="mr-1 size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Mail className="mr-1 size-3.5" aria-hidden="true" />
+ )}
+ <span>Invite</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Send invitation emails</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Select
+ onValueChange={(value: VendorCandidates["status"]) => {
+ handleSelectStatus(value)
+ }}
+ >
+ <Tooltip>
+ <SelectTrigger asChild>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground"
+ disabled={isPending}
+ >
+ {isPending && action === "update-status" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <CheckCircle2
+ className="size-3.5"
+ aria-hidden="true"
+ />
+ )}
+ </Button>
+ </TooltipTrigger>
+ </SelectTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Update status</p>
+ </TooltipContent>
+ </Tooltip>
+ <SelectContent align="center">
+ <SelectGroup>
+ {vendorCandidates.status.enumValues.map((status) => (
+ <SelectItem
+ key={status}
+ value={status}
+ className="capitalize"
+ >
+ {status}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={() => {
+ setAction("export")
+
+ startTransition(() => {
+ exportTableToExcel(table, {
+ excludeColumns: ["select", "actions"],
+ onlySelected: true,
+ })
+ })
+ }}
+ disabled={isPending}
+ >
+ {isPending && action === "export" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Download className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Export candidates</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={handleDeleteConfirm}
+ disabled={isPending}
+ >
+ {isPending && action === "delete" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Trash2 className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Delete candidates</p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 공용 Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={isPending && (action === "delete" || action === "update-status" || action === "invite")}
+ confirmLabel={
+ action === "delete"
+ ? "Delete"
+ : action === "update-status"
+ ? "Update"
+ : action === "invite"
+ ? "Invite"
+ : "Confirm"
+ }
+ confirmVariant={
+ action === "delete" ? "destructive" : "default"
+ }
+ />
+ </Portal>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx
new file mode 100644
index 00000000..a2229a54
--- /dev/null
+++ b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx
@@ -0,0 +1,93 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, FileDown, Upload } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { AddCandidateDialog } from "./add-candidates-dialog"
+import { VendorCandidates } from "@/db/schema/vendors"
+import { DeleteCandidatesDialog } from "./delete-candidates-dialog"
+import { InviteCandidatesDialog } from "./invite-candidates-dialog"
+import { ImportVendorCandidatesButton } from "./import-button"
+import { exportVendorCandidateTemplate } from "./excel-template-download"
+
+
+interface CandidatesTableToolbarActionsProps {
+ table: Table<VendorCandidates>
+}
+
+export function CandidatesTableToolbarActions({ table }: CandidatesTableToolbarActionsProps) {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const hasSelection = selectedRows.length > 0
+ const [refreshKey, setRefreshKey] = React.useState(0)
+
+ // Handler to refresh the table after import
+ const handleImportSuccess = () => {
+ // Trigger a refresh of the table data
+ setRefreshKey(prev => prev + 1)
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* Show actions only when rows are selected */}
+ {hasSelection ? (
+ <>
+ {/* Invite dialog - new addition */}
+ <InviteCandidatesDialog
+ candidates={selectedRows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+
+ {/* Delete dialog */}
+ <DeleteCandidatesDialog
+ candidates={selectedRows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ </>
+ ) : null}
+
+ {/* Add new candidate dialog */}
+ <AddCandidateDialog />
+
+ {/* Import Excel button */}
+ <ImportVendorCandidatesButton onSuccess={handleImportSuccess} />
+
+ {/* Export dropdown menu */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => {
+ exportTableToExcel(table, {
+ filename: "vendor-candidates",
+ excludeColumns: ["select", "actions"],
+ useGroupHeader: false,
+ })
+ }}
+ >
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>Export Current Data</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={exportVendorCandidateTemplate}>
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>Download Template</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-candidates/table/candidates-table.tsx b/lib/vendor-candidates/table/candidates-table.tsx
new file mode 100644
index 00000000..2c01733c
--- /dev/null
+++ b/lib/vendor-candidates/table/candidates-table.tsx
@@ -0,0 +1,173 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ 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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+import { useFeatureFlags } from "./feature-flags-provider"
+import { getVendorCandidateCounts, getVendorCandidates } from "../service"
+import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors"
+import { VendorCandidateTableFloatingBar } from "./candidates-table-floating-bar"
+import { getColumns } from "./candidates-table-columns"
+import { CandidatesTableToolbarActions } from "./candidates-table-toolbar-actions"
+import { DeleteCandidatesDialog } from "./delete-candidates-dialog"
+import { UpdateCandidateSheet } from "./update-candidate-sheet"
+import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils"
+
+interface VendorCandidatesTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendorCandidates>>,
+ Awaited<ReturnType<typeof getVendorCandidateCounts>>,
+ ]
+ >
+}
+
+export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }, statusCounts] =
+ React.use(promises)
+
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<VendorCandidates> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ /**
+ * This component can render either a faceted filter or a search filter based on the `options` prop.
+ *
+ * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered.
+ *
+ * Each `option` object has the following properties:
+ * @prop {string} label - The label for the filter option.
+ * @prop {string} value - The value for the filter option.
+ * @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
+ * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
+ */
+ const filterFields: DataTableFilterField<VendorCandidates>[] = [
+
+ {
+ id: "status",
+ label: "Status",
+ options: vendorCandidates.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ count: statusCounts[status],
+ })),
+ },
+
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ * These fields provide more complex filtering options compared to the regular filterFields.
+ *
+ * Key differences from regular filterFields:
+ * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'.
+ * 2. Enhanced flexibility: Allows for more precise and varied filtering options.
+ * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
+ * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorCandidates>[] = [
+ {
+ id: "companyName",
+ label: "Company Name",
+ type: "text",
+ },
+ {
+ id: "contactEmail",
+ label: "Contact Email",
+ type: "text",
+ },
+ {
+ id: "contactPhone",
+ label: "Contact Phone",
+ type: "text",
+ },
+ {
+ id: "source",
+ label: "source",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "Status",
+ type: "multi-select",
+ options: vendorCandidates.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ icon: getCandidateStatusIcon(status),
+ count: statusCounts[status],
+ })),
+ },
+
+ {
+ id: "createdAt",
+ label: "Created at",
+ type: "date",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ floatingBar={<VendorCandidateTableFloatingBar table={table} />}
+ >
+
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <CandidatesTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+ <UpdateCandidateSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ candidate={rowAction?.row.original ?? null}
+ />
+ <DeleteCandidatesDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ candidates={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+ </>
+ )
+}
diff --git a/lib/vendor-candidates/table/delete-candidates-dialog.tsx b/lib/vendor-candidates/table/delete-candidates-dialog.tsx
new file mode 100644
index 00000000..e9fabf76
--- /dev/null
+++ b/lib/vendor-candidates/table/delete-candidates-dialog.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } 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 { VendorCandidates } from "@/db/schema/vendors"
+import { removeCandidates } from "../service"
+
+interface DeleteCandidatesDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ candidates: Row<VendorCandidates>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteCandidatesDialog({
+ candidates,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteCandidatesDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeCandidates({
+ ids: candidates.map((candidate) => candidate.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Candidates deleted")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({candidates.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{candidates.length}</span>
+ {candidates.length === 1 ? " candidate" : " candidates"} from our servers.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({candidates.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{candidates.length}</span>
+ {candidates.length === 1 ? " candidate" : " candidates"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/vendor-candidates/table/excel-template-download.tsx b/lib/vendor-candidates/table/excel-template-download.tsx
new file mode 100644
index 00000000..b69ab821
--- /dev/null
+++ b/lib/vendor-candidates/table/excel-template-download.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import { type Table } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { VendorCandidates } from "@/db/schema/vendors"
+
+/**
+ * Export an empty template for vendor candidates with column headers
+ * matching the expected import format
+ */
+export async function exportVendorCandidateTemplate() {
+ // Create a new workbook and worksheet
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Vendor Candidates")
+
+ // Define the columns with expected headers
+ const columns = [
+ { header: "Company Name", key: "companyName", width: 30 },
+ { header: "Contact Email", key: "contactEmail", width: 30 },
+ { header: "Contact Phone", key: "contactPhone", width: 20 },
+ { header: "Country", key: "country", width: 20 },
+ { header: "Source", key: "source", width: 20 },
+ { header: "Status", key: "status", width: 15 },
+ ]
+
+ // Add columns to the worksheet
+ worksheet.columns = columns
+
+ // Style the header row
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.alignment = { horizontal: "center" }
+ headerRow.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+
+ // Add example data rows
+ const exampleData = [
+ {
+ companyName: "ABC Corporation",
+ contactEmail: "contact@abc.com",
+ contactPhone: "+1-123-456-7890",
+ country: "US",
+ source: "Website",
+ status: "COLLECTED",
+ },
+ {
+ companyName: "XYZ Ltd.",
+ contactEmail: "info@xyz.com",
+ contactPhone: "+44-987-654-3210",
+ country: "GB",
+ source: "Referral",
+ status: "COLLECTED",
+ },
+ ]
+
+ // Add the example rows to the worksheet
+ exampleData.forEach((data) => {
+ worksheet.addRow(data)
+ })
+
+ // Add data validation for Status column
+ const statusValues = ["COLLECTED", "INVITED", "DISCARDED"]
+ for (let i = 2; i <= 100; i++) { // Apply to rows 2-100
+ worksheet.getCell(`F${i}`).dataValidation = {
+ type: 'list',
+ allowBlank: true,
+ formulae: [`"${statusValues.join(',')}"`]
+ }
+ }
+
+ // Add instructions row
+ worksheet.insertRow(1, ["Please fill in the data below. Required fields: Company Name, Contact Email"])
+ worksheet.mergeCells("A1:F1")
+ const instructionRow = worksheet.getRow(1)
+ instructionRow.font = { bold: true, color: { argb: "FF0000FF" } }
+ instructionRow.alignment = { horizontal: "center" }
+
+ // Download the workbook
+ 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 = "vendor-candidates-template.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+} \ No newline at end of file
diff --git a/lib/vendor-candidates/table/feature-flags-provider.tsx b/lib/vendor-candidates/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendor-candidates/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/vendor-candidates/table/feature-flags.tsx b/lib/vendor-candidates/table/feature-flags.tsx
new file mode 100644
index 00000000..aaae6af2
--- /dev/null
+++ b/lib/vendor-candidates/table/feature-flags.tsx
@@ -0,0 +1,96 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface TasksTableContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const TasksTableContext = React.createContext<TasksTableContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useTasksTable() {
+ const context = React.useContext(TasksTableContext)
+ if (!context) {
+ throw new Error("useTasksTable must be used within a TasksTableProvider")
+ }
+ return context
+}
+
+export function TasksTableProvider({ children }: React.PropsWithChildren) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "featureFlags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ }
+ )
+
+ return (
+ <TasksTableContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit"
+ >
+ {dataTableConfig.featureFlags.map((flag) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className="whitespace-nowrap px-3 text-xs"
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon
+ className="mr-2 size-3.5 shrink-0"
+ aria-hidden="true"
+ />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </TasksTableContext.Provider>
+ )
+}
diff --git a/lib/vendor-candidates/table/import-button.tsx b/lib/vendor-candidates/table/import-button.tsx
new file mode 100644
index 00000000..1a2a4f7c
--- /dev/null
+++ b/lib/vendor-candidates/table/import-button.tsx
@@ -0,0 +1,211 @@
+"use client"
+
+import React, { useRef } from 'react'
+import ExcelJS from 'exceljs'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Upload, Loader } from 'lucide-react'
+import { createVendorCandidate } from '../service'
+import { Input } from '@/components/ui/input'
+
+interface ImportExcelProps {
+ onSuccess?: () => void
+}
+
+export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) {
+ const fileInputRef = useRef<HTMLInputElement>(null)
+ const [isImporting, setIsImporting] = React.useState(false)
+
+ // Helper function to get cell value as string
+ const getCellValueAsString = (cell: ExcelJS.Cell): string => {
+ if (!cell || cell.value === undefined || cell.value === null) return '';
+
+ if (typeof cell.value === 'string') return cell.value.trim();
+ if (typeof cell.value === 'number') return cell.value.toString();
+
+ // Handle rich text
+ if (typeof cell.value === 'object' && 'richText' in cell.value) {
+ return cell.value.richText.map((rt: any) => rt.text).join('');
+ }
+
+ // Handle dates
+ if (cell.value instanceof Date) {
+ return cell.value.toISOString().split('T')[0];
+ }
+
+ // Fallback
+ return String(cell.value);
+ }
+
+ const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ setIsImporting(true)
+
+ try {
+ // Read the Excel file using ExcelJS
+ const data = await file.arrayBuffer()
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(data)
+
+ // Get the first worksheet
+ const worksheet = workbook.getWorksheet(1)
+ if (!worksheet) {
+ toast.error("No worksheet found in the spreadsheet")
+ return
+ }
+
+ // Check if there's an instruction row
+ const hasInstructionRow = worksheet.getRow(1).getCell(1).value !== null &&
+ worksheet.getRow(1).getCell(2).value === null;
+
+ // Get header row index (row 2 if there's an instruction row, otherwise row 1)
+ const headerRowIndex = hasInstructionRow ? 2 : 1;
+
+ // Get column headers and their indices
+ const headerRow = worksheet.getRow(headerRowIndex);
+ const headers: Record<number, string> = {};
+ const columnIndices: Record<string, number> = {};
+
+ headerRow.eachCell((cell, colNumber) => {
+ const header = getCellValueAsString(cell);
+ headers[colNumber] = header;
+ columnIndices[header] = colNumber;
+ });
+
+ // Process data rows
+ const rows: any[] = [];
+ const startRow = headerRowIndex + 1;
+
+ for (let i = startRow; i <= worksheet.rowCount; i++) {
+ const row = worksheet.getRow(i);
+
+ // Skip empty rows
+ if (row.cellCount === 0) continue;
+
+ // Check if this is likely an example row
+ const isExample = i === startRow && worksheet.getRow(i+1).values?.length === 0;
+ if (isExample) continue;
+
+ const rowData: Record<string, any> = {};
+ let hasData = false;
+
+ // Map the data using header indices
+ Object.entries(columnIndices).forEach(([header, colIndex]) => {
+ const value = getCellValueAsString(row.getCell(colIndex));
+ if (value) {
+ rowData[header] = value;
+ hasData = true;
+ }
+ });
+
+ if (hasData) {
+ rows.push(rowData);
+ }
+ }
+
+ if (rows.length === 0) {
+ toast.error("No data found in the spreadsheet")
+ setIsImporting(false)
+ return
+ }
+
+ // Process each row
+ let successCount = 0;
+ let errorCount = 0;
+
+ // Create promises for all vendor candidate creation operations
+ const promises = rows.map(async (row) => {
+ try {
+ // Map Excel columns to our data model
+ const candidateData = {
+ companyName: String(row['Company Name'] || ''),
+ contactEmail: String(row['Contact Email'] || ''),
+ contactPhone: String(row['Contact Phone'] || ''),
+ country: String(row['Country'] || ''),
+ source: String(row['Source'] || ''),
+ // Default to COLLECTED if not specified
+ status: (row['Status'] || 'COLLECTED') as "COLLECTED" | "INVITED" | "DISCARDED"
+ };
+
+ // Validate required fields
+ if (!candidateData.companyName || !candidateData.contactEmail) {
+ console.error("Missing required fields", candidateData);
+ errorCount++;
+ return null;
+ }
+
+ // Create the vendor candidate
+ const result = await createVendorCandidate(candidateData);
+
+ if (result.error) {
+ console.error(`Failed to import row: ${result.error}`, candidateData);
+ errorCount++;
+ return null;
+ }
+
+ successCount++;
+ return result.data;
+ } catch (error) {
+ console.error("Error processing row:", error, row);
+ errorCount++;
+ return null;
+ }
+ });
+
+ // Wait for all operations to complete
+ await Promise.all(promises);
+
+ // Show results
+ if (successCount > 0) {
+ toast.success(`Successfully imported ${successCount} vendor candidates`);
+ if (errorCount > 0) {
+ toast.warning(`Failed to import ${errorCount} rows due to errors`);
+ }
+ // Call the success callback to refresh data
+ onSuccess?.();
+ } else if (errorCount > 0) {
+ toast.error(`Failed to import all ${errorCount} rows due to errors`);
+ }
+
+ } catch (error) {
+ console.error("Import error:", error);
+ toast.error("Error importing data. Please check file format.");
+ } finally {
+ setIsImporting(false);
+ // Reset the file input
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ }
+ }
+
+ return (
+ <>
+ <Input
+ type="file"
+ ref={fileInputRef}
+ onChange={handleImport}
+ accept=".xlsx,.xls"
+ className="hidden"
+ />
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => fileInputRef.current?.click()}
+ disabled={isImporting}
+ className="gap-2"
+ >
+ {isImporting ? (
+ <Loader className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Upload className="size-4" aria-hidden="true" />
+ )}
+ <span className="hidden sm:inline">
+ {isImporting ? "Importing..." : "Import"}
+ </span>
+ </Button>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-candidates/table/invite-candidates-dialog.tsx b/lib/vendor-candidates/table/invite-candidates-dialog.tsx
new file mode 100644
index 00000000..366b6f45
--- /dev/null
+++ b/lib/vendor-candidates/table/invite-candidates-dialog.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Mail } 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 { VendorCandidates } from "@/db/schema/vendors"
+import { bulkUpdateVendorCandidateStatus } from "../service"
+
+interface InviteCandidatesDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ candidates: Row<VendorCandidates>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function InviteCandidatesDialog({
+ candidates,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: InviteCandidatesDialogProps) {
+ const [isInvitePending, startInviteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onInvite() {
+ startInviteTransition(async () => {
+ const { error } = await bulkUpdateVendorCandidateStatus({
+ ids: candidates.map((candidate) => candidate.id),
+ status: "INVITED",
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Invitation emails sent")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Mail className="size-4" aria-hidden="true" />
+ Invite ({candidates.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Send invitations?</DialogTitle>
+ <DialogDescription>
+ This will send invitation emails to{" "}
+ <span className="font-medium">{candidates.length}</span>
+ {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Invite selected vendors"
+ variant="default"
+ onClick={onInvite}
+ disabled={isInvitePending}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Send Invitations
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Mail className="size-4" aria-hidden="true" />
+ Invite ({candidates.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Send invitations?</DrawerTitle>
+ <DrawerDescription>
+ This will send invitation emails to{" "}
+ <span className="font-medium">{candidates.length}</span>
+ {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Invite selected vendors"
+ variant="default"
+ onClick={onInvite}
+ disabled={isInvitePending}
+ >
+ {isInvitePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Send Invitations
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-candidates/table/update-candidate-sheet.tsx b/lib/vendor-candidates/table/update-candidate-sheet.tsx
new file mode 100644
index 00000000..c475210b
--- /dev/null
+++ b/lib/vendor-candidates/table/update-candidate-sheet.tsx
@@ -0,0 +1,339 @@
+"use client"
+
+import * as React from "react"
+import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import i18nIsoCountries from "i18n-iso-countries"
+import enLocale from "i18n-iso-countries/langs/en.json"
+import koLocale from "i18n-iso-countries/langs/ko.json"
+import { cn } from "@/lib/utils"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+
+import { updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "../validations"
+import { updateVendorCandidate } from "../service"
+
+// Register locales for countries
+i18nIsoCountries.registerLocale(enLocale)
+i18nIsoCountries.registerLocale(koLocale)
+
+// Generate country array
+const locale = "ko"
+const countryMap = i18nIsoCountries.getNames(locale, { select: "official" })
+const countryArray = Object.entries(countryMap).map(([code, label]) => ({
+ code,
+ label,
+}))
+
+interface UpdateCandidateSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ candidate: VendorCandidates | null
+}
+
+export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ // Set default values from candidate data when the component receives a new candidate
+ React.useEffect(() => {
+ if (candidate) {
+ form.reset({
+ id: candidate.id,
+ companyName: candidate.companyName,
+ contactEmail: candidate.contactEmail,
+ contactPhone: candidate.contactPhone || "",
+ country: candidate.country || "",
+ source: candidate.source || "",
+ status: candidate.status,
+ })
+ }
+ }, [candidate])
+
+ const form = useForm<UpdateVendorCandidateSchema>({
+ resolver: zodResolver(updateVendorCandidateSchema),
+ defaultValues: {
+ id: candidate?.id || 0,
+ companyName: candidate?.companyName || "",
+ contactEmail: candidate?.contactEmail || "",
+ contactPhone: candidate?.contactPhone || "",
+ country: candidate?.country || "",
+ source: candidate?.source || "",
+ status: candidate?.status || "COLLECTED",
+ },
+ })
+
+ function onSubmit(input: UpdateVendorCandidateSchema) {
+ startUpdateTransition(async () => {
+ if (!candidate) return
+
+ const { error } = await updateVendorCandidate({
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("Vendor candidate updated")
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update Vendor Candidate</SheetTitle>
+ <SheetDescription>
+ Update the vendor candidate details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* Company Name Field */}
+ <FormField
+ control={form.control}
+ name="companyName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Company Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Enter company name"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Contact Email Field */}
+ <FormField
+ control={form.control}
+ name="contactEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Email</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="email@example.com"
+ type="email"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Contact Phone Field */}
+ <FormField
+ control={form.control}
+ name="contactPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Phone</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="+82-10-1234-5678"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Country Field */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => {
+ const selectedCountry = countryArray.find(
+ (c) => c.code === field.value
+ )
+ return (
+ <FormItem>
+ <FormLabel>Country</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ disabled={isUpdatePending}
+ >
+ {selectedCountry
+ ? selectedCountry.label
+ : "Select a country"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[300px] p-0">
+ <Command>
+ <CommandInput placeholder="Search country..." />
+ <CommandList>
+ <CommandEmpty>No country found.</CommandEmpty>
+ <CommandGroup className="max-h-[300px] overflow-y-auto">
+ {countryArray.map((country) => (
+ <CommandItem
+ key={country.code}
+ value={country.label}
+ onSelect={() =>
+ field.onChange(country.code)
+ }
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ country.code === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {country.label}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+
+ {/* Source Field */}
+ <FormField
+ control={form.control}
+ name="source"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Source</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Where this candidate was found"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Status Field */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Status</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={isUpdatePending}
+ >
+ <FormControl>
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select a status" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ {vendorCandidates.status.enumValues.map((item) => (
+ <SelectItem
+ key={item}
+ value={item}
+ className="capitalize"
+ >
+ {item}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline" disabled={isUpdatePending}>
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-candidates/utils.ts b/lib/vendor-candidates/utils.ts
new file mode 100644
index 00000000..8973d557
--- /dev/null
+++ b/lib/vendor-candidates/utils.ts
@@ -0,0 +1,40 @@
+import {
+ Activity,
+ AlertCircle,
+ AlertTriangle,
+ ArrowDownIcon,
+ ArrowRightIcon,
+ ArrowUpIcon,
+ AwardIcon,
+ BadgeCheck,
+ CheckCircle2,
+ CircleHelp,
+ CircleIcon,
+ CircleX,
+ ClipboardCheck,
+ ClipboardList,
+ FileCheck2,
+ FilePenLine,
+ FileX2,
+ MailCheck,
+ PencilIcon,
+ SearchIcon,
+ SendIcon,
+ Timer,
+ Trash2,
+ XCircle,
+} from "lucide-react"
+
+import { VendorCandidates } from "@/db/schema/vendors"
+
+
+export function getCandidateStatusIcon(status: VendorCandidates["status"]) {
+ const statusIcons = {
+ COLLECTED: ClipboardList, // Data collection icon
+ INVITED: MailCheck, // Email sent and checked icon
+ DISCARDED: Trash2, // Trashed/discarded icon
+ }
+
+ return statusIcons[status] || CircleIcon
+}
+
diff --git a/lib/vendor-candidates/validations.ts b/lib/vendor-candidates/validations.ts
new file mode 100644
index 00000000..0abb568e
--- /dev/null
+++ b/lib/vendor-candidates/validations.ts
@@ -0,0 +1,84 @@
+import { vendorCandidates } from "@/db/schema/vendors"
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+
+export const searchParamsCandidateCache = createSearchParamsCache({
+ // Common flags
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // Paging
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // Sorting - adjusting for vendorInvestigationsView
+ sort: getSortingStateParser<typeof vendorCandidates.$inferSelect>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // Advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // Global search
+ search: parseAsString.withDefault(""),
+
+ // -----------------------------------------------------------------
+ // Fields specific to vendor investigations
+ // -----------------------------------------------------------------
+
+ // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED
+ status: parseAsStringEnum(["COLLECTED", "INVITED", "DISCARDED"]),
+
+ // In case you also want to filter by vendorName, vendorCode, etc.
+ companyName: parseAsString.withDefault(""),
+ contactEmail: parseAsString.withDefault(""),
+ contactPhone: parseAsString.withDefault(""),
+ country: parseAsString.withDefault(""),
+ source: parseAsString.withDefault(""),
+
+
+})
+
+// Finally, export the type you can use in your server action:
+export type GetVendorsCandidateSchema = Awaited<ReturnType<typeof searchParamsCandidateCache.parse>>
+
+
+// Updated version of the updateVendorCandidateSchema
+export const updateVendorCandidateSchema = z.object({
+ id: z.number(),
+ companyName: z.string().min(1).max(255).optional(),
+ contactEmail: z.string().email().max(255).optional(),
+ contactPhone: z.string().max(50).optional(),
+ country: z.string().max(100).optional(),
+ source: z.string().max(100).optional(),
+ status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).optional(),
+ updatedAt: z.date().optional().default(() => new Date()),
+});
+
+// Create schema for vendor candidates
+export const createVendorCandidateSchema = z.object({
+ companyName: z.string().min(1).max(255),
+ contactEmail: z.string().email().max(255),
+ contactPhone: z.string().max(50).optional(),
+ country: z.string().max(100).optional(),
+ source: z.string().max(100).optional(),
+ status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).default("COLLECTED"),
+});
+
+// Export types for both schemas
+export type UpdateVendorCandidateSchema = z.infer<typeof updateVendorCandidateSchema>;
+export type CreateVendorCandidateSchema = z.infer<typeof createVendorCandidateSchema>;
+
+
+export const removeCandidatesSchema = z.object({
+ ids: z.array(z.number()).min(1, "At least one candidate ID must be provided"),
+});
+
+export type RemoveCandidatesInput = z.infer<typeof removeCandidatesSchema>; \ No newline at end of file
diff --git a/lib/vendor-document/service.ts b/lib/vendor-document/service.ts
index b14a64e0..c0a30808 100644
--- a/lib/vendor-document/service.ts
+++ b/lib/vendor-document/service.ts
@@ -3,7 +3,6 @@
import { eq, SQL } from "drizzle-orm"
import db from "@/db/db"
import { documentAttachments, documents, issueStages, revisions, vendorDocumentsView } from "@/db/schema/vendorDocu"
-import { contracts } from "@/db/schema/vendorData"
import { GetVendorDcoumentsSchema } from "./validations"
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts
new file mode 100644
index 00000000..b731a95c
--- /dev/null
+++ b/lib/vendor-investigation/service.ts
@@ -0,0 +1,229 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView } from "@/db/schema/vendors"
+import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema } from "./validations"
+import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm";
+import { revalidateTag, unstable_noStore } from "next/cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+import db from "@/db/db";
+import { sendEmail } from "../mail/sendEmail";
+import fs from "fs"
+import path from "path"
+import { v4 as uuid } from "uuid"
+
+export async function getVendorsInvestigation(input: GetVendorsInvestigationSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 1) Advanced filters
+ const advancedWhere = filterColumns({
+ table: vendorInvestigationsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
+
+ // 2) Global search
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(vendorInvestigationsView.vendorName, s),
+ ilike(vendorInvestigationsView.vendorCode, s),
+ ilike(vendorInvestigationsView.investigationNotes, s),
+ ilike(vendorInvestigationsView.vendorEmail, s)
+ // etc.
+ )
+ }
+
+ // 3) Combine finalWhere
+ // Example: Only show vendorStatus = "PQ_SUBMITTED"
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere,
+ eq(vendorInvestigationsView.vendorStatus, "PQ_SUBMITTED")
+ )
+
+
+
+ // 5) Sorting
+ const orderBy =
+ input.sort && input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(vendorInvestigationsView[item.id])
+ : asc(vendorInvestigationsView[item.id])
+ )
+ : [desc(vendorInvestigationsView.investigationCreatedAt)]
+
+ // 6) Query & count
+ const { data, total } = await db.transaction(async (tx) => {
+ // a) Select from the view
+ const investigationsData = await tx
+ .select()
+ .from(vendorInvestigationsView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(input.perPage)
+
+ // b) Count total
+ const resCount = await tx
+ .select({ count: count() })
+ .from(vendorInvestigationsView)
+ .where(finalWhere)
+
+ return { data: investigationsData, total: resCount[0]?.count }
+ })
+
+ // 7) Calculate pageCount
+ const pageCount = Math.ceil(total / input.perPage)
+
+ // Now 'data' already contains JSON arrays of contacts & items
+ // thanks to the subqueries in the view definition!
+ return { data, pageCount }
+ } catch (err) {
+ console.error(err)
+ return { data: [], pageCount: 0 }
+ }
+ },
+ // Cache key
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["vendors-in-investigation"],
+ }
+ )()
+}
+
+
+interface RequestInvestigateVendorsInput {
+ ids: number[]
+}
+
+export async function requestInvestigateVendors({
+ ids,
+}: RequestInvestigateVendorsInput) {
+ try {
+ if (!ids || ids.length === 0) {
+ return { error: "No vendor IDs provided." }
+ }
+
+ // 1. Create a new investigation row for each vendor
+ // You could also check if an investigation already exists for each vendor
+ // before inserting. For now, we’ll assume we always insert new ones.
+ const newRecords = await db
+ .insert(vendorInvestigations)
+ .values(
+ ids.map((vendorId) => ({
+ vendorId
+ }))
+ )
+ .returning()
+
+ // 2. Optionally, send an email notification
+ // Adjust recipient, subject, and body as needed.
+ await sendEmail({
+ to: "dujin.kim@dtsolution.io",
+ subject: "New Vendor Investigation(s) Requested",
+ // This template name could match a Handlebars file like: `investigation-request.hbs`
+ template: "investigation-request",
+ context: {
+ // For example, if you're translating in Korean:
+ language: "ko",
+ // Add any data you want to use within the template
+ vendorIds: ids,
+ notes: "Please initiate the planned investigations soon."
+ },
+ })
+
+ // 3. Optionally, revalidate any pages that might show updated data
+ // revalidatePath("/your-vendors-page") // or wherever you list the vendors
+
+ return { data: newRecords, error: null }
+ } catch (err: unknown) {
+ const errorMessage = err instanceof Error ? err.message : String(err)
+ return { error: errorMessage }
+ }
+}
+
+
+export async function updateVendorInvestigationAction(formData: FormData) {
+ try {
+ // 1) Separate text fields from file fields
+ const textEntries: Record<string, string> = {}
+ for (const [key, value] of formData.entries()) {
+ if (typeof value === "string") {
+ textEntries[key] = value
+ }
+ }
+
+ // 2) Convert text-based "investigationId" to a number
+ if (textEntries.investigationId) {
+ textEntries.investigationId = String(Number(textEntries.investigationId))
+ }
+
+ // 3) Parse/validate with Zod
+ const parsed = updateVendorInvestigationSchema.parse(textEntries)
+ // parsed is type UpdateVendorInvestigationSchema
+
+ // 4) Update the vendor_investigations table
+ await db
+ .update(vendorInvestigations)
+ .set({
+ investigationStatus: parsed.investigationStatus,
+ scheduledStartAt: parsed.scheduledStartAt
+ ? new Date(parsed.scheduledStartAt)
+ : null,
+ scheduledEndAt: parsed.scheduledEndAt ? new Date(parsed.scheduledEndAt) : null,
+ completedAt: parsed.completedAt ? new Date(parsed.completedAt) : null,
+ investigationNotes: parsed.investigationNotes ?? "",
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorInvestigations.id, parsed.investigationId))
+
+ // 5) Handle file attachments
+ // formData.getAll("attachments") can contain multiple files
+ const files = formData.getAll("attachments") as File[]
+
+ // Make sure the folder exists
+ const uploadDir = path.join(process.cwd(), "public", "vendor-investigation")
+ if (!fs.existsSync(uploadDir)) {
+ fs.mkdirSync(uploadDir, { recursive: true })
+ }
+
+ for (const file of files) {
+ if (file && file.size > 0) {
+ // Create a unique filename
+ const ext = path.extname(file.name) // e.g. ".pdf"
+ const newFileName = `${uuid()}${ext}`
+
+ const filePath = path.join(uploadDir, newFileName)
+
+ // 6) Write file to disk
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+ fs.writeFileSync(filePath, buffer)
+
+ // 7) Insert a record in vendor_investigation_attachments
+ await db.insert(vendorInvestigationAttachments).values({
+ investigationId: parsed.investigationId,
+ fileName: file.name, // original name
+ filePath: `/vendor-investigation/${newFileName}`, // relative path in public/
+ attachmentType: "REPORT", // or user-specified
+ })
+ }
+ }
+
+ // Revalidate anything if needed
+ revalidateTag("vendors-in-investigation")
+
+ return { data: "OK", error: null }
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err)
+ return { error: message }
+ }
+} \ No newline at end of file
diff --git a/lib/vendor-investigation/table/feature-flags-provider.tsx b/lib/vendor-investigation/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendor-investigation/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx
new file mode 100644
index 00000000..fd76a9a5
--- /dev/null
+++ b/lib/vendor-investigation/table/investigation-table-columns.tsx
@@ -0,0 +1,251 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Ellipsis, Users, Boxes } from "lucide-react"
+// import { toast } from "sonner" // If needed
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { formatDate } from "@/lib/utils" // or your date util
+
+// Example: If you have a type for row actions
+import { type DataTableRowAction } from "@/types/table"
+import { ContactItem, PossibleItem, vendorInvestigationsColumnsConfig, VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
+
+// Props that define how we handle special columns (contacts, items, actions, etc.)
+interface GetVendorInvestigationsColumnsProps {
+ setRowAction?: React.Dispatch<
+ React.SetStateAction<
+ DataTableRowAction<VendorInvestigationsViewWithContacts> | null
+ >
+ >
+ openContactsModal?: (investigationId: number, contacts: ContactItem[]) => void
+ openItemsDrawer?: (investigationId: number, items: PossibleItem[]) => void
+}
+
+// This function returns the array of columns for TanStack Table
+export function getColumns({
+ setRowAction,
+ openContactsModal,
+ openItemsDrawer,
+}: GetVendorInvestigationsColumnsProps): ColumnDef<
+ VendorInvestigationsViewWithContacts
+>[] {
+ // --------------------------------------------
+ // 1) Select (checkbox) column
+ // --------------------------------------------
+ const selectColumn: ColumnDef<VendorInvestigationsViewWithContacts> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // --------------------------------------------
+ // 2) Actions column (optional)
+ // --------------------------------------------
+ const actionsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = {
+ id: "actions",
+ enableHiding: false,
+ cell: ({ row }) => {
+ const inv = row.original
+
+ return (
+ <Button
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ aria-label="Open menu"
+ onClick={() => {
+ // e.g. open a dropdown or set your row action
+ setRowAction?.({ type: "update", row })
+ }}
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ )
+ },
+ size: 40,
+ }
+
+ // --------------------------------------------
+ // 3) Contacts column (badge count -> open modal)
+ // --------------------------------------------
+ const contactsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = {
+ id: "contacts",
+ header: "Contacts",
+ cell: ({ row }) => {
+ const { contacts, investigationId } = row.original
+ const count = contacts?.length ?? 0
+
+ const handleClick = () => {
+ openContactsModal?.(investigationId, contacts)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ count > 0 ? `View ${count} contacts` : "Add contacts"
+ }
+ >
+ <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {count > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {count}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {count > 0 ? `${count} Contacts` : "Add Contacts"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ size: 60,
+ }
+
+ // --------------------------------------------
+ // 4) Possible Items column (badge count -> open drawer)
+ // --------------------------------------------
+ const possibleItemsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = {
+ id: "possibleItems",
+ header: "Items",
+ cell: ({ row }) => {
+ const { possibleItems, investigationId } = row.original
+ const count = possibleItems?.length ?? 0
+
+ const handleClick = () => {
+ openItemsDrawer?.(investigationId, possibleItems)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ count > 0 ? `View ${count} items` : "Add items"
+ }
+ >
+ <Boxes className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {count > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {count}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {count > 0 ? `${count} Items` : "Add Items"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ size: 60,
+ }
+
+ // --------------------------------------------
+ // 5) Build "grouped" columns from config
+ // --------------------------------------------
+ const groupMap: Record<string, ColumnDef<VendorInvestigationsViewWithContacts>[]> = {}
+
+ vendorInvestigationsColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ const childCol: ColumnDef<VendorInvestigationsViewWithContacts> = {
+ accessorKey: cfg.id,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ const val = cell.getValue()
+
+ // Example: Format date fields
+ if (
+ cfg.id === "investigationCreatedAt" ||
+ cfg.id === "investigationUpdatedAt" ||
+ cfg.id === "scheduledStartAt" ||
+ cfg.id === "scheduledEndAt" ||
+ cfg.id === "completedAt"
+ ) {
+ const dateVal = val ? new Date(val as string) : null
+ return dateVal ? formatDate(dateVal) : ""
+ }
+
+ // Example: You could show an icon for "investigationStatus"
+ if (cfg.id === "investigationStatus") {
+ return <span className="capitalize">{val as string}</span>
+ }
+
+ return val ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // Turn the groupMap into nested columns
+ const nestedColumns: ColumnDef<VendorInvestigationsViewWithContacts>[] = []
+ for (const [groupName, colDefs] of Object.entries(groupMap)) {
+ if (groupName === "_noGroup") {
+ nestedColumns.push(...colDefs)
+ } else {
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ }
+
+ // --------------------------------------------
+ // 6) Return final columns array
+ // (You can reorder these as you wish.)
+ // --------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ contactsColumn,
+ possibleItemsColumn,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx
new file mode 100644
index 00000000..9f89a6ac
--- /dev/null
+++ b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx
@@ -0,0 +1,41 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload, Check } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
+
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorInvestigationsViewWithContacts>
+}
+
+export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx
new file mode 100644
index 00000000..fa4e2ab8
--- /dev/null
+++ b/lib/vendor-investigation/table/investigation-table.tsx
@@ -0,0 +1,133 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+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 "./investigation-table-columns"
+import { getVendorsInvestigation } from "../service"
+import { VendorsTableToolbarActions } from "./investigation-table-toolbar-actions"
+import {
+ VendorInvestigationsViewWithContacts,
+ ContactItem,
+ PossibleItem
+} from "@/config/vendorInvestigationsColumnsConfig"
+import { UpdateVendorInvestigationSheet } from "./update-investigation-sheet"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendorsInvestigation>>,
+ ]
+ >
+}
+
+export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Get data from Suspense
+ const [rawResponse] = React.use(promises)
+
+ // Transform the data to match the expected types
+ const transformedData: VendorInvestigationsViewWithContacts[] = React.useMemo(() => {
+ return rawResponse.data.map(item => {
+ // Parse contacts field if it's a string
+ let contacts: ContactItem[] = []
+ if (typeof item.contacts === 'string') {
+ try {
+ contacts = JSON.parse(item.contacts) as ContactItem[]
+ } catch (e) {
+ console.error('Failed to parse contacts:', e)
+ }
+ } else if (Array.isArray(item.contacts)) {
+ contacts = item.contacts
+ }
+
+ // Parse possibleItems field if it's a string
+ let possibleItems: PossibleItem[] = []
+ if (typeof item.possibleItems === 'string') {
+ try {
+ possibleItems = JSON.parse(item.possibleItems) as PossibleItem[]
+ } catch (e) {
+ console.error('Failed to parse possibleItems:', e)
+ }
+ } else if (Array.isArray(item.possibleItems)) {
+ possibleItems = item.possibleItems
+ }
+
+ // Return a new object with the transformed fields
+ return {
+ ...item,
+ contacts,
+ possibleItems
+ } as VendorInvestigationsViewWithContacts
+ })
+ }, [rawResponse.data])
+
+ const pageCount = rawResponse.pageCount
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorInvestigationsViewWithContacts> | null>(null)
+
+ // Get router
+ const router = useRouter()
+
+ // Call getColumns() with router injection
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction, router]
+ )
+
+ const filterFields: DataTableFilterField<VendorInvestigationsViewWithContacts>[] = [
+ { id: "vendorCode", label: "Vendor Code" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorInvestigationsViewWithContacts>[] = [
+ { id: "vendorName", label: "Vendor Name", type: "text" },
+ { id: "vendorCode", label: "Vendor Code", type: "text" },
+ ]
+
+ const { table } = useDataTable({
+ data: transformedData,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "investigationCreatedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.investigationId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ <UpdateVendorInvestigationSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ investigation={rowAction?.row.original ?? null}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx
new file mode 100644
index 00000000..fe30c892
--- /dev/null
+++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx
@@ -0,0 +1,324 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { Loader } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+import {
+ updateVendorInvestigationSchema,
+ type UpdateVendorInvestigationSchema,
+} from "../validations"
+import { updateVendorInvestigationAction } from "../service"
+import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
+
+/**
+ * The shape of `vendorInvestigation`
+ * might come from your `vendorInvestigationsView` row
+ * or your existing type for a single investigation.
+ */
+
+interface UpdateVendorInvestigationSheetProps
+ extends React.ComponentPropsWithoutRef<typeof Sheet> {
+ investigation: VendorInvestigationsViewWithContacts | null
+}
+
+/**
+ * A sheet for updating a vendor investigation (plus optional attachments).
+ */
+export function UpdateVendorInvestigationSheet({
+ investigation,
+ ...props
+}: UpdateVendorInvestigationSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ // RHF + Zod
+ const form = useForm<UpdateVendorInvestigationSchema>({
+ resolver: zodResolver(updateVendorInvestigationSchema),
+ defaultValues: {
+ investigationId: investigation?.investigationId ?? 0,
+ investigationStatus: investigation?.investigationStatus ?? "PLANNED",
+ scheduledStartAt: investigation?.scheduledStartAt ?? undefined,
+ scheduledEndAt: investigation?.scheduledEndAt ?? undefined,
+ completedAt: investigation?.completedAt ?? undefined,
+ investigationNotes: investigation?.investigationNotes ?? "",
+ },
+ })
+
+ React.useEffect(() => {
+ if (investigation) {
+ form.reset({
+ investigationId: investigation.investigationId,
+ investigationStatus: investigation.investigationStatus || "PLANNED",
+ scheduledStartAt: investigation.scheduledStartAt ?? undefined,
+ scheduledEndAt: investigation.scheduledEndAt ?? undefined,
+ completedAt: investigation.completedAt ?? undefined,
+ investigationNotes: investigation.investigationNotes ?? "",
+ })
+ }
+ }, [investigation, form])
+
+ // Format date for form data
+ const formatDateForFormData = (date: Date | undefined): string | null => {
+ if (!date) return null;
+ return date.toISOString();
+ }
+
+ // Submit handler
+ async function onSubmit(values: UpdateVendorInvestigationSchema) {
+ if (!values.investigationId) return
+
+ startTransition(async () => {
+ // 1) Build a FormData object for the server action
+ const formData = new FormData()
+
+ // Add text fields
+ formData.append("investigationId", String(values.investigationId))
+ formData.append("investigationStatus", values.investigationStatus)
+
+ // Format dates properly before appending to FormData
+ if (values.scheduledStartAt) {
+ const formattedDate = formatDateForFormData(values.scheduledStartAt)
+ if (formattedDate) formData.append("scheduledStartAt", formattedDate)
+ }
+
+ if (values.scheduledEndAt) {
+ const formattedDate = formatDateForFormData(values.scheduledEndAt)
+ if (formattedDate) formData.append("scheduledEndAt", formattedDate)
+ }
+
+ if (values.completedAt) {
+ const formattedDate = formatDateForFormData(values.completedAt)
+ if (formattedDate) formData.append("completedAt", formattedDate)
+ }
+
+ if (values.investigationNotes) {
+ formData.append("investigationNotes", values.investigationNotes)
+ }
+
+ // Add attachments (if any)
+ // Note: If you have multiple files in "attachments", we store them in the form under the same key.
+ const attachmentValue = form.getValues("attachments");
+ if (attachmentValue instanceof FileList) {
+ for (let i = 0; i < attachmentValue.length; i++) {
+ formData.append("attachments", attachmentValue[i]);
+ }
+ }
+
+ const { error } = await updateVendorInvestigationAction(formData)
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ toast.success("Investigation updated!")
+ form.reset()
+ props.onOpenChange?.(false)
+ })
+ }
+
+ // Format date value for input field
+ const formatDateForInput = (date: Date | undefined): string => {
+ if (!date) return "";
+ return date instanceof Date ? date.toISOString().slice(0, 10) : "";
+ }
+
+ // Handle date input change
+ const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>, onChange: (...event: any[]) => void) => {
+ const val = e.target.value;
+ if (val) {
+ // Ensure proper date handling by setting to noon to avoid timezone issues
+ const newDate = new Date(`${val}T12:00:00`);
+ onChange(newDate);
+ } else {
+ onChange(undefined);
+ }
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update Investigation</SheetTitle>
+ <SheetDescription>
+ Change the investigation details &amp; attachments
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ // Must use multipart to support file uploads
+ encType="multipart/form-data"
+ >
+ {/* investigationStatus */}
+ <FormField
+ control={form.control}
+ name="investigationStatus"
+ 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>
+ <SelectItem value="PLANNED">PLANNED</SelectItem>
+ <SelectItem value="IN_PROGRESS">IN_PROGRESS</SelectItem>
+ <SelectItem value="COMPLETED">COMPLETED</SelectItem>
+ <SelectItem value="CANCELED">CANCELED</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* scheduledStartAt */}
+ <FormField
+ control={form.control}
+ name="scheduledStartAt"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Scheduled Start</FormLabel>
+ <FormControl>
+ <Input
+ type="date"
+ value={formatDateForInput(field.value)}
+ onChange={(e) => handleDateChange(e, field.onChange)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* scheduledEndAt */}
+ <FormField
+ control={form.control}
+ name="scheduledEndAt"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Scheduled End</FormLabel>
+ <FormControl>
+ <Input
+ type="date"
+ value={formatDateForInput(field.value)}
+ onChange={(e) => handleDateChange(e, field.onChange)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* completedAt */}
+ <FormField
+ control={form.control}
+ name="completedAt"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Completed At</FormLabel>
+ <FormControl>
+ <Input
+ type="date"
+ value={formatDateForInput(field.value)}
+ onChange={(e) => handleDateChange(e, field.onChange)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* investigationNotes */}
+ <FormField
+ control={form.control}
+ name="investigationNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Notes</FormLabel>
+ <FormControl>
+ <Input placeholder="Notes about the investigation..." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* attachments: multiple file upload */}
+ <FormField
+ control={form.control}
+ name="attachments"
+ render={({ field: { value, onChange, ...fieldProps } }) => (
+ <FormItem>
+ <FormLabel>Attachments</FormLabel>
+ <FormControl>
+ <Input
+ type="file"
+ multiple
+ onChange={(e) => {
+ onChange(e.target.files); // Store the FileList directly
+ }}
+ {...fieldProps}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Footer Buttons */}
+ <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>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts
new file mode 100644
index 00000000..18a50022
--- /dev/null
+++ b/lib/vendor-investigation/validations.ts
@@ -0,0 +1,93 @@
+import { vendorInvestigationsView } from "@/db/schema/vendors"
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+
+export const searchParamsInvestigationCache = createSearchParamsCache({
+ // Common flags
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // Paging
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // Sorting - adjusting for vendorInvestigationsView
+ sort: getSortingStateParser<typeof vendorInvestigationsView.$inferSelect>().withDefault([
+ { id: "investigationCreatedAt", desc: true },
+ ]),
+
+ // Advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // Global search
+ search: parseAsString.withDefault(""),
+
+ // -----------------------------------------------------------------
+ // Fields specific to vendor investigations
+ // -----------------------------------------------------------------
+
+ // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED
+ investigationStatus: parseAsStringEnum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"]),
+
+ // In case you also want to filter by vendorName, vendorCode, etc.
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+
+ // If you need to filter by vendor status (e.g., PQ_SUBMITTED, ACTIVE, etc.),
+ // you can include it here too. Example:
+ // vendorStatus: parseAsStringEnum([
+ // "PENDING_REVIEW",
+ // "IN_REVIEW",
+ // "REJECTED",
+ // "IN_PQ",
+ // "PQ_SUBMITTED",
+ // "PQ_FAILED",
+ // "PQ_APPROVED",
+ // "APPROVED",
+ // "ACTIVE",
+ // "INACTIVE",
+ // "BLACKLISTED",
+ // ]).optional(),
+})
+
+// Finally, export the type you can use in your server action:
+export type GetVendorsInvestigationSchema = Awaited<ReturnType<typeof searchParamsInvestigationCache.parse>>
+
+
+export const updateVendorInvestigationSchema = z.object({
+ investigationId: z.number(),
+ investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"]),
+
+ // If the user might send empty strings, we'll allow it by unioning with z.literal('')
+ // Then transform empty string to undefined
+ scheduledStartAt: z.preprocess(
+ // null이나 빈 문자열을 undefined로 변환
+ (val) => (val === null || val === '') ? undefined : val,
+ z.date().optional()
+ ),
+
+ scheduledEndAt:z.preprocess(
+ // null이나 빈 문자열을 undefined로 변환
+ (val) => (val === null || val === '') ? undefined : val,
+ z.date().optional()
+ ),
+
+ completedAt: z.preprocess(
+ // null이나 빈 문자열을 undefined로 변환
+ (val) => (val === null || val === '') ? undefined : val,
+ z.date().optional()
+ ),
+ investigationNotes: z.string().optional(),
+ attachments: z.any().optional(),
+ })
+
+export type UpdateVendorInvestigationSchema = z.infer<
+ typeof updateVendorInvestigationSchema
+> \ No newline at end of file
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 2da16888..8f095c0e 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -2,7 +2,7 @@
import { revalidateTag, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import { vendorAttachments, VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
+import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
import logger from '@/lib/logger';
import { filterColumns } from "@/lib/filter-columns";
@@ -38,7 +38,7 @@ import type {
GetRfqHistorySchema,
} from "./validations";
-import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull } from "drizzle-orm";
+import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm";
import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq";
import path from "path";
import fs from "fs/promises";
@@ -48,8 +48,10 @@ import { promises as fsPromises } from 'fs';
import { sendEmail } from "../mail/sendEmail";
import { PgTransaction } from "drizzle-orm/pg-core";
import { items } from "@/db/schema/items";
-import { id_ID } from "@faker-js/faker";
import { users } from "@/db/schema/users";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { projects, vendorProjectPQs } from "@/db/schema";
/* -----------------------------------------------------
@@ -178,7 +180,9 @@ export async function getVendorStatusCounts() {
"REJECTED": 0,
"IN_PQ": 0,
"PQ_FAILED": 0,
+ "PQ_APPROVED": 0,
"APPROVED": 0,
+ "READY_TO_SEND": 0,
"PQ_SUBMITTED": 0
};
@@ -275,7 +279,7 @@ export async function createVendor(params: {
vendorData: CreateVendorData
// 기존의 일반 첨부파일
files?: File[]
-
+
// 신용평가 / 현금흐름 등급 첨부
creditRatingFiles?: File[]
cashFlowRatingFiles?: File[]
@@ -288,25 +292,25 @@ export async function createVendor(params: {
}[]
}) {
unstable_noStore() // Next.js 서버 액션 캐싱 방지
-
+
try {
const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params
-
+
// 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인
const existingUser = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, vendorData.email))
.limit(1);
-
+
// 이미 사용자가 존재하면 에러 반환
if (existingUser.length > 0) {
- return {
- data: null,
- error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)`
+ return {
+ data: null,
+ error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)`
};
}
-
+
await db.transaction(async (tx) => {
// 1) Insert the vendor (확장 필드도 함께)
const [newVendor] = await insertVendor(tx, {
@@ -319,36 +323,36 @@ export async function createVendor(params: {
website: vendorData.website || null,
status: vendorData.status ?? "PENDING_REVIEW",
taxId: vendorData.taxId,
-
+
// 대표자 정보
representativeName: vendorData.representativeName || null,
representativeBirth: vendorData.representativeBirth || null,
representativeEmail: vendorData.representativeEmail || null,
representativePhone: vendorData.representativePhone || null,
corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null,
-
+
// 신용/현금흐름
creditAgency: vendorData.creditAgency || null,
creditRating: vendorData.creditRating || null,
cashFlowRating: vendorData.cashFlowRating || null,
})
-
+
// 2) If there are attached files, store them
// (2-1) 일반 첨부
if (files.length > 0) {
await storeVendorFiles(tx, newVendor.id, files, "GENERAL")
}
-
+
// (2-2) 신용평가 파일
if (creditRatingFiles.length > 0) {
await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING")
}
-
+
// (2-3) 현금흐름 파일
if (cashFlowRatingFiles.length > 0) {
await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING")
}
-
+
for (const contact of contacts) {
await tx.insert(vendorContacts).values({
vendorId: newVendor.id,
@@ -360,7 +364,7 @@ export async function createVendor(params: {
})
}
})
-
+
revalidateTag("vendors")
return { data: null, error: null }
} catch (error) {
@@ -665,21 +669,21 @@ export async function getItemsForVendor(vendorId: number) {
// 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함
// 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회
const itemsData = await db
- .select({
- itemCode: items.itemCode,
- itemName: items.itemName,
- description: items.description,
- })
- .from(items)
- .leftJoin(
- vendorPossibleItems,
- eq(items.itemCode, vendorPossibleItems.itemCode)
- )
- // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만
- .where(
- isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode)
- )
- .orderBy(asc(items.itemName))
+ .select({
+ itemCode: items.itemCode,
+ itemName: items.itemName,
+ description: items.description,
+ })
+ .from(items)
+ .leftJoin(
+ vendorPossibleItems,
+ eq(items.itemCode, vendorPossibleItems.itemCode)
+ )
+ // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만
+ .where(
+ isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode)
+ )
+ .orderBy(asc(items.itemName))
return {
data: itemsData.map((item) => ({
@@ -843,14 +847,15 @@ export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number
export async function checkJoinPortal(taxID: string) {
try {
// 이미 등록된 회사가 있는지 검색
- const result = await db.select().from(vendors).where(eq(vendors.taxId, taxID)).limit(1)
+ const result = await db.query.vendors.findFirst({
+ where: eq(vendors.taxId, taxID)
+ });
- if (result.length > 0) {
+ if (result) {
// 이미 가입되어 있음
- // data에 예시로 vendorName이나 다른 정보를 담아 반환
return {
success: false,
- data: result[0].vendorName ?? "Already joined",
+ data: result.vendorName ?? "Already joined",
}
}
@@ -888,11 +893,9 @@ interface CreateCompanyInput {
export async function downloadVendorAttachments(vendorId: number, fileId?: number) {
try {
// 벤더 정보 조회
- const vendor = await db.select()
- .from(vendors)
- .where(eq(vendors.id, vendorId))
- .limit(1)
- .then(rows => rows[0]);
+ const vendor = await db.query.vendors.findFirst({
+ where: eq(vendors.id, vendorId)
+ });
if (!vendor) {
throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`);
@@ -1007,6 +1010,7 @@ export async function cleanupTempFiles(fileName: string) {
interface ApproveVendorsInput {
ids: number[];
+ projectId?: number | null
}
/**
@@ -1014,7 +1018,7 @@ interface ApproveVendorsInput {
*/
export async function approveVendors(input: ApproveVendorsInput) {
unstable_noStore();
-
+
try {
// 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송
const result = await db.transaction(async (tx) => {
@@ -1027,7 +1031,7 @@ export async function approveVendors(input: ApproveVendorsInput) {
})
.where(inArray(vendors.id, input.ids))
.returning();
-
+
// 2. 업데이트된 벤더 정보 조회
const updatedVendors = await tx
.select({
@@ -1037,21 +1041,22 @@ export async function approveVendors(input: ApproveVendorsInput) {
})
.from(vendors)
.where(inArray(vendors.id, input.ids));
-
+
// 3. 각 벤더에 대한 유저 계정 생성
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
-
+
// 이미 존재하는 유저인지 확인
- const existingUser = await tx
- .select({ id: users.id })
- .from(users)
- .where(eq(users.email, vendor.email))
- .limit(1);
-
+ const existingUser = await db.query.users.findFirst({
+ where: eq(users.email, vendor.email),
+ columns: {
+ id: true
+ }
+ });
+
// 유저가 존재하지 않는 경우에만 생성
- if (existingUser.length === 0) {
+ if (!existingUser) {
await tx.insert(users).values({
name: vendor.vendorName,
email: vendor.email,
@@ -1061,20 +1066,20 @@ export async function approveVendors(input: ApproveVendorsInput) {
}
})
);
-
+
// 4. 각 벤더에게 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
-
+
try {
const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
-
- const subject =
+
+ const subject =
"[eVCP] Admin Account Created";
-
+
const loginUrl = "http://3.36.56.124:3000/en/login";
-
+
await sendEmail({
to: vendor.email,
subject,
@@ -1091,25 +1096,44 @@ export async function approveVendors(input: ApproveVendorsInput) {
}
})
);
-
+
return updated;
});
-
+
// 캐시 무효화
revalidateTag("vendors");
revalidateTag("vendor-status-counts");
revalidateTag("users"); // 유저 캐시도 무효화
-
+
return { data: result, error: null };
} catch (err) {
console.error("Error approving vendors:", err);
return { data: null, error: getErrorMessage(err) };
}
}
+
export async function requestPQVendors(input: ApproveVendorsInput) {
unstable_noStore();
-
+
try {
+ // 프로젝트 정보 가져오기 (projectId가 있는 경우)
+ let projectInfo = null;
+ if (input.projectId) {
+ const project = await db
+ .select({
+ id: projects.id,
+ projectCode: projects.code,
+ projectName: projects.name,
+ })
+ .from(projects)
+ .where(eq(projects.id, input.projectId))
+ .limit(1);
+
+ if (project.length > 0) {
+ projectInfo = project[0];
+ }
+ }
+
// 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송
const result = await db.transaction(async (tx) => {
// 1. 벤더 상태 업데이트
@@ -1121,7 +1145,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
})
.where(inArray(vendors.id, input.ids))
.returning();
-
+
// 2. 업데이트된 벤더 정보 조회
const updatedVendors = await tx
.select({
@@ -1131,28 +1155,51 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
})
.from(vendors)
.where(inArray(vendors.id, input.ids));
-
- // 3. 각 벤더에게 이메일 발송
+
+ // 3. 프로젝트 PQ인 경우, vendorProjectPQs 테이블에 레코드 추가
+ if (input.projectId && projectInfo) {
+ // 각 벤더에 대해 프로젝트 PQ 연결 생성
+ const vendorProjectPQsData = input.ids.map(vendorId => ({
+ vendorId,
+ projectId: input.projectId!,
+ status: "REQUESTED",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }));
+
+ await tx.insert(vendorProjectPQs).values(vendorProjectPQsData);
+ }
+
+ // 4. 각 벤더에게 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
-
+
try {
const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
-
- const subject =
- "[eVCP] You are invited to submit PQ";
-
- const loginUrl = "http://3.36.56.124:3000/en/login";
-
+
+ // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경
+ const subject = input.projectId
+ ? `[eVCP] You are invited to submit Project PQ for ${projectInfo?.projectCode || 'a project'}`
+ : "[eVCP] You are invited to submit PQ";
+
+ // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우)
+ const baseLoginUrl = "http://3.36.56.124:3000/en/login";
+ const loginUrl = input.projectId
+ ? `${baseLoginUrl}?projectId=${input.projectId}`
+ : baseLoginUrl;
+
await sendEmail({
to: vendor.email,
subject,
- template: "pq", // 이메일 템플릿 이름
+ template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용
context: {
vendorName: vendor.vendorName,
loginUrl,
language: userLang,
+ projectCode: projectInfo?.projectCode || '',
+ projectName: projectInfo?.projectName || '',
+ hasProject: !!input.projectId,
},
});
} catch (emailError) {
@@ -1161,17 +1208,20 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
}
})
);
-
+
return updated;
});
-
+
// 캐시 무효화
revalidateTag("vendors");
revalidateTag("vendor-status-counts");
-
+ if (input.projectId) {
+ revalidateTag(`project-${input.projectId}`);
+ }
+
return { data: result, error: null };
} catch (err) {
- console.error("Error approving vendors:", err);
+ console.error("Error requesting PQ from vendors:", err);
return { data: null, error: getErrorMessage(err) };
}
}
@@ -1190,46 +1240,40 @@ export async function sendVendors(input: SendVendorsInput) {
// 트랜잭션 내에서 진행
const result = await db.transaction(async (tx) => {
// 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링
- const approvedVendors = await tx
- .select()
- .from(vendors)
- .where(
- and(
- inArray(vendors.id, input.ids),
- eq(vendors.status, "APPROVED")
- )
- );
+ const approvedVendors = await db.query.vendors.findMany({
+ where: and(
+ inArray(vendors.id, input.ids),
+ eq(vendors.status, "APPROVED")
+ )
+ });
if (!approvedVendors.length) {
throw new Error("No approved vendors found in the selection");
}
-
// 벤더별 처리 결과를 저장할 배열
const results = [];
// 2. 각 벤더에 대해 처리
for (const vendor of approvedVendors) {
// 2-1. 벤더 연락처 정보 조회
- const contacts = await tx
- .select()
- .from(vendorContacts)
- .where(eq(vendorContacts.vendorId, vendor.id));
+ const contacts = await db.query.vendorContacts.findMany({
+ where: eq(vendorContacts.vendorId, vendor.id)
+ });
// 2-2. 벤더 가능 아이템 조회
- const possibleItems = await tx
- .select()
- .from(vendorPossibleItems)
- .where(eq(vendorPossibleItems.vendorId, vendor.id));
-
+ const possibleItems = await db.query.vendorPossibleItems.findMany({
+ where: eq(vendorPossibleItems.vendorId, vendor.id)
+ });
// 2-3. 벤더 첨부파일 조회
- const attachments = await tx
- .select({
- id: vendorAttachments.id,
- fileName: vendorAttachments.fileName,
- filePath: vendorAttachments.filePath,
- })
- .from(vendorAttachments)
- .where(eq(vendorAttachments.vendorId, vendor.id));
+ const attachments = await db.query.vendorAttachments.findMany({
+ where: eq(vendorAttachments.vendorId, vendor.id),
+ columns: {
+ id: true,
+ fileName: true,
+ filePath: true
+ }
+ });
+
// 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
const vendorData = {
@@ -1287,7 +1331,7 @@ export async function sendVendors(input: SendVendorsInput) {
const subject =
"[eVCP] Vendor Registration Completed";
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
const portalUrl = `${baseUrl}/en/partners`;
@@ -1343,3 +1387,298 @@ export async function sendVendors(input: SendVendorsInput) {
}
}
+
+interface RequestInfoProps {
+ ids: number[];
+}
+
+export async function requestInfo({ ids }: RequestInfoProps) {
+ try {
+ // 1. 벤더 정보 가져오기
+ const vendorList = await db.query.vendors.findMany({
+ where: inArray(vendors.id, ids),
+ });
+
+ if (!vendorList.length) {
+ return { error: "벤더 정보를 찾을 수 없습니다." };
+ }
+
+ // 2. 각 벤더에게 이메일 보내기
+ for (const vendor of vendorList) {
+ // 이메일이 없는 경우 스킵
+ if (!vendor.email) continue;
+
+ // 벤더 정보 페이지 URL 생성
+ const vendorInfoUrl = `${process.env.NEXT_PUBLIC_APP_URL}/partners/info?vendorId=${vendor.id}`;
+
+ // 벤더에게 이메일 보내기
+ await sendEmail({
+ to: vendor.email,
+ subject: "[EVCP] 추가 정보 요청 / Additional Information Request",
+ template: "vendor-additional-info",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorInfoUrl: vendorInfoUrl,
+ language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음
+ },
+ });
+ }
+
+ // 3. 성공적으로 처리됨
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 정보 요청 중 오류 발생:", error);
+ return { error: "벤더 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." };
+ }
+}
+
+
+export async function getVendorDetailById(id: number) {
+ try {
+ // View를 통해 벤더 정보 조회
+ const vendor = await db
+ .select()
+ .from(vendorDetailView)
+ .where(eq(vendorDetailView.id, id))
+ .limit(1)
+ .then(rows => rows[0] || null);
+
+ if (!vendor) {
+ return null;
+ }
+
+ // JSON 문자열로 반환된 contacts와 attachments를 JavaScript 객체로 파싱
+ const contacts = typeof vendor.contacts === 'string'
+ ? JSON.parse(vendor.contacts)
+ : vendor.contacts;
+
+ const attachments = typeof vendor.attachments === 'string'
+ ? JSON.parse(vendor.attachments)
+ : vendor.attachments;
+
+ // 파싱된 데이터로 반환
+ return {
+ ...vendor,
+ contacts,
+ attachments
+ };
+ } catch (error) {
+ console.error("Error fetching vendor detail:", error);
+ throw new Error("Failed to fetch vendor detail");
+ }
+}
+
+export type UpdateVendorInfoData = {
+ id: number
+ vendorName: string
+ website?: string
+ address?: string
+ email: string
+ phone?: string
+ country?: string
+ representativeName?: string
+ representativeBirth?: string
+ representativeEmail?: string
+ representativePhone?: string
+ corporateRegistrationNumber?: string
+ creditAgency?: string
+ creditRating?: string
+ cashFlowRating?: string
+}
+
+export type ContactInfo = {
+ id?: number
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ isPrimary?: boolean
+}
+
+/**
+ * 벤더 정보를 업데이트하는 함수
+ */
+export async function updateVendorInfo(params: {
+ vendorData: UpdateVendorInfoData
+ files?: File[]
+ creditRatingFiles?: File[]
+ cashFlowRatingFiles?: File[]
+ contacts: ContactInfo[]
+ filesToDelete?: number[] // 삭제할 파일 ID 목록
+}) {
+ try {
+ const {
+ vendorData,
+ files = [],
+ creditRatingFiles = [],
+ cashFlowRatingFiles = [],
+ contacts,
+ filesToDelete = []
+ } = params
+
+ // 세션 및 권한 확인
+ const session = await getServerSession(authOptions)
+ if (!session?.user || !session.user.companyId) {
+ return { data: null, error: "권한이 없습니다. 로그인이 필요합니다." };
+ }
+
+ const companyId = Number(session.user.companyId);
+
+ // 자신의 회사 정보만 수정 가능 (관리자는 모든 회사 정보 수정 가능)
+ if (
+ // !session.user.isAdmin &&
+ vendorData.id !== companyId) {
+ return { data: null, error: "자신의 회사 정보만 수정할 수 있습니다." };
+ }
+
+ // 트랜잭션으로 업데이트 수행
+ await db.transaction(async (tx) => {
+ // 1. 벤더 정보 업데이트
+ await tx.update(vendors).set({
+ vendorName: vendorData.vendorName,
+ address: vendorData.address || null,
+ email: vendorData.email,
+ phone: vendorData.phone || null,
+ website: vendorData.website || null,
+ country: vendorData.country || null,
+ representativeName: vendorData.representativeName || null,
+ representativeBirth: vendorData.representativeBirth || null,
+ representativeEmail: vendorData.representativeEmail || null,
+ representativePhone: vendorData.representativePhone || null,
+ corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null,
+ creditAgency: vendorData.creditAgency || null,
+ creditRating: vendorData.creditRating || null,
+ cashFlowRating: vendorData.cashFlowRating || null,
+ updatedAt: new Date(),
+ }).where(eq(vendors.id, vendorData.id))
+
+ // 2. 연락처 정보 관리
+ // 2-1. 기존 연락처 가져오기
+ const existingContacts = await tx
+ .select()
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorData.id))
+
+ // 2-2. 기존 연락처 ID 목록
+ const existingContactIds = existingContacts.map(c => c.id)
+
+ // 2-3. 업데이트할 연락처와 새로 추가할 연락처 분류
+ const contactsToUpdate = contacts.filter(c => c.id && existingContactIds.includes(c.id))
+ const contactsToAdd = contacts.filter(c => !c.id)
+
+ // 2-4. 삭제할 연락처 (기존에 있지만 새 목록에 없는 것)
+ const contactIdsToKeep = contactsToUpdate.map(c => c.id)
+ .filter((id): id is number => id !== undefined)
+ const contactIdsToDelete = existingContactIds.filter(id => !contactIdsToKeep.includes(id))
+
+ // 2-5. 연락처 삭제
+ if (contactIdsToDelete.length > 0) {
+ await tx
+ .delete(vendorContacts)
+ .where(and(
+ eq(vendorContacts.vendorId, vendorData.id),
+ inArray(vendorContacts.id, contactIdsToDelete)
+ ))
+ }
+
+ // 2-6. 연락처 업데이트
+ for (const contact of contactsToUpdate) {
+ if (contact.id !== undefined) {
+ await tx
+ .update(vendorContacts)
+ .set({
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: contact.isPrimary || false,
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(vendorContacts.id, contact.id),
+ eq(vendorContacts.vendorId, vendorData.id)
+ ))
+ }
+ }
+
+ // 2-7. 연락처 추가
+ for (const contact of contactsToAdd) {
+ await tx
+ .insert(vendorContacts)
+ .values({
+ vendorId: vendorData.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: contact.isPrimary || false,
+ })
+ }
+
+ // 3. 파일 삭제 처리
+ if (filesToDelete.length > 0) {
+ // 3-1. 삭제할 파일 정보 가져오기
+ const attachmentsToDelete = await tx
+ .select()
+ .from(vendorAttachments)
+ .where(and(
+ eq(vendorAttachments.vendorId, vendorData.id),
+ inArray(vendorAttachments.id, filesToDelete)
+ ))
+
+ // 3-2. 파일 시스템에서 파일 삭제
+ for (const attachment of attachmentsToDelete) {
+ try {
+ // 파일 경로는 /public 기준이므로 process.cwd()/public을 앞에 붙임
+ const filePath = path.join(process.cwd(), 'public', attachment.filePath.replace(/^\//, ''))
+ await fs.access(filePath, fs.constants.F_OK) // 파일 존재 확인
+ await fs.unlink(filePath) // 파일 삭제
+ } catch (error) {
+ console.warn(`Failed to delete file for attachment ${attachment.id}:`, error)
+ // 파일 삭제 실패해도 DB에서는 삭제 진행
+ }
+ }
+
+ // 3-3. DB에서 파일 기록 삭제
+ await tx
+ .delete(vendorAttachments)
+ .where(and(
+ eq(vendorAttachments.vendorId, vendorData.id),
+ inArray(vendorAttachments.id, filesToDelete)
+ ))
+ }
+
+ // 4. 새 파일 저장 (제공된 storeVendorFiles 함수 활용)
+ // 4-1. 일반 파일 저장
+ if (files.length > 0) {
+ await storeVendorFiles(tx, vendorData.id, files, "GENERAL");
+ }
+
+ // 4-2. 신용평가 파일 저장
+ if (creditRatingFiles.length > 0) {
+ await storeVendorFiles(tx, vendorData.id, creditRatingFiles, "CREDIT_RATING");
+ }
+
+ // 4-3. 현금흐름 파일 저장
+ if (cashFlowRatingFiles.length > 0) {
+ await storeVendorFiles(tx, vendorData.id, cashFlowRatingFiles, "CASH_FLOW_RATING");
+ }
+ })
+
+ // 캐시 무효화
+ revalidateTag("vendors")
+ revalidateTag(`vendor-${vendorData.id}`)
+
+ return {
+ data: {
+ success: true,
+ message: '벤더 정보가 성공적으로 업데이트되었습니다.',
+ vendorId: vendorData.id
+ },
+ error: null
+ }
+ } catch (error) {
+ console.error("Vendor info update error:", error);
+ return { data: null, error: getErrorMessage(error) }
+ }
+} \ No newline at end of file
diff --git a/lib/vendors/table/attachmentButton.tsx b/lib/vendors/table/attachmentButton.tsx
index a82f59e1..3ffa9c5f 100644
--- a/lib/vendors/table/attachmentButton.tsx
+++ b/lib/vendors/table/attachmentButton.tsx
@@ -16,25 +16,25 @@ interface AttachmentsButtonProps {
export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) {
if (!hasAttachments) return null;
-
+
const handleDownload = async () => {
try {
toast.loading('첨부파일을 준비하는 중...');
-
+
// 서버 액션 호출
const result = await downloadVendorAttachments(vendorId);
-
+
// 로딩 토스트 닫기
toast.dismiss();
-
+
if (!result || !result.url) {
toast.error('다운로드 준비 중 오류가 발생했습니다.');
return;
}
-
+
// 파일 다운로드 트리거
toast.success('첨부파일 다운로드가 시작되었습니다.');
-
+
// 다운로드 링크 열기
const a = document.createElement('a');
a.href = result.url;
@@ -43,27 +43,34 @@ export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList =
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
-
+
} catch (error) {
toast.dismiss();
toast.error('첨부파일 다운로드에 실패했습니다.');
console.error('첨부파일 다운로드 오류:', error);
}
};
-
+
return (
- <Button
- variant="ghost"
- size="icon"
- onClick={handleDownload}
- title={`${attachmentsList.length}개 파일 다운로드`}
- >
- <PaperclipIcon className="h-4 w-4" />
- {attachmentsList.length > 1 && (
- <Badge variant="outline" className="ml-1 h-5 min-w-5 px-1">
+ <>
+ {attachmentsList && attachmentsList.length > 0 &&
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={handleDownload}
+ title={`${attachmentsList.length}개 파일 다운로드`}
+ >
+ <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {/* {attachmentsList.length > 1 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center"
+ >
{attachmentsList.length}
</Badge>
- )}
- </Button>
+ )} */}
+ </Button>
+ }
+ </>
);
}
diff --git a/lib/vendors/table/request-additional-Info-dialog.tsx b/lib/vendors/table/request-additional-Info-dialog.tsx
new file mode 100644
index 00000000..872162dd
--- /dev/null
+++ b/lib/vendors/table/request-additional-Info-dialog.tsx
@@ -0,0 +1,152 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Send } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Vendor } from "@/db/schema/vendors"
+import { requestInfo } from "../service"
+
+interface RequestInfoDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function RequestInfoDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: RequestInfoDialogProps) {
+ const [isRequestPending, startRequestTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onApprove() {
+ startRequestTransition(async () => {
+ const { error, success } = await requestInfo({
+ ids: vendors.map((vendor) => vendor.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("추가 정보 요청이 성공적으로 벤더에게 발송되었습니다.")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Send className="size-4" aria-hidden="true" />
+ 추가 정보 요청 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>벤더 추가 정보 요청 확인</DialogTitle>
+ <DialogDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까?
+ <br /><br />
+ 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
+ 추가 정보를 입력하게 됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="Send request to selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isRequestPending}
+ >
+ {isRequestPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 요청 발송
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Send className="size-4" aria-hidden="true" />
+ 추가 정보 요청 ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>벤더 추가 정보 요청 확인</DrawerTitle>
+ <DrawerDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까?
+ <br /><br />
+ 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
+ 추가 정보를 입력하게 됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Send request to selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isRequestPending}
+ >
+ {isRequestPending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 요청 발송
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/request-project-pq-dialog.tsx b/lib/vendors/table/request-project-pq-dialog.tsx
new file mode 100644
index 00000000..c590d7ec
--- /dev/null
+++ b/lib/vendors/table/request-project-pq-dialog.tsx
@@ -0,0 +1,242 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, ChevronDown, BuildingIcon } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Label } from "@/components/ui/label"
+import { Vendor } from "@/db/schema/vendors"
+import { requestPQVendors } from "../service"
+import { getProjects, type Project } from "@/lib/rfqs/service"
+
+interface RequestProjectPQDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function RequestProjectPQDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: RequestProjectPQDialogProps) {
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const [projects, setProjects] = React.useState<Project[]>([])
+ const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null)
+ const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
+
+ // 프로젝트 목록 로드
+ React.useEffect(() => {
+ async function loadProjects() {
+ setIsLoadingProjects(true)
+ try {
+ const projectsList = await getProjects()
+ setProjects(projectsList)
+ } catch (error) {
+ console.error("프로젝트 목록 로드 오류:", error)
+ toast.error("프로젝트 목록을 불러오는 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoadingProjects(false)
+ }
+ }
+
+ loadProjects()
+ }, [])
+
+ // 다이얼로그가 닫힐 때 선택된 프로젝트 초기화
+ React.useEffect(() => {
+ if (!props.open) {
+ setSelectedProjectId(null)
+ }
+ }, [props.open])
+
+ // 프로젝트 선택 처리
+ const handleProjectChange = (value: string) => {
+ setSelectedProjectId(Number(value))
+ }
+
+ function onApprove() {
+ if (!selectedProjectId) {
+ toast.error("프로젝트를 선택해주세요.")
+ return
+ }
+
+ startApproveTransition(async () => {
+ const { error } = await requestPQVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ projectId: selectedProjectId,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+
+ toast.success(`벤더에게 프로젝트 PQ가 성공적으로 요청되었습니다.`)
+ onSuccess?.()
+ })
+ }
+
+ const dialogContent = (
+ <>
+ <div className="space-y-4 py-2">
+ <div className="space-y-2">
+ <Label htmlFor="project-selection">프로젝트 선택</Label>
+ <Select
+ onValueChange={handleProjectChange}
+ disabled={isLoadingProjects || isApprovePending}
+ >
+ <SelectTrigger id="project-selection" className="w-full">
+ <SelectValue placeholder="프로젝트를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {isLoadingProjects ? (
+ <SelectItem value="loading" disabled>프로젝트 로딩 중...</SelectItem>
+ ) : projects.length === 0 ? (
+ <SelectItem value="empty" disabled>등록된 프로젝트가 없습니다</SelectItem>
+ ) : (
+ projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.projectCode} - {project.projectName}
+ </SelectItem>
+ ))
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <BuildingIcon className="size-4" aria-hidden="true" />
+ 프로젝트 PQ 요청 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>프로젝트 PQ 요청 확인</DialogTitle>
+ <DialogDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
+ 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {dialogContent}
+
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 벤더에게 요청하기"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending || !selectedProjectId}
+ >
+ {isApprovePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 요청하기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <BuildingIcon className="size-4" aria-hidden="true" />
+ 프로젝트 PQ 요청 ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>프로젝트 PQ 요청 확인</DrawerTitle>
+ <DrawerDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
+ 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="px-4">
+ {dialogContent}
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 벤더에게 요청하기"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending || !selectedProjectId}
+ >
+ {isApprovePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 요청하기
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/request-vendor-investigate-dialog.tsx b/lib/vendors/table/request-vendor-investigate-dialog.tsx
new file mode 100644
index 00000000..0309ee4a
--- /dev/null
+++ b/lib/vendors/table/request-vendor-investigate-dialog.tsx
@@ -0,0 +1,152 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Check, SendHorizonal } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Vendor } from "@/db/schema/vendors"
+import { requestInvestigateVendors } from "@/lib/vendor-investigation/service"
+
+interface ApprovalVendorDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function RequestVendorsInvestigateDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: ApprovalVendorDialogProps) {
+
+ console.log(vendors)
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onApprove() {
+ startApproveTransition(async () => {
+ const { error } = await requestInvestigateVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Vendor Investigation successfully sent to 벤더실사담당자")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <SendHorizonal className="size-4" aria-hidden="true" />
+ Vendor Investigation Request ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Vendor Investigation Requst</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to request{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After sent, 벤더실사담당자 will be notified and can manage it.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Request selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Request
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Check className="size-4" aria-hidden="true" />
+ Investigation Request ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Confirm Vendor Investigation</DrawerTitle>
+ <DrawerDescription>
+ Are you sure you want to request{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After sent, 벤더실사담당자 will be notified and can manage it.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Request selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Request
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/send-vendor-dialog.tsx b/lib/vendors/table/send-vendor-dialog.tsx
index a34abb77..1f93bd7f 100644
--- a/lib/vendors/table/send-vendor-dialog.tsx
+++ b/lib/vendors/table/send-vendor-dialog.tsx
@@ -28,7 +28,7 @@ import {
DrawerTrigger,
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
-import { requestPQVendors, sendVendors } from "../service"
+import { sendVendors } from "../service"
interface ApprovalVendorDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -58,7 +58,7 @@ export function SendVendorsDialog({
}
props.onOpenChange?.(false)
- toast.success("PQ successfully sent to vendors")
+ toast.success("Vendor Information successfully sent to MDG")
onSuccess?.()
})
}
diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx
index c503e369..77750c47 100644
--- a/lib/vendors/table/vendors-table-columns.tsx
+++ b/lib/vendors/table/vendors-table-columns.tsx
@@ -79,82 +79,96 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
// ----------------------------------------------------------------
// 2) actions 컬럼 (Dropdown 메뉴)
// ----------------------------------------------------------------
- const actionsColumn: ColumnDef<Vendor> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
+// ----------------------------------------------------------------
+// 2) actions 컬럼 (Dropdown 메뉴)
+// ----------------------------------------------------------------
+const actionsColumn: ColumnDef<Vendor> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const isApproved = row.original.status === "APPROVED";
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- Edit
- </DropdownMenuItem>
- <DropdownMenuItem
- onSelect={() => {
- // 1) 만약 rowAction을 열고 싶다면
- // setRowAction({ row, type: "update" })
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ // 1) 만약 rowAction을 열고 싶다면
+ // setRowAction({ row, type: "update" })
- // 2) 자세히 보기 페이지로 클라이언트 라우팅
- router.push(`/evcp/vendors/${row.original.id}/info`);
- }}
+ // 2) 자세히 보기 페이지로 클라이언트 라우팅
+ router.push(`/evcp/vendors/${row.original.id}/info`);
+ }}
+ >
+ Details
+ </DropdownMenuItem>
+
+ {/* APPROVED 상태일 때만 추가 정보 기입 메뉴 표시 */}
+ {isApproved && (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "requestInfo" })}
+ className="text-blue-600 font-medium"
>
- Details
+ 추가 정보 기입
</DropdownMenuItem>
- <Separator />
- <DropdownMenuSub>
- <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
- <DropdownMenuSubContent>
- <DropdownMenuRadioGroup
- value={row.original.status}
- onValueChange={(value) => {
- startUpdateTransition(() => {
- toast.promise(
- modifyVendor({
- id: String(row.original.id),
- status: value as Vendor["status"],
- }),
- {
- loading: "Updating...",
- success: "Label updated",
- error: (err) => getErrorMessage(err),
- }
- )
- })
- }}
- >
- {vendors.status.enumValues.map((status) => (
- <DropdownMenuRadioItem
- key={status}
- value={status}
- className="capitalize"
- disabled={isUpdatePending}
- >
- {status}
- </DropdownMenuRadioItem>
- ))}
- </DropdownMenuRadioGroup>
- </DropdownMenuSubContent>
- </DropdownMenuSub>
-
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
+ )}
+
+ <Separator />
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.status}
+ onValueChange={(value) => {
+ startUpdateTransition(() => {
+ toast.promise(
+ modifyVendor({
+ id: String(row.original.id),
+ status: value as Vendor["status"],
+ }),
+ {
+ loading: "Updating...",
+ success: "Label updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {vendors.status.enumValues.map((status) => (
+ <DropdownMenuRadioItem
+ key={status}
+ value={status}
+ className="capitalize"
+ disabled={isUpdatePending}
+ >
+ {status}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+}
// ----------------------------------------------------------------
// 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx
index c0605191..3cb2c552 100644
--- a/lib/vendors/table/vendors-table-toolbar-actions.tsx
+++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx
@@ -2,15 +2,24 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, Upload, Check } from "lucide-react"
+import { Download, Upload, Check, BuildingIcon } from "lucide-react"
import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
import { Vendor } from "@/db/schema/vendors"
import { ApproveVendorsDialog } from "./approve-vendor-dialog"
import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog"
+import { RequestProjectPQDialog } from "./request-project-pq-dialog"
import { SendVendorsDialog } from "./send-vendor-dialog"
+import { RequestVendorsInvestigateDialog } from "./request-vendor-investigate-dialog"
+import { RequestInfoDialog } from "./request-additional-Info-dialog"
interface VendorsTableToolbarActionsProps {
table: Table<Vendor>
@@ -19,7 +28,7 @@ interface VendorsTableToolbarActionsProps {
export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
// 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
const fileInputRef = React.useRef<HTMLInputElement>(null)
-
+
// 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링
const pendingReviewVendors = React.useMemo(() => {
return table
@@ -28,9 +37,8 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
.map(row => row.original)
.filter(vendor => vendor.status === "PENDING_REVIEW");
}, [table.getFilteredSelectedRowModel().rows]);
-
-
- // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링
+
+ // 선택된 벤더 중 IN_REVIEW 상태인 벤더만 필터링
const inReviewVendors = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -38,7 +46,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
.map(row => row.original)
.filter(vendor => vendor.status === "IN_REVIEW");
}, [table.getFilteredSelectedRowModel().rows]);
-
+
const approvedVendors = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -46,14 +54,36 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
.map(row => row.original)
.filter(vendor => vendor.status === "APPROVED");
}, [table.getFilteredSelectedRowModel().rows]);
-
-
-
+
+ const sendVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.status === "READY_TO_SEND");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ const pqApprovedVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.status === "PQ_APPROVED");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ // 프로젝트 PQ를 보낼 수 있는 벤더 상태 필터링
+ const projectPQEligibleVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor =>
+ ["PENDING_REVIEW", "IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status)
+ );
+ }, [table.getFilteredSelectedRowModel().rows]);
+
return (
<div className="flex items-center gap-2">
-
-
-
{/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */}
{pendingReviewVendors.length > 0 && (
<ApproveVendorsDialog
@@ -61,22 +91,44 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
onSuccess={() => table.toggleAllRowsSelected(false)}
/>
)}
-
+
+ {/* 일반 PQ 요청: IN_REVIEW 상태인 벤더가 있을 때만 표시 */}
{inReviewVendors.length > 0 && (
<RequestPQVendorsDialog
vendors={inReviewVendors}
onSuccess={() => table.toggleAllRowsSelected(false)}
/>
)}
-
+
+ {/* 프로젝트 PQ 요청: 적격 상태의 벤더가 있을 때만 표시 */}
+ {projectPQEligibleVendors.length > 0 && (
+ <RequestProjectPQDialog
+ vendors={projectPQEligibleVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
{approvedVendors.length > 0 && (
- <SendVendorsDialog
+ <RequestInfoDialog
vendors={approvedVendors}
onSuccess={() => table.toggleAllRowsSelected(false)}
/>
)}
-
-
+
+ {sendVendors.length > 0 && (
+ <RequestInfoDialog
+ vendors={sendVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
+ {pqApprovedVendors.length > 0 && (
+ <RequestVendorsInvestigateDialog
+ vendors={pqApprovedVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
{/** 4) Export 버튼 */}
<Button
variant="outline"
diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx
index c04d57a9..36fd45bd 100644
--- a/lib/vendors/table/vendors-table.tsx
+++ b/lib/vendors/table/vendors-table.tsx
@@ -20,6 +20,7 @@ import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
import { VendorsTableFloatingBar } from "./vendors-table-floating-bar"
import { UpdateTaskSheet } from "@/lib/tasks/table/update-task-sheet"
import { UpdateVendorSheet } from "./update-vendor-sheet"
+import { getVendorStatusIcon } from "@/lib/vendors/utils"
interface VendorsTableProps {
promises: Promise<
@@ -72,9 +73,11 @@ export function VendorsTable({ promises }: VendorsTableProps) {
label: "Status",
type: "multi-select",
options: vendors.status.enumValues.map((status) => ({
- label: toSentenceCase(status),
+ label: (status),
value: status,
count: statusCounts[status],
+ icon: getVendorStatusIcon(status),
+
})),
},
{ id: "createdAt", label: "Created at", type: "date" },
diff --git a/lib/vendors/utils.ts b/lib/vendors/utils.ts
new file mode 100644
index 00000000..305d772d
--- /dev/null
+++ b/lib/vendors/utils.ts
@@ -0,0 +1,48 @@
+import {
+ Activity,
+ AlertCircle,
+ AlertTriangle,
+ ArrowDownIcon,
+ ArrowRightIcon,
+ ArrowUpIcon,
+ AwardIcon,
+ BadgeCheck,
+ CheckCircle2,
+ CircleHelp,
+ CircleIcon,
+ CircleX,
+ ClipboardCheck,
+ ClipboardList,
+ FileCheck2,
+ FilePenLine,
+ FileX2,
+ MailCheck,
+ PencilIcon,
+ SearchIcon,
+ SendIcon,
+ Timer,
+ Trash2,
+ XCircle,
+} from "lucide-react"
+
+import { Vendor } from "@/db/schema/vendors"
+
+export function getVendorStatusIcon(status: Vendor["status"]) {
+ const statusIcons = {
+ PENDING_REVIEW: ClipboardList, // 가입 신청 중 (초기 신청)
+ IN_REVIEW: FilePenLine, // 심사 중
+ REJECTED: XCircle, // 심사 거부됨
+ IN_PQ: ClipboardCheck, // PQ 진행 중
+ PQ_SUBMITTED: FileCheck2, // PQ 제출
+ PQ_FAILED: FileX2, // PQ 실패
+ PQ_APPROVED: BadgeCheck, // PQ 통과, 승인됨
+ APPROVED: CheckCircle2, // PQ 통과, 승인됨
+ READY_TO_SEND: CheckCircle2, // PQ 통과, 승인됨
+ ACTIVE: Activity, // 활성 상태 (실제 거래 중)
+ INACTIVE: AlertCircle, // 비활성 상태 (일시적)
+ BLACKLISTED: AlertTriangle, // 거래 금지 상태
+ }
+
+ return statusIcons[status] || CircleIcon
+}
+
diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts
index 14efc8dc..1c08f8ff 100644
--- a/lib/vendors/validations.ts
+++ b/lib/vendors/validations.ts
@@ -1,4 +1,3 @@
-import { tasks, type Task } from "@/db/schema/tasks";
import {
createSearchParamsCache,
parseAsArrayOf,
@@ -9,7 +8,7 @@ import {
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { Vendor, VendorContact, VendorItemsView, vendors } from "@/db/schema/vendors";
+import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors } from "@/db/schema/vendors";
import { rfqs } from "@/db/schema/rfq"
@@ -339,3 +338,103 @@ export type UpdateVendorContactSchema = z.infer<typeof updateVendorContactSchema
export type CreateVendorItemSchema = z.infer<typeof createVendorItemSchema>
export type UpdateVendorItemSchema = z.infer<typeof updateVendorItemSchema>
export type GetRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>>
+
+
+
+export const updateVendorInfoSchema = z.object({
+ vendorName: z.string().min(1, "업체명은 필수 입력사항입니다."),
+ taxId: z.string(),
+ address: z.string().optional(),
+ country: z.string().min(1, "국가를 선택해 주세요."),
+ phone: z.string().optional(),
+ email: z.string().email("유효한 이메일을 입력해 주세요."),
+ website: z.string().optional(),
+
+ // 한국 사업자 정보 (KR일 경우 필수 항목들)
+ representativeName: z.string().optional(),
+ representativeBirth: z.string().optional(),
+ representativeEmail: z.string().optional(),
+ representativePhone: z.string().optional(),
+ corporateRegistrationNumber: z.string().optional(),
+
+ // 신용평가 정보
+ creditAgency: z.string().optional(),
+ creditRating: z.string().optional(),
+ cashFlowRating: z.string().optional(),
+
+ // 첨부파일
+ attachedFiles: z.any().optional(),
+ creditRatingAttachment: z.any().optional(),
+ cashFlowRatingAttachment: z.any().optional(),
+
+ // 연락처 정보
+ contacts: z.array(contactSchema).min(1, "최소 1명의 담당자가 필요합니다."),
+})
+
+export const updateVendorSchemaWithConditions = updateVendorInfoSchema.superRefine(
+ (data, ctx) => {
+ // 국가가 한국(KR)인 경우, 한국 사업자 정보 필수
+ if (data.country === "KR") {
+ if (!data.representativeName) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "대표자 이름은 필수 입력사항입니다.",
+ path: ["representativeName"],
+ })
+ }
+
+ if (!data.representativeBirth) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "대표자 생년월일은 필수 입력사항입니다.",
+ path: ["representativeBirth"],
+ })
+ }
+
+ if (!data.representativeEmail) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "대표자 이메일은 필수 입력사항입니다.",
+ path: ["representativeEmail"],
+ })
+ }
+
+ if (!data.representativePhone) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "대표자 전화번호는 필수 입력사항입니다.",
+ path: ["representativePhone"],
+ })
+ }
+
+ if (!data.corporateRegistrationNumber) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "법인등록번호는 필수 입력사항입니다.",
+ path: ["corporateRegistrationNumber"],
+ })
+ }
+
+ // 신용평가사가 선택된 경우, 등급 정보 필수
+ if (data.creditAgency) {
+ if (!data.creditRating) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "신용평가등급은 필수 입력사항입니다.",
+ path: ["creditRating"],
+ })
+ }
+
+ if (!data.cashFlowRating) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "현금흐름등급은 필수 입력사항입니다.",
+ path: ["cashFlowRating"],
+ })
+ }
+ }
+ }
+ }
+)
+
+export type UpdateVendorInfoSchema = z.infer<typeof updateVendorInfoSchema> \ No newline at end of file