diff options
Diffstat (limited to 'lib/vendor-evaluation-submit')
7 files changed, 3233 insertions, 0 deletions
diff --git a/lib/vendor-evaluation-submit/service.ts b/lib/vendor-evaluation-submit/service.ts new file mode 100644 index 00000000..5ab1206e --- /dev/null +++ b/lib/vendor-evaluation-submit/service.ts @@ -0,0 +1,885 @@ +'use server' + +import db from "@/db/db"; +import { + evaluationSubmissions, + vendors, + generalEvaluationResponses, + esgEvaluationResponses, + vendorEvaluationAttachments, + EvaluationSubmission, + GeneralEvaluationResponse, + NewGeneralEvaluationResponse, + generalEvaluations, + GeneralEvaluation, + EsgEvaluationItem, + EsgAnswerOption, + EsgEvaluationResponse, + esgEvaluations, + esgAnswerOptions, + esgEvaluationItems +} from "@/db/schema"; +import { and, asc, desc, eq, ilike, or, SQL, count , sql, avg} from "drizzle-orm"; +import { filterColumns } from "@/lib/filter-columns"; +import { GetEvaluationsSubmitSchema } from "./validation"; + +// 평가 제출 목록 조회용 뷰 타입 +export type EvaluationSubmissionWithVendor = EvaluationSubmission & { + vendor: { + id: number; + vendorCode: string; + vendorName: string; + countryCode: string; + contactEmail: string; + }; + _count: { + generalResponses: number; + esgResponses: number; + attachments: number; + }; +}; + +/** + * 평가 제출 목록을 조회합니다 + */ +export async function getEvaluationSubmissions(input: GetEvaluationsSubmitSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + // 고급 필터링 + const advancedWhere = filterColumns({ + table: evaluationSubmissions, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 + let globalWhere: SQL<unknown> | undefined; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(evaluationSubmissions.submissionId, s), + ilike(vendors.vendorName, s), + ilike(vendors.vendorCode, s), + ilike(evaluationSubmissions.submissionStatus, s), + ilike(evaluationSubmissions.evaluationRound, s) + ); + } + + const finalWhere = and( + advancedWhere, + globalWhere, + eq(evaluationSubmissions.isActive, true) + ); + + // 정렬 + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + return item.desc + ? desc(evaluationSubmissions[item.id]) + : asc(evaluationSubmissions[item.id]); + }) + : [desc(evaluationSubmissions.createdAt)]; + + // 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + // 메인 데이터 조회 + const data = await tx + .select({ + id: evaluationSubmissions.id, + submissionId: evaluationSubmissions.submissionId, + companyId: evaluationSubmissions.companyId, + evaluationYear: evaluationSubmissions.evaluationYear, + evaluationRound: evaluationSubmissions.evaluationRound, + submissionStatus: evaluationSubmissions.submissionStatus, + submittedAt: evaluationSubmissions.submittedAt, + reviewedAt: evaluationSubmissions.reviewedAt, + reviewedBy: evaluationSubmissions.reviewedBy, + reviewComments: evaluationSubmissions.reviewComments, + averageEsgScore: evaluationSubmissions.averageEsgScore, + totalGeneralItems: evaluationSubmissions.totalGeneralItems, + completedGeneralItems: evaluationSubmissions.completedGeneralItems, + totalEsgItems: evaluationSubmissions.totalEsgItems, + completedEsgItems: evaluationSubmissions.completedEsgItems, + isActive: evaluationSubmissions.isActive, + createdAt: evaluationSubmissions.createdAt, + updatedAt: evaluationSubmissions.updatedAt, + // Vendor 정보 + vendor: { + id: vendors.id, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + countryCode: vendors.country, + contactEmail: vendors.email, + }, + }) + .from(evaluationSubmissions) + .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) + .where(finalWhere) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + // 각 제출에 대한 응답/첨부파일 수 조회 + const dataWithCounts = await Promise.all( + data.map(async (submission) => { + const [generalCount, esgCount, attachmentCount] = await Promise.all([ + tx + .select({ count: count() }) + .from(generalEvaluationResponses) + .where( + and( + eq(generalEvaluationResponses.submissionId, submission.id), + eq(generalEvaluationResponses.isActive, true) + ) + ) + .then(result => result[0]?.count || 0), + + tx + .select({ count: count() }) + .from(esgEvaluationResponses) + .where( + and( + eq(esgEvaluationResponses.submissionId, submission.id), + eq(esgEvaluationResponses.isActive, true) + ) + ) + .then(result => result[0]?.count || 0), + + tx + .select({ count: count() }) + .from(vendorEvaluationAttachments) + .where( + and( + eq(vendorEvaluationAttachments.submissionId, submission.id), + eq(vendorEvaluationAttachments.isActive, true) + ) + ) + .then(result => result[0]?.count || 0), + ]); + + return { + ...submission, + _count: { + generalResponses: generalCount, + esgResponses: esgCount, + attachments: attachmentCount, + }, + }; + }) + ); + + // 총 개수 조회 + const totalResult = await tx + .select({ count: count() }) + .from(evaluationSubmissions) + .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + + return { data: dataWithCounts, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + console.error('Error in getEvaluationSubmissions:', err); + return { data: [], pageCount: 0 }; + } +} + +/** + * 특정 평가 제출의 상세 정보를 조회합니다 + */ +export async function getEvaluationSubmissionById(id: number) { + try { + const result = await db + .select() + .from(evaluationSubmissions) + .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) + .where( + and( + eq(evaluationSubmissions.id, id), + eq(evaluationSubmissions.isActive, true) + ) + ) + .limit(1); + + if (result.length === 0) { + return null; + } + + const submission = result[0]; + + // 응답 데이터도 함께 조회 + const [generalResponses, esgResponses, attachments] = await Promise.all([ + db + .select() + .from(generalEvaluationResponses) + .where( + and( + eq(generalEvaluationResponses.submissionId, id), + eq(generalEvaluationResponses.isActive, true) + ) + ), + + db + .select() + .from(esgEvaluationResponses) + .where( + and( + eq(esgEvaluationResponses.submissionId, id), + eq(esgEvaluationResponses.isActive, true) + ) + ), + + db + .select() + .from(vendorEvaluationAttachments) + .where( + and( + eq(vendorEvaluationAttachments.submissionId, id), + eq(vendorEvaluationAttachments.isActive, true) + ) + ), + ]); + + return { + ...submission, + generalResponses, + esgResponses, + attachments, + }; + } catch (err) { + console.error('Error in getEvaluationSubmissionById:', err); + return null; + } +} + + +/** + * 평가 제출의 완성도를 확인합니다 (간단 버전) + */ +export async function getEvaluationSubmissionCompleteness(submissionId: number) { + const result = await db.transaction(async (tx) => { + // 제출 정보 조회 + const submissionInfo = await tx + .select({ + submissionId: evaluationSubmissions.id, + countryCode: vendors.country, + averageEsgScore: evaluationSubmissions.averageEsgScore, + totalGeneralItems: evaluationSubmissions.totalGeneralItems, + completedGeneralItems: evaluationSubmissions.completedGeneralItems, + totalEsgItems: evaluationSubmissions.totalEsgItems, + completedEsgItems: evaluationSubmissions.completedEsgItems, + }) + .from(evaluationSubmissions) + .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) + .where(eq(evaluationSubmissions.id, submissionId)) + .limit(1); + + if (submissionInfo.length === 0) { + throw new Error("Submission not found"); + } + + const info = submissionInfo[0]; + const isKorean = info.countryCode === 'KR'; + + // 🔄 실제 평가 항목 수 조회 (전체 시스템에 등록된 평가 항목 수) + const [actualGeneralTotal, actualEsgTotal] = await Promise.all([ + // 활성화된 일반평가 항목 수 + tx + .select({ count: count() }) + .from(generalEvaluations) + .where(eq(generalEvaluations.isActive, true)) + .then(result => result[0]?.count || 0), + + // 활성화된 ESG 평가 항목 수 (한국 업체인 경우에만) + isKorean ? tx + .select({ count: count() }) + .from(esgEvaluationItems) + .innerJoin(esgEvaluations, eq(esgEvaluationItems.esgEvaluationId, esgEvaluations.id)) + .where( + and( + eq(esgEvaluationItems.isActive, true), + eq(esgEvaluations.isActive, true) + ) + ) + .then(result => result[0]?.count || 0) : Promise.resolve(0) + ]); + + // 실시간 완성도 계산 (실제 응답된 것만) + const [generalStats, esgStats] = await Promise.all([ + tx + .select({ + total: count(), + completed: sql<number>`COUNT(CASE WHEN ${generalEvaluationResponses.responseText} IS NOT NULL AND ${generalEvaluationResponses.responseText} != '' THEN 1 END)`, + }) + .from(generalEvaluationResponses) + .where( + and( + eq(generalEvaluationResponses.submissionId, submissionId), + eq(generalEvaluationResponses.isActive, true) + ) + ), + + isKorean ? tx + .select({ + total: count(), + completed: count(esgEvaluationResponses.selectedScore), + averageScore: avg(esgEvaluationResponses.selectedScore), + }) + .from(esgEvaluationResponses) + .where( + and( + eq(esgEvaluationResponses.submissionId, submissionId), + eq(esgEvaluationResponses.isActive, true) + ) + ) : Promise.resolve([{ total: 0, completed: 0, averageScore: null }]) + ]); + + // 실제 완료된 항목 수 + const generalCompleted = generalStats[0]?.completed || 0; + const esgCompleted = esgStats[0]?.completed || 0; + const esgAverage = parseFloat(esgStats[0]?.averageScore?.toString() || '0'); + + // 🎯 실제 평가 항목 수를 기준으로 완성도 계산 + return { + general: { + total: actualGeneralTotal, + completed: generalCompleted, + percentage: actualGeneralTotal > 0 ? (generalCompleted / actualGeneralTotal) * 100 : 0, + isComplete: actualGeneralTotal > 0 && generalCompleted === actualGeneralTotal, + }, + esg: { + total: actualEsgTotal, + completed: esgCompleted, + percentage: actualEsgTotal > 0 ? (esgCompleted / actualEsgTotal) * 100 : 0, + averageScore: esgAverage, + isComplete: actualEsgTotal === 0 || esgCompleted === actualEsgTotal, + }, + overall: { + isComplete: + (actualGeneralTotal > 0 && generalCompleted === actualGeneralTotal) && + (actualEsgTotal === 0 || esgCompleted === actualEsgTotal), + totalItems: actualGeneralTotal + actualEsgTotal, + completedItems: generalCompleted + esgCompleted, + }, + }; + }); + + return result; +} + +/** + * 평가 제출 상태를 업데이트합니다 (완성도 검증 포함) + */ +export async function updateEvaluationSubmissionStatus( + submissionId: number, + newStatus: string, + reviewData?: { + reviewedBy: string; + reviewComments?: string; + } +) { + return await db.transaction(async (tx) => { + // 제출 시에는 완성도 검증 + if (newStatus === 'submitted') { + const completeness = await getEvaluationSubmissionCompleteness(submissionId); + + if (!completeness.overall.isComplete) { + throw new Error( + `평가가 완료되지 않았습니다. ` + + `일반평가: ${completeness.general.completed}/${completeness.general.total}, ` + + `ESG평가: ${completeness.esg.completed}/${completeness.esg.total}` + ); + } + } + + // 상태 업데이트 + const updateData: any = { + submissionStatus: newStatus, + updatedAt: new Date(), + }; + + if (newStatus === 'submitted') { + updateData.submittedAt = new Date(); + } + + if (reviewData) { + updateData.reviewedAt = new Date(); + updateData.reviewedBy = reviewData.reviewedBy; + updateData.reviewComments = reviewData.reviewComments; + } + + const [updatedSubmission] = await tx + .update(evaluationSubmissions) + .set(updateData) + .where(eq(evaluationSubmissions.id, submissionId)) + .returning(); + + return updatedSubmission; + }); +} + +export type GeneralEvaluationFormData = { + submission: { + id: number; + submissionId: string; + vendorName: string; + submissionStatus: string; + }; + evaluations: Array<{ + evaluation: GeneralEvaluation; + response: GeneralEvaluationResponse | null; + attachments: Array<{ + id: number; + fileId: string; + originalFileName: string; + fileSize: number; + mimeType: string | null; + createdAt: Date; + }>; + }>; +}; + +/** + * 일반평가 폼 데이터를 조회하고, 응답 레코드가 없으면 생성합니다 + */ +export async function getGeneralEvaluationFormData(submissionId: number): Promise<GeneralEvaluationFormData> { + return await db.transaction(async (tx) => { + // 1. 제출 정보 조회 + const submissionResult = await tx + .select({ + id: evaluationSubmissions.id, + submissionId: evaluationSubmissions.submissionId, + vendorName: vendors.vendorName, + submissionStatus: evaluationSubmissions.submissionStatus, + }) + .from(evaluationSubmissions) + .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) + .where(eq(evaluationSubmissions.id, submissionId)) + .limit(1); + + if (submissionResult.length === 0) { + throw new Error("제출 정보를 찾을 수 없습니다."); + } + + const submission = submissionResult[0]; + + // 2. 활성화된 일반평가 항목들 조회 + const activeEvaluations = await tx + .select() + .from(generalEvaluations) + .where(eq(generalEvaluations.isActive, true)) + .orderBy(asc(generalEvaluations.serialNumber)); + + // 3. 기존 응답들 조회 + const existingResponses = await tx + .select() + .from(generalEvaluationResponses) + .where( + and( + eq(generalEvaluationResponses.submissionId, submissionId), + eq(generalEvaluationResponses.isActive, true) + ) + ); + + // 4. 응답이 없는 평가 항목들에 대해 빈 응답 레코드 생성 + const responseMap = new Map(existingResponses.map(r => [r.generalEvaluationId, r])); + const missingResponses: NewGeneralEvaluationResponse[] = []; + + for (const evaluation of activeEvaluations) { + if (!responseMap.has(evaluation.id)) { + missingResponses.push({ + submissionId, + generalEvaluationId: evaluation.id, + responseText: '', + hasAttachments: false, + }); + } + } + + // 5. 누락된 응답 레코드들 생성 + let newResponses: GeneralEvaluationResponse[] = []; + if (missingResponses.length > 0) { + newResponses = await tx + .insert(generalEvaluationResponses) + .values(missingResponses) + .returning(); + } + + // 6. 응답 맵 업데이트 + newResponses.forEach(response => { + responseMap.set(response.generalEvaluationId, response); + }); + + // 7. 각 응답의 첨부파일들 조회 + const evaluationData = await Promise.all( + activeEvaluations.map(async (evaluation) => { + const response = responseMap.get(evaluation.id) || null; + + let attachments: any[] = []; + if (response) { + attachments = await tx + .select({ + id: vendorEvaluationAttachments.id, + fileId: vendorEvaluationAttachments.fileId, + originalFileName: vendorEvaluationAttachments.originalFileName, + fileSize: vendorEvaluationAttachments.fileSize, + mimeType: vendorEvaluationAttachments.mimeType, + createdAt: vendorEvaluationAttachments.createdAt, + }) + .from(vendorEvaluationAttachments) + .where( + and( + eq(vendorEvaluationAttachments.generalEvaluationResponseId, response.id), + eq(vendorEvaluationAttachments.isActive, true) + ) + ) + .orderBy(desc(vendorEvaluationAttachments.createdAt)); + } + + return { + evaluation, + response, + attachments, + }; + }) + ); + + return { + submission, + evaluations: evaluationData, + }; + }); +} + +/** + * 일반평가 응답을 저장합니다 + */ +export async function saveGeneralEvaluationResponse(data: { + responseId: number; + responseText: string; + hasAttachments?: boolean; +}) { + try { + const [updatedResponse] = await db + .update(generalEvaluationResponses) + .set({ + responseText: data.responseText, + hasAttachments: data.hasAttachments || false, + updatedAt: new Date(), + }) + .where(eq(generalEvaluationResponses.id, data.responseId)) + .returning(); + + return updatedResponse; + } catch (error) { + console.error('Error saving general evaluation response:', error); + throw error; + } +} + +/** + * 평가 제출의 진행률과 ESG 평균점수를 계산합니다 + */ +export async function recalculateEvaluationProgress(submissionId: number) { + try { + return await db.transaction(async (tx) => { + // 1. 일반평가 진행률 계산 (점수 계산 제거) + const generalProgressResult = await tx + .select({ + totalItems: count(), + completedItems: sql<number>`COUNT(CASE WHEN ${generalEvaluationResponses.responseText} IS NOT NULL AND ${generalEvaluationResponses.responseText} != '' THEN 1 END)`, + }) + .from(generalEvaluationResponses) + .where( + and( + eq(generalEvaluationResponses.submissionId, submissionId), + eq(generalEvaluationResponses.isActive, true) + ) + ); + + const generalStats = generalProgressResult[0]; + const totalGeneralItems = generalStats.totalItems || 0; + const completedGeneralItems = generalStats.completedItems || 0; + + // 2. ESG 평가 평균 점수 계산 + const esgScoreResult = await tx + .select({ + averageScore: avg(esgEvaluationResponses.selectedScore), + totalItems: count(), + completedItems: count(esgEvaluationResponses.selectedScore), + }) + .from(esgEvaluationResponses) + .where( + and( + eq(esgEvaluationResponses.submissionId, submissionId), + eq(esgEvaluationResponses.isActive, true) + ) + ); + + const esgStats = esgScoreResult[0]; + const averageEsgScore = parseFloat(esgStats.averageScore?.toString() || '0'); + const totalEsgItems = esgStats.totalItems || 0; + const completedEsgItems = esgStats.completedItems || 0; + + // 3. submission 테이블 업데이트 + const [updatedSubmission] = await tx + .update(evaluationSubmissions) + .set({ + // ❌ averageGeneralScore 제거 + averageEsgScore: averageEsgScore > 0 ? averageEsgScore.toString() : null, + totalGeneralItems, + completedGeneralItems, + totalEsgItems, + completedEsgItems, + updatedAt: new Date(), + }) + .where(eq(evaluationSubmissions.id, submissionId)) + .returning(); + + return { + submission: updatedSubmission, + stats: { + general: { + total: totalGeneralItems, + completed: completedGeneralItems, + percentage: totalGeneralItems > 0 ? (completedGeneralItems / totalGeneralItems) * 100 : 0, + }, + esg: { + average: averageEsgScore, + total: totalEsgItems, + completed: completedEsgItems, + percentage: totalEsgItems > 0 ? (completedEsgItems / totalEsgItems) * 100 : 0, + }, + }, + }; + }); + } catch (error) { + console.error('Error recalculating evaluation progress:', error); + throw error; + } +} + + +// ================================================================ +// ESG평가 관련 서버 액션들 +// ================================================================ + +export type EsgEvaluationFormData = { + submission: { + id: number; + submissionId: string; + vendorName: string; + submissionStatus: string; + }; + evaluations: Array<{ + evaluation: { + id: number; + serialNumber: string; + category: string; + inspectionItem: string; + }; + items: Array<{ + item: EsgEvaluationItem; + answerOptions: EsgAnswerOption[]; + response: EsgEvaluationResponse | null; + }>; + }>; +}; + +/** + * ESG평가 폼 데이터를 조회합니다 (응답은 실시간 생성) + */ +export async function getEsgEvaluationFormData(submissionId: number): Promise<EsgEvaluationFormData> { + return await db.transaction(async (tx) => { + // 1. 제출 정보 조회 + const submissionResult = await tx + .select({ + id: evaluationSubmissions.id, + submissionId: evaluationSubmissions.submissionId, + vendorName: vendors.vendorName, + submissionStatus: evaluationSubmissions.submissionStatus, + }) + .from(evaluationSubmissions) + .innerJoin(vendors, eq(evaluationSubmissions.companyId, vendors.id)) + .where(eq(evaluationSubmissions.id, submissionId)) + .limit(1); + + if (submissionResult.length === 0) { + throw new Error("제출 정보를 찾을 수 없습니다."); + } + + const submission = submissionResult[0]; + + // 2. 활성화된 ESG 평가표들 조회 + const activeEsgEvaluations = await tx + .select({ + id: esgEvaluations.id, + serialNumber: esgEvaluations.serialNumber, + category: esgEvaluations.category, + inspectionItem: esgEvaluations.inspectionItem, + }) + .from(esgEvaluations) + .where(eq(esgEvaluations.isActive, true)) + .orderBy(asc(esgEvaluations.serialNumber)); + + // 3. 각 ESG 평가표의 항목들과 답변 옵션들 조회 + const evaluationData = await Promise.all( + activeEsgEvaluations.map(async (evaluation) => { + // 평가 항목들 조회 + const items = await tx + .select() + .from(esgEvaluationItems) + .where( + and( + eq(esgEvaluationItems.esgEvaluationId, evaluation.id), + eq(esgEvaluationItems.isActive, true) + ) + ) + .orderBy(asc(esgEvaluationItems.orderIndex)); + + // 각 항목의 답변 옵션들과 기존 응답 조회 + const itemsWithOptions = await Promise.all( + items.map(async (item) => { + // 답변 옵션들 조회 + const answerOptions = await tx + .select() + .from(esgAnswerOptions) + .where( + and( + eq(esgAnswerOptions.esgEvaluationItemId, item.id), + eq(esgAnswerOptions.isActive, true) + ) + ) + .orderBy(asc(esgAnswerOptions.orderIndex)); + + // 기존 응답 조회 + const existingResponse = await tx + .select() + .from(esgEvaluationResponses) + .where( + and( + eq(esgEvaluationResponses.submissionId, submissionId), + eq(esgEvaluationResponses.esgEvaluationItemId, item.id), + eq(esgEvaluationResponses.isActive, true) + ) + ) + .limit(1); + + return { + item, + answerOptions, + response: existingResponse[0] || null, + }; + }) + ); + + return { + evaluation, + items: itemsWithOptions, + }; + }) + ); + + return { + submission, + evaluations: evaluationData, + }; + }); +} + +/** + * ESG평가 응답을 저장합니다 + */ +export async function saveEsgEvaluationResponse(data: { + submissionId: number; + esgEvaluationItemId: number; + esgAnswerOptionId: number; + selectedScore: number; + additionalComments?: string; +}) { + try { + return await db.transaction(async (tx) => { + // 기존 응답이 있는지 확인 + const existingResponse = await tx + .select() + .from(esgEvaluationResponses) + .where( + and( + eq(esgEvaluationResponses.submissionId, data.submissionId), + eq(esgEvaluationResponses.esgEvaluationItemId, data.esgEvaluationItemId), + eq(esgEvaluationResponses.isActive, true) + ) + ) + .limit(1); + + if (existingResponse.length > 0) { + // 기존 응답 업데이트 + const [updatedResponse] = await tx + .update(esgEvaluationResponses) + .set({ + esgAnswerOptionId: data.esgAnswerOptionId, + selectedScore: data.selectedScore.toString(), + additionalComments: data.additionalComments || null, + updatedAt: new Date(), + }) + .where(eq(esgEvaluationResponses.id, existingResponse[0].id)) + .returning(); + + return updatedResponse; + } else { + // 새 응답 생성 + const [newResponse] = await tx + .insert(esgEvaluationResponses) + .values({ + submissionId: data.submissionId, + esgEvaluationItemId: data.esgEvaluationItemId, + esgAnswerOptionId: data.esgAnswerOptionId, + selectedScore: data.selectedScore.toString(), + additionalComments: data.additionalComments || null, + }) + .returning(); + + return newResponse; + } + }); + } catch (error) { + console.error('Error saving ESG evaluation response:', error); + throw error; + } +} + +export async function updateAttachmentStatus(responseId: number) { + try { + // 활성 첨부파일 개수 확인 + const attachmentCount = await db + .select({ count: vendorEvaluationAttachments.id }) + .from(vendorEvaluationAttachments) + .where( + and( + eq(vendorEvaluationAttachments.generalEvaluationResponseId, responseId), + eq(vendorEvaluationAttachments.isActive, true) + ) + ) + + const hasAttachments = attachmentCount.length > 0 + + // 응답 테이블의 hasAttachments 필드 업데이트 + await db + .update(generalEvaluationResponses) + .set({ + hasAttachments, + updatedAt: new Date() + }) + .where(eq(generalEvaluationResponses.id, responseId)) + + return { hasAttachments, count: attachmentCount.length } + } catch (error) { + console.error('Error updating attachment status:', error) + throw error + } +} diff --git a/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx b/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx new file mode 100644 index 00000000..53d25382 --- /dev/null +++ b/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx @@ -0,0 +1,503 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { SaveIcon, CheckIcon, XIcon, BarChart3Icon, TrendingUpIcon } from "lucide-react" + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { toast } from "sonner" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" + +import { + getEsgEvaluationFormData, + saveEsgEvaluationResponse, + recalculateEvaluationProgress, + EsgEvaluationFormData +} from "../service" +import { EvaluationSubmissionWithVendor } from "../service" + +interface EsgEvaluationFormSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: EvaluationSubmissionWithVendor | null + onSuccess: () => void +} + +// 폼 스키마 정의 +const formSchema = z.object({ + responses: z.array(z.object({ + itemId: z.number(), + selectedOptionId: z.number().optional(), + selectedScore: z.number().default(0), + additionalComments: z.string().optional(), + })) +}) + +type FormData = z.infer<typeof formSchema> + +export function EsgEvaluationFormSheet({ + open, + onOpenChange, + submission, + onSuccess, +}: EsgEvaluationFormSheetProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + const [formData, setFormData] = React.useState<EsgEvaluationFormData | null>(null) + const [currentScores, setCurrentScores] = React.useState<Record<number, number>>({}) + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + defaultValues: { + responses: [] + } + }) + + // 데이터 로딩 + React.useEffect(() => { + if (open && submission?.id) { + loadFormData() + } + }, [open, submission?.id]) + + const loadFormData = async () => { + if (!submission?.id) return + + setIsLoading(true) + try { + const data = await getEsgEvaluationFormData(submission.id) + setFormData(data) + + // 폼 초기값 설정 + const responses: any[] = [] + const scores: Record<number, number> = {} + + data.evaluations.forEach(evaluation => { + evaluation.items.forEach(item => { + responses.push({ + itemId: item.item.id, + selectedOptionId: item.response?.esgAnswerOptionId, + selectedScore: item.response?.selectedScore || 0, + additionalComments: item.response?.additionalComments || '', + }) + + if (item.response?.selectedScore) { + scores[item.item.id] = item.response.selectedScore + } + }) + }) + + setCurrentScores(scores) + form.reset({ responses }) + } catch (error) { + console.error('Error loading ESG form data:', error) + toast.error('ESG 평가 데이터를 불러오는데 실패했습니다.') + } finally { + setIsLoading(false) + } + } + + // 개별 응답 저장 + const handleSaveResponse = async (itemId: number, optionId: number, score: number) => { + if (!submission?.id) return + + try { + const formResponse = form.getValues('responses').find(r => r.itemId === itemId) + + await saveEsgEvaluationResponse({ + submissionId: submission.id, + esgEvaluationItemId: itemId, + esgAnswerOptionId: optionId, + selectedScore: score, + additionalComments: formResponse?.additionalComments || '', + }) + + // 현재 점수 업데이트 + setCurrentScores(prev => ({ + ...prev, + [itemId]: score + })) + + // 평균 점수 재계산 + await recalculateEvaluationProgress(submission.id) + + toast.success('응답이 저장되었습니다.') + } catch (error) { + console.error('Error saving ESG response:', error) + toast.error('응답 저장에 실패했습니다.') + } + } + + // 선택 변경 핸들러 + const handleOptionChange = (itemId: number, optionId: string, score: number) => { + const responseIndex = form.getValues('responses').findIndex(r => r.itemId === itemId) + if (responseIndex >= 0) { + form.setValue(`responses.${responseIndex}.selectedOptionId`, parseInt(optionId)) + form.setValue(`responses.${responseIndex}.selectedScore`, score) + } + + // 자동 저장 + handleSaveResponse(itemId, parseInt(optionId), score) + } + + // 전체 저장 + const onSubmit = async (data: FormData) => { + if (!submission?.id || !formData) return + + setIsSaving(true) + try { + // 모든 응답을 순차적으로 저장 + for (const response of data.responses) { + if (response.selectedOptionId && response.selectedScore > 0) { + await saveEsgEvaluationResponse({ + submissionId: submission.id, + esgEvaluationItemId: response.itemId, + esgAnswerOptionId: response.selectedOptionId, + selectedScore: response.selectedScore, + additionalComments: response.additionalComments || '', + }) + } + } + + // 평균 점수 재계산 + await recalculateEvaluationProgress(submission.id) + + toast.success('모든 ESG 평가가 저장되었습니다.') + onSuccess() + } catch (error) { + console.error('Error saving all ESG responses:', error) + toast.error('ESG 평가 저장에 실패했습니다.') + } finally { + setIsSaving(false) + } + } + + // 진행률 및 점수 계산 + const getProgress = () => { + if (!formData) return { + completed: 0, + total: 0, + percentage: 0, + averageScore: 0, + maxAverageScore: 0 + } + + let total = 0 + let completed = 0 + let totalScore = 0 + let maxTotalScore = 0 + + formData.evaluations.forEach(evaluation => { + evaluation.items.forEach(item => { + total++ + if (currentScores[item.item.id] > 0) { + completed++ + totalScore += currentScores[item.item.id] + } + + // 최대 점수 계산 + const maxOptionScore = Math.max(...item.answerOptions.map(opt => parseFloat(opt.score.toString()))) + maxTotalScore += maxOptionScore + }) + }) + + const percentage = total > 0 ? Math.round((completed / total) * 100) : 0 + const averageScore = completed > 0 ? totalScore / completed : 0 + const maxAverageScore = total > 0 ? maxTotalScore / total : 0 + + return { completed, total, percentage, averageScore, maxAverageScore } + } + + const progress = getProgress() + + if (isLoading) { + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] sm:max-w-[900px]"> + <div className="flex items-center justify-center h-full"> + <div className="text-center space-y-4"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto"></div> + <p>ESG 평가 데이터를 불러오는 중...</p> + </div> + </div> + </SheetContent> + </Sheet> + ) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] sm:max-w-[900px] flex flex-col" style={{width:900, maxWidth:900}}> + <SheetHeader> + <SheetTitle>ESG 평가 작성</SheetTitle> + <SheetDescription> + {formData?.submission.vendorName}의 ESG 평가를 작성해주세요. + </SheetDescription> + </SheetHeader> + + {formData && ( + <> + {/* 진행률 및 점수 표시 */} + <div className="mt-6 grid grid-cols-2 gap-4"> + <Card> + <CardContent className="p-4"> + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-2"> + <CheckIcon className="h-4 w-4" /> + <span className="text-sm font-medium">진행률</span> + </div> + <span className="text-sm text-muted-foreground"> + {progress.completed}/{progress.total} + </span> + </div> + <div className="w-full bg-gray-200 rounded-full h-2 mb-2"> + <div + className="bg-green-600 h-2 rounded-full transition-all duration-300" + style={{ width: `${progress.percentage}%` }} + /> + </div> + <p className="text-xs text-muted-foreground"> + {progress.percentage}% 완료 + </p> + </CardContent> + </Card> + + <Card> + <CardContent className="p-4"> + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-2"> + <TrendingUpIcon className="h-4 w-4" /> + <span className="text-sm font-medium">평균 점수</span> + </div> + <Badge variant="outline"> + {progress.averageScore.toFixed(1)} / {progress.maxAverageScore.toFixed(1)} + </Badge> + </div> + <div className="w-full bg-gray-200 rounded-full h-2 mb-2"> + <div + className="bg-blue-600 h-2 rounded-full transition-all duration-300" + style={{ + width: `${progress.maxAverageScore > 0 ? (progress.averageScore / progress.maxAverageScore) * 100 : 0}%` + }} + /> + </div> + <p className="text-xs text-muted-foreground"> + {progress.completed > 0 ? + `${progress.completed}개 항목 평균` : '응답 없음'} + </p> + </CardContent> + </Card> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <div className="flex-1 overflow-y-auto min-h-0"> + + <ScrollArea className="h-full pr-4"> + <div className="space-y-4 pr-4"> + <Accordion type="multiple" defaultValue={formData.evaluations.map((_, i) => `evaluation-${i}`)}> + {formData.evaluations.map((evaluation, evalIndex) => ( + <AccordionItem + key={evaluation.evaluation.id} + value={`evaluation-${evalIndex}`} + > + <AccordionTrigger className="hover:no-underline"> + <div className="flex items-center justify-between w-full mr-4"> + <div className="flex items-center gap-3"> + <Badge variant="outline"> + {evaluation.evaluation.serialNumber} + </Badge> + <div className="text-left"> + <div className="font-medium"> + {evaluation.evaluation.category} + </div> + <div className="text-sm text-muted-foreground"> + {evaluation.evaluation.inspectionItem} + </div> + </div> + </div> + <div className="flex items-center gap-2"> + <BarChart3Icon className="h-4 w-4" /> + <span className="text-sm"> + {evaluation.items.filter(item => + currentScores[item.item.id] > 0 + ).length}/{evaluation.items.length} + </span> + </div> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-6 pt-4"> + {evaluation.items.map((item, itemIndex) => { + const responseIndex = form.getValues('responses').findIndex( + r => r.itemId === item.item.id + ) + + return ( + <Card key={item.item.id} className="bg-gray-50"> + <CardHeader className="pb-3"> + <CardTitle className="text-sm flex items-center justify-between"> + <span>{item.item.evaluationItem}</span> + {currentScores[item.item.id] > 0 && ( + <Badge variant="default" className="bg-green-100 text-green-800"> + {currentScores[item.item.id]}점 + </Badge> + )} + </CardTitle> + {item.item.evaluationItemDescription && ( + <p className="text-xs text-muted-foreground"> + {item.item.evaluationItemDescription} + </p> + )} + </CardHeader> + <CardContent className="space-y-4"> + {/* 답변 옵션들 */} + <RadioGroup + value={item.response?.esgAnswerOptionId?.toString() || ''} + onValueChange={(value) => { + const option = item.answerOptions.find( + opt => opt.id === parseInt(value) + ) + if (option) { + handleOptionChange( + item.item.id, + value, + parseFloat(option.score.toString()) + ) + } + }} + > + <div className="space-y-2"> + {item.answerOptions.map((option) => ( + <div + key={option.id} + className="flex items-center space-x-3 p-3 rounded-md border hover:bg-white transition-colors" + > + <RadioGroupItem + value={option.id.toString()} + id={`option-${option.id}`} + /> + <label + htmlFor={`option-${option.id}`} + className="flex-1 cursor-pointer" + > + <div className="flex items-center justify-between"> + <span className="text-sm"> + {option.answerText} + </span> + <Badge + variant="secondary" + className="ml-2" + > + {option.score}점 + </Badge> + </div> + </label> + </div> + ))} + </div> + </RadioGroup> + + {/* 추가 의견 */} + {responseIndex >= 0 && ( + <FormField + control={form.control} + name={`responses.${responseIndex}.additionalComments`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 추가 의견 (선택사항) + </FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="추가적인 설명이나 의견을 입력하세요..." + className="min-h-[60px] text-sm" + /> + </FormControl> + </FormItem> + )} + /> + )} + </CardContent> + </Card> + ) + })} + </div> + </AccordionContent> + </AccordionItem> + ))} + </Accordion> + </div> + </ScrollArea> + </div> + + <Separator /> + + {/* 하단 버튼 영역 */} + <div className="flex-shrink-0 flex items-center justify-between pt-4"> + <div className="text-sm text-muted-foreground"> + {progress.percentage === 100 ? ( + <div className="flex items-center gap-2 text-green-600"> + <CheckIcon className="h-4 w-4" /> + 모든 ESG 평가가 완료되었습니다 + </div> + ) : ( + <div className="flex items-center gap-2"> + <XIcon className="h-4 w-4" /> + {progress.total - progress.completed}개 항목이 미완료입니다 + </div> + )} + </div> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + 닫기 + </Button> + <Button + type="submit" + disabled={isSaving || progress.completed === 0} + > + {isSaving ? "저장 중..." : "최종 저장"} + </Button> + </div> + </div> + </form> + </Form> + </> + )} + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-evaluation-submit/table/evaluation-submissions-table-columns.tsx b/lib/vendor-evaluation-submit/table/evaluation-submissions-table-columns.tsx new file mode 100644 index 00000000..869839cb --- /dev/null +++ b/lib/vendor-evaluation-submit/table/evaluation-submissions-table-columns.tsx @@ -0,0 +1,641 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { + Ellipsis, + InfoIcon, + PenToolIcon, + FileTextIcon, + ClipboardListIcon, + DownloadIcon, + CheckIcon, + XIcon, + ClockIcon, + Send +} from "lucide-react" + +import { formatDate, formatCurrency } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Badge } from "@/components/ui/badge" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { EvaluationSubmissionWithVendor } from "../service" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EvaluationSubmissionWithVendor> | null>> +} + +/** + * 제출 상태에 따른 배지 스타일 및 아이콘 + */ +const getStatusBadge = (status: string) => { + switch (status) { + case 'draft': + return { + variant: "secondary" as const, + icon: <ClockIcon className="h-3 w-3" />, + label: "임시저장" + } + case 'submitted': + return { + variant: "default" as const, + icon: <FileTextIcon className="h-3 w-3" />, + label: "제출완료" + } + case 'under_review': + return { + variant: "outline" as const, + icon: <ClipboardListIcon className="h-3 w-3" />, + label: "검토중" + } + case 'approved': + return { + variant: "default" as const, + icon: <CheckIcon className="h-3 w-3" />, + label: "승인", + className: "bg-green-100 text-green-800 border-green-200" + } + case 'rejected': + return { + variant: "destructive" as const, + icon: <XIcon className="h-3 w-3" />, + label: "반려" + } + default: + return { + variant: "secondary" as const, + icon: null, + label: status + } + } +} + +/** + * 평가 제출 테이블 컬럼 정의 + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<EvaluationSubmissionWithVendor>[] { + + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<EvaluationSubmissionWithVendor> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + } + + // ---------------------------------------------------------------- + // 2) 기본 정보 컬럼들 + // ---------------------------------------------------------------- + const basicColumns: ColumnDef<EvaluationSubmissionWithVendor>[] = [ + // { + // accessorKey: "submissionId", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="제출 ID" /> + // ), + // cell: ({ row }) => ( + // <div className="font-mono text-sm"> + // {row.getValue("submissionId")} + // </div> + // ), + // enableSorting: true, + // enableHiding: true, + // size: 400, + // minSize: 400, + // }, + + { + id: "vendorInfo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체" /> + ), + cell: ({ row }) => { + const vendor = row.original.vendor; + return ( + <div className="space-y-1"> + <div className="font-medium">{vendor.vendorName}</div> + <div className="text-sm text-muted-foreground"> + {vendor.vendorCode} • {vendor.countryCode} + </div> + </div> + ); + }, + enableSorting: false, + size: 200, + }, + + { + accessorKey: "evaluationYear", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="평가연도" /> + ), + cell: ({ row }) => ( + <Badge variant="outline"> + {row.getValue("evaluationYear")}년 + </Badge> + ), + size: 60, + }, + + { + accessorKey: "evaluationRound", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="평가회차" /> + ), + cell: ({ row }) => { + const round = row.getValue("evaluationRound") as string; + return round ? ( + <Badge variant="secondary">{round}</Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 60, + }, + ] + + // ---------------------------------------------------------------- + // 3) 상태 정보 컬럼들 + // ---------------------------------------------------------------- + const statusColumns: ColumnDef<EvaluationSubmissionWithVendor>[] = [ + { + accessorKey: "submissionStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="제출상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("submissionStatus") as string; + const badgeInfo = getStatusBadge(status); + + return ( + <Badge + variant={badgeInfo.variant} + className={`flex items-center gap-1 ${badgeInfo.className || ''}`} + > + {badgeInfo.icon} + {badgeInfo.label} + </Badge> + ); + }, + size: 120, + }, + + { + accessorKey: "submittedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="제출일시" /> + ), + cell: ({ row }) => { + const date = row.getValue("submittedAt") as Date; + return date ? formatDate(date) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 140, + }, + + { + id: "reviewInfo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="검토정보" /> + ), + cell: ({ row }) => { + const reviewedAt = row.original.reviewedAt; + const reviewedBy = row.original.reviewedBy; + + if (!reviewedAt) { + return <span className="text-muted-foreground">미검토</span>; + } + + return ( + <div className="space-y-1"> + <div className="text-sm">{formatDate(reviewedAt)}</div> + {reviewedBy && ( + <div className="text-xs text-muted-foreground">{reviewedBy}</div> + )} + </div> + ); + }, + enableSorting: false, + size: 140, + }, + ] + + // ---------------------------------------------------------------- + // 4) 점수 및 통계 컬럼들 + // ---------------------------------------------------------------- + const scoreColumns: ColumnDef<EvaluationSubmissionWithVendor>[] = [ + { + id: "generalProgress", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="일반평가" /> + ), + cell: ({ row }) => { + const totalItems = row.original.totalGeneralItems || 0; + const completedItems = row.original.completedGeneralItems || 0; + const completionRate = totalItems > 0 ? (completedItems / totalItems) * 100 : 0; + + return ( + <div className="text-center space-y-1"> + {/* ❌ 점수 표시 제거 */} + <div className="font-medium"> + {completionRate === 100 ? "완료" : "진행중"} + </div> + <div className="flex items-center gap-1"> + <Badge variant="outline" className="text-xs"> + {completedItems}/{totalItems}개 + </Badge> + {completionRate > 0 && ( + <span className="text-xs text-muted-foreground"> + ({completionRate.toFixed(0)}%) + </span> + )} + </div> + {/* 📊 진행률 바 */} + <div className="w-full bg-gray-200 rounded-full h-1"> + <div + className={`h-1 rounded-full transition-all duration-300 ${ + completionRate === 100 + ? 'bg-green-500' + : completionRate >= 50 + ? 'bg-blue-500' + : 'bg-yellow-500' + }`} + style={{ width: `${completionRate}%` }} + /> + </div> + </div> + ); + }, + enableSorting: false, + size: 120, + }, + + { + id: "esgScore", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="ESG평가" /> + ), + cell: ({ row }) => { + const averageScore = row.original.averageEsgScore; + const totalItems = row.original.totalEsgItems || 0; + const completedItems = row.original.completedEsgItems || 0; + const completionRate = totalItems > 0 ? (completedItems / totalItems) * 100 : 0; + const isKorean = row.original.vendor.countryCode === 'KR'; + + if (!isKorean) { + return ( + <div className="text-center text-muted-foreground"> + <Badge variant="outline">해당없음</Badge> + </div> + ); + } + + return ( + <div className="text-center space-y-1"> + {/* ✅ ESG는 평균점수 표시 */} + <div className="font-medium"> + {averageScore ? ( + <span className="text-blue-600"> + 평균 {parseFloat(averageScore.toString()).toFixed(1)}점 + </span> + ) : ( + <span className="text-muted-foreground">미완료</span> + )} + </div> + <div className="flex items-center gap-1"> + <Badge variant="outline" className="text-xs"> + {completedItems}/{totalItems}개 + </Badge> + {completionRate > 0 && ( + <span className="text-xs text-muted-foreground"> + ({completionRate.toFixed(0)}%) + </span> + )} + </div> + {/* 📊 진행률 바 */} + <div className="w-full bg-gray-200 rounded-full h-1"> + <div + className={`h-1 rounded-full transition-all duration-300 ${ + completionRate === 100 + ? 'bg-green-500' + : completionRate >= 50 + ? 'bg-blue-500' + : 'bg-yellow-500' + }`} + style={{ width: `${completionRate}%` }} + /> + </div> + </div> + ); + }, + enableSorting: false, + size: 140, + }, + + { + id: "overallProgress", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="전체 진행률" /> + ), + cell: ({ row }) => { + const totalGeneral = row.original.totalGeneralItems || 0; + const completedGeneral = row.original.completedGeneralItems || 0; + const totalEsg = row.original.totalEsgItems || 0; + const completedEsg = row.original.completedEsgItems || 0; + const isKorean = row.original.vendor.countryCode === 'KR'; + + const totalItems = totalGeneral + (isKorean ? totalEsg : 0); + const completedItems = completedGeneral + (isKorean ? completedEsg : 0); + const completionRate = totalItems > 0 ? (completedItems / totalItems) * 100 : 0; + + return ( + <div className="text-center space-y-2"> + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className={`h-2 rounded-full transition-all duration-300 ${ + completionRate === 100 + ? 'bg-green-500' + : completionRate >= 50 + ? 'bg-blue-500' + : 'bg-yellow-500' + }`} + style={{ width: `${completionRate}%` }} + /> + </div> + <div className="text-xs space-y-1"> + <div className="font-medium"> + {completionRate.toFixed(0)}% 완료 + </div> + <div className="text-muted-foreground"> + {completedItems}/{totalItems}개 항목 + </div> + </div> + </div> + ); + }, + enableSorting: false, + size: 120, + }, + + { + id: "attachments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="첨부파일" /> + ), + cell: ({ row }) => { + const count = row.original._count.attachments; + + return ( + <div className="text-center"> + <Badge variant="outline"> + {count}개 파일 + </Badge> + </div> + ); + }, + enableSorting: false, + size: 100, + }, + ] + + + // ---------------------------------------------------------------- + // 5) 메타데이터 컬럼들 + // ---------------------------------------------------------------- + const metaColumns: ColumnDef<EvaluationSubmissionWithVendor>[] = [ + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + cell: ({ row }) => { + const date = row.getValue("createdAt") as Date; + return formatDate(date); + }, + size: 140, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정일" /> + ), + cell: ({ row }) => { + const date = row.getValue("updatedAt") as Date; + return formatDate(date); + }, + size: 140, + }, + ] + + // ---------------------------------------------------------------- + // 6) actions 컬럼 (드롭다운 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<EvaluationSubmissionWithVendor> = { + id: "actions", + header: "작업", + enableHiding: false, + cell: function Cell({ row }) { + const status = row.original.submissionStatus; + const isKorean = row.original.vendor.countryCode === 'KR'; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <Ellipsis className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "general_evaluation" })} + > + <FileTextIcon className="mr-2 h-4 w-4" /> + 일반평가 작성 + </DropdownMenuItem> + + {isKorean && ( + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "esg_evaluation" })} + > + <ClipboardListIcon className="mr-2 h-4 w-4" /> + ESG평가 작성 + </DropdownMenuItem> + )} + + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "submit" })} + > + <Send className="mr-2 h-4 w-4" /> + 제출 + </DropdownMenuItem> + + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 80, + } + + // ---------------------------------------------------------------- + // 7) 최종 컬럼 배열 (그룹화 버전) + // ---------------------------------------------------------------- + return [ + selectColumn, + { + id: "basicInfo", + header: "기본 정보", + columns: basicColumns, + }, + { + id: "statusInfo", + header: "상태 정보", + columns: statusColumns, + }, + { + id: "scoreInfo", + header: "점수 및 통계", + columns: scoreColumns, + }, + { + id: "metadata", + header: "메타데이터", + columns: metaColumns, + }, + actionsColumn, + ] +} + +// ---------------------------------------------------------------- +// 8) 컬럼 설정 (필터링용) +// ---------------------------------------------------------------- +export const evaluationSubmissionsColumnsConfig = [ + { + id: "submissionId", + label: "제출 ID", + group: "기본 정보", + type: "text", + excelHeader: "Submission ID", + }, + { + id: "vendorName", + label: "협력업체명", + group: "기본 정보", + type: "text", + excelHeader: "Vendor Name", + }, + { + id: "vendorCode", + label: "협력업체 코드", + group: "기본 정보", + type: "text", + excelHeader: "Vendor Code", + }, + { + id: "evaluationYear", + label: "평가연도", + group: "기본 정보", + type: "number", + excelHeader: "Evaluation Year", + }, + { + id: "evaluationRound", + label: "평가회차", + group: "기본 정보", + type: "text", + excelHeader: "Evaluation Round", + }, + { + id: "submissionStatus", + label: "제출상태", + group: "상태 정보", + type: "select", + options: [ + { label: "임시저장", value: "draft" }, + { label: "제출완료", value: "submitted" }, + { label: "검토중", value: "under_review" }, + { label: "승인", value: "approved" }, + { label: "반려", value: "rejected" }, + ], + excelHeader: "Submission Status", + }, + { + id: "submittedAt", + label: "제출일시", + group: "상태 정보", + type: "date", + excelHeader: "Submitted At", + }, + { + id: "reviewedAt", + label: "검토일시", + group: "상태 정보", + type: "date", + excelHeader: "Reviewed At", + }, + { + id: "totalGeneralScore", + label: "일반평가 점수", + group: "점수 정보", + type: "number", + excelHeader: "Total General Score", + }, + { + id: "totalEsgScore", + label: "ESG평가 점수", + group: "점수 정보", + type: "number", + excelHeader: "Total ESG Score", + }, + { + id: "createdAt", + label: "생성일", + group: "메타데이터", + type: "date", + excelHeader: "Created At", + }, + { + id: "updatedAt", + label: "수정일", + group: "메타데이터", + type: "date", + excelHeader: "Updated At", + }, +] as const;
\ No newline at end of file diff --git a/lib/vendor-evaluation-submit/table/evaluation-submit-dialog.tsx b/lib/vendor-evaluation-submit/table/evaluation-submit-dialog.tsx new file mode 100644 index 00000000..20ed5f30 --- /dev/null +++ b/lib/vendor-evaluation-submit/table/evaluation-submit-dialog.tsx @@ -0,0 +1,353 @@ +"use client" + +import * as React from "react" +import { + AlertTriangleIcon, + CheckCircleIcon, + SendIcon, + XCircleIcon, + FileTextIcon, + ClipboardListIcon, + LoaderIcon +} from "lucide-react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { toast } from "sonner" + +// Progress 컴포넌트 (간단한 구현) +function Progress({ value, className }: { value: number; className?: string }) { + return ( + <div className={`w-full bg-gray-200 rounded-full overflow-hidden ${className}`}> + <div + className={`h-full bg-blue-600 transition-all duration-300 ${ + value === 100 ? 'bg-green-500' : value >= 50 ? 'bg-blue-500' : 'bg-yellow-500' + }`} + style={{ width: `${Math.min(100, Math.max(0, value))}%` }} + /> + </div> + ) +} + +import { + getEvaluationSubmissionCompleteness, + updateEvaluationSubmissionStatus +} from "../service" +import type { EvaluationSubmissionWithVendor } from "../service" + +interface EvaluationSubmissionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: EvaluationSubmissionWithVendor | null + onSuccess: () => void +} + +type CompletenessData = { + general: { + total: number + completed: number + percentage: number + isComplete: boolean + } + esg: { + total: number + completed: number + percentage: number + averageScore: number + isComplete: boolean + } + overall: { + isComplete: boolean + totalItems: number + completedItems: number + } +} + +export function EvaluationSubmissionDialog({ + open, + onOpenChange, + submission, + onSuccess, +}: EvaluationSubmissionDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [completeness, setCompleteness] = React.useState<CompletenessData | null>(null) + + // 완성도 데이터 로딩 + React.useEffect(() => { + if (open && submission?.id) { + loadCompleteness() + } + }, [open, submission?.id]) + + const loadCompleteness = async () => { + if (!submission?.id) return + + setIsLoading(true) + try { + const data = await getEvaluationSubmissionCompleteness(submission.id) + setCompleteness(data) + } catch (error) { + console.error('Error loading completeness:', error) + toast.error('완성도 정보를 불러오는데 실패했습니다.') + } finally { + setIsLoading(false) + } + } + + // 제출하기 + const handleSubmit = async () => { + if (!submission?.id || !completeness) return + + if (!completeness.overall.isComplete) { + toast.error('모든 평가 항목을 완료해야 제출할 수 있습니다.') + return + } + + setIsSubmitting(true) + try { + await updateEvaluationSubmissionStatus(submission.id, 'submitted') + toast.success('평가가 성공적으로 제출되었습니다.') + onSuccess() + } catch (error: any) { + console.error('Error submitting evaluation:', error) + toast.error(error.message || '제출에 실패했습니다.') + } finally { + setIsSubmitting(false) + } + } + + const isKorean = submission?.vendor.countryCode === 'KR' + + if (isLoading) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <div className="flex items-center justify-center py-8"> + <div className="text-center space-y-4"> + <LoaderIcon className="h-8 w-8 animate-spin mx-auto" /> + <p>완성도를 확인하는 중...</p> + </div> + </div> + </DialogContent> + </Dialog> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <SendIcon className="h-5 w-5" /> + 평가 제출하기 + </DialogTitle> + <DialogDescription> + {submission?.vendor.vendorName}의 {submission?.evaluationYear}년 평가를 제출합니다. + </DialogDescription> + </DialogHeader> + + {completeness && ( + <div className="space-y-6"> + {/* 전체 완성도 카드 */} + <Card> + <CardHeader> + <CardTitle className="text-base flex items-center justify-between"> + <span>전체 완성도</span> + <Badge + variant={completeness.overall.isComplete ? "default" : "secondary"} + className={ + completeness.overall.isComplete + ? "bg-green-100 text-green-800 border-green-200" + : "" + } + > + {completeness.overall.isComplete ? "완료" : "미완료"} + </Badge> + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>전체 진행률</span> + <span className="font-medium"> + {completeness.overall.completedItems}/{completeness.overall.totalItems}개 완료 + </span> + </div> + <Progress + value={ + completeness.overall.totalItems > 0 + ? (completeness.overall.completedItems / completeness.overall.totalItems) * 100 + : 0 + } + className="h-2" + /> + <p className="text-xs text-muted-foreground"> + {completeness.overall.totalItems > 0 + ? Math.round((completeness.overall.completedItems / completeness.overall.totalItems) * 100) + : 0}% 완료 + </p> + </div> + </CardContent> + </Card> + + {/* 세부 완성도 */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* 일반평가 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-sm flex items-center gap-2"> + <FileTextIcon className="h-4 w-4" /> + 일반평가 + {completeness.general.isComplete ? ( + <CheckCircleIcon className="h-4 w-4 text-green-600" /> + ) : ( + <XCircleIcon className="h-4 w-4 text-red-600" /> + )} + </CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="space-y-1"> + <div className="flex items-center justify-between text-xs"> + <span>응답 완료</span> + <span className="font-medium"> + {completeness.general.completed}/{completeness.general.total}개 + </span> + </div> + <Progress value={completeness.general.percentage} className="h-1" /> + <p className="text-xs text-muted-foreground"> + {completeness.general.percentage.toFixed(0)}% 완료 + </p> + </div> + + {!completeness.general.isComplete && ( + <p className="text-xs text-red-600"> + {completeness.general.total - completeness.general.completed}개 항목이 미완료입니다. + </p> + )} + </CardContent> + </Card> + + {/* ESG평가 */} + {isKorean ? ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-sm flex items-center gap-2"> + <ClipboardListIcon className="h-4 w-4" /> + ESG평가 + {completeness.esg.isComplete ? ( + <CheckCircleIcon className="h-4 w-4 text-green-600" /> + ) : ( + <XCircleIcon className="h-4 w-4 text-red-600" /> + )} + </CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="space-y-1"> + <div className="flex items-center justify-between text-xs"> + <span>응답 완료</span> + <span className="font-medium"> + {completeness.esg.completed}/{completeness.esg.total}개 + </span> + </div> + <Progress value={completeness.esg.percentage} className="h-1" /> + <p className="text-xs text-muted-foreground"> + {completeness.esg.percentage.toFixed(0)}% 완료 + </p> + </div> + + {completeness.esg.completed > 0 && ( + <div className="text-xs"> + <span className="text-muted-foreground">평균 점수: </span> + <span className="font-medium text-blue-600"> + {completeness.esg.averageScore.toFixed(1)}점 + </span> + </div> + )} + + {!completeness.esg.isComplete && ( + <p className="text-xs text-red-600"> + {completeness.esg.total - completeness.esg.completed}개 항목이 미완료입니다. + </p> + )} + </CardContent> + </Card> + ) : ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-sm flex items-center gap-2"> + <ClipboardListIcon className="h-4 w-4" /> + ESG평가 + </CardTitle> + </CardHeader> + <CardContent> + <div className="text-center text-muted-foreground"> + <Badge variant="outline">해당없음</Badge> + <p className="text-xs mt-2">한국 업체가 아니므로 ESG 평가가 제외됩니다.</p> + </div> + </CardContent> + </Card> + )} + </div> + + {/* 제출 상태 알림 */} + {completeness.overall.isComplete ? ( + <Alert> + <CheckCircleIcon className="h-4 w-4" /> + <AlertTitle>제출 준비 완료</AlertTitle> + <AlertDescription> + 모든 평가 항목이 완료되었습니다. 제출하시겠습니까? + </AlertDescription> + </Alert> + ) : ( + <Alert variant="destructive"> + <AlertTriangleIcon className="h-4 w-4" /> + <AlertTitle>제출 불가</AlertTitle> + <AlertDescription> + 아직 완료되지 않은 평가 항목이 있습니다. 모든 항목을 완료한 후 제출해 주세요. + </AlertDescription> + </Alert> + )} + </div> + )} + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={!completeness?.overall.isComplete || isSubmitting} + className="min-w-[100px]" + > + {isSubmitting ? ( + <> + <LoaderIcon className="mr-2 h-4 w-4 animate-spin" /> + 제출 중... + </> + ) : ( + <> + <SendIcon className="mr-2 h-4 w-4" /> + 제출하기 + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx b/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx new file mode 100644 index 00000000..cc80e29c --- /dev/null +++ b/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx @@ -0,0 +1,609 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { FileIcon, SaveIcon, CheckIcon, XIcon, PlusIcon, TrashIcon } from "lucide-react" + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { toast } from "sonner" +import { Input } from "@/components/ui/input" + +import { + getGeneralEvaluationFormData, + saveGeneralEvaluationResponse, + recalculateEvaluationProgress, // 진행률만 계산 + GeneralEvaluationFormData, +} from "../service" +import { EvaluationSubmissionWithVendor } from "../service" + +interface GeneralEvaluationFormSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: EvaluationSubmissionWithVendor | null + onSuccess: () => void +} + +// 📝 간단한 폼 스키마 - 점수 필드 제거 +const formSchema = z.object({ + responses: z.array(z.object({ + responseId: z.number(), + responseText: z.string().min(1, "응답을 입력해주세요."), + hasAttachments: z.boolean().default(false), + })) +}) + +type FormData = z.infer<typeof formSchema> + +export function GeneralEvaluationFormSheet({ + open, + onOpenChange, + submission, + onSuccess, +}: GeneralEvaluationFormSheetProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + const [formData, setFormData] = React.useState<GeneralEvaluationFormData | null>(null) + const [uploadedFiles, setUploadedFiles] = React.useState<Record<number, File[]>>({}) + const fileInputRefs = React.useRef<Record<number, HTMLInputElement | null>>({}) + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + defaultValues: { + responses: [] + } + }) + + // 데이터 로딩 + React.useEffect(() => { + if (open && submission?.id) { + // 시트가 열릴 때마다 uploadedFiles 상태 초기화 + setUploadedFiles({}) + loadFormData() + } + }, [open, submission?.id]) + + const loadFormData = async () => { + if (!submission?.id) return + + setIsLoading(true) + try { + const data = await getGeneralEvaluationFormData(submission.id) + setFormData(data) + + // 📝 폼 초기값 설정 (점수 필드 제거) + const responses = data.evaluations.map(item => ({ + responseId: item.response?.id || 0, + responseText: item.response?.responseText || '', + hasAttachments: item.response?.hasAttachments || false, + })) + + form.reset({ responses }) + } catch (error) { + console.error('Error loading form data:', error) + toast.error('평가 데이터를 불러오는데 실패했습니다.') + } finally { + setIsLoading(false) + } + } + + // 개별 응답 저장 (파일 업로드 포함) + const handleSaveResponse = async (index: number) => { + if (!formData) return + + const responseData = form.getValues(`responses.${index}`) + if (!responseData.responseId) return + + try { + // 1. 새로 선택된 파일들이 있으면 먼저 업로드 + const newFiles = uploadedFiles[responseData.responseId] || [] + if (newFiles.length > 0) { + const uploadFormData = new FormData() + newFiles.forEach(file => { + uploadFormData.append('files', file) + }) + uploadFormData.append('submissionId', submission?.id.toString() || '') + uploadFormData.append('responseId', responseData.responseId.toString()) + uploadFormData.append('uploadedBy', 'current-user') // 실제 사용자 정보로 교체 + + const uploadResponse = await fetch('/api/vendor-evaluation/upload-attachment', { + method: 'POST', + body: uploadFormData, + }) + + if (!uploadResponse.ok) { + const errorData = await uploadResponse.json() + throw new Error(errorData.error || '파일 업로드에 실패했습니다.') + } + + // 업로드 성공 시 UI 상태 초기화 + setUploadedFiles(prev => ({ + ...prev, + [responseData.responseId]: [] + })) + } + + // 2. 응답 텍스트 저장 + await saveGeneralEvaluationResponse({ + responseId: responseData.responseId, + responseText: responseData.responseText, + hasAttachments: (uploadedFiles[responseData.responseId]?.length > 0) || + formData.evaluations[index]?.attachments.length > 0, + }) + + // 3. 진행률 재계산 + if (submission?.id) { + await recalculateEvaluationProgress(submission.id) + } + + // 4. 폼 데이터 새로고침 (새로 업로드된 파일을 기존 파일 목록에 반영) + await loadFormData() + + toast.success('응답이 저장되었습니다.') + } catch (error) { + console.error('Error saving response:', error) + toast.error(error instanceof Error ? error.message : '응답 저장에 실패했습니다.') + } + } + + // 전체 저장 (파일 업로드 포함) + const onSubmit = async (data: FormData) => { + if (!formData) return + + setIsSaving(true) + try { + // 모든 응답을 순차적으로 저장 + for (let i = 0; i < data.responses.length; i++) { + const response = data.responses[i] + if (response.responseId && response.responseText.trim()) { + + // 1. 새로 선택된 파일들이 있으면 먼저 업로드 + const newFiles = uploadedFiles[response.responseId] || [] + if (newFiles.length > 0) { + const uploadFormData = new FormData() + newFiles.forEach(file => { + uploadFormData.append('files', file) + }) + uploadFormData.append('submissionId', submission?.id.toString() || '') + uploadFormData.append('responseId', response.responseId.toString()) + uploadFormData.append('uploadedBy', 'current-user') // 실제 사용자 정보로 교체 + + const uploadResponse = await fetch('/api/vendor-evaluation/upload-attachment', { + method: 'POST', + body: uploadFormData, + }) + + if (!uploadResponse.ok) { + const errorData = await uploadResponse.json() + throw new Error(`파일 업로드 실패: ${errorData.error || '알 수 없는 오류'}`) + } + } + + // 2. 응답 텍스트 저장 + await saveGeneralEvaluationResponse({ + responseId: response.responseId, + responseText: response.responseText, + hasAttachments: (uploadedFiles[response.responseId]?.length > 0) || + formData.evaluations[i]?.attachments.length > 0, + }) + } + } + + // 모든 새 파일 상태 초기화 + setUploadedFiles({}) + + // 진행률 재계산 + if (submission?.id) { + await recalculateEvaluationProgress(submission.id) + } + + toast.success('모든 응답이 저장되었습니다.') + onSuccess() + } catch (error) { + console.error('Error saving all responses:', error) + toast.error(error instanceof Error ? error.message : '응답 저장에 실패했습니다.') + } finally { + setIsSaving(false) + } + } + + // 파일 업로드 핸들러 (UI만 업데이트) + const handleFileUpload = (responseId: number, files: FileList | null) => { + if (!files || files.length === 0) return + + const fileArray = Array.from(files) + setUploadedFiles(prev => ({ + ...prev, + [responseId]: [...(prev[responseId] || []), ...fileArray] + })) + + // hasAttachments 필드 업데이트 + const responseIndex = formData?.evaluations.findIndex( + item => item.response?.id === responseId + ) ?? -1 + + if (responseIndex >= 0) { + form.setValue(`responses.${responseIndex}.hasAttachments`, true) + } + + // 파일 입력 초기화 (같은 파일 다시 선택 가능하도록) + if (fileInputRefs.current[responseId]) { + fileInputRefs.current[responseId]!.value = '' + } + } + + // 첨부파일 상태 업데이트 헬퍼 함수 + const updateAttachmentStatusHelper = async (responseId: number) => { + try { + await updateAttachmentStatus(responseId) + } catch (error) { + console.error('Error updating attachment status:', error) + } + } + + // 파일 삭제 핸들러 (새로 업로드된 파일용) + const handleFileRemove = (responseId: number, fileIndex: number) => { + setUploadedFiles(prev => { + const newFiles = [...(prev[responseId] || [])] + newFiles.splice(fileIndex, 1) + + const responseIndex = formData?.evaluations.findIndex( + item => item.response?.id === responseId + ) ?? -1 + + if (responseIndex >= 0) { + form.setValue(`responses.${responseIndex}.hasAttachments`, newFiles.length > 0) + } + + return { + ...prev, + [responseId]: newFiles + } + }) + } + + // 기존 첨부파일 삭제 핸들러 + const handleExistingFileDelete = async (attachmentId: number, responseId: number) => { + try { + // 실제 구현에서는 deleteAttachment 서버 액션을 import해서 사용 + // await deleteAttachment(attachmentId) + + // API 호출로 파일 삭제 + const response = await fetch(`/api/delete-attachment/${attachmentId}`, { + method: 'DELETE', + }) + + if (!response.ok) { + throw new Error('파일 삭제에 실패했습니다.') + } + + toast.success('파일이 삭제되었습니다.') + + // 첨부파일 상태 업데이트 + await updateAttachmentStatusHelper(responseId) + + // 폼 데이터 새로고침 + loadFormData() + + } catch (error) { + console.error('Error deleting file:', error) + toast.error('파일 삭제에 실패했습니다.') + } + } + + // 📊 진행률 계산 (점수 계산 제거) + const getProgress = () => { + if (!formData) return { completed: 0, total: 0, percentage: 0, pendingFiles: 0 } + + const responses = form.getValues('responses') + const completed = responses.filter(r => r.responseText.trim().length > 0).length + const total = formData.evaluations.length + const percentage = total > 0 ? Math.round((completed / total) * 100) : 0 + + // 대기 중인 파일 개수 계산 + const pendingFiles = Object.values(uploadedFiles).reduce((sum, files) => sum + files.length, 0) + + return { completed, total, percentage, pendingFiles } + } + + const progress = getProgress() + + if (isLoading) { + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] sm:max-w-[900px] flex flex-col" style={{width:900, maxWidth:900}}> + <div className="flex items-center justify-center h-full"> + <div className="text-center space-y-4"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto"></div> + <p>평가 데이터를 불러오는 중...</p> + </div> + </div> + </SheetContent> + </Sheet> + ) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] sm:max-w-[900px] flex flex-col" style={{width:900, maxWidth:900}}> + {/* 📌 고정 헤더 영역 */} + <SheetHeader className="flex-shrink-0 pb-4"> + <SheetTitle>일반평가 작성</SheetTitle> + <SheetDescription> + {formData?.submission.vendorName}의 일반평가를 작성해주세요. + </SheetDescription> + </SheetHeader> + + {formData && ( + <> + {/* 📊 고정 진행률 표시 */} + <div className="flex-shrink-0 p-4 bg-gray-50 rounded-lg mb-4"> + <div className="flex items-center justify-between mb-2"> + <span className="text-sm font-medium">응답 진행률</span> + <span className="text-sm text-muted-foreground"> + {progress.completed}/{progress.total} 완료 + </span> + </div> + <div className="w-full bg-gray-200 rounded-full h-2"> + <div + className={`h-2 rounded-full transition-all duration-300 ${ + progress.percentage === 100 + ? 'bg-green-500' + : progress.percentage >= 50 + ? 'bg-blue-500' + : 'bg-yellow-500' + }`} + style={{ width: `${progress.percentage}%` }} + /> + </div> + <p className="text-xs text-muted-foreground mt-1"> + {progress.percentage}% 완료 + </p> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + {/* 🔄 스크롤 가능한 중간 영역 */} + <div className="flex-1 overflow-y-auto min-h-0"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-6"> + {formData.evaluations.map((item, index) => ( + <Card key={item.evaluation.id}> + <CardHeader> + <CardTitle className="text-base flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline"> + {item.evaluation.serialNumber} + </Badge> + <span className="text-sm font-medium"> + {item.evaluation.category} + </span> + </div> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => handleSaveResponse(index)} + disabled={!item.response?.id} + > + <SaveIcon className="h-4 w-4 mr-1" /> + 저장 + {item.response?.id && uploadedFiles[item.response.id]?.length > 0 && ( + <Badge variant="secondary" className="ml-2 text-xs"> + +{uploadedFiles[item.response.id].length} + </Badge> + )} + </Button> + </CardTitle> + <p className="text-sm text-muted-foreground"> + {item.evaluation.inspectionItem} + </p> + {item.evaluation.remarks && ( + <p className="text-xs text-blue-600 bg-blue-50 p-2 rounded"> + 💡 {item.evaluation.remarks} + </p> + )} + </CardHeader> + <CardContent className="space-y-4"> + {/* 📝 응답 텍스트만 (점수 입력 제거) */} + <FormField + control={form.control} + name={`responses.${index}.responseText`} + render={({ field }) => ( + <FormItem> + <FormLabel>응답 내용 *</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="평가 항목에 대한 응답을 상세히 작성해주세요..." + className="min-h-[120px]" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 📎 첨부파일 영역 */} + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <FormLabel>첨부파일</FormLabel> + <div> + <Input + ref={(el) => item.response?.id && (fileInputRefs.current[item.response.id] = el)} + type="file" + multiple + className="hidden" + onChange={(e) => + item.response?.id && + handleFileUpload(item.response.id, e.target.files) + } + /> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + if (item.response?.id && fileInputRefs.current[item.response.id]) { + fileInputRefs.current[item.response.id]?.click() + } + }} + > + <PlusIcon className="h-4 w-4 mr-1" /> + 파일 추가 + </Button> + </div> + </div> + + {/* 기존 첨부파일 목록 */} + {item.attachments.length > 0 && ( + <div className="space-y-2"> + <p className="text-xs text-muted-foreground">기존 파일 (저장됨):</p> + {item.attachments.map((file) => ( + <div + key={file.id} + className="flex items-center justify-between p-2 bg-gray-50 rounded text-sm" + > + <div className="flex items-center gap-2"> + <div className="flex items-center gap-1"> + <FileIcon className="h-4 w-4 text-gray-600" /> + <span className="text-xs text-green-600">✓</span> + </div> + <span>{file.originalFileName}</span> + <Badge variant="secondary" className="text-xs"> + {(file.fileSize / 1024).toFixed(1)}KB + </Badge> + <Badge variant="outline" className="text-xs text-green-600 border-green-300"> + 저장됨 + </Badge> + </div> + <Button + type="button" + variant="ghost" + size="sm" + className="text-red-600 hover:text-red-800 hover:bg-red-50" + onClick={() => + item.response?.id && + handleExistingFileDelete(file.id, item.response.id) + } + > + <TrashIcon className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + )} + + {/* 새로 업로드된 파일 목록 */} + {item.response?.id && uploadedFiles[item.response.id]?.length > 0 && ( + <div className="space-y-2"> + <p className="text-xs text-muted-foreground">새 파일 (저장 시 업로드됨):</p> + {uploadedFiles[item.response.id].map((file, fileIndex) => ( + <div + key={fileIndex} + className="flex items-center justify-between p-2 bg-blue-50 border border-blue-200 rounded text-sm" + > + <div className="flex items-center gap-2"> + <div className="flex items-center gap-1"> + <FileIcon className="h-4 w-4 text-blue-600" /> + <span className="text-xs text-blue-600">📎</span> + </div> + <span className="text-blue-800">{file.name}</span> + <Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700"> + {(file.size / 1024).toFixed(1)}KB + </Badge> + <Badge variant="outline" className="text-xs text-blue-600 border-blue-300"> + 대기중 + </Badge> + </div> + <Button + type="button" + variant="ghost" + size="sm" + className="text-blue-600 hover:text-blue-800 hover:bg-blue-100" + onClick={() => + item.response?.id && + handleFileRemove(item.response.id, fileIndex) + } + > + <TrashIcon className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + )} + </div> + </CardContent> + </Card> + ))} + </div> + </ScrollArea> + </div> + + <Separator className="my-4" /> + + {/* 📌 고정 하단 버튼 영역 */} + <div className="flex-shrink-0 flex items-center justify-between pt-4"> + <div className="text-sm text-muted-foreground"> + {progress.percentage === 100 ? ( + <div className="flex items-center gap-2 text-green-600"> + <CheckIcon className="h-4 w-4" /> + 모든 항목이 완료되었습니다 + </div> + ) : ( + <div className="flex items-center gap-2"> + <XIcon className="h-4 w-4" /> + {formData.evaluations.length - progress.completed}개 항목이 미완료입니다 + </div> + )} + </div> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSaving || progress.completed === 0} + > + {isSaving ? "저장 중..." : "모두 저장"} + {progress.pendingFiles > 0 && ( + <Badge variant="secondary" className="ml-2 text-xs bg-blue-100 text-blue-700"> + 파일 {progress.pendingFiles}개 + </Badge> + )} + </Button> + </div> + </div> + </form> + </Form> + </> + )} + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-evaluation-submit/table/submit-table.tsx b/lib/vendor-evaluation-submit/table/submit-table.tsx new file mode 100644 index 00000000..71002023 --- /dev/null +++ b/lib/vendor-evaluation-submit/table/submit-table.tsx @@ -0,0 +1,212 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { getEvaluationSubmissions, EvaluationSubmissionWithVendor } from "../service" +import { getColumns } from "./evaluation-submissions-table-columns" +import { EsgEvaluationFormSheet } from "./esg-evaluation-form-sheet" +import { useRouter } from "next/navigation" +import { GeneralEvaluationFormSheet } from "./general-evaluation-form-sheet" +import { EvaluationSubmissionDialog } from "./evaluation-submit-dialog" + +interface EvaluationSubmissionsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getEvaluationSubmissions>>, + ] + > +} + +export function EvaluationSubmissionsTable({ promises }: EvaluationSubmissionsTableProps) { + // 1. 데이터 로딩 상태 관리 + const [isLoading, setIsLoading] = React.useState(true) + const [tableData, setTableData] = React.useState<{ + data: EvaluationSubmissionWithVendor[] + pageCount: number + }>({ data: [], pageCount: 0 }) + const router = useRouter() + + + // 2. 행 액션 상태 관리 + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<EvaluationSubmissionWithVendor> | null>(null) + + // 3. Promise 해결을 useEffect로 처리 + React.useEffect(() => { + promises + .then(([result]) => { + setTableData(result) + setIsLoading(false) + }) + // .catch((error) => { + // console.error('Failed to load evaluation submissions:', error) + // setIsLoading(false) + // }) + }, [promises]) + + // 4. 컬럼 정의 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 5. 필터 필드 정의 + const filterFields: DataTableFilterField<EvaluationSubmissionWithVendor>[] = [ + { + id: "submissionStatus", + label: "제출상태", + placeholder: "상태 선택...", + }, + { + id: "evaluationYear", + label: "평가연도", + placeholder: "연도 선택...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<EvaluationSubmissionWithVendor>[] = [ + { + id: "submissionId", + label: "제출 ID", + type: "text", + }, + { + id: "evaluationYear", + label: "평가연도", + type: "number", + }, + { + id: "evaluationRound", + label: "평가회차", + type: "text", + }, + { + id: "submissionStatus", + label: "제출상태", + type: "select", + options: [ + { label: "임시저장", value: "draft" }, + { label: "제출완료", value: "submitted" }, + { label: "검토중", value: "under_review" }, + { label: "승인", value: "approved" }, + { label: "반려", value: "rejected" }, + ], + }, + { + id: "submittedAt", + label: "제출일시", + type: "date", + }, + { + id: "reviewedAt", + label: "검토일시", + type: "date", + }, + { + id: "averageEsgScore", + label: "ESG 점수", + type: "number", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + { + id: "updatedAt", + label: "수정일", + type: "date", + }, + ] + + // 6. 데이터 테이블 설정 + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { left: ["select"], right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + // 7. 데이터 새로고침 함수 + const handleRefresh = React.useCallback(() => { + setIsLoading(true) + router.refresh() + }, [router]) + + // 8. 각종 성공 핸들러 + const handleActionSuccess = React.useCallback(() => { + setRowAction(null) + table.resetRowSelection() + handleRefresh() + }, [handleRefresh, table]) + + // 9. 로딩 상태 표시 + if (isLoading) { + return ( + <div className="flex items-center justify-center h-32"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div> + <span className="ml-2">평가 제출 목록을 불러오는 중...</span> + </div> + ) + } + + return ( + <> + {/* 메인 테이블 */} + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + </DataTableAdvancedToolbar> + </DataTable> + + + {/* 일반평가 작성 시트 */} + <GeneralEvaluationFormSheet + open={rowAction?.type === "general_evaluation"} + onOpenChange={() => setRowAction(null)} + submission={rowAction?.row.original ?? null} + onSuccess={handleActionSuccess} + /> + + {/* ESG평가 작성 시트 */} + <EsgEvaluationFormSheet + open={rowAction?.type === "esg_evaluation"} + onOpenChange={() => setRowAction(null)} + submission={rowAction?.row.original ?? null} + onSuccess={handleActionSuccess} + /> + + <EvaluationSubmissionDialog + open={rowAction?.type === "submit"} + onOpenChange={() => setRowAction(null)} + submission={rowAction?.row.original ?? null} + onSuccess={handleActionSuccess} + /> + + + + </> + ) +} + diff --git a/lib/vendor-evaluation-submit/validation.ts b/lib/vendor-evaluation-submit/validation.ts new file mode 100644 index 00000000..d4eb9e56 --- /dev/null +++ b/lib/vendor-evaluation-submit/validation.ts @@ -0,0 +1,30 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { EvaluationSubmission } from "@/db/schema"; + + +export const getEvaluationsSubmitSchema =createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<EvaluationSubmission>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); + +export type GetEvaluationsSubmitSchema = Awaited<ReturnType<typeof getEvaluationsSubmitSchema.parse>>
\ No newline at end of file |
