diff options
Diffstat (limited to 'lib')
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 & 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 |
