diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-02 09:54:08 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-02 09:54:08 +0000 |
| commit | dfdfae3018f8499240f48d28ce634f4a5c56e006 (patch) | |
| tree | 4493b172c061fa5bf4e94c083788110eb1507f6d /lib/pq | |
| parent | 21a72eeddc74cf775e2a76e2c569de970bd62a7f (diff) | |
벤더 코멘트 처리
Diffstat (limited to 'lib/pq')
| -rw-r--r-- | lib/pq/service.ts | 1172 | ||||
| -rw-r--r-- | lib/pq/table/add-pq-dialog.tsx | 431 | ||||
| -rw-r--r-- | lib/pq/table/import-pq-button.tsx | 258 | ||||
| -rw-r--r-- | lib/pq/table/import-pq-handler.tsx | 146 | ||||
| -rw-r--r-- | lib/pq/table/pq-excel-template.tsx | 205 | ||||
| -rw-r--r-- | lib/pq/table/pq-table-toolbar-actions.tsx | 86 | ||||
| -rw-r--r-- | lib/pq/table/pq-table.tsx | 4 |
7 files changed, 1894 insertions, 408 deletions
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> |
