summaryrefslogtreecommitdiff
path: root/lib/pq
diff options
context:
space:
mode:
Diffstat (limited to 'lib/pq')
-rw-r--r--lib/pq/service.ts1172
-rw-r--r--lib/pq/table/add-pq-dialog.tsx431
-rw-r--r--lib/pq/table/import-pq-button.tsx258
-rw-r--r--lib/pq/table/import-pq-handler.tsx146
-rw-r--r--lib/pq/table/pq-excel-template.tsx205
-rw-r--r--lib/pq/table/pq-table-toolbar-actions.tsx86
-rw-r--r--lib/pq/table/pq-table.tsx4
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>