diff options
Diffstat (limited to 'lib')
51 files changed, 12117 insertions, 13 deletions
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts index c5398e6c..83f0bbb5 100644 --- a/lib/b-rfq/service.ts +++ b/lib/b-rfq/service.ts @@ -227,9 +227,6 @@ export async function createRfqAction(input: CreateRfqInput) { rfqCode: bRfqs.rfqCode, }) - // 관련 페이지 캐시 무효화 - revalidateTag(tag.rfqDashboard); - revalidateTag(tag.rfq(id)); return { @@ -584,9 +581,6 @@ export async function confirmDocuments(rfqId: number) { }) .where(eq(bRfqs.id, rfqId)) - revalidateTag(tag.rfq(rfqId)); - revalidateTag(tag.rfqDashboard); - revalidateTag(tag.rfqAttachments(rfqId)); return { success: true, @@ -1245,7 +1239,6 @@ export async function removeInitialRfqs(input: RemoveInitialRfqsSchema) { await tx.delete(initialRfq).where(inArray(initialRfq.id, ids)) }) - revalidateTag(tag.initialRfqDetail) return { data: null, @@ -1298,7 +1291,6 @@ export async function modifyInitialRfq(input: ModifyInitialRfqInput) { .where(eq(initialRfq.id, id)) }) - revalidateTag(tag.initialRfqDetail) return { data: null, @@ -1675,9 +1667,6 @@ export async function sendBulkInitialRfqEmails(input: BulkEmailInput) { } } - // 9. 페이지 새로고침 - revalidateTag(tag.initialRfqDetail) - revalidateTag(tag.rfqDashboard) // 📋 RFQ 대시보드도 새로고침 return { diff --git a/lib/esg-check-list/repository.ts b/lib/esg-check-list/repository.ts new file mode 100644 index 00000000..dfe04eb3 --- /dev/null +++ b/lib/esg-check-list/repository.ts @@ -0,0 +1,133 @@ +import db from "@/db/db"; +import { esgAnswerOptions, esgEvaluationItems, esgEvaluations, esgEvaluationsView, projects } from "@/db/schema"; +import { Item, items } from "@/db/schema/items"; +import { formListsView, tagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +// import { DatabaseConnection } from '@/types/database'; + + +export async function selectEsgEvaluations( + tx: PgTransaction<any, any, any>, + params: { + where?: any; + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + + const { where, orderBy, offset = 0, limit = 10 } = params; + + return await tx + .select() + .from(esgEvaluationsView) + .where(where) + .orderBy(...(orderBy ?? [asc(esgEvaluationsView.createdAt)])) + .offset(offset ?? 0) + .limit(limit ?? 10); +} + +export async function countEsgEvaluations( + tx: PgTransaction<any, any, any>, + where?: any +) { + const result = await tx + .select({ count: count() }) + .from(esgEvaluationsView) + .where(where); + + return result[0]?.count ?? 0; +} + +// 상세 데이터 조회 (평가항목과 답변 옵션 포함) +export async function getEsgEvaluationWithDetails( + tx: PgTransaction<any, any, any>, + id: number +) { + // 메인 평가표 정보 + const evaluation = await tx + .select() + .from(esgEvaluations) + .where(eq(esgEvaluations.id, id)) + .limit(1); + + if (!evaluation[0]) return null; + + // 평가항목들과 답변 옵션들 + const items = await tx + .select({ + // 평가항목 필드들 + itemId: esgEvaluationItems.id, + evaluationItem: esgEvaluationItems.evaluationItem, + evaluationItemDescription: esgEvaluationItems.evaluationItemDescription, + itemOrderIndex: esgEvaluationItems.orderIndex, + itemIsActive: esgEvaluationItems.isActive, + itemCreatedAt: esgEvaluationItems.createdAt, + itemUpdatedAt: esgEvaluationItems.updatedAt, + // 답변 옵션 필드들 + optionId: esgAnswerOptions.id, + answerText: esgAnswerOptions.answerText, + score: esgAnswerOptions.score, + optionOrderIndex: esgAnswerOptions.orderIndex, + optionIsActive: esgAnswerOptions.isActive, + optionCreatedAt: esgAnswerOptions.createdAt, + optionUpdatedAt: esgAnswerOptions.updatedAt, + }) + .from(esgEvaluationItems) + .leftJoin(esgAnswerOptions, eq(esgEvaluationItems.id, esgAnswerOptions.esgEvaluationItemId)) + .where(eq(esgEvaluationItems.esgEvaluationId, id)) + .orderBy( + asc(esgEvaluationItems.orderIndex), + asc(esgAnswerOptions.orderIndex) + ); + + + + // 데이터 구조화 + const itemsMap = new Map(); + + items.forEach((row) => { + if (!itemsMap.has(row.itemId)) { + itemsMap.set(row.itemId, { + id: row.itemId, + evaluationItem: row.evaluationItem, + evaluationItemDescription: row.evaluationItemDescription, + orderIndex: row.itemOrderIndex, + isActive: row.itemIsActive, + createdAt: row.itemCreatedAt, + updatedAt: row.itemUpdatedAt, + answerOptions: [], + }); + } + + if (row.optionId) { + itemsMap.get(row.itemId).answerOptions.push({ + id: row.optionId, + answerText: row.answerText, + score: row.score, + orderIndex: row.optionOrderIndex, + isActive: row.optionIsActive, + createdAt: row.optionCreatedAt, + updatedAt: row.optionUpdatedAt, + }); + } + }); + + return { + ...evaluation[0], + evaluationItems: Array.from(itemsMap.values()), + }; +}
\ No newline at end of file diff --git a/lib/esg-check-list/service.ts b/lib/esg-check-list/service.ts new file mode 100644 index 00000000..500cd82c --- /dev/null +++ b/lib/esg-check-list/service.ts @@ -0,0 +1,601 @@ +'use server' + +import { and, asc, desc, ilike, or } from 'drizzle-orm'; +import db from '@/db/db'; +import { filterColumns } from "@/lib/filter-columns"; + + +import { + esgEvaluations, + esgEvaluationItems, + esgAnswerOptions, + NewEsgEvaluation, + NewEsgEvaluationItem, + NewEsgAnswerOption, + EsgEvaluationWithItems, + esgEvaluationsView +} from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { GetEsgEvaluationsSchema } from './validation'; +import { countEsgEvaluations, getEsgEvaluationWithDetails, selectEsgEvaluations } from './repository'; + +// ============ 조회 함수들 ============ + +export async function getEsgEvaluations(input: GetEsgEvaluationsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + // 고급 필터링 + const advancedWhere = filterColumns({ + table: esgEvaluationsView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(esgEvaluationsView.serialNumber, s), + ilike(esgEvaluationsView.category, s), + ilike(esgEvaluationsView.inspectionItem, s) + ); + } + + const finalWhere = and(advancedWhere, globalWhere); + + // 정렬 + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + return item.desc + ? desc(esgEvaluationsView[item.id]) + : asc(esgEvaluationsView[item.id]); + }) + : [desc(esgEvaluationsView.createdAt)]; + + // 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectEsgEvaluations(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countEsgEvaluations(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + console.error('Error in getEsgEvaluations:', err); + return { data: [], pageCount: 0 }; + } +} + +// 단일 평가표 상세 조회 (평가항목과 답변 옵션 포함) +export async function getEsgEvaluationDetails(id: number) { + try { + return await db.transaction(async (tx) => { + return await getEsgEvaluationWithDetails(tx, id); + }); + } catch (err) { + console.error('Error in getEsgEvaluationDetails:', err); + return null; + } +} + +// ============ 생성 함수들 ============ + +export async function createEsgEvaluation(data: NewEsgEvaluation) { + try { + return await db.transaction(async (tx) => { + const [result] = await tx + .insert(esgEvaluations) + .values(data) + .returning(); + return result; + }); + } catch (err) { + console.error('Error creating ESG evaluation:', err); + throw new Error('Failed to create ESG evaluation'); + } +} + +export async function createEsgEvaluationWithItems( + evaluationData: NewEsgEvaluation, + items: Array<{ + evaluationItem: string; + orderIndex?: number; + answerOptions: Array<{ + answerText: string; + score: number; + orderIndex?: number; + }>; + }> +) { + try { + return await db.transaction(async (tx) => { + // 1. 평가표 생성 + const [evaluation] = await tx + .insert(esgEvaluations) + .values(evaluationData) + .returning(); + + // 2. 평가항목들 생성 + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const [evaluationItem] = await tx + .insert(esgEvaluationItems) + .values({ + esgEvaluationId: evaluation.id, + evaluationItem: item.evaluationItem, + orderIndex: item.orderIndex ?? i, + }) + .returning(); + + // 3. 답변 옵션들 생성 + if (item.answerOptions.length > 0) { + await tx.insert(esgAnswerOptions).values( + item.answerOptions.map((option, optionIndex) => ({ + esgEvaluationItemId: evaluationItem.id, + answerText: option.answerText, + score: option.score.toString(), + orderIndex: option.orderIndex ?? optionIndex, + })) + ); + } + } + + return evaluation; + }); + } catch (err) { + console.error('Error creating ESG evaluation with items:', err); + throw new Error('Failed to create ESG evaluation with items'); + } +} + +// ============ 수정 함수들 ============ + +export async function updateEsgEvaluation( + id: number, + data: Partial<NewEsgEvaluation> +) { + try { + return await db.transaction(async (tx) => { + const [result] = await tx + .update(esgEvaluations) + .set({ ...data, updatedAt: new Date() }) + .where(eq(esgEvaluations.id, id)) + .returning(); + return result; + }); + } catch (err) { + console.error('Error updating ESG evaluation:', err); + throw new Error('Failed to update ESG evaluation'); + } +} + +export async function updateEsgEvaluationItem( + id: number, + data: Partial<NewEsgEvaluationItem> +) { + try { + return await db.transaction(async (tx) => { + const [result] = await tx + .update(esgEvaluationItems) + .set({ ...data, updatedAt: new Date() }) + .where(eq(esgEvaluationItems.id, id)) + .returning(); + return result; + }); + } catch (err) { + console.error('Error updating ESG evaluation item:', err); + throw new Error('Failed to update ESG evaluation item'); + } +} + +export async function updateEsgAnswerOption( + id: number, + data: Partial<NewEsgAnswerOption> +) { + try { + return await db.transaction(async (tx) => { + const [result] = await tx + .update(esgAnswerOptions) + .set({ ...data, updatedAt: new Date() }) + .where(eq(esgAnswerOptions.id, id)) + .returning(); + return result; + }); + } catch (err) { + console.error('Error updating ESG answer option:', err); + throw new Error('Failed to update ESG answer option'); + } +} + +// ============ 삭제 함수들 ============ + +export async function deleteEsgEvaluation(id: number) { + try { + return await db.transaction(async (tx) => { + // Cascade delete가 설정되어 있어서 평가항목과 답변옵션들도 자동 삭제됨 + const [result] = await tx + .delete(esgEvaluations) + .where(eq(esgEvaluations.id, id)) + .returning(); + return result; + }); + } catch (err) { + console.error('Error deleting ESG evaluation:', err); + throw new Error('Failed to delete ESG evaluation'); + } +} + +export async function deleteEsgEvaluationItem(id: number) { + try { + return await db.transaction(async (tx) => { + // Cascade delete가 설정되어 있어서 답변옵션들도 자동 삭제됨 + const [result] = await tx + .delete(esgEvaluationItems) + .where(eq(esgEvaluationItems.id, id)) + .returning(); + return result; + }); + } catch (err) { + console.error('Error deleting ESG evaluation item:', err); + throw new Error('Failed to delete ESG evaluation item'); + } +} + +export async function deleteEsgAnswerOption(id: number) { + try { + return await db.transaction(async (tx) => { + const [result] = await tx + .delete(esgAnswerOptions) + .where(eq(esgAnswerOptions.id, id)) + .returning(); + return result; + }); + } catch (err) { + console.error('Error deleting ESG answer option:', err); + throw new Error('Failed to delete ESG answer option'); + } +} + +// ============ 소프트 삭제 함수들 ============ + +export async function softDeleteEsgEvaluation(id: number) { + return updateEsgEvaluation(id, { isActive: false }); +} + +export async function softDeleteEsgEvaluationItem(id: number) { + return updateEsgEvaluationItem(id, { isActive: false }); +} + +export async function softDeleteEsgAnswerOption(id: number) { + return updateEsgAnswerOption(id, { isActive: false }); +} + + + +export async function updateEsgEvaluationWithItems( + id: number, + evaluationData: { + serialNumber: string; + category: string; + inspectionItem: string; + }, + items: Array<{ + evaluationItem: string; + evaluationItemDescription: string; + answerOptions: Array<{ + answerText: string; + score: number; + }>; + }> +) { + try { + return await db.transaction(async (tx) => { + // 1. 기본 정보 수정 + const [updatedEvaluation] = await tx + .update(esgEvaluations) + .set({ + ...evaluationData, + updatedAt: new Date(), + }) + .where(eq(esgEvaluations.id, id)) + .returning(); + + // 2. 기존 평가항목들과 답변 옵션들 모두 삭제 (cascade delete로 답변옵션도 함께 삭제됨) + await tx + .delete(esgEvaluationItems) + .where(eq(esgEvaluationItems.esgEvaluationId, id)); + + // 3. 새로운 평가항목들과 답변 옵션들 생성 + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + const [evaluationItem] = await tx + .insert(esgEvaluationItems) + .values({ + esgEvaluationId: id, + evaluationItem: item.evaluationItem, + orderIndex: i, + }) + .returning(); + + // 답변 옵션들 생성 + if (item.answerOptions.length > 0) { + await tx.insert(esgAnswerOptions).values( + item.answerOptions.map((option, optionIndex) => ({ + esgEvaluationItemId: evaluationItem.id, + answerText: option.answerText, + score: option.score.toString(), + orderIndex: optionIndex, + })) + ); + } + } + + return updatedEvaluation; + }); + } catch (err) { + console.error('Error updating ESG evaluation with items:', err); + + // 시리얼 번호 중복 에러 처리 + if (err instanceof Error && err.message.includes('unique')) { + throw new Error('이미 존재하는 시리얼번호입니다.'); + } + + throw new Error('평가표 수정에 실패했습니다.'); + } +} + +// ============ 소프트 삭제 버전 (데이터 보존) ============ + +export async function updateEsgEvaluationWithItemsSoft( + id: number, + evaluationData: { + serialNumber: string; + category: string; + inspectionItem: string; + }, + items: Array<{ + evaluationItem: string; + answerOptions: Array<{ + answerText: string; + score: number; + }>; + }> +) { + try { + return await db.transaction(async (tx) => { + // 1. 기본 정보 수정 + const [updatedEvaluation] = await tx + .update(esgEvaluations) + .set({ + ...evaluationData, + updatedAt: new Date(), + }) + .where(eq(esgEvaluations.id, id)) + .returning(); + + // 2. 기존 평가항목들 소프트 삭제 + await tx + .update(esgEvaluationItems) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(esgEvaluationItems.esgEvaluationId, id)); + + // 기존 답변 옵션들도 소프트 삭제 + const existingItems = await tx + .select({ id: esgEvaluationItems.id }) + .from(esgEvaluationItems) + .where(eq(esgEvaluationItems.esgEvaluationId, id)); + + for (const item of existingItems) { + await tx + .update(esgAnswerOptions) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(esgAnswerOptions.esgEvaluationItemId, item.id)); + } + + // 3. 새로운 평가항목들과 답변 옵션들 생성 + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + const [evaluationItem] = await tx + .insert(esgEvaluationItems) + .values({ + esgEvaluationId: id, + evaluationItem: item.evaluationItem, + orderIndex: i, + }) + .returning(); + + // 답변 옵션들 생성 + if (item.answerOptions.length > 0) { + await tx.insert(esgAnswerOptions).values( + item.answerOptions.map((option, optionIndex) => ({ + esgEvaluationItemId: evaluationItem.id, + answerText: option.answerText, + score: option.score.toString(), + orderIndex: optionIndex, + })) + ); + } + } + + return updatedEvaluation; + }); + } catch (err) { + console.error('Error updating ESG evaluation with items (soft):', err); + + if (err instanceof Error && err.message.includes('unique')) { + throw new Error('이미 존재하는 시리얼번호입니다.'); + } + + throw new Error('평가표 수정에 실패했습니다.'); + } +} + +// ============ 생성 함수 개선 (에러 처리 추가) ============ + +export async function createEsgEvaluationWithItemsEnhanced( + evaluationData: { + serialNumber: string; + category: string; + inspectionItem: string; + }, + items: Array<{ + evaluationItem: string; + evaluationItemDescription: string; + answerOptions: Array<{ + answerText: string; + score: number; + }>; + }> +) { + try { + return await db.transaction(async (tx) => { + // 1. 평가표 생성 + const [evaluation] = await tx + .insert(esgEvaluations) + .values(evaluationData) + .returning(); + + // 2. 평가항목들과 답변 옵션들 생성 + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + const [evaluationItem] = await tx + .insert(esgEvaluationItems) + .values({ + esgEvaluationId: evaluation.id, + evaluationItem: item.evaluationItem, + evaluationItemDescription: item.evaluationItemDescription, + orderIndex: i, + }) + .returning(); + + // 답변 옵션들 생성 + if (item.answerOptions.length > 0) { + await tx.insert(esgAnswerOptions).values( + item.answerOptions.map((option, optionIndex) => ({ + esgEvaluationItemId: evaluationItem.id, + answerText: option.answerText, + score: option.score.toString(), + orderIndex: optionIndex, + })) + ); + } + } + + return evaluation; + }); + } catch (err) { + console.error('Error creating ESG evaluation with items:', err); + + // 시리얼 번호 중복 에러 처리 + if (err instanceof Error && err.message.includes('unique')) { + throw new Error('이미 존재하는 시리얼번호입니다.'); + } + + throw new Error('평가표 생성에 실패했습니다.'); + } +} + +export async function deleteEsgEvaluationsBatch(ids: number[]) { + try { + if (ids.length === 0) { + throw new Error('삭제할 평가표가 없습니다.'); + } + + return await db.transaction(async (tx) => { + let deletedCount = 0; + + for (const id of ids) { + try { + // 각 평가표 삭제 (cascade delete로 관련 데이터도 함께 삭제됨) + await tx + .delete(esgEvaluations) + .where(eq(esgEvaluations.id, id)); + + deletedCount++; + } catch (error) { + console.error(`Error deleting evaluation ${id}:`, error); + // 개별 삭제 실패는 로그만 남기고 계속 진행 + } + } + + return { + total: ids.length, + deleted: deletedCount, + failed: ids.length - deletedCount + }; + }); + } catch (err) { + console.error('Error in batch delete ESG evaluations:', err); + throw new Error('평가표 일괄 삭제에 실패했습니다.'); + } +} + +export async function softDeleteEsgEvaluationsBatch(ids: number[]) { + try { + if (ids.length === 0) { + throw new Error('삭제할 평가표가 없습니다.'); + } + + return await db.transaction(async (tx) => { + let deletedCount = 0; + + for (const id of ids) { + try { + // 평가표 소프트 삭제 + await tx + .update(esgEvaluations) + .set({ + isActive: false, + updatedAt: new Date(), + }) + .where(eq(esgEvaluations.id, id)); + + // 관련 평가항목들 소프트 삭제 + await tx + .update(esgEvaluationItems) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(esgEvaluationItems.esgEvaluationId, id)); + + // 관련 답변 옵션들 소프트 삭제 + const evaluationItems = await tx + .select({ id: esgEvaluationItems.id }) + .from(esgEvaluationItems) + .where(eq(esgEvaluationItems.esgEvaluationId, id)); + + for (const item of evaluationItems) { + await tx + .update(esgAnswerOptions) + .set({ isActive: false, updatedAt: new Date() }) + .where(eq(esgAnswerOptions.esgEvaluationItemId, item.id)); + } + + deletedCount++; + } catch (error) { + console.error(`Error soft deleting evaluation ${id}:`, error); + // 개별 삭제 실패는 로그만 남기고 계속 진행 + } + } + + return { + total: ids.length, + deleted: deletedCount, + failed: ids.length - deletedCount + }; + }); + } catch (err) { + console.error('Error in batch soft delete ESG evaluations:', err); + throw new Error('평가표 일괄 삭제에 실패했습니다.'); + } +}
\ No newline at end of file diff --git a/lib/esg-check-list/table/esg-evaluation-delete-dialog.tsx b/lib/esg-check-list/table/esg-evaluation-delete-dialog.tsx new file mode 100644 index 00000000..ac667483 --- /dev/null +++ b/lib/esg-check-list/table/esg-evaluation-delete-dialog.tsx @@ -0,0 +1,168 @@ +"use client" + +import { EsgEvaluationsView } from "@/db/schema" +import React from "react" +import { toast } from "sonner" +import { useTransition } from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" + +// 서비스 함수 import +import { deleteEsgEvaluationsBatch, softDeleteEsgEvaluationsBatch } from "../service" + +interface EsgEvaluationBatchDeleteDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluations: EsgEvaluationsView[] + onSuccess: () => void + useSoftDelete?: boolean // 소프트 삭제 사용 여부 +} + +export function EsgEvaluationBatchDeleteDialog({ + open, + onOpenChange, + evaluations, + onSuccess, + useSoftDelete = false, +}: EsgEvaluationBatchDeleteDialogProps) { + const [isPending, startTransition] = useTransition() + + const evaluationCount = evaluations.length + const isSingle = evaluationCount === 1 + + const handleDelete = async () => { + if (evaluationCount === 0) return + + startTransition(async () => { + try { + const ids = evaluations.map((evaluation) => evaluation.id) + + let result + if (useSoftDelete) { + result = await softDeleteEsgEvaluationsBatch(ids) + } else { + result = await deleteEsgEvaluationsBatch(ids) + } + + // 성공 메시지 + if (result.failed > 0) { + toast.warning( + `${result.deleted}개 삭제 완료, ${result.failed}개 실패했습니다.` + ) + } else { + toast.success( + isSingle + ? '평가표가 삭제되었습니다.' + : `${result.deleted}개의 평가표가 삭제되었습니다.` + ) + } + + onSuccess() + onOpenChange(false) + } catch (error) { + console.error('Error deleting evaluations:', error) + toast.error( + error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' + ) + } + }) + } + + if (evaluationCount === 0) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle> + {isSingle ? '평가표 삭제' : `평가표 일괄 삭제 (${evaluationCount}개)`} + </DialogTitle> + <DialogDescription> + {isSingle ? ( + <> + 정말로 이 ESG 평가표를 삭제하시겠습니까? + <br /> + 이 작업은 되돌릴 수 없으며, 연관된 모든 평가항목과 답변옵션들도 함께 삭제됩니다. + </> + ) : ( + <> + 선택된 {evaluationCount}개의 ESG 평가표를 삭제하시겠습니까? + <br /> + 이 작업은 되돌릴 수 없으며, 연관된 모든 평가항목과 답변옵션들도 함께 삭제됩니다. + </> + )} + </DialogDescription> + </DialogHeader> + + {/* 삭제될 평가표 목록 */} + <div className="py-4"> + <h4 className="text-sm font-medium mb-3"> + {isSingle ? '삭제될 평가표:' : '삭제될 평가표 목록:'} + </h4> + + <ScrollArea className={evaluationCount > 5 ? "h-[200px]" : "h-auto"}> + <div className="space-y-3"> + {evaluations.map((evaluation, index) => ( + <div + key={evaluation.id} + className="p-3 border rounded-lg bg-muted/50" + > + <div className="flex items-start justify-between gap-3"> + <div className="flex-1 space-y-1"> + <div className="flex items-center gap-2"> + <Badge variant="outline" className="text-xs"> + {evaluation.serialNumber} + </Badge> + <span className="text-sm font-medium"> + {evaluation.category} + </span> + </div> + <p className="text-xs text-muted-foreground line-clamp-2"> + {evaluation.inspectionItem} + </p> + <div className="flex gap-4 text-xs text-muted-foreground"> + <span>평가항목: {evaluation.totalEvaluationItems}개</span> + <span>답변옵션: {evaluation.totalAnswerOptions}개</span> + </div> + </div> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + <Button + variant="destructive" + onClick={handleDelete} + disabled={isPending} + > + {isPending + ? '삭제 중...' + : isSingle + ? '삭제' + : `${evaluationCount}개 삭제` + } + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/esg-check-list/table/esg-evaluation-details-sheet.tsx b/lib/esg-check-list/table/esg-evaluation-details-sheet.tsx new file mode 100644 index 00000000..a954b58f --- /dev/null +++ b/lib/esg-check-list/table/esg-evaluation-details-sheet.tsx @@ -0,0 +1,188 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { toast } from "sonner" +import { Plus, X, GripVertical, Trash2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { getEsgEvaluationDetails } from "../service" + +interface EsgEvaluationDetailsSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluationId: number | null + } + + export function EsgEvaluationDetailsSheet({ + open, + onOpenChange, + evaluationId, + }: EsgEvaluationDetailsSheetProps) { + const [evaluation, setEvaluation] = React.useState<any>(null) + const [isLoading, setIsLoading] = React.useState(false) + + console.log(evaluation) + + // 데이터 로드 + React.useEffect(() => { + if (open && evaluationId) { + setIsLoading(true) + getEsgEvaluationDetails(evaluationId) + .then(setEvaluation) + .catch((error) => { + console.error('Error loading evaluation details:', error) + toast.error('평가표 상세 정보를 불러오는데 실패했습니다.') + }) + .finally(() => setIsLoading(false)) + } + }, [open, evaluationId]) + + if (!open) return null + + 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"> + <SheetTitle>ESG 평가표 상세보기</SheetTitle> + <SheetDescription> + 평가표의 상세 정보와 평가항목들을 확인합니다. + </SheetDescription> + </SheetHeader> + + {isLoading ? ( + <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> + ) : evaluation ? ( + <div className="flex-1 overflow-y-auto px-1"> + <div className="space-y-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <label className="text-sm font-medium">시리얼번호</label> + <p className="text-sm text-muted-foreground">{evaluation.serialNumber}</p> + </div> + <div> + <label className="text-sm font-medium">분류</label> + <p className="text-sm text-muted-foreground"> + <Badge variant="secondary">{evaluation.category}</Badge> + </p> + </div> + <div> + <label className="text-sm font-medium">점검항목</label> + <p className="text-sm text-muted-foreground">{evaluation.inspectionItem}</p> + </div> + </CardContent> + </Card> + + {/* 평가항목들 */} + <Card> + <CardHeader> + <CardTitle>평가항목들 ({evaluation.evaluationItems?.length || 0}개)</CardTitle> + </CardHeader> + <CardContent> + {evaluation.evaluationItems?.length > 0 ? ( + <Accordion type="multiple" className="w-full"> + {evaluation.evaluationItems.map((item: any, index: number) => ( + <AccordionItem key={item.id} value={`item-${item.id}`}> + <AccordionTrigger className="text-left"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{index + 1}</Badge> + <span className="truncate">{item.evaluationItem}</span> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-3"> + <div className="text-sm text-muted-foreground"> + 답변 옵션들 ({item.answerOptions?.length || 0}개) + </div> + {item.answerOptions?.map((option: any, optionIndex: number) => ( + <div + key={option.id} + className="flex items-center justify-between p-3 bg-muted/50 rounded-lg" + > + <div className="flex items-center gap-2"> + <Badge variant="default" className="text-xs"> + {optionIndex + 1} + </Badge> + <span className="text-sm">{option.answerText}</span> + </div> + <Badge variant="secondary"> + {Math.floor(Number(option.score))}점 + + </Badge> + </div> + ))} + </div> + </AccordionContent> + </AccordionItem> + ))} + </Accordion> + ) : ( + <p className="text-sm text-muted-foreground text-center py-8"> + 등록된 평가항목이 없습니다. + </p> + )} + </CardContent> + </Card> + </div> + </div> + ) : ( + <div className="text-center text-muted-foreground py-8"> + 평가표를 찾을 수 없습니다. + </div> + )} + </SheetContent> + </Sheet> + ) + }
\ No newline at end of file diff --git a/lib/esg-check-list/table/esg-evaluation-form-sheet.tsx b/lib/esg-check-list/table/esg-evaluation-form-sheet.tsx new file mode 100644 index 00000000..be5ea735 --- /dev/null +++ b/lib/esg-check-list/table/esg-evaluation-form-sheet.tsx @@ -0,0 +1,492 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { toast } from "sonner" +import { Plus, X, Trash2 } from "lucide-react" +import { useTransition } from "react" + +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { ScrollArea } from "@/components/ui/scroll-area" + +// 기존 서비스 함수 import +import { + getEsgEvaluationDetails, + createEsgEvaluationWithItemsEnhanced, + updateEsgEvaluationWithItems +} from "../service" // 기존 서비스 파일 경로에 맞게 수정 + +import { EsgEvaluationsView } from "@/db/schema" + +// 폼 스키마 정의 +const evaluationFormSchema = z.object({ + serialNumber: z.string().min(1, "시리얼번호는 필수입니다"), + category: z.string().min(1, "분류는 필수입니다"), + inspectionItem: z.string().min(1, "점검항목은 필수입니다"), + evaluationItems: z.array( + z.object({ + evaluationItem: z.string().min(1, "평가항목은 필수입니다"), + evaluationItemDescription: z.string().min(1, "평가항목 설명은 필수입니다"), + answerOptions: z.array( + z.object({ + answerText: z.string().min(1, "답변 내용은 필수입니다"), + score: z.coerce.number().min(0, "점수는 0 이상이어야 합니다"), + }) + ).min(1, "최소 1개의 답변 옵션이 필요합니다"), + }) + ).min(1, "최소 1개의 평가항목이 필요합니다"), +}) + +type EvaluationFormData = z.infer<typeof evaluationFormSchema> + +interface EsgEvaluationFormSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluation: EsgEvaluationsView | null + onSuccess: () => void +} + +export function EsgEvaluationFormSheet({ + open, + onOpenChange, + evaluation, + onSuccess, +}: EsgEvaluationFormSheetProps) { + const [isPending, startTransition] = useTransition() + const isEdit = !!evaluation + + const form = useForm<EvaluationFormData>({ + resolver: zodResolver(evaluationFormSchema), + defaultValues: { + serialNumber: "", + category: "", + inspectionItem: "", + evaluationItems: [ + { + evaluationItem: "", + evaluationItemDescription: "", + answerOptions: [ + { answerText: "", score: 0 }, + { answerText: "", score: 0 }, + ], + }, + ], + }, + }) + + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "evaluationItems", + }) + + // 편집 모드일 때 기존 데이터 로드 + React.useEffect(() => { + if (open && isEdit && evaluation) { + // 기존 서비스 함수를 사용하여 상세 데이터 로드 + startTransition(async () => { + try { + const details = await getEsgEvaluationDetails(evaluation.id) + console.log(details) + + if (details) { + form.reset({ + serialNumber: details.serialNumber, + category: details.category, + inspectionItem: details.inspectionItem, + evaluationItems: details.evaluationItems?.map((item) => ({ + evaluationItem: item.evaluationItem, + evaluationItemDescription: item.evaluationItemDescription, + answerOptions: item.answerOptions?.map((option) => ({ + answerText: option.answerText, + score: parseFloat(option.score), + })) || [], + })) || [], + }) + } + } catch (error) { + console.error('Error loading evaluation for edit:', error) + toast.error(error instanceof Error ? error.message : '편집할 데이터를 불러오는데 실패했습니다.') + } + }) + } else if (open && !isEdit) { + // 새 생성 모드 + form.reset({ + serialNumber: "", + category: "", + inspectionItem: "", + evaluationItems: [ + { + evaluationItem: "", + evaluationItemDescription: "", + answerOptions: [ + { answerText: "", score: 0 }, + { answerText: "", score: 0 }, + ], + }, + ], + }) + } + }, [open, isEdit, evaluation, form]) + + const onSubmit = async (data: EvaluationFormData) => { + startTransition(async () => { + try { + // 폼 데이터를 서비스 함수에 맞는 형태로 변환 + const evaluationData = { + serialNumber: data.serialNumber, + category: data.category, + inspectionItem: data.inspectionItem, + } + + const items = data.evaluationItems.map(item => ({ + evaluationItem: item.evaluationItem, + evaluationItemDescription: item.evaluationItemDescription, + answerOptions: item.answerOptions.map(option => ({ + answerText: option.answerText, + score: option.score, + })) + })) + + if (isEdit && evaluation) { + // 수정 - 전체 평가표 수정 + await updateEsgEvaluationWithItems(evaluation.id, evaluationData, items) + toast.success('평가표가 수정되었습니다.') + } else { + // 생성 - 평가표와 항목들 함께 생성 + await createEsgEvaluationWithItemsEnhanced(evaluationData, items) + toast.success('평가표가 생성되었습니다.') + } + + onSuccess() + onOpenChange(false) + } catch (error) { + console.error('Error saving evaluation:', error) + toast.error( + error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.' + ) + } + }) + } + + if (!open) return null + + 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-6"> + <SheetTitle> + {isEdit ? 'ESG 평가표 수정' : '새 ESG 평가표 생성'} + </SheetTitle> + <SheetDescription> + {isEdit + ? '평가표의 정보를 수정합니다.' + : '새로운 ESG 평가표를 생성합니다.'} + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col flex-1 min-h-0" + > + {/* 스크롤 가능한 콘텐츠 영역 */} + <ScrollArea className="flex-1 pr-4"> + <div className="space-y-6 pb-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <FormField + control={form.control} + name="serialNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>시리얼번호</FormLabel> + <FormControl> + <Input placeholder="P-1" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>분류</FormLabel> + <FormControl> + <Input placeholder="정보공시" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="inspectionItem" + render={({ field }) => ( + <FormItem> + <FormLabel>점검항목</FormLabel> + <FormControl> + <Input placeholder="ESG 정보공시 형식" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* 평가항목들 */} + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>평가항목들</CardTitle> + <CardDescription> + 각 평가항목과 해당 답변 옵션들을 설정합니다. + </CardDescription> + </div> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => + append({ + evaluationItem: "", + evaluationItemDescription: "", + answerOptions: [ + { answerText: "", score: 0 }, + { answerText: "", score: 0 }, + ], + }) + } + disabled={isPending} + > + <Plus className="w-4 h-4 mr-2" /> + 항목 추가 + </Button> + </div> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {fields.map((field, index) => ( + <EvaluationItemForm + key={field.id} + index={index} + form={form} + onRemove={() => remove(index)} + canRemove={fields.length > 1} + disabled={isPending} + /> + ))} + </div> + </CardContent> + </Card> + </div> + </ScrollArea> + + {/* 고정 버튼 영역 */} + <div className="flex-shrink-0 flex justify-end gap-2 pt-4 border-t bg-background"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + <Button type="submit" disabled={isPending}> + {isPending + ? '저장 중...' + : isEdit + ? '수정하기' + : '생성하기'} + </Button> + </div> + </form> + </Form> + </SheetContent> + </Sheet> + ) +} + +// 평가항목 개별 폼 컴포넌트 +interface EvaluationItemFormProps { + index: number + form: any + onRemove: () => void + canRemove: boolean + disabled?: boolean +} + +function EvaluationItemForm({ + index, + form, + onRemove, + canRemove, + disabled = false, +}: EvaluationItemFormProps) { + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: `evaluationItems.${index}.answerOptions`, + }) + + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle className="text-lg">평가항목 {index + 1}</CardTitle> + {canRemove && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={onRemove} + className="text-destructive hover:text-destructive" + disabled={disabled} + > + <Trash2 className="w-4 h-4" /> + </Button> + )} + </div> + </CardHeader> + <CardContent className="space-y-4"> + <FormField + control={form.control} + name={`evaluationItems.${index}.evaluationItem`} + render={({ field }) => ( + <FormItem> + <FormLabel>평가항목</FormLabel> + <FormControl> + <Input placeholder="평가항목을 입력해주세요..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`evaluationItems.${index}.evaluationItemDescription`} + render={({ field }) => ( + <FormItem> + <FormLabel>평가항목 설명</FormLabel> + <FormControl> + <Textarea + placeholder="평가할 항목에 대한 설명을 입력해주세요..." + {...field} + disabled={disabled} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div> + <div className="flex items-center justify-between mb-2"> + <label className="text-sm font-medium">답변 옵션들</label> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => append({ answerText: "", score: 0 })} + disabled={disabled} + > + <Plus className="w-4 h-4 mr-2" /> + 옵션 추가 + </Button> + </div> + + <div className="space-y-2"> + {fields.map((option, optionIndex) => ( + <div key={option.id} className="flex gap-2"> + <FormField + control={form.control} + name={`evaluationItems.${index}.answerOptions.${optionIndex}.answerText`} + render={({ field }) => ( + <FormItem className="flex-1"> + <FormControl> + <Input + placeholder="답변 내용" + {...field} + disabled={disabled} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`evaluationItems.${index}.answerOptions.${optionIndex}.score`} + render={({ field }) => ( + <FormItem className="w-24"> + <FormControl> + <Input + type="number" + step="0.1" + placeholder="점수" + {...field} + disabled={disabled} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {fields.length > 1 && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => remove(optionIndex)} + className="text-destructive hover:text-destructive" + disabled={disabled} + > + <X className="w-4 h-4" /> + </Button> + )} + </div> + ))} + </div> + </div> + </CardContent> + </Card> + ) +}
\ No newline at end of file diff --git a/lib/esg-check-list/table/esg-evaluations-table-columns.tsx b/lib/esg-check-list/table/esg-evaluations-table-columns.tsx new file mode 100644 index 00000000..399f9f4a --- /dev/null +++ b/lib/esg-check-list/table/esg-evaluations-table-columns.tsx @@ -0,0 +1,357 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, InfoIcon, PenToolIcon, TrashIcon } from "lucide-react" + +import { formatDate } 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, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Badge } from "@/components/ui/badge" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { EsgEvaluationsView } from "@/db/schema" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EsgEvaluationsView> | null>> +} + +/** + * ESG 평가표 테이블 컬럼 정의 + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<EsgEvaluationsView>[] { + + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<EsgEvaluationsView> = { + 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<EsgEvaluationsView>[] = [ + { + accessorKey: "serialNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="시리얼번호" /> + ), + cell: ({ row }) => ( + <div className="font-medium"> + {row.getValue("serialNumber")} + </div> + ), + enableSorting: true, + enableHiding: true, + }, + + { + accessorKey: "category", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="분류" /> + ), + cell: ({ row }) => ( + <Badge variant="secondary"> + {row.getValue("category")} + </Badge> + ), + }, + { + accessorKey: "inspectionItem", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="점검항목" /> + ), + cell: ({ row }) => ( + <div className="max-w-[300px] truncate" title={row.getValue("inspectionItem")}> + {row.getValue("inspectionItem")} + </div> + ), + }, + ] + + // ---------------------------------------------------------------- + // 3) 통계 정보 컬럼들 + // ---------------------------------------------------------------- + const statsColumns: ColumnDef<EsgEvaluationsView>[] = [ + { + id: "evaluationItems", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="평가항목" /> + ), + cell: ({ row }) => { + const evaluation = row.original; + const count = evaluation.totalEvaluationItems || 0; + + // evaluationItemsList가 있다면 사용, 없다면 개수만 표시 + const items = (evaluation as any).evaluationItemsList || []; + + if (items.length > 0) { + return ( + <div className="max-w-[250px]"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="cursor-help"> + <div className="flex flex-wrap gap-1"> + {items.slice(0, 3).map((item: string, index: number) => ( + <Badge key={index} variant="outline" className="text-xs"> + {item.length > 15 ? `${item.substring(0, 15)}...` : item} + </Badge> + ))} + {items.length > 3 && ( + <Badge variant="secondary" className="text-xs"> + +{items.length - 3}개 더 + </Badge> + )} + </div> + </div> + </TooltipTrigger> + <TooltipContent className="max-w-[300px]"> + <div className="space-y-1"> + <p className="font-medium">평가항목 목록:</p> + {items.map((item: string, index: number) => ( + <p key={index} className="text-sm"> + {index + 1}. {item} + </p> + ))} + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ); + } + + // 평가항목이 없는 경우 + return ( + <div className="text-center text-muted-foreground"> + <Badge variant="outline"> + {count > 0 ? `${count}개 항목` : "항목 없음"} + </Badge> + </div> + ); + }, + enableSorting: false, + }, + { + accessorKey: "totalAnswerOptions", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="답변옵션" /> + ), + cell: ({ row }) => ( + <div className="text-center"> + <Badge variant="outline"> + {row.getValue("totalAnswerOptions") || 0}개 + </Badge> + </div> + ), + }, + ] + + // ---------------------------------------------------------------- + // 4) 메타데이터 컬럼들 + // ---------------------------------------------------------------- + const metaColumns: ColumnDef<EsgEvaluationsView>[] = [ + { + accessorKey: "isActive", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => ( + <Badge variant={row.getValue("isActive") ? "default" : "secondary"}> + {row.getValue("isActive") ? "활성" : "비활성"} + </Badge> + ), + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + cell: ({ row }) => { + const date = row.getValue("createdAt") as Date + return formatDate(date) + }, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정일" /> + ), + cell: ({ row }) => { + const date = row.getValue("updatedAt") as Date + return formatDate(date) + }, + }, + ] + + // ---------------------------------------------------------------- + // 5) actions 컬럼 (드롭다운 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<EsgEvaluationsView> = { + id: "actions", + header: "작업", + enableHiding: false, + cell: function Cell({ row }) { + 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: "view" })} + > + <InfoIcon className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "update" })} + > + <PenToolIcon className="mr-2 h-4 w-4" /> + 수정하기 + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "delete" })} + className="text-destructive" + > + <TrashIcon className="mr-2 h-4 w-4" /> + 삭제하기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 80, + } + + // ---------------------------------------------------------------- + // 6) 최종 컬럼 배열 (그룹화 버전) + // ---------------------------------------------------------------- + return [ + selectColumn, + { + id: "basicInfo", + header: "기본 정보", + columns: basicColumns, + }, + { + id: "statistics", + header: "통계", + columns: statsColumns, + }, + { + id: "metadata", + header: "메타데이터", + columns: metaColumns, + }, + actionsColumn, + ] +} + +// ---------------------------------------------------------------- +// 7) 컬럼 설정 (필터링용) +// ---------------------------------------------------------------- +export const esgEvaluationsColumnsConfig = [ + { + id: "serialNumber", + label: "시리얼번호", + group: "기본 정보", + type: "text", + excelHeader: "Serial Number", + }, + { + id: "category", + label: "분류", + group: "기본 정보", + type: "text", + excelHeader: "Category", + }, + { + id: "inspectionItem", + label: "점검항목", + group: "기본 정보", + type: "text", + excelHeader: "Inspection Item", + }, + { + id: "totalEvaluationItems", + label: "평가항목 수", + group: "통계", + type: "number", + excelHeader: "Total Evaluation Items", + }, + { + id: "totalAnswerOptions", + label: "답변옵션 수", + group: "통계", + type: "number", + excelHeader: "Total Answer Options", + }, + // { + // id: "maxPossibleScore", + // label: "최대점수", + // group: "통계", + // type: "number", + // excelHeader: "Max Possible Score", + // }, + { + id: "isActive", + label: "상태", + group: "메타데이터", + type: "boolean", + excelHeader: "Is Active", + }, + { + 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/esg-check-list/table/esg-evaluations-table-toolbar-actions.tsx b/lib/esg-check-list/table/esg-evaluations-table-toolbar-actions.tsx new file mode 100644 index 00000000..5d92d869 --- /dev/null +++ b/lib/esg-check-list/table/esg-evaluations-table-toolbar-actions.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Plus, Trash2, Upload, FileSpreadsheet } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { EsgEvaluationsView } from "@/db/schema" +import { EsgEvaluationBatchDeleteDialog } from "./esg-evaluation-delete-dialog" +import { downloadEsgTemplate } from "./excel-utils" +import { useRouter } from "next/navigation" +import { ExcelImportDialog } from "./esg-excel-import" + +interface EsgEvaluationsTableToolbarActionsProps { + table: Table<EsgEvaluationsView> + onCreateNew?: () => void + onRefresh?: () => void +} + +export function EsgEvaluationsTableToolbarActions({ + table, + onCreateNew, + onRefresh +}: EsgEvaluationsTableToolbarActionsProps) { + const [isRefreshing, setIsRefreshing] = React.useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const [importDialogOpen, setImportDialogOpen] = React.useState(false) + const router = useRouter() + + // 선택된 행들 + const selectedRows = table.getFilteredSelectedRowModel().rows + const hasSelection = selectedRows.length > 0 + const selectedEvaluations = selectedRows.map(row => row.original) + + // ---------------------------------------------------------------- + // 새 평가표 생성 + // ---------------------------------------------------------------- + const handleCreateNew = () => { + if (onCreateNew) { + onCreateNew() + } else { + toast.info("새 ESG 평가표 생성 기능을 구현해주세요.") + } + } + + // ---------------------------------------------------------------- + // 선택된 평가표들 삭제 다이얼로그 열기 + // ---------------------------------------------------------------- + const handleDeleteSelected = () => { + if (!hasSelection) return + setDeleteDialogOpen(true) + } + + // ---------------------------------------------------------------- + // 삭제 성공 후 처리 + // ---------------------------------------------------------------- + const handleDeleteSuccess = async () => { + // 선택 해제 + table.resetRowSelection() + router.refresh() + } + + // ---------------------------------------------------------------- + // Excel 템플릿 다운로드 + // ---------------------------------------------------------------- + const handleDownloadTemplate = async () => { + try { + await downloadEsgTemplate() + toast.success("Excel 템플릿이 다운로드되었습니다.") + } catch (error) { + console.error('Error downloading template:', error) + toast.error("템플릿 다운로드 중 오류가 발생했습니다.") + } + } + + // ---------------------------------------------------------------- + // Excel 내보내기 + // ---------------------------------------------------------------- + const handleExport = () => { + try { + exportTableToExcel(table, { + filename: "ESG_Evaluations", + excludeColumns: ["select", "actions"], + }) + toast.success("Excel 파일이 다운로드되었습니다.") + } catch (error) { + console.error('Error exporting to Excel:', error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + } + } + + // ---------------------------------------------------------------- + // 임포트 성공 후 처리 + // ---------------------------------------------------------------- + const handleImportSuccess = () => { + router.refresh() + } + + return ( + <> + <div className="flex items-center gap-2"> + {/* 새 평가표 생성 버튼 */} + <Button + variant="default" + size="sm" + className="gap-2" + onClick={handleCreateNew} + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">새 평가표</span> + </Button> + + {/* Excel 관련 버튼들 */} + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + {/* Excel 템플릿 다운로드 */} + <Button + variant="outline" + size="sm" + onClick={handleDownloadTemplate} + className="gap-2" + > + <FileSpreadsheet className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">템플릿</span> + </Button> + + {/* Excel 데이터 임포트 */} + <Button + variant="outline" + size="sm" + onClick={() => setImportDialogOpen(true)} + className="gap-2" + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">임포트</span> + </Button> + + {/* Excel 데이터 내보내기 */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">내보내기</span> + </Button> + </div> + + {/* 선택된 항목 삭제 버튼 */} + {hasSelection && ( + <Button + variant="destructive" + size="sm" + className="gap-2 ml-2" + onClick={handleDeleteSelected} + > + <Trash2 className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 선택 삭제 ({selectedRows.length}) + </span> + </Button> + )} + </div> + + {/* 배치 삭제 다이얼로그 */} + <EsgEvaluationBatchDeleteDialog + open={deleteDialogOpen} + onOpenChange={setDeleteDialogOpen} + evaluations={selectedEvaluations} + onSuccess={handleDeleteSuccess} + useSoftDelete={false} // true로 설정하면 소프트 삭제 사용 + /> + + {/* Excel 임포트 다이얼로그 */} + <ExcelImportDialog + open={importDialogOpen} + onOpenChange={setImportDialogOpen} + onSuccess={handleImportSuccess} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/esg-check-list/table/esg-excel-import.tsx b/lib/esg-check-list/table/esg-excel-import.tsx new file mode 100644 index 00000000..0990e0e8 --- /dev/null +++ b/lib/esg-check-list/table/esg-excel-import.tsx @@ -0,0 +1,399 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { useTransition } from "react" +import { Upload, FileSpreadsheet, AlertCircle, CheckCircle, X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +import { + parseEsgExcelFile, + validateExcelData, + type ParsedExcelData +} from "./excel-utils" +import { + importEsgDataFromExcel, + checkDuplicateSerials, + type ImportOptions +} from "./excel-actions" + +interface ExcelImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function ExcelImportDialog({ + open, + onOpenChange, + onSuccess, +}: ExcelImportDialogProps) { + const [isPending, startTransition] = useTransition() + const [file, setFile] = React.useState<File | null>(null) + const [parsedData, setParsedData] = React.useState<ParsedExcelData | null>(null) + const [validationErrors, setValidationErrors] = React.useState<string[]>([]) + const [duplicateSerials, setDuplicateSerials] = React.useState<string[]>([]) + const [currentStep, setCurrentStep] = React.useState<'upload' | 'preview' | 'options'>('upload') + + // 임포트 옵션 + const [importOptions, setImportOptions] = React.useState<ImportOptions>({ + skipDuplicates: false, + updateExisting: false, + }) + + // 파일 선택 처리 + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = event.target.files?.[0] + if (selectedFile) { + if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + setFile(selectedFile) + } + } + + // 파일 파싱 + const handleParseFile = async () => { + if (!file) return + + startTransition(async () => { + try { + const data = await parseEsgExcelFile(file) + setParsedData(data) + + // 검증 + const errors = validateExcelData(data) + setValidationErrors(errors) + + // 중복 확인 + const serials = data.evaluations.map(e => e.serialNumber) + const duplicates = await checkDuplicateSerials(serials) + setDuplicateSerials(duplicates) + + setCurrentStep('preview') + } catch (error) { + console.error('Parsing error:', error) + toast.error(error instanceof Error ? error.message : 'Excel 파일 파싱에 실패했습니다.') + } + }) + } + + // 임포트 실행 + const handleImport = async () => { + if (!parsedData) return + + startTransition(async () => { + try { + const result = await importEsgDataFromExcel(parsedData, importOptions) + + if (result.success) { + toast.success(result.message) + onSuccess() + onOpenChange(false) + } else { + toast.error(result.message) + } + + // 상세 결과가 있으면 콘솔에 출력 + if (result.details.errors.length > 0) { + console.warn('Import errors:', result.details.errors) + } + } catch (error) { + console.error('Import error:', error) + toast.error('임포트 중 오류가 발생했습니다.') + } + }) + } + + // 다이얼로그 닫기 시 상태 리셋 + const handleClose = () => { + setFile(null) + setParsedData(null) + setValidationErrors([]) + setDuplicateSerials([]) + setCurrentStep('upload') + setImportOptions({ skipDuplicates: false, updateExisting: false }) + onOpenChange(false) + } + + const canProceed = parsedData && validationErrors.length === 0 + const hasDuplicates = duplicateSerials.length > 0 + + return ( + <Dialog open={open} onOpenChange={handleClose}> + <DialogContent className="max-w-4xl max-h-[80vh] flex flex-col" style={{maxWidth:900, width:900}}> + <DialogHeader className="flex-shrink-0"> + <DialogTitle className="flex items-center gap-2"> + <FileSpreadsheet className="w-5 h-5" /> + Excel 데이터 임포트 + </DialogTitle> + <DialogDescription> + Excel 파일에서 ESG 평가표 데이터를 임포트합니다. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-y-auto px-1"> + <Tabs value={currentStep} className="w-full"> + <TabsList className="grid w-full grid-cols-3"> + <TabsTrigger value="upload">파일 업로드</TabsTrigger> + <TabsTrigger value="preview" disabled={!parsedData}>데이터 미리보기</TabsTrigger> + <TabsTrigger value="options" disabled={!canProceed}>임포트 옵션</TabsTrigger> + </TabsList> + + {/* 파일 업로드 탭 */} + <TabsContent value="upload" className="space-y-4"> + <div className="space-y-4"> + <div> + <Label htmlFor="excel-file">Excel 파일 선택</Label> + <Input + id="excel-file" + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + className="mt-1" + /> + </div> + + {file && ( + <div className="p-4 border rounded-lg bg-muted/50"> + <div className="flex items-center gap-2"> + <FileSpreadsheet className="w-4 h-4" /> + <span className="font-medium">{file.name}</span> + <Badge variant="outline"> + {(file.size / 1024).toFixed(1)} KB + </Badge> + </div> + </div> + )} + + <Button + onClick={handleParseFile} + disabled={!file || isPending} + className="w-full" + > + {isPending ? '파싱 중...' : '파일 분석하기'} + </Button> + </div> + </TabsContent> + + {/* 데이터 미리보기 탭 */} + <TabsContent value="preview" className="space-y-4"> + {parsedData && ( + <div className="space-y-4"> + {/* 검증 결과 */} + <div className="space-y-2"> + {validationErrors.length > 0 ? ( + <div className="p-4 border border-destructive/20 rounded-lg bg-destructive/10"> + <div className="flex items-center gap-2 mb-2"> + <AlertCircle className="w-4 h-4 text-destructive" /> + <span className="font-medium text-destructive">검증 오류</span> + </div> + <ul className="space-y-1"> + {validationErrors.map((error, index) => ( + <li key={index} className="text-sm text-destructive"> + • {error} + </li> + ))} + </ul> + </div> + ) : ( + <div className="p-4 border border-green-200 rounded-lg bg-green-50"> + <div className="flex items-center gap-2"> + <CheckCircle className="w-4 h-4 text-green-600" /> + <span className="font-medium text-green-800">검증 완료</span> + </div> + </div> + )} + + {/* 중복 알림 */} + {hasDuplicates && ( + <div className="p-4 border border-yellow-200 rounded-lg bg-yellow-50"> + <div className="flex items-center gap-2 mb-2"> + <AlertCircle className="w-4 h-4 text-yellow-600" /> + <span className="font-medium text-yellow-800">중복 데이터 발견</span> + </div> + <p className="text-sm text-yellow-700 mb-2"> + 다음 시리얼번호가 이미 존재합니다: + </p> + <div className="flex flex-wrap gap-1"> + {duplicateSerials.map(serial => ( + <Badge key={serial} variant="outline" className="text-yellow-800"> + {serial} + </Badge> + ))} + </div> + </div> + )} + </div> + + {/* 데이터 요약 */} + <div className="grid grid-cols-3 gap-4"> + <div className="p-4 border rounded-lg text-center"> + <div className="text-2xl font-bold text-blue-600"> + {parsedData.evaluations.length} + </div> + <div className="text-sm text-muted-foreground">평가표</div> + </div> + <div className="p-4 border rounded-lg text-center"> + <div className="text-2xl font-bold text-green-600"> + {parsedData.evaluationItems.length} + </div> + <div className="text-sm text-muted-foreground">평가항목</div> + </div> + <div className="p-4 border rounded-lg text-center"> + <div className="text-2xl font-bold text-purple-600"> + {parsedData.answerOptions.length} + </div> + <div className="text-sm text-muted-foreground">답변옵션</div> + </div> + </div> + + {/* 평가표 미리보기 */} + <div> + <h4 className="font-medium mb-2">평가표 미리보기</h4> + <ScrollArea className="h-[200px] border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead>시리얼번호</TableHead> + <TableHead>분류</TableHead> + <TableHead>점검항목</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {parsedData.evaluations.slice(0, 10).map((evaluation, index) => ( + <TableRow key={index}> + <TableCell className="font-medium"> + {evaluation.serialNumber} + {duplicateSerials.includes(evaluation.serialNumber) && ( + <Badge variant="destructive" className="ml-2 text-xs"> + 중복 + </Badge> + )} + </TableCell> + <TableCell>{evaluation.category}</TableCell> + <TableCell className="max-w-[200px] truncate"> + {evaluation.inspectionItem} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </ScrollArea> + {parsedData.evaluations.length > 10 && ( + <p className="text-sm text-muted-foreground mt-2"> + ...외 {parsedData.evaluations.length - 10}개 더 + </p> + )} + </div> + + {canProceed && ( + <Button + onClick={() => setCurrentStep('options')} + className="w-full" + > + 다음 단계 + </Button> + )} + </div> + )} + </TabsContent> + + {/* 임포트 옵션 탭 */} + <TabsContent value="options" className="space-y-4"> + <div className="space-y-4"> + <h4 className="font-medium">임포트 옵션</h4> + + {hasDuplicates && ( + <div className="space-y-3"> + <div className="flex items-center space-x-2"> + <Checkbox + id="skip-duplicates" + checked={importOptions.skipDuplicates} + onCheckedChange={(checked) => + setImportOptions(prev => ({ + ...prev, + skipDuplicates: !!checked, + updateExisting: false, // 상호 배타적 + })) + } + /> + <Label htmlFor="skip-duplicates" className="text-sm"> + 중복 데이터 건너뛰기 + </Label> + </div> + + <div className="flex items-center space-x-2"> + <Checkbox + id="update-existing" + checked={importOptions.updateExisting} + onCheckedChange={(checked) => + setImportOptions(prev => ({ + ...prev, + updateExisting: !!checked, + skipDuplicates: false, // 상호 배타적 + })) + } + /> + <Label htmlFor="update-existing" className="text-sm"> + 기존 데이터 업데이트 (덮어쓰기) + </Label> + </div> + + <div className="p-3 border border-yellow-200 rounded-lg bg-yellow-50 text-sm"> + <p className="text-yellow-800"> + <strong>주의:</strong> 기존 데이터 업데이트를 선택하면 해당 평가표의 모든 평가항목과 답변옵션이 교체됩니다. + </p> + </div> + </div> + )} + + <Button + onClick={handleImport} + disabled={isPending || (hasDuplicates && !importOptions.skipDuplicates && !importOptions.updateExisting)} + className="w-full" + > + {isPending ? '임포트 중...' : '데이터 임포트 실행'} + </Button> + </div> + </TabsContent> + </Tabs> + </div> + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={handleClose}> + 취소 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/esg-check-list/table/esg-table.tsx b/lib/esg-check-list/table/esg-table.tsx new file mode 100644 index 00000000..2ff0f568 --- /dev/null +++ b/lib/esg-check-list/table/esg-table.tsx @@ -0,0 +1,236 @@ +"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 { getEsgEvaluations } from "../service" +import { getColumns } from "./esg-evaluations-table-columns" +import { EsgEvaluationsTableToolbarActions } from "./esg-evaluations-table-toolbar-actions" +import { EsgEvaluationDetailsSheet } from "./esg-evaluation-details-sheet" +import { EsgEvaluationFormSheet } from "./esg-evaluation-form-sheet" +import { EsgEvaluationBatchDeleteDialog } from "./esg-evaluation-delete-dialog" +import { EsgEvaluationsView } from "@/db/schema" +import { useRouter } from "next/navigation" + +interface EsgEvaluationsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getEsgEvaluations>>, + ] + > +} + +export function EsgEvaluationsTable({ promises }: EsgEvaluationsTableProps) { + // 1. 데이터 로딩 상태 관리 + const [isLoading, setIsLoading] = React.useState(true) + const [tableData, setTableData] = React.useState<{ + data: EsgEvaluationsView[] + pageCount: number + }>({ data: [], pageCount: 0 }) + const router = useRouter() + + + console.log(tableData) + // 2. 행 액션 상태 관리 + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<EsgEvaluationsView> | null>(null) + + // 3. 새 평가표 생성 상태 관리 + const [isCreateFormOpen, setIsCreateFormOpen] = React.useState(false) + + // 4. Promise 해결을 useEffect로 처리 + React.useEffect(() => { + promises + .then(([result]) => { + setTableData(result) + setIsLoading(false) + }) + // .catch((error) => { + // console.error('Failed to load ESG evaluations:', error) + // setIsLoading(false) + // }) + }, [promises]) + + // 5. 컬럼 정의 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 6. 필터 필드 정의 + const filterFields: DataTableFilterField<EsgEvaluationsView>[] = [ + { + id: "category", + label: "분류", + placeholder: "분류 선택...", + }, + { + id: "isActive", + label: "상태", + placeholder: "상태 선택...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<EsgEvaluationsView>[] = [ + { + id: "serialNumber", + label: "시리얼번호", + type: "text", + }, + { + id: "category", + label: "분류", + type: "text", + }, + { + id: "inspectionItem", + label: "점검항목", + type: "text", + }, + { + id: "totalEvaluationItems", + label: "평가항목 수", + type: "number", + }, + { + id: "totalAnswerOptions", + label: "답변옵션 수", + type: "number", + }, + { + id: "maxPossibleScore", + label: "최대점수", + type: "number", + }, + { + id: "isActive", + label: "상태", + type: "select", + options: [ + { label: "활성", value: "true" }, + { label: "비활성", value: "false" }, + ], + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + { + id: "updatedAt", + label: "수정일", + type: "date", + }, + ] + + // 7. 데이터 테이블 설정 + 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, + }) + + // 8. 데이터 새로고침 함수 + const handleRefresh = React.useCallback(() => { + setIsLoading(true) + router.refresh() + }, []) + + // 9. 새 평가표 생성 성공 핸들러 + const handleCreateSuccess = React.useCallback(() => { + setIsCreateFormOpen(false) + handleRefresh() + }, [handleRefresh]) + + // 10. 평가표 수정 성공 핸들러 + const handleEditSuccess = React.useCallback(() => { + setRowAction(null) + handleRefresh() + }, [handleRefresh]) + + // 11. 평가표 삭제 성공 핸들러 + const handleDeleteSuccess = React.useCallback(() => { + setRowAction(null) + table.resetRowSelection() + + handleRefresh() + }, [handleRefresh, table]) + + // 12. 로딩 상태 표시 + 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">ESG 평가표를 불러오는 중...</span> + </div> + ) + } + + return ( + <> + {/* 메인 테이블 */} + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <EsgEvaluationsTableToolbarActions + table={table} + onCreateNew={() => setIsCreateFormOpen(true)} + onRefresh={handleRefresh} + /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 상세보기 시트 */} + <EsgEvaluationDetailsSheet + open={rowAction?.type === "view"} + onOpenChange={() => setRowAction(null)} + evaluationId={rowAction?.row.original.id ?? null} + /> + + {/* 수정 폼 시트 */} + <EsgEvaluationFormSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + evaluation={rowAction?.row.original ?? null} + onSuccess={handleEditSuccess} + /> + + {/* 새 평가표 생성 폼 시트 */} + <EsgEvaluationFormSheet + open={isCreateFormOpen} + onOpenChange={setIsCreateFormOpen} + evaluation={null} + onSuccess={handleCreateSuccess} + /> + + {/* 삭제 확인 다이얼로그 */} + <EsgEvaluationBatchDeleteDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + evaluations={rowAction?.row.original ? [rowAction.row.original] : []} + onSuccess={handleDeleteSuccess} + /> + </> + ) +} diff --git a/lib/esg-check-list/table/excel-actions.tsx b/lib/esg-check-list/table/excel-actions.tsx new file mode 100644 index 00000000..7f38b600 --- /dev/null +++ b/lib/esg-check-list/table/excel-actions.tsx @@ -0,0 +1,233 @@ +// @/lib/esg-check-list/excel-actions.ts +"use server" + +import db from "@/db/db" +import { + esgEvaluations, + esgEvaluationItems, + esgAnswerOptions +} from "@/db/schema" +import { eq, inArray } from "drizzle-orm" +import type { ParsedExcelData } from "./excel-utils" + +export interface ImportOptions { + skipDuplicates?: boolean // 중복 시 스킵 + updateExisting?: boolean // 기존 데이터 업데이트 +} + +export interface ImportResult { + success: boolean + message: string + details: { + evaluationsCreated: number + evaluationsUpdated: number + evaluationsSkipped: number + itemsCreated: number + optionsCreated: number + errors: string[] + } +} + +export async function importEsgDataFromExcel( + data: ParsedExcelData, + options: ImportOptions = {} +): Promise<ImportResult> { + const result: ImportResult = { + success: false, + message: '', + details: { + evaluationsCreated: 0, + evaluationsUpdated: 0, + evaluationsSkipped: 0, + itemsCreated: 0, + optionsCreated: 0, + errors: [] + } + } + + try { + await db.transaction(async (tx) => { + // 1. 기존 평가표 확인 + const existingSerials = data.evaluations.map(e => e.serialNumber) + const existingEvaluations = await tx + .select() + .from(esgEvaluations) + .where(inArray(esgEvaluations.serialNumber, existingSerials)) + + const existingSerialNumbers = existingEvaluations.map(e => e.serialNumber) + + // 2. 평가표 처리 + for (const evaluation of data.evaluations) { + const exists = existingSerialNumbers.includes(evaluation.serialNumber) + + if (exists) { + if (options.skipDuplicates) { + result.details.evaluationsSkipped++ + continue + } else if (options.updateExisting) { + // 기존 데이터 업데이트 + await tx + .update(esgEvaluations) + .set({ + category: evaluation.category, + inspectionItem: evaluation.inspectionItem, + updatedAt: new Date(), + }) + .where(eq(esgEvaluations.serialNumber, evaluation.serialNumber)) + + // 기존 평가항목과 답변옵션 삭제 + const existingEvaluation = existingEvaluations.find(e => e.serialNumber === evaluation.serialNumber) + if (existingEvaluation) { + await tx + .delete(esgEvaluationItems) + .where(eq(esgEvaluationItems.esgEvaluationId, existingEvaluation.id)) + // 답변옵션은 CASCADE DELETE로 자동 삭제됨 + } + + result.details.evaluationsUpdated++ + } else { + result.details.errors.push(`시리얼번호 '${evaluation.serialNumber}'가 이미 존재합니다.`) + continue + } + } else { + // 새 평가표 생성 + await tx + .insert(esgEvaluations) + .values({ + serialNumber: evaluation.serialNumber, + category: evaluation.category, + inspectionItem: evaluation.inspectionItem, + }) + + result.details.evaluationsCreated++ + } + } + + // 3. 최신 평가표 목록 다시 조회 (새로 생성되거나 업데이트된 것들 포함) + const currentEvaluations = await tx + .select() + .from(esgEvaluations) + .where(inArray(esgEvaluations.serialNumber, existingSerials)) + + const evaluationMap = new Map( + currentEvaluations.map(e => [e.serialNumber, e.id]) + ) + + // 4. 평가항목 처리 + const itemsToInsert = [] + for (const item of data.evaluationItems) { + const evaluationId = evaluationMap.get(item.serialNumber) + + if (!evaluationId) { + result.details.errors.push( + `평가항목의 시리얼번호 '${item.serialNumber}'에 해당하는 평가표를 찾을 수 없습니다.` + ) + continue + } + + itemsToInsert.push({ + esgEvaluationId: evaluationId, + evaluationItem: item.evaluationItem, + evaluationItemDescription: item.evaluationItemDescription, + orderIndex: item.orderIndex, + }) + } + + if (itemsToInsert.length > 0) { + const insertedItems = await tx + .insert(esgEvaluationItems) + .values(itemsToInsert) + .returning() + + result.details.itemsCreated = insertedItems.length + + // 5. 답변옵션 처리 + const itemMap = new Map() + for (const insertedItem of insertedItems) { + const originalItem = itemsToInsert.find( + item => item.esgEvaluationId === insertedItem.esgEvaluationId && + item.evaluationItem === insertedItem.evaluationItem + ) + if (originalItem) { + const evaluation = currentEvaluations.find(e => e.id === originalItem.esgEvaluationId) + if (evaluation) { + const key = `${evaluation.serialNumber}:${originalItem.evaluationItem}` + itemMap.set(key, insertedItem.id) + } + } + } + + const optionsToInsert = [] + for (const option of data.answerOptions) { + const key = `${option.serialNumber}:${option.evaluationItem}` + const itemId = itemMap.get(key) + + if (!itemId) { + result.details.errors.push( + `답변옵션의 평가항목 '${option.evaluationItem}'을 찾을 수 없습니다.` + ) + continue + } + + optionsToInsert.push({ + esgEvaluationItemId: itemId, + answerText: option.answerText, + score: option.score.toString(), + orderIndex: option.orderIndex, + }) + } + + if (optionsToInsert.length > 0) { + const insertedOptions = await tx + .insert(esgAnswerOptions) + .values(optionsToInsert) + .returning() + + result.details.optionsCreated = insertedOptions.length + } + } + }) + + // 결과 메시지 생성 + const { details } = result + const totalProcessed = details.evaluationsCreated + details.evaluationsUpdated + details.evaluationsSkipped + + if (details.errors.length === 0) { + result.success = true + result.message = `성공적으로 처리되었습니다. 평가표 ${totalProcessed}개 (생성: ${details.evaluationsCreated}, 업데이트: ${details.evaluationsUpdated}, 스킵: ${details.evaluationsSkipped}), 평가항목 ${details.itemsCreated}개, 답변옵션 ${details.optionsCreated}개` + } else if (details.evaluationsCreated > 0 || details.evaluationsUpdated > 0) { + result.success = true + result.message = `부분적으로 성공했습니다. ${details.errors.length}개의 오류가 있었습니다.` + } else { + result.success = false + result.message = `임포트에 실패했습니다. ${details.errors.length}개의 오류가 발생했습니다.` + } + + + return result + + } catch (error) { + console.error('Excel import error:', error) + + result.success = false + result.message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + result.details.errors.push(result.message) + + return result + } +} + +// 중복 확인 함수 +export async function checkDuplicateSerials(serialNumbers: string[]) { + try { + const existing = await db + .select({ serialNumber: esgEvaluations.serialNumber }) + .from(esgEvaluations) + .where(inArray(esgEvaluations.serialNumber, serialNumbers)) + + return existing.map(e => e.serialNumber) + } catch (error) { + console.error('Error checking duplicates:', error) + return [] + } +}
\ No newline at end of file diff --git a/lib/esg-check-list/table/excel-utils.tsx b/lib/esg-check-list/table/excel-utils.tsx new file mode 100644 index 00000000..77b66a8b --- /dev/null +++ b/lib/esg-check-list/table/excel-utils.tsx @@ -0,0 +1,304 @@ +// @/lib/esg-check-list/excel-utils.ts +import * as ExcelJS from 'exceljs'; +import { saveAs } from 'file-saver'; + +// ==================================================================== +// 타입 정의 +// ==================================================================== + +export interface ExcelEvaluation { + serialNumber: string; + category: string; + inspectionItem: string; +} + +export interface ExcelEvaluationItem { + serialNumber: string; + evaluationItem: string; + evaluationItemDescription?: string; + orderIndex: number; +} + +export interface ExcelAnswerOption { + serialNumber: string; + evaluationItem: string; + answerText: string; + score: number; + orderIndex: number; +} + +export interface ParsedExcelData { + evaluations: ExcelEvaluation[]; + evaluationItems: ExcelEvaluationItem[]; + answerOptions: ExcelAnswerOption[]; +} + +// ==================================================================== +// 템플릿 다운로드 +// ==================================================================== + +export async function downloadEsgTemplate() { + const workbook = new ExcelJS.Workbook(); + + // 시트 1: 평가표 기본 정보 + const evaluationsSheet = workbook.addWorksheet('평가표'); + evaluationsSheet.columns = [ + { header: '시리얼번호*', key: 'serialNumber', width: 15 }, + { header: '분류*', key: 'category', width: 20 }, + { header: '점검항목*', key: 'inspectionItem', width: 50 }, + ]; + + // 예시 데이터 + evaluationsSheet.addRows([ + { serialNumber: 'P-1', category: '정보공시', inspectionItem: 'ESG 정보공시 형식' }, + { serialNumber: 'E-1', category: '환경 (Environmental)', inspectionItem: '환경경영 체계 ' }, + ]); + + // 헤더 스타일링 + const evaluationsHeaderRow = evaluationsSheet.getRow(1); + evaluationsHeaderRow.font = { bold: true }; + evaluationsHeaderRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE6F3FF' } + }; + + // 시트 2: 평가항목 + const itemsSheet = workbook.addWorksheet('평가항목'); + itemsSheet.columns = [ + { header: '시리얼번호*', key: 'serialNumber', width: 15 }, + { header: '평가항목*', key: 'evaluationItem', width: 40 }, + { header: '평가항목설명', key: 'evaluationItemDescription', width: 50 }, + { header: '순서*', key: 'orderIndex', width: 10 }, + ]; + + // 예시 데이터 + itemsSheet.addRows([ + { + serialNumber: 'P-1', + evaluationItem: 'ESG 보고서 작성 여부', + evaluationItemDescription: '연간 ESG 보고서를 작성하고 공시하는지 확인', + orderIndex: 1 + }, + { + serialNumber: 'P-1', + evaluationItem: '지속가능경영 전략 수립', + evaluationItemDescription: '장기적인 지속가능경영 전략이 수립되어 있는지 확인', + orderIndex: 2 + }, + { + serialNumber: 'P-1', + evaluationItem: '환경경영시스템 인증', + evaluationItemDescription: 'ISO 14001 등 환경경영시스템 인증 보유 여부', + orderIndex: 1 + }, + ]); + + // 헤더 스타일링 + const itemsHeaderRow = itemsSheet.getRow(1); + itemsHeaderRow.font = { bold: true }; + itemsHeaderRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFFEEE6' } + }; + + // 시트 3: 답변옵션 + const optionsSheet = workbook.addWorksheet('답변옵션'); + optionsSheet.columns = [ + { header: '시리얼번호*', key: 'serialNumber', width: 15 }, + { header: '평가항목*', key: 'evaluationItem', width: 40 }, + { header: '답변내용*', key: 'answerText', width: 30 }, + { header: '점수*', key: 'score', width: 10 }, + { header: '순서*', key: 'orderIndex', width: 10 }, + ]; + + // 예시 데이터 + optionsSheet.addRows([ + { serialNumber: 'P-1', evaluationItem: 'ESG 보고서 작성 여부', answerText: '매년 정기적으로 작성', score: 5, orderIndex: 1 }, + { serialNumber: 'P-1', evaluationItem: 'ESG 보고서 작성 여부', answerText: '비정기적으로 작성', score: 3, orderIndex: 2 }, + { serialNumber: 'P-1', evaluationItem: 'ESG 보고서 작성 여부', answerText: '작성하지 않음', score: 0, orderIndex: 3 }, + { serialNumber: 'P-1', evaluationItem: '지속가능경영 전략 수립', answerText: '체계적인 전략 보유', score: 5, orderIndex: 1 }, + { serialNumber: 'P-1', evaluationItem: '지속가능경영 전략 수립', answerText: '기본적인 계획 보유', score: 3, orderIndex: 2 }, + { serialNumber: 'P-1', evaluationItem: '지속가능경영 전략 수립', answerText: '전략 없음', score: 0, orderIndex: 3 }, + ]); + + // 헤더 스타일링 + const optionsHeaderRow = optionsSheet.getRow(1); + optionsHeaderRow.font = { bold: true }; + optionsHeaderRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE6F7E6' } + }; + + // 안내 시트 추가 + const guideSheet = workbook.addWorksheet('사용안내', { state: 'visible' }); + guideSheet.columns = [ + { header: '항목', key: 'item', width: 20 }, + { header: '설명', key: 'description', width: 60 }, + ]; + + guideSheet.addRows([ + { item: '사용 방법', description: '1. 각 시트의 예시 데이터를 참고하여 데이터를 입력해주세요.' }, + { item: '', description: '2. "*" 표시된 필드는 필수 입력 항목입니다.' }, + { item: '', description: '3. 시리얼번호는 모든 시트에서 일관성 있게 사용해야 합니다.' }, + { item: '', description: '4. 순서는 1부터 시작하는 숫자로 입력해주세요.' }, + { item: '주의사항', description: '• 시리얼번호는 고유해야 합니다 (중복 불가)' }, + { item: '', description: '• 평가항목과 답변옵션의 시리얼번호는 평가표 시트에 있어야 합니다' }, + { item: '', description: '• 점수는 숫자만 입력 가능합니다' }, + { item: '', description: '• 순서는 각 그룹 내에서 연속된 숫자여야 합니다' }, + ]); + + // 안내 시트 스타일링 + const guideHeaderRow = guideSheet.getRow(1); + guideHeaderRow.font = { bold: true, size: 12 }; + guideHeaderRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF0F0F0' } + }; + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + + const fileName = `ESG_평가표_템플릿_${new Date().toISOString().split('T')[0]}.xlsx`; + saveAs(blob, fileName); +} + +// ==================================================================== +// Excel 파일 파싱 +// ==================================================================== + +export async function parseEsgExcelFile(file: File): Promise<ParsedExcelData> { + const workbook = new ExcelJS.Workbook(); + const arrayBuffer = await file.arrayBuffer(); + await workbook.xlsx.load(arrayBuffer); + + const result: ParsedExcelData = { + evaluations: [], + evaluationItems: [], + answerOptions: [], + }; + + try { + // 시트 1: 평가표 파싱 + const evaluationsSheet = workbook.getWorksheet('평가표') || workbook.getWorksheet(1); + if (evaluationsSheet) { + evaluationsSheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; // 헤더 스킵 + + const serialNumber = row.getCell(1).value?.toString()?.trim(); + const category = row.getCell(2).value?.toString()?.trim(); + const inspectionItem = row.getCell(3).value?.toString()?.trim(); + + if (serialNumber && category && inspectionItem) { + result.evaluations.push({ + serialNumber, + category, + inspectionItem, + }); + } + }); + } + + // 시트 2: 평가항목 파싱 + const itemsSheet = workbook.getWorksheet('평가항목') || workbook.getWorksheet(2); + if (itemsSheet) { + itemsSheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; // 헤더 스킵 + + const serialNumber = row.getCell(1).value?.toString()?.trim(); + const evaluationItem = row.getCell(2).value?.toString()?.trim(); + const evaluationItemDescription = row.getCell(3).value?.toString()?.trim(); + const orderIndex = parseInt(row.getCell(4).value?.toString() || '0'); + + if (serialNumber && evaluationItem && !isNaN(orderIndex)) { + result.evaluationItems.push({ + serialNumber, + evaluationItem, + evaluationItemDescription, + orderIndex, + }); + } + }); + } + + // 시트 3: 답변옵션 파싱 + const optionsSheet = workbook.getWorksheet('답변옵션') || workbook.getWorksheet(3); + if (optionsSheet) { + optionsSheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; // 헤더 스킵 + + const serialNumber = row.getCell(1).value?.toString()?.trim(); + const evaluationItem = row.getCell(2).value?.toString()?.trim(); + const answerText = row.getCell(3).value?.toString()?.trim(); + const score = parseFloat(row.getCell(4).value?.toString() || '0'); + const orderIndex = parseInt(row.getCell(5).value?.toString() || '0'); + + if (serialNumber && evaluationItem && answerText && !isNaN(score) && !isNaN(orderIndex)) { + result.answerOptions.push({ + serialNumber, + evaluationItem, + answerText, + score, + orderIndex, + }); + } + }); + } + + return result; + } catch (error) { + console.error('Excel parsing error:', error); + throw new Error('Excel 파일을 파싱하는 중 오류가 발생했습니다.'); + } +} + +// ==================================================================== +// 데이터 검증 +// ==================================================================== + +export function validateExcelData(data: ParsedExcelData): string[] { + const errors: string[] = []; + + // 평가표 검증 + if (data.evaluations.length === 0) { + errors.push('평가표 데이터가 없습니다.'); + } + + // 시리얼번호 중복 확인 + const serialNumbers = data.evaluations.map(e => e.serialNumber); + const duplicateSerials = serialNumbers.filter((item, index) => serialNumbers.indexOf(item) !== index); + if (duplicateSerials.length > 0) { + errors.push(`중복된 시리얼번호가 있습니다: ${duplicateSerials.join(', ')}`); + } + + // 평가항목 검증 + for (const item of data.evaluationItems) { + if (!serialNumbers.includes(item.serialNumber)) { + errors.push(`평가항목의 시리얼번호 '${item.serialNumber}'이 평가표에 없습니다.`); + } + } + + // 답변옵션 검증 + for (const option of data.answerOptions) { + if (!serialNumbers.includes(option.serialNumber)) { + errors.push(`답변옵션의 시리얼번호 '${option.serialNumber}'이 평가표에 없습니다.`); + } + + const hasMatchingItem = data.evaluationItems.some( + item => item.serialNumber === option.serialNumber && + item.evaluationItem === option.evaluationItem + ); + + if (!hasMatchingItem) { + errors.push(`답변옵션의 평가항목 '${option.evaluationItem}'이 평가항목 시트에 없습니다.`); + } + } + + return errors; +}
\ No newline at end of file diff --git a/lib/esg-check-list/validation.ts b/lib/esg-check-list/validation.ts new file mode 100644 index 00000000..bcc9b703 --- /dev/null +++ b/lib/esg-check-list/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 { EsgEvaluationsView } from "@/db/schema"; + + +export const getEsgEvaluationsSchema =createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<EsgEvaluationsView>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); + +export type GetEsgEvaluationsSchema = Awaited<ReturnType<typeof getEsgEvaluationsSchema.parse>>
\ No newline at end of file diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts new file mode 100644 index 00000000..62f0f0ef --- /dev/null +++ b/lib/evaluation-target-list/service.ts @@ -0,0 +1,395 @@ +'use server' + +import { and, or, desc, asc, ilike, eq, isNull, sql, count } from "drizzle-orm"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { filterColumns } from "@/lib/filter-columns"; +import db from "@/db/db"; +import { + evaluationTargets, + evaluationTargetReviewers, + evaluationTargetReviews, + users, + vendors, + type EvaluationTargetStatus, + type Division, + type MaterialType, + type DomesticForeign, + EVALUATION_DEPARTMENT_CODES, + EvaluationTargetWithDepartments, + evaluationTargetsWithDepartments +} from "@/db/schema"; +import { GetEvaluationTargetsSchema } from "./validation"; +import { PgTransaction } from "drizzle-orm/pg-core"; + + +export async function selectEvaluationTargetsFromView( + tx: PgTransaction<any, any, any>, + params: { + where?: any; + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(evaluationTargetsWithDepartments) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} + +/** 총 개수 count */ +export async function countEvaluationTargetsFromView( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx + .select({ count: count() }) + .from(evaluationTargetsWithDepartments) + .where(where); + + return res[0]?.count ?? 0; +} + +// ============= 메인 서버 액션도 함께 수정 ============= + +export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + // 고급 필터링 (View 테이블 기준) + const advancedWhere = filterColumns({ + table: evaluationTargetsWithDepartments, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 베이직 필터링 (커스텀 필터) + let basicWhere; + if (input.basicFilters && input.basicFilters.length > 0) { + basicWhere = filterColumns({ + table: evaluationTargetsWithDepartments, + filters: input.basicFilters, + joinOperator: input.basicJoinOperator || "and", + }); + } + + // 전역 검색 (View 테이블 기준) + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(evaluationTargetsWithDepartments.vendorCode, s), + ilike(evaluationTargetsWithDepartments.vendorName, s), + ilike(evaluationTargetsWithDepartments.adminComment, s), + ilike(evaluationTargetsWithDepartments.consolidatedComment, s), + // 담당자 이름으로도 검색 가능 + ilike(evaluationTargetsWithDepartments.orderReviewerName, s), + ilike(evaluationTargetsWithDepartments.procurementReviewerName, s), + ilike(evaluationTargetsWithDepartments.qualityReviewerName, s), + ilike(evaluationTargetsWithDepartments.designReviewerName, s), + ilike(evaluationTargetsWithDepartments.csReviewerName, s) + ); + } + + const finalWhere = and(advancedWhere, basicWhere, globalWhere); + + // 정렬 (View 테이블 기준) + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + const column = evaluationTargetsWithDepartments[item.id as keyof typeof evaluationTargetsWithDepartments]; + return item.desc ? desc(column) : asc(column); + }) + : [desc(evaluationTargetsWithDepartments.createdAt)]; + + // 데이터 조회 - View 테이블 사용 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectEvaluationTargetsFromView(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countEvaluationTargetsFromView(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount, total }; + } catch (err) { + console.error("Error in getEvaluationTargets:", err); + return { data: [], pageCount: 0 }; + } +} + +// ============= 개별 조회 함수도 업데이트 ============= + +export async function getEvaluationTargetById(id: number): Promise<EvaluationTargetWithDepartments | null> { + try { + const results = await db.transaction(async (tx) => { + return await selectEvaluationTargetsFromView(tx, { + where: eq(evaluationTargetsWithDepartments.id, id), + limit: 1, + }); + }); + + return results[0] || null; + } catch (err) { + console.error("Error in getEvaluationTargetById:", err); + return null; + } +} + +// 통계 조회도 View 기반으로 변경 +export async function getEvaluationTargetsStats(evaluationYear: number) { + try { + const stats = await db.transaction(async (tx) => { + const result = await tx + .select({ + total: count(), + pending: sql<number>`sum(case when status = 'PENDING' then 1 else 0 end)`, + confirmed: sql<number>`sum(case when status = 'CONFIRMED' then 1 else 0 end)`, + excluded: sql<number>`sum(case when status = 'EXCLUDED' then 1 else 0 end)`, + consensusTrue: sql<number>`sum(case when consensus_status = true then 1 else 0 end)`, + consensusFalse: sql<number>`sum(case when consensus_status = false then 1 else 0 end)`, + consensusNull: sql<number>`sum(case when consensus_status is null then 1 else 0 end)`, + oceanDivision: sql<number>`sum(case when division = 'OCEAN' then 1 else 0 end)`, + shipyardDivision: sql<number>`sum(case when division = 'SHIPYARD' then 1 else 0 end)`, + }) + .from(evaluationTargetsWithDepartments) + .where(eq(evaluationTargetsWithDepartments.evaluationYear, evaluationYear)); + + return result[0]; + }); + + return stats; + } catch (err) { + console.error("Error in getEvaluationTargetsStats:", err); + return null; + } +} + + +// ============= 수동 생성 관련 서버 액션 ============= + +// 평가 대상 수동 생성 인터페이스 +export interface CreateEvaluationTargetInput { + evaluationYear: number; + division: Division; + vendorId: number; + materialType: MaterialType; + adminComment?: string; + // 각 부서별 담당자 지정 + reviewers: { + departmentCode: keyof typeof EVALUATION_DEPARTMENT_CODES; + reviewerUserId: number; + }[]; +} + +// 평가 대상 수동 생성 +// service.ts 파일의 CreateEvaluationTargetInput 타입 수정 +export interface CreateEvaluationTargetInput { + evaluationYear: number + division: "OCEAN" | "SHIPYARD" + vendorId: number + materialType: "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK" + adminComment?: string + // ✅ 추가된 L/D 클레임 필드들 + ldClaimCount?: number + ldClaimAmount?: number + ldClaimCurrency?: "KRW" | "USD" | "EUR" | "JPY" + reviewers: Array<{ + departmentCode: string + reviewerUserId: number + }> +} + +// createEvaluationTarget 함수 수정 +// service.ts 수정 +export async function createEvaluationTarget( + input: CreateEvaluationTargetInput, + createdBy: number +) { + + console.log(input,"input") + try { + return await db.transaction(async (tx) => { + // 벤더 정보 조회 (기존과 동일) + const vendor = await tx + .select({ + id: vendors.id, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + country: vendors.country, + }) + .from(vendors) + .where(eq(vendors.id, input.vendorId)) + .limit(1); + + if (!vendor.length) { + throw new Error("벤더를 찾을 수 없습니다."); + } + + const vendorInfo = vendor[0]; + + // 중복 체크 (기존과 동일) + const existing = await tx + .select({ id: evaluationTargets.id }) + .from(evaluationTargets) + .where( + and( + eq(evaluationTargets.evaluationYear, input.evaluationYear), + eq(evaluationTargets.vendorId, input.vendorId), + eq(evaluationTargets.materialType, input.materialType) + ) + ) + .limit(1); + + if (existing.length > 0) { + throw new Error("이미 동일한 평가 대상이 존재합니다."); + } + + // 평가 대상 생성 (기존과 동일) + const newEvaluationTarget = await tx + .insert(evaluationTargets) + .values({ + evaluationYear: input.evaluationYear, + division: input.division, + vendorId: input.vendorId, + vendorCode: vendorInfo.vendorCode || "", + vendorName: vendorInfo.vendorName, + domesticForeign: vendorInfo.country === "KR" ? "DOMESTIC" : "FOREIGN", + materialType: input.materialType, + status: "PENDING", + adminComment: input.adminComment, + adminUserId: createdBy, + ldClaimCount: input.ldClaimCount || 0, + ldClaimAmount: input.ldClaimAmount?.toString() || "0", + ldClaimCurrency: input.ldClaimCurrency || "KRW", + }) + .returning({ id: evaluationTargets.id }); + + const evaluationTargetId = newEvaluationTarget[0].id; + + // ✅ 담당자들 지정 (departmentNameFrom 추가) + if (input.reviewers && input.reviewers.length > 0) { + // 담당자들의 부서 정보 조회 + const reviewerIds = input.reviewers.map(r => r.reviewerUserId); + const reviewerInfos = await tx + .select({ + id: users.id, + departmentName: users.departmentName, // users 테이블에 부서명 필드가 있다고 가정 + }) + .from(users) + .where(sql`${users.id} = ANY(${reviewerIds})`); + + const reviewerAssignments = input.reviewers.map((reviewer) => { + const reviewerInfo = reviewerInfos.find(info => info.id === reviewer.reviewerUserId); + + return { + evaluationTargetId, + departmentCode: reviewer.departmentCode, + departmentNameFrom: reviewerInfo?.departmentName || null, // ✅ 실제 부서명 저장 + reviewerUserId: reviewer.reviewerUserId, + assignedBy: createdBy, + }; + }); + + await tx.insert(evaluationTargetReviewers).values(reviewerAssignments); + } + + return { + success: true, + evaluationTargetId, + message: "평가 대상이 성공적으로 생성되었습니다.", + }; + }); + } catch (error) { + console.error("Error creating evaluation target:", error); + return { + success: false, + error: error instanceof Error ? error.message : "평가 대상 생성 중 오류가 발생했습니다.", + }; + } +} + +// 담당자 목록 조회 시 부서 정보도 함께 반환 +export async function getAvailableReviewers(departmentCode?: string) { + try { + const reviewers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + // departmentName: "API로 추후", // ✅ 부서명도 반환 + }) + .from(users) + .orderBy(users.name) + .limit(100); + + return reviewers; + } catch (error) { + console.error("Error fetching available reviewers:", error); + return []; + } +} + +// 사용 가능한 벤더 목록 조회 +export async function getAvailableVendors(search?: string) { + try { + let query = db + .select({ + id: vendors.id, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + status: vendors.status, + }) + .from(vendors) + .where( + and( + // 활성 상태인 벤더만 + // eq(vendors.status, "ACTIVE"), + // 검색어가 있으면 적용 + search + ? or( + ilike(vendors.vendorCode, `%${search}%`), + ilike(vendors.vendorName, `%${search}%`) + ) + : undefined + ) + ) + .orderBy(vendors.vendorName) + .limit(100); + + return await query; + } catch (error) { + console.error("Error fetching available vendors:", error); + return []; + } +} + + +// 부서 정보 조회 (상수에서) +export async function getDepartmentInfo() { + return Object.entries(EVALUATION_DEPARTMENT_CODES).map(([key, value]) => { + const departmentNames = { + ORDER_EVAL: "발주 평가 담당", + PROCUREMENT_EVAL: "조달 평가 담당", + QUALITY_EVAL: "품질 평가 담당", + DESIGN_EVAL: "설계 평가 담당", + CS_EVAL: "CS 평가 담당", + }; + + return { + code: value, + name: departmentNames[key as keyof typeof departmentNames], + key, + }; + }); +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx new file mode 100644 index 00000000..15837733 --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -0,0 +1,452 @@ +"use client" + +import * as React from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeftClose, PanelLeftOpen } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +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 { getEvaluationTargets, getEvaluationTargetsStats } from "../service" +import { cn } from "@/lib/utils" +import { useTablePresets } from "@/components/data-table/use-table-presets" +import { TablePresetManager } from "@/components/data-table/data-table-preset" +import { useMemo } from "react" +import { getEvaluationTargetsColumns } from "./evaluation-targets-columns" +import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions" +import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet" +import { EvaluationTargetWithDepartments } from "@/db/schema" + +interface EvaluationTargetsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]> + evaluationYear: number + className?: string +} + +// 통계 카드 컴포넌트 (클라이언트 컴포넌트용) +function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) { + const [stats, setStats] = React.useState<any>(null) + const [isLoading, setIsLoading] = React.useState(true) + const [error, setError] = React.useState<string | null>(null) + + React.useEffect(() => { + let isMounted = true + + async function fetchStats() { + try { + setIsLoading(true) + setError(null) + const statsData = await getEvaluationTargetsStats(evaluationYear) + + if (isMounted) { + setStats(statsData) + } + } catch (err) { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to fetch stats') + console.error('Error fetching evaluation targets stats:', err) + } + } finally { + if (isMounted) { + setIsLoading(false) + } + } + } + + fetchStats() + + return () => { + isMounted = false + } + }, []) + + if (isLoading) { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + {Array.from({ length: 4 }).map((_, i) => ( + <Card key={i}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <Skeleton className="h-4 w-20" /> + </CardHeader> + <CardContent> + <Skeleton className="h-8 w-16" /> + </CardContent> + </Card> + ))} + </div> + ) + } + + if (error) { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6"> + <div className="text-center text-sm text-muted-foreground"> + 통계 데이터를 불러올 수 없습니다: {error} + </div> + </CardContent> + </Card> + </div> + ) + } + + if (!stats) { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6"> + <div className="text-center text-sm text-muted-foreground"> + 통계 데이터가 없습니다. + </div> + </CardContent> + </Card> + </div> + ) + } + + const totalTargets = stats.total || 0 + const pendingTargets = stats.pending || 0 + const confirmedTargets = stats.confirmed || 0 + const excludedTargets = stats.excluded || 0 + const consensusRate = totalTargets > 0 ? Math.round(((stats.consensusTrue || 0) / totalTargets) * 100) : 0 + + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + {/* 총 평가 대상 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">총 평가 대상</CardTitle> + <Badge variant="outline">{evaluationYear}년</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{totalTargets.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + 해양 {stats.oceanDivision || 0}개 | 조선 {stats.shipyardDivision || 0}개 + </div> + </CardContent> + </Card> + + {/* 검토 중 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">검토 중</CardTitle> + <Badge variant="secondary">대기</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{pendingTargets.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {totalTargets > 0 ? Math.round((pendingTargets / totalTargets) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + {/* 확정 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">확정</CardTitle> + <Badge variant="default" className="bg-green-600">완료</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{confirmedTargets.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {totalTargets > 0 ? Math.round((confirmedTargets / totalTargets) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + {/* 의견 일치율 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">의견 일치율</CardTitle> + <Badge variant={consensusRate >= 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}> + {consensusRate}% + </Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{consensusRate}%</div> + <div className="text-xs text-muted-foreground mt-1"> + 일치 {stats.consensusTrue || 0}개 | 불일치 {stats.consensusFalse || 0}개 + </div> + </CardContent> + </Card> + </div> + ) +} + +export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null) + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + console.count("E Targets render"); + const router = useRouter() + const searchParams = useSearchParams() + + const containerRef = React.useRef<HTMLDivElement>(null) + const [containerTop, setContainerTop] = React.useState(0) + + const updateContainerBounds = React.useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setContainerTop(rect.top) + } + }, []) + + React.useEffect(() => { + updateContainerBounds() + + const handleResize = () => { + updateContainerBounds() + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', updateContainerBounds) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', updateContainerBounds) + } + }, [updateContainerBounds]) + + const [promiseData] = React.use(promises) + const tableData = promiseData + + console.log("Evaluation Targets Table Data:", { + dataLength: tableData.data?.length, + pageCount: tableData.pageCount, + total: tableData.total, + sampleData: tableData.data?.[0] + }) + + const initialSettings = React.useMemo(() => ({ + page: parseInt(searchParams.get('page') || '1'), + perPage: parseInt(searchParams.get('perPage') || '10'), + sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }], + filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], + joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", + basicFilters: searchParams.get('basicFilters') ? + JSON.parse(searchParams.get('basicFilters')!) : [], + basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", + search: searchParams.get('search') || '', + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: ["actions"] }, + groupBy: [], + expandedRows: [] + }), [searchParams]) + + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + updateClientState, + getCurrentSettings, + } = useTablePresets<EvaluationTargetWithDepartments>('evaluation-targets-table', initialSettings) + + const columns = React.useMemo( + () => getEvaluationTargetsColumns(), + [] + ) + + const filterFields: DataTableFilterField<EvaluationTargetWithDepartments>[] = [ + { id: "vendorCode", label: "벤더 코드" }, + { id: "vendorName", label: "벤더명" }, + { id: "status", label: "상태" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<EvaluationTargetWithDepartments>[] = [ + { id: "evaluationYear", label: "평가년도", type: "number" }, + { id: "division", label: "구분", type: "select", options: [ + { label: "해양", value: "OCEAN" }, + { label: "조선", value: "SHIPYARD" }, + ]}, + { id: "vendorCode", label: "벤더 코드", type: "text" }, + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "domesticForeign", label: "내외자", type: "select", options: [ + { label: "내자", value: "DOMESTIC" }, + { label: "외자", value: "FOREIGN" }, + ]}, + { id: "materialType", label: "자재구분", type: "select", options: [ + { label: "기자재", value: "EQUIPMENT" }, + { label: "벌크", value: "BULK" }, + { label: "기자재/벌크", value: "EQUIPMENT_BULK" }, + ]}, + { id: "status", label: "상태", type: "select", options: [ + { label: "검토 중", value: "PENDING" }, + { label: "확정", value: "CONFIRMED" }, + { label: "제외", value: "EXCLUDED" }, + ]}, + { id: "consensusStatus", label: "의견 일치", type: "select", options: [ + { label: "의견 일치", value: "true" }, + { label: "의견 불일치", value: "false" }, + { label: "검토 중", value: "null" }, + ]}, + { id: "adminComment", label: "관리자 의견", type: "text" }, + { id: "consolidatedComment", label: "종합 의견", type: "text" }, + { id: "confirmedAt", label: "확정일", type: "date" }, + { id: "createdAt", label: "생성일", type: "date" }, + ] + + const currentSettings = useMemo(() => { + return getCurrentSettings() + }, [getCurrentSettings]) + + const initialState = useMemo(() => { + return { + sorting: initialSettings.sort.filter(sortItem => { + const columnExists = columns.some(col => col.accessorKey === sortItem.id || col.id === sortItem.id) + return columnExists + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + const handleSearch = () => { + setIsFilterPanelOpen(false) + } + + const getActiveBasicFilterCount = () => { + try { + const basicFilters = searchParams.get('basicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch (e) { + return 0 + } + } + + const FILTER_PANEL_WIDTH = 400; + + return ( + <> + {/* Filter Panel */} + <div + className={cn( + "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + top: `${containerTop}px`, + height: `calc(100vh - ${containerTop}px)` + }} + > + <div className="h-full"> + <EvaluationTargetFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} + /> + </div> + </div> + + {/* Main Content Container */} + <div + ref={containerRef} + className={cn("relative w-full overflow-hidden", className)} + > + <div className="flex w-full h-full"> + <div + className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px' + }} + > + {/* Header Bar */} + <div className="flex items-center justify-between p-4 bg-background shrink-0"> + <div className="flex items-center gap-3"> + <Button + variant="outline" + size="sm" + type='button' + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} + {getActiveBasicFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveBasicFilterCount()} + </span> + )} + </Button> + </div> + + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || tableData.data.length}건</span> + )} + </div> + </div> + + {/* 통계 카드들 */} + <div className="px-4"> + <EvaluationTargetsStats evaluationYear={evaluationYear} /> + </div> + + {/* Table Content Area */} + <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 500px)' }}> + <div className="h-full w-full"> + <DataTable table={table} className="h-full"> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <TablePresetManager<EvaluationTargetWithDepartments> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <EvaluationTargetsTableToolbarActions table={table} /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + </div> + </div> + </div> + </div> + </div> + </> + ) +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx new file mode 100644 index 00000000..b1e19434 --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx @@ -0,0 +1,345 @@ +"use client"; +import * as React from "react"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Pencil, Eye, MessageSquare, Check, X } from "lucide-react"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import { EvaluationTargetWithDepartments } from "@/db/schema"; + +// 상태별 색상 매핑 +const getStatusBadgeVariant = (status: string) => { + switch (status) { + case "PENDING": + return "secondary"; + case "CONFIRMED": + return "default"; + case "EXCLUDED": + return "destructive"; + default: + return "outline"; + } +}; + +// 의견 일치 여부 배지 +const getConsensusBadge = (consensusStatus: boolean | null) => { + if (consensusStatus === null) { + return <Badge variant="outline">검토 중</Badge>; + } + if (consensusStatus === true) { + return <Badge variant="default" className="bg-green-600">의견 일치</Badge>; + } + return <Badge variant="destructive">의견 불일치</Badge>; +}; + +// 구분 배지 +const getDivisionBadge = (division: string) => { + return ( + <Badge variant={division === "OCEAN" ? "default" : "secondary"}> + {division === "OCEAN" ? "해양" : "조선"} + </Badge> + ); +}; + +// 자재구분 배지 +const getMaterialTypeBadge = (materialType: string) => { + const typeMap = { + EQUIPMENT: "기자재", + BULK: "벌크", + EQUIPMENT_BULK: "기자재/벌크" + }; + return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>; +}; + +// 내외자 배지 +const getDomesticForeignBadge = (domesticForeign: string) => { + return ( + <Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}> + {domesticForeign === "DOMESTIC" ? "내자" : "외자"} + </Badge> + ); +}; + +export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDepartments>[] { + return [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // ░░░ 평가년도 ░░░ + { + accessorKey: "evaluationYear", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />, + cell: ({ row }) => <span className="font-medium">{row.getValue("evaluationYear")}</span>, + size: 100, + }, + + // ░░░ 구분 ░░░ + { + accessorKey: "division", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />, + cell: ({ row }) => getDivisionBadge(row.getValue("division")), + size: 80, + }, + + // ░░░ 벤더 코드 ░░░ + { + accessorKey: "vendorCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더 코드" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.getValue("vendorCode")}</span> + ), + size: 120, + }, + + // ░░░ 벤더명 ░░░ + { + accessorKey: "vendorName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />, + cell: ({ row }) => ( + <div className="truncate max-w-[200px]" title={row.getValue<string>("vendorName")!}> + {row.getValue("vendorName") as string} + </div> + ), + size: 200, + }, + + // ░░░ 내외자 ░░░ + { + accessorKey: "domesticForeign", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />, + cell: ({ row }) => getDomesticForeignBadge(row.getValue("domesticForeign")), + size: 80, + }, + + // ░░░ 자재구분 ░░░ + { + accessorKey: "materialType", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />, + cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")), + size: 120, + }, + + // ░░░ 상태 ░░░ + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />, + cell: ({ row }) => { + const status = row.getValue<string>("status"); + const statusMap = { + PENDING: "검토 중", + CONFIRMED: "확정", + EXCLUDED: "제외" + }; + return ( + <Badge variant={getStatusBadgeVariant(status)}> + {statusMap[status] || status} + </Badge> + ); + }, + size: 100, + }, + + // ░░░ 의견 일치 여부 ░░░ + { + accessorKey: "consensusStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="의견 일치" />, + cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")), + size: 100, + }, + + // ░░░ 담당자 현황 ░░░ + { + id: "reviewers", + header: "담당자 현황", + cell: ({ row }) => { + const reviewers = row.original.reviewers || []; + const totalReviewers = reviewers.length; + const completedReviews = reviewers.filter(r => r.review?.isApproved !== null).length; + const approvedReviews = reviewers.filter(r => r.review?.isApproved === true).length; + + return ( + <div className="flex items-center gap-2"> + <div className="text-xs"> + <span className="text-green-600 font-medium">{approvedReviews}</span> + <span className="text-muted-foreground">/{completedReviews}</span> + <span className="text-muted-foreground">/{totalReviewers}</span> + </div> + {totalReviewers > 0 && ( + <div className="flex gap-1"> + {reviewers.slice(0, 3).map((reviewer, idx) => ( + <div + key={idx} + className={`w-2 h-2 rounded-full ${ + reviewer.review?.isApproved === true + ? "bg-green-500" + : reviewer.review?.isApproved === false + ? "bg-red-500" + : "bg-gray-300" + }`} + title={`${reviewer.departmentCode}: ${ + reviewer.review?.isApproved === true + ? "승인" + : reviewer.review?.isApproved === false + ? "거부" + : "대기중" + }`} + /> + ))} + {totalReviewers > 3 && ( + <span className="text-xs text-muted-foreground">+{totalReviewers - 3}</span> + )} + </div> + )} + </div> + ); + }, + size: 120, + enableSorting: false, + }, + + // ░░░ 관리자 의견 ░░░ + { + accessorKey: "adminComment", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="관리자 의견" />, + cell: ({ row }) => { + const comment = row.getValue<string>("adminComment"); + return comment ? ( + <div className="truncate max-w-[150px]" title={comment}> + {comment} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 150, + }, + + // ░░░ 종합 의견 ░░░ + { + accessorKey: "consolidatedComment", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="종합 의견" />, + cell: ({ row }) => { + const comment = row.getValue<string>("consolidatedComment"); + return comment ? ( + <div className="truncate max-w-[150px]" title={comment}> + {comment} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 150, + }, + + // ░░░ 확정일 ░░░ + { + accessorKey: "confirmedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />, + cell: ({ row }) => { + const confirmedAt = row.getValue<Date>("confirmedAt"); + return confirmedAt ? ( + <span className="text-sm"> + {new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date(confirmedAt))} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + + // ░░░ Actions ░░░ + { + id: "actions", + enableHiding: false, + size: 120, + minSize: 120, + cell: ({ row }) => { + const record = row.original; + const [openDetail, setOpenDetail] = React.useState(false); + const [openEdit, setOpenEdit] = React.useState(false); + const [openRequest, setOpenRequest] = React.useState(false); + + return ( + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setOpenDetail(true)} + aria-label="상세보기" + title="상세보기" + > + <Eye className="size-4" /> + </Button> + + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setOpenEdit(true)} + aria-label="수정" + title="수정" + > + <Pencil className="size-4" /> + </Button> + + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setOpenRequest(true)} + aria-label="의견요청" + title="의견요청" + > + <MessageSquare className="size-4" /> + </Button> + + {/* TODO: 실제 다이얼로그 컴포넌트들로 교체 */} + {openDetail && ( + <div onClick={() => setOpenDetail(false)}> + {/* <EvaluationTargetDetailDialog /> */} + </div> + )} + {openEdit && ( + <div onClick={() => setOpenEdit(false)}> + {/* <EditEvaluationTargetDialog /> */} + </div> + )} + {openRequest && ( + <div onClick={() => setOpenRequest(false)}> + {/* <RequestReviewDialog /> */} + </div> + )} + </div> + ); + }, + }, + ]; +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx new file mode 100644 index 00000000..c14ae83f --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx @@ -0,0 +1,756 @@ +"use client" + +import { useEffect, useTransition, useState, useRef } from "react" +import { useRouter, useParams } from "next/navigation" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Search, X } from "lucide-react" +import { customAlphabet } from "nanoid" +import { parseAsStringEnum, useQueryState } from "nuqs" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { getFiltersStateParser } from "@/lib/parsers" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// 평가 대상 필터 스키마 정의 +const evaluationTargetFilterSchema = z.object({ + evaluationYear: z.string().optional(), + division: z.string().optional(), + status: z.string().optional(), + domesticForeign: z.string().optional(), + materialType: z.string().optional(), + consensusStatus: z.string().optional(), + vendorCode: z.string().optional(), + vendorName: z.string().optional(), + reviewerUserId: z.string().optional(), // 담당자 ID로 필터링 +}) + +// 옵션 정의 +const divisionOptions = [ + { value: "OCEAN", label: "해양" }, + { value: "SHIPYARD", label: "조선" }, +] + +const statusOptions = [ + { value: "PENDING", label: "검토 중" }, + { value: "CONFIRMED", label: "확정" }, + { value: "EXCLUDED", label: "제외" }, +] + +const domesticForeignOptions = [ + { value: "DOMESTIC", label: "내자" }, + { value: "FOREIGN", label: "외자" }, +] + +const materialTypeOptions = [ + { value: "EQUIPMENT", label: "기자재" }, + { value: "BULK", label: "벌크" }, + { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, +] + +const consensusStatusOptions = [ + { value: "true", label: "의견 일치" }, + { value: "false", label: "의견 불일치" }, + { value: "null", label: "검토 중" }, +] + +type EvaluationTargetFilterFormValues = z.infer<typeof evaluationTargetFilterSchema> + +interface EvaluationTargetFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +export function EvaluationTargetFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: EvaluationTargetFilterSheetProps) { + const router = useRouter() + const params = useParams(); + const lng = params ? (params.lng as string) : 'ko'; + + const [isPending, startTransition] = useTransition() + + // 초기화 상태 추가 + const [isInitializing, setIsInitializing] = useState(false) + const lastAppliedFilters = useRef<string>("") + + // nuqs로 URL 상태 관리 + const [filters, setFilters] = useQueryState( + "basicFilters", + getFiltersStateParser().withDefault([]) + ) + + // joinOperator 설정 + const [joinOperator, setJoinOperator] = useQueryState( + "basicJoinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and") + ) + + // 현재 URL의 페이지 파라미터도 가져옴 + const [page, setPage] = useQueryState("page", { defaultValue: "1" }) + + // 폼 상태 초기화 + const form = useForm<EvaluationTargetFilterFormValues>({ + resolver: zodResolver(evaluationTargetFilterSchema), + defaultValues: { + evaluationYear: new Date().getFullYear().toString(), + division: "", + status: "", + domesticForeign: "", + materialType: "", + consensusStatus: "", + vendorCode: "", + vendorName: "", + reviewerUserId: "", + }, + }) + + // URL 필터에서 초기 폼 상태 설정 + useEffect(() => { + const currentFiltersString = JSON.stringify(filters); + + if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { + setIsInitializing(true); + + const formValues = { ...form.getValues() }; + let formUpdated = false; + + filters.forEach(filter => { + if (filter.id in formValues) { + // @ts-ignore - 동적 필드 접근 + formValues[filter.id] = filter.value; + formUpdated = true; + } + }); + + if (formUpdated) { + form.reset(formValues); + lastAppliedFilters.current = currentFiltersString; + } + + setIsInitializing(false); + } + }, [filters, isOpen]) + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + + // 폼 제출 핸들러 + async function onSubmit(data: EvaluationTargetFilterFormValues) { + if (isInitializing) return; + + startTransition(async () => { + try { + const newFilters = [] + + if (data.evaluationYear?.trim()) { + newFilters.push({ + id: "evaluationYear", + value: parseInt(data.evaluationYear.trim()), + type: "number", + operator: "eq", + rowId: generateId() + }) + } + + if (data.division?.trim()) { + newFilters.push({ + id: "division", + value: data.division.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.domesticForeign?.trim()) { + newFilters.push({ + id: "domesticForeign", + value: data.domesticForeign.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.materialType?.trim()) { + newFilters.push({ + id: "materialType", + value: data.materialType.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.consensusStatus?.trim()) { + newFilters.push({ + id: "consensusStatus", + value: data.consensusStatus.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.vendorCode?.trim()) { + newFilters.push({ + id: "vendorCode", + value: data.vendorCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.vendorName?.trim()) { + newFilters.push({ + id: "vendorName", + value: data.vendorName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.reviewerUserId?.trim()) { + newFilters.push({ + id: "reviewerUserId", + value: parseInt(data.reviewerUserId.trim()), + type: "number", + operator: "eq", + rowId: generateId() + }) + } + + // URL 업데이트 + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 기존 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('basicJoinOperator'); + params.delete('page'); + + // 새로운 필터 추가 + if (newFilters.length > 0) { + params.set('basicFilters', JSON.stringify(newFilters)); + params.set('basicJoinOperator', joinOperator); + } + + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("New Evaluation Target Filter URL:", newUrl); + + // 페이지 완전 새로고침 + window.location.href = newUrl; + + lastAppliedFilters.current = JSON.stringify(newFilters); + + if (onSearch) { + console.log("Calling evaluation target onSearch..."); + onSearch(); + } + + console.log("=== Evaluation Target Filter Submit Complete ==="); + } catch (error) { + console.error("평가 대상 필터 적용 오류:", error); + } + }) + } + + // 필터 초기화 핸들러 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + evaluationYear: new Date().getFullYear().toString(), + division: "", + status: "", + domesticForeign: "", + materialType: "", + consensusStatus: "", + vendorCode: "", + vendorName: "", + reviewerUserId: "", + }); + + // URL 초기화 + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + params.delete('basicFilters'); + params.delete('basicJoinOperator'); + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + window.location.href = newUrl; + + lastAppliedFilters.current = ""; + setIsInitializing(false); + } catch (error) { + console.error("평가 대상 필터 초기화 오류:", error); + setIsInitializing(false); + } + } + + if (!isOpen) { + return null; + } + + return ( + <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> + {/* Filter Panel Header */} + <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> + <h3 className="text-lg font-semibold whitespace-nowrap">평가 대상 검색 필터</h3> + <div className="flex items-center gap-2"> + {getActiveFilterCount() > 0 && ( + <Badge variant="secondary" className="px-2 py-1"> + {getActiveFilterCount()}개 필터 적용됨 + </Badge> + )} + </div> + </div> + + {/* Join Operator Selection */} + <div className="px-6 shrink-0"> + <label className="text-sm font-medium">조건 결합 방식</label> + <Select + value={joinOperator} + onValueChange={(value: "and" | "or") => setJoinOperator(value)} + disabled={isInitializing} + > + <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> + <SelectValue placeholder="조건 결합 방식" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> + <SelectItem value="or">하나라도 충족 (OR)</SelectItem> + </SelectContent> + </Select> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> + {/* Scrollable content area */} + <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> + <div className="space-y-4 pt-2"> + + {/* 평가년도 */} + <FormField + control={form.control} + name="evaluationYear" + render={({ field }) => ( + <FormItem> + <FormLabel>평가년도</FormLabel> + <FormControl> + <div className="relative"> + <Input + type="number" + placeholder="평가년도 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("evaluationYear", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 구분 */} + <FormField + control={form.control} + name="division" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="구분 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("division", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {divisionOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 상태 */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>상태</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="상태 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("status", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {statusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 내외자 구분 */} + <FormField + control={form.control} + name="domesticForeign" + render={({ field }) => ( + <FormItem> + <FormLabel>내외자 구분</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="내외자 구분 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("domesticForeign", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {domesticForeignOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재구분 */} + <FormField + control={form.control} + name="materialType" + render={({ field }) => ( + <FormItem> + <FormLabel>자재구분</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="자재구분 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("materialType", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {materialTypeOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 의견 일치 여부 */} + <FormField + control={form.control} + name="consensusStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>의견 일치 여부</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="의견 일치 여부 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("consensusStatus", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {consensusStatusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더 코드 */} + <FormField + control={form.control} + name="vendorCode" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더 코드</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="벤더 코드 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("vendorCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더명 */} + <FormField + control={form.control} + name="vendorName" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더명</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="벤더명 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("vendorName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + </div> + </div> + + {/* Fixed buttons at bottom */} + <div className="p-4 shrink-0"> + <div className="flex gap-2 justify-end"> + <Button + type="button" + variant="outline" + onClick={handleReset} + disabled={isPending || getActiveFilterCount() === 0 || isInitializing} + className="px-4" + > + 초기화 + </Button> + <Button + type="submit" + variant="samsung" + disabled={isPending || isLoading || isInitializing} + className="px-4" + > + <Search className="size-4 mr-2" /> + {isPending || isLoading ? "조회 중..." : "조회"} + </Button> + </div> + </div> + </form> + </Form> + </div> + ) +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx new file mode 100644 index 00000000..3fb47771 --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -0,0 +1,298 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + Plus, + Check, + MessageSquare, + X, + Download, + Upload, + RefreshCw, + Settings +} from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { ManualCreateEvaluationTargetDialog } from "./manual-create-evaluation-target-dialog" +import { EvaluationTargetWithDepartments } from "@/db/schema" + +interface EvaluationTargetsTableToolbarActionsProps { + table: Table<EvaluationTargetWithDepartments> + onRefresh?: () => void +} + +export function EvaluationTargetsTableToolbarActions({ + table, + onRefresh +}: EvaluationTargetsTableToolbarActionsProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [manualCreateDialogOpen, setManualCreateDialogOpen] = React.useState(false) + const router = useRouter() + + // 선택된 행들 + const selectedRows = table.getFilteredSelectedRowModel().rows + const hasSelection = selectedRows.length > 0 + const selectedTargets = selectedRows.map(row => row.original) + + // 선택된 항목들의 상태 분석 + const selectedStats = React.useMemo(() => { + const pending = selectedTargets.filter(t => t.status === "PENDING").length + const confirmed = selectedTargets.filter(t => t.status === "CONFIRMED").length + const excluded = selectedTargets.filter(t => t.status === "EXCLUDED").length + const consensusTrue = selectedTargets.filter(t => t.consensusStatus === true).length + const consensusFalse = selectedTargets.filter(t => t.consensusStatus === false).length + const consensusNull = selectedTargets.filter(t => t.consensusStatus === null).length + + return { + pending, + confirmed, + excluded, + consensusTrue, + consensusFalse, + consensusNull, + canConfirm: pending > 0 && consensusTrue > 0, + canExclude: pending > 0, + canRequestReview: pending > 0 + } + }, [selectedTargets]) + + // ---------------------------------------------------------------- + // 신규 평가 대상 생성 (자동) + // ---------------------------------------------------------------- + const handleAutoGenerate = async () => { + setIsLoading(true) + try { + // TODO: 발주실적에서 자동 추출 API 호출 + toast.success("평가 대상이 자동으로 생성되었습니다.") + router.refresh() + } catch (error) { + console.error('Error auto generating targets:', error) + toast.error("자동 생성 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + // ---------------------------------------------------------------- + // 신규 평가 대상 생성 (수동) + // ---------------------------------------------------------------- + const handleManualCreate = () => { + setManualCreateDialogOpen(true) + } + + // ---------------------------------------------------------------- + // 선택된 항목들 확정 + // ---------------------------------------------------------------- + const handleConfirmSelected = async () => { + if (!hasSelection || !selectedStats.canConfirm) return + + setIsLoading(true) + try { + // TODO: 확정 API 호출 + const confirmableTargets = selectedTargets.filter( + t => t.status === "PENDING" && t.consensusStatus === true + ) + + toast.success(`${confirmableTargets.length}개 항목이 확정되었습니다.`) + table.resetRowSelection() + router.refresh() + } catch (error) { + console.error('Error confirming targets:', error) + toast.error("확정 처리 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + // ---------------------------------------------------------------- + // 선택된 항목들 제외 + // ---------------------------------------------------------------- + const handleExcludeSelected = async () => { + if (!hasSelection || !selectedStats.canExclude) return + + setIsLoading(true) + try { + // TODO: 제외 API 호출 + const excludableTargets = selectedTargets.filter(t => t.status === "PENDING") + + toast.success(`${excludableTargets.length}개 항목이 제외되었습니다.`) + table.resetRowSelection() + router.refresh() + } catch (error) { + console.error('Error excluding targets:', error) + toast.error("제외 처리 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + // ---------------------------------------------------------------- + // 선택된 항목들 의견 요청 + // ---------------------------------------------------------------- + const handleRequestReview = async () => { + if (!hasSelection || !selectedStats.canRequestReview) return + + // TODO: 의견 요청 다이얼로그 열기 + toast.info("의견 요청 다이얼로그를 구현해주세요.") + } + + // ---------------------------------------------------------------- + // Excel 내보내기 + // ---------------------------------------------------------------- + const handleExport = () => { + try { + // TODO: Excel 내보내기 구현 + toast.success("Excel 파일이 다운로드되었습니다.") + } catch (error) { + console.error('Error exporting to Excel:', error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + } + } + + // ---------------------------------------------------------------- + // 새로고침 + // ---------------------------------------------------------------- + const handleRefresh = () => { + if (onRefresh) { + onRefresh() + } else { + router.refresh() + } + toast.success("데이터가 새로고침되었습니다.") + } + + return ( + <> + <div className="flex items-center gap-2"> + {/* 신규 생성 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="default" + size="sm" + className="gap-2" + disabled={isLoading} + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">신규 생성</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem onClick={handleAutoGenerate} disabled={isLoading}> + <RefreshCw className="size-4 mr-2" /> + 자동 생성 (발주실적 기반) + </DropdownMenuItem> + <DropdownMenuItem onClick={handleManualCreate}> + <Plus className="size-4 mr-2" /> + 수동 생성 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* 유틸리티 버튼들 */} + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + <Button + variant="outline" + size="sm" + onClick={handleExport} + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">내보내기</span> + </Button> + + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + className="gap-2" + > + <RefreshCw className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">새로고침</span> + </Button> + </div> + + {/* 선택된 항목 액션 버튼들 */} + {hasSelection && ( + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + {/* 확정 버튼 */} + {selectedStats.canConfirm && ( + <Button + variant="default" + size="sm" + className="gap-2 bg-green-600 hover:bg-green-700" + onClick={handleConfirmSelected} + disabled={isLoading} + > + <Check className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 확정 ({selectedStats.consensusTrue}) + </span> + </Button> + )} + + {/* 제외 버튼 */} + {selectedStats.canExclude && ( + <Button + variant="destructive" + size="sm" + className="gap-2" + onClick={handleExcludeSelected} + disabled={isLoading} + > + <X className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 제외 ({selectedStats.pending}) + </span> + </Button> + )} + + {/* 의견 요청 버튼 */} + {selectedStats.canRequestReview && ( + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={handleRequestReview} + disabled={isLoading} + > + <MessageSquare className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 의견 요청 ({selectedStats.pending}) + </span> + </Button> + )} + </div> + )} + </div> + + {/* 수동 생성 다이얼로그 */} + <ManualCreateEvaluationTargetDialog + open={manualCreateDialogOpen} + onOpenChange={setManualCreateDialogOpen} + /> + + {/* 선택 정보 표시 */} + {hasSelection && ( + <div className="text-xs text-muted-foreground"> + 선택된 {selectedRows.length}개 항목: + 대기중 {selectedStats.pending}개, + 확정 {selectedStats.confirmed}개, + 제외 {selectedStats.excluded}개 + {selectedStats.consensusTrue > 0 && ` | 의견일치 ${selectedStats.consensusTrue}개`} + {selectedStats.consensusFalse > 0 && ` | 의견불일치 ${selectedStats.consensusFalse}개`} + </div> + )} + </> + ) +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx new file mode 100644 index 00000000..5704cba1 --- /dev/null +++ b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx @@ -0,0 +1,772 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Check, ChevronsUpDown, Loader2, Search } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +import { + createEvaluationTarget, + getAvailableVendors, + getAvailableReviewers, + getDepartmentInfo, + type CreateEvaluationTargetInput, +} from "../service" +import { EVALUATION_TARGET_FILTER_OPTIONS, getDefaultEvaluationYear } from "../validation" +import { useSession } from "next-auth/react" + +// 폼 스키마 정의 +const createEvaluationTargetSchema = z.object({ + evaluationYear: z.number().min(2020).max(2030), + division: z.enum(["OCEAN", "SHIPYARD"]), + vendorId: z.number().min(1, "벤더를 선택해주세요"), + materialType: z.enum(["EQUIPMENT", "BULK", "EQUIPMENT_BULK"]), + adminComment: z.string().optional(), + // L/D 클레임 정보 + ldClaimCount: z.number().min(0).optional(), + ldClaimAmount: z.number().min(0).optional(), + ldClaimCurrency: z.enum(["KRW", "USD", "EUR", "JPY"]).optional(), + reviewers: z.array( + z.object({ + departmentCode: z.string(), + reviewerUserId: z.number().min(1, "담당자를 선택해주세요"), + }) + ).min(1, "최소 1명의 담당자를 지정해주세요"), +}) + +type CreateEvaluationTargetFormValues = z.infer<typeof createEvaluationTargetSchema> + +interface ManualCreateEvaluationTargetDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ManualCreateEvaluationTargetDialog({ + open, + onOpenChange, +}: ManualCreateEvaluationTargetDialogProps) { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = React.useState(false) + const { data: session } = useSession() + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : 1; + }, [session]); + + // 벤더 관련 상태 + const [vendors, setVendors] = React.useState<Array<{ id: number, vendorCode: string, vendorName: string }>>([]) + const [vendorSearch, setVendorSearch] = React.useState("") + const [vendorOpen, setVendorOpen] = React.useState(false) + const [isLoadingVendors, setIsLoadingVendors] = React.useState(false) + + // 담당자 관련 상태 + const [reviewers, setReviewers] = React.useState<Array<{ id: number, name: string, email: string }>>([]) + const [isLoadingReviewers, setIsLoadingReviewers] = React.useState(false) + + // 각 부서별 담당자 선택 상태 + const [reviewerSearches, setReviewerSearches] = React.useState<Record<string, string>>({}) + const [reviewerOpens, setReviewerOpens] = React.useState<Record<string, boolean>>({}) + + // 부서 정보 상태 + const [departments, setDepartments] = React.useState<Array<{ code: string, name: string, key: string }>>([]) + + const form = useForm<CreateEvaluationTargetFormValues>({ + resolver: zodResolver(createEvaluationTargetSchema), + defaultValues: { + evaluationYear: getDefaultEvaluationYear(), + division: "OCEAN", + vendorId: 0, + materialType: "EQUIPMENT", + adminComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + reviewers: [], // 초기에는 빈 배열, useEffect에서 설정 + }, + }) + + // 부서 정보 로드 + const loadDepartments = React.useCallback(async () => { + try { + const departmentList = await getDepartmentInfo() + setDepartments(departmentList) + } catch (error) { + console.error("Error loading departments:", error) + toast.error("부서 정보를 불러오는데 실패했습니다.") + } + }, []) // form 의존성 제거 + + // 벤더 목록 로드 + const loadVendors = React.useCallback(async (search?: string) => { + setIsLoadingVendors(true) + try { + const vendorList = await getAvailableVendors(search) + setVendors(vendorList) + } catch (error) { + console.error("Error loading vendors:", error) + toast.error("벤더 목록을 불러오는데 실패했습니다.") + } finally { + setIsLoadingVendors(false) + } + }, []) + + // 담당자 목록 로드 + const loadReviewers = React.useCallback(async () => { + setIsLoadingReviewers(true) + try { + const reviewerList = await getAvailableReviewers() + setReviewers(reviewerList) + } catch (error) { + console.error("Error loading reviewers:", error) + toast.error("담당자 목록을 불러오는데 실패했습니다.") + } finally { + setIsLoadingReviewers(false) + } + }, []) + + // 다이얼로그 열릴 때 데이터 로드 + React.useEffect(() => { + if (open) { + loadDepartments() + loadVendors() + loadReviewers() + } + }, [open]) // 함수 의존성 제거 + + // 부서 정보가 로드되면 reviewers 기본값 설정 + React.useEffect(() => { + if (departments.length > 0 && open) { + const currentReviewers = form.getValues("reviewers") + + // 이미 설정되어 있으면 다시 설정하지 않음 + if (currentReviewers.length === 0) { + const defaultReviewers = departments.map(dept => ({ + departmentCode: dept.code, + reviewerUserId: 0, + })) + form.setValue('reviewers', defaultReviewers) + } + } + }, [departments, open]) // form 의존성 제거하고 조건 추가 + + console.log(departments) + + // 벤더 검색 + React.useEffect(() => { + const timeoutId = setTimeout(() => { + if (vendorSearch || vendorOpen) { + loadVendors(vendorSearch) + } + }, 300) + + return () => clearTimeout(timeoutId) + }, [vendorSearch, vendorOpen]) // loadVendors 의존성 제거 + + // 폼 제출 + async function onSubmit(data: CreateEvaluationTargetFormValues) { + console.log("Form submitted with data:", data) // 디버깅용 + setIsSubmitting(true) + try { + // 담당자가 지정되지 않은 부서 제외 + const validReviewers = data.reviewers.filter(r => r.reviewerUserId > 0) + + if (validReviewers.length === 0) { + toast.error("최소 1명의 담당자를 지정해주세요.") + return + } + + const input: CreateEvaluationTargetInput = { + ...data, + reviewers: validReviewers, + } + + console.log(input,"client") + + const result = await createEvaluationTarget(input, userId) + + if (result.success) { + toast.success(result.message) + onOpenChange(false) + // 폼과 상태 초기화 + form.reset({ + evaluationYear: getDefaultEvaluationYear(), + division: "OCEAN", + vendorId: 0, + materialType: "EQUIPMENT", + adminComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + reviewers: [], + }) + setDepartments([]) + setVendors([]) + setReviewers([]) + setVendorSearch("") + setReviewerSearches({}) + setReviewerOpens({}) + router.refresh() + } else { + toast.error(result.error || "평가 대상 생성에 실패했습니다.") + } + } catch (error) { + console.error("Error creating evaluation target:", error) + toast.error("평가 대상 생성 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 다이얼로그 닫기 핸들러 + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + + // 다이얼로그가 닫힐 때 상태 초기화 + if (!open) { + form.reset({ + evaluationYear: getDefaultEvaluationYear(), + division: "OCEAN", + vendorId: 0, + materialType: "EQUIPMENT", + adminComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + reviewers: [], + }) + setDepartments([]) + setVendors([]) + setReviewers([]) + setVendorSearch("") + setReviewerSearches({}) + setReviewerOpens({}) + } + } + + // 선택된 벤더 정보 + const selectedVendor = vendors.find(v => v.id === form.watch("vendorId")) + + // 현재 선택된 자재구분 + const currentMaterialType = form.watch("materialType") + + // BULK 자재일 때 비활성화할 부서 코드들 + const BULK_DISABLED_DEPARTMENTS = ["DESIGN_EVAL", "CS_EVAL"] // 설계(DES), CS 부서 코드 (실제 코드에 맞게 수정 필요) + + // 담당자 검색 필터링 + const getFilteredReviewers = (search: string) => { + if (!search) return reviewers + return reviewers.filter(reviewer => + reviewer.name.toLowerCase().includes(search.toLowerCase()) || + reviewer.email.toLowerCase().includes(search.toLowerCase()) + ) + } + + // 부서가 비활성화되어야 하는지 확인 + const isDepartmentDisabled = (departmentCode: string) => { + return currentMaterialType === "BULK" && BULK_DISABLED_DEPARTMENTS.includes(departmentCode) + } + + // 자재구분 변경 시 BULK 비활성화 부서들의 담당자 초기화 + React.useEffect(() => { + if (currentMaterialType === "BULK") { + const currentReviewers = form.getValues("reviewers") + const updatedReviewers = currentReviewers.map(reviewer => { + if (BULK_DISABLED_DEPARTMENTS.includes(reviewer.departmentCode)) { + return { ...reviewer, reviewerUserId: 0 } + } + return reviewer + }) + form.setValue("reviewers", updatedReviewers) + } + }, [currentMaterialType, form]) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="max-w-lg flex flex-col h-[90vh]"> + {/* 고정 헤더 */} + <DialogHeader className="flex-shrink-0"> + <DialogTitle>평가 대상 수동 생성</DialogTitle> + <DialogDescription> + 새로운 평가 대상을 수동으로 생성하고 담당자를 지정합니다. + </DialogDescription> + </DialogHeader> + + {/* Form을 전체 콘텐츠를 감싸도록 수정 */} + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col flex-1" + id="evaluation-target-form" + > + {/* 스크롤 가능한 콘텐츠 영역 */} + <div className="flex-1 overflow-y-auto"> + <div className="space-y-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + {/* 평가년도 */} + <FormField + control={form.control} + name="evaluationYear" + render={({ field }) => ( + <FormItem> + <FormLabel>평가년도</FormLabel> + <FormControl> + <Input + type="number" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value))} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 구분 */} + <FormField + control={form.control} + name="division" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {EVALUATION_TARGET_FILTER_OPTIONS.DIVISIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 벤더 선택 */} + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더</FormLabel> + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className="w-full justify-between" + > + {selectedVendor ? ( + <span className="flex items-center gap-2"> + <Badge variant="outline">{selectedVendor.vendorCode}</Badge> + {selectedVendor.vendorName} + </span> + ) : ( + "벤더 선택..." + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput + placeholder="벤더 검색..." + value={vendorSearch} + onValueChange={setVendorSearch} + /> + <CommandList> + <CommandEmpty> + {isLoadingVendors ? ( + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + 로딩 중... + </div> + ) : ( + "검색 결과가 없습니다." + )} + </CommandEmpty> + <CommandGroup> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorCode} ${vendor.vendorName}`} + onSelect={() => { + field.onChange(vendor.id) + setVendorOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + vendor.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex items-center gap-2"> + <Badge variant="outline">{vendor.vendorCode}</Badge> + <span>{vendor.vendorName}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재구분 */} + <FormField + control={form.control} + name="materialType" + render={({ field }) => ( + <FormItem> + <FormLabel>자재구분</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="자재구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {EVALUATION_TARGET_FILTER_OPTIONS.MATERIAL_TYPES.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 관리자 의견 */} + <FormField + control={form.control} + name="adminComment" + render={({ field }) => ( + <FormItem> + <FormLabel>관리자 의견 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="관리자 의견을 입력하세요..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* L/D 클레임 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">L/D 클레임 정보</CardTitle> + <p className="text-sm text-muted-foreground"> + 지연 배송(Late Delivery) 클레임 관련 정보를 입력하세요. + </p> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-3 gap-4"> + {/* 클레임 건수 */} + <FormField + control={form.control} + name="ldClaimCount" + render={({ field }) => ( + <FormItem> + <FormLabel>클레임 건수</FormLabel> + <FormControl> + <Input + type="number" + min="0" + placeholder="0" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 클레임 금액 */} + <FormField + control={form.control} + name="ldClaimAmount" + render={({ field }) => ( + <FormItem> + <FormLabel>클레임 금액</FormLabel> + <FormControl> + <Input + type="number" + min="0" + step="0.01" + placeholder="0" + {...field} + onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 통화단위 */} + <FormField + control={form.control} + name="ldClaimCurrency" + render={({ field }) => ( + <FormItem> + <FormLabel>통화단위</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="통화 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="KRW">KRW (원)</SelectItem> + <SelectItem value="USD">USD (달러)</SelectItem> + <SelectItem value="EUR">EUR (유로)</SelectItem> + <SelectItem value="JPY">JPY (엔)</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + {/* 담당자 지정 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">담당자 지정</CardTitle> + <p className="text-sm text-muted-foreground"> + 각 부서별로 담당자를 지정해주세요. 최소 1명 이상 지정해야 합니다. + </p> + </CardHeader> + <CardContent> + {departments.length === 0 ? ( + <div className="flex items-center justify-center py-8"> + <div className="flex items-center gap-2 text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + 부서 정보를 불러오는 중... + </div> + </div> + ) : ( + <div className="space-y-4"> + {departments.map((department, index) => { + const selectedReviewer = reviewers.find(r => r.id === form.watch(`reviewers.${index}.reviewerUserId`)) + const filteredReviewers = getFilteredReviewers(reviewerSearches[department.code] || "") + const isDisabled = isDepartmentDisabled(department.code) + + return ( + <FormField + key={department.code} + control={form.control} + name={`reviewers.${index}.reviewerUserId`} + render={({ field }) => ( + <FormItem> + <FormLabel className={isDisabled ? "text-muted-foreground" : ""}> + {department.name} + {isDisabled && ( + <span className="text-xs ml-2 text-muted-foreground"> + (벌크 자재 시 비활성화) + </span> + )} + </FormLabel> + <Popover + open={!isDisabled && (reviewerOpens[department.code] || false)} + onOpenChange={(open) => !isDisabled && setReviewerOpens(prev => ({...prev, [department.code]: open}))} + > + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={reviewerOpens[department.code]} + className={cn( + "w-full justify-between", + isDisabled && "opacity-50 cursor-not-allowed" + )} + disabled={isDisabled} + > + {selectedReviewer && !isDisabled ? ( + <span className="flex items-center gap-2"> + <span>{selectedReviewer.name}</span> + <span className="text-xs text-muted-foreground"> + ({selectedReviewer.email}) + </span> + </span> + ) : ( + isDisabled ? "벌크 자재 시 비활성화" : "담당자 선택 (선택사항)" + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput + placeholder="담당자 검색..." + value={reviewerSearches[department.code] || ""} + onValueChange={(value) => setReviewerSearches(prev => ({...prev, [department.code]: value}))} + /> + <CommandList> + <CommandEmpty> + {isLoadingReviewers ? ( + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + 로딩 중... + </div> + ) : ( + "검색 결과가 없습니다." + )} + </CommandEmpty> + <CommandGroup> + <CommandItem + value="선택 안함" + onSelect={() => { + field.onChange(0) + setReviewerOpens(prev => ({...prev, [department.code]: false})) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + field.value === 0 ? "opacity-100" : "opacity-0" + )} + /> + 선택 안함 + </CommandItem> + {filteredReviewers.map((reviewer) => ( + <CommandItem + key={reviewer.id} + value={`${reviewer.name} ${reviewer.email}`} + onSelect={() => { + field.onChange(reviewer.id) + setReviewerOpens(prev => ({...prev, [department.code]: false})) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + reviewer.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex items-center gap-2"> + <span>{reviewer.name}</span> + <span className="text-xs text-muted-foreground"> + ({reviewer.email}) + </span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + ) + })} + </div> + )} + </CardContent> + </Card> + </div> + </div> + + {/* 고정 버튼 영역 */} + <div className="flex-shrink-0 flex justify-end gap-3 pt-4 border-t"> + <Button + type="button" + variant="outline" + onClick={() => handleOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSubmitting} + > + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 생성 + </Button> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/validation.ts b/lib/evaluation-target-list/validation.ts new file mode 100644 index 00000000..e42f536b --- /dev/null +++ b/lib/evaluation-target-list/validation.ts @@ -0,0 +1,169 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + } from "nuqs/server"; + import * as z from "zod"; + + import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; + + // ============= 메인 검색 파라미터 스키마 ============= + + export const searchParamsEvaluationTargetsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<any>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 기본 필터들 + evaluationYear: parseAsInteger.withDefault(new Date().getFullYear()), + division: parseAsString.withDefault(""), + status: parseAsString.withDefault(""), + domesticForeign: parseAsString.withDefault(""), + materialType: parseAsString.withDefault(""), + consensusStatus: parseAsString.withDefault(""), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 베이직 필터 (커스텀 필터 패널용) + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 + search: parseAsString.withDefault(""), + }); + + // ============= 타입 정의 ============= + + export type GetEvaluationTargetsSchema = Awaited< + ReturnType<typeof searchParamsEvaluationTargetsCache.parse> + >; + + export type EvaluationTargetStatus = "PENDING" | "CONFIRMED" | "EXCLUDED"; + export type Division = "OCEAN" | "SHIPYARD"; + export type MaterialType = "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK"; + export type DomesticForeign = "DOMESTIC" | "FOREIGN"; + + // ============= 필터 옵션 상수들 ============= + + export const EVALUATION_TARGET_FILTER_OPTIONS = { + DIVISIONS: [ + { value: "OCEAN", label: "해양" }, + { value: "SHIPYARD", label: "조선" }, + ], + STATUSES: [ + { value: "PENDING", label: "검토 중" }, + { value: "CONFIRMED", label: "확정" }, + { value: "EXCLUDED", label: "제외" }, + ], + DOMESTIC_FOREIGN: [ + { value: "DOMESTIC", label: "내자" }, + { value: "FOREIGN", label: "외자" }, + ], + MATERIAL_TYPES: [ + { value: "EQUIPMENT", label: "기자재" }, + { value: "BULK", label: "벌크" }, + { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, + ], + CONSENSUS_STATUS: [ + { value: "true", label: "의견 일치" }, + { value: "false", label: "의견 불일치" }, + { value: "null", label: "검토 중" }, + ], + } as const; + + // ============= 유효성 검사 함수들 ============= + + export function validateEvaluationYear(year: number): boolean { + const currentYear = new Date().getFullYear(); + return year >= 2020 && year <= currentYear + 1; + } + + export function validateDivision(division: string): division is Division { + return ["OCEAN", "SHIPYARD"].includes(division); + } + + export function validateStatus(status: string): status is EvaluationTargetStatus { + return ["PENDING", "CONFIRMED", "EXCLUDED"].includes(status); + } + + export function validateMaterialType(materialType: string): materialType is MaterialType { + return ["EQUIPMENT", "BULK", "EQUIPMENT_BULK"].includes(materialType); + } + + export function validateDomesticForeign(domesticForeign: string): domesticForeign is DomesticForeign { + return ["DOMESTIC", "FOREIGN"].includes(domesticForeign); + } + + // ============= 기본값 제공 함수들 ============= + + export function getDefaultEvaluationYear(): number { + return new Date().getFullYear(); + } + + export function getDefaultSearchParams(): GetEvaluationTargetsSchema { + return { + flags: [], + page: 1, + perPage: 10, + sort: [{ id: "createdAt", desc: true }], + evaluationYear: getDefaultEvaluationYear(), + division: "", + status: "", + domesticForeign: "", + materialType: "", + consensusStatus: "", + filters: [], + joinOperator: "and", + basicFilters: [], + basicJoinOperator: "and", + search: "", + }; + } + + // ============= 편의 함수들 ============= + + // 상태별 라벨 반환 + export function getStatusLabel(status: EvaluationTargetStatus): string { + const statusMap = { + PENDING: "검토 중", + CONFIRMED: "확정", + EXCLUDED: "제외" + }; + return statusMap[status] || status; + } + + // 구분별 라벨 반환 + export function getDivisionLabel(division: Division): string { + const divisionMap = { + OCEAN: "해양", + SHIPYARD: "조선" + }; + return divisionMap[division] || division; + } + + // 자재구분별 라벨 반환 + export function getMaterialTypeLabel(materialType: MaterialType): string { + const materialTypeMap = { + EQUIPMENT: "기자재", + BULK: "벌크", + EQUIPMENT_BULK: "기자재/벌크" + }; + return materialTypeMap[materialType] || materialType; + } + + // 내외자별 라벨 반환 + export function getDomesticForeignLabel(domesticForeign: DomesticForeign): string { + const domesticForeignMap = { + DOMESTIC: "내자", + FOREIGN: "외자" + }; + return domesticForeignMap[domesticForeign] || domesticForeign; + } + diff --git a/lib/forms/services.ts b/lib/forms/services.ts index 27f2f5c2..021bb767 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -282,7 +282,7 @@ export async function getFormData(formCode: string, contractItemId: number) { const entry = entryRows[0] ?? null; let columns = meta.columns as DataTableColumnJSON[]; - const excludeKeys = ['CLS_ID', 'BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; + const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; columns = columns.filter(col => !excludeKeys.includes(col.key)); columns.forEach((col) => { @@ -382,7 +382,7 @@ export async function getFormData(formCode: string, contractItemId: number) { const entry = entryRows[0] ?? null; let columns = meta.columns as DataTableColumnJSON[]; - const excludeKeys = ['CLS_ID', 'BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; + const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; columns = columns.filter(col => !excludeKeys.includes(col.key)); columns.forEach((col) => { diff --git a/lib/general-check-list/repository.ts b/lib/general-check-list/repository.ts new file mode 100644 index 00000000..100975ab --- /dev/null +++ b/lib/general-check-list/repository.ts @@ -0,0 +1,49 @@ +import { generalEvaluations } from "@/db/schema"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + + +export async function selectGeneralCheckLists( + tx: PgTransaction<any, any, any>, + params: { + where?: any; + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + + const { where, orderBy, offset = 0, limit = 10 } = params; + + return await tx + .select() + .from(generalEvaluations) + .where(where) + .orderBy(...(orderBy ?? [asc(generalEvaluations.createdAt)])) + .offset(offset ?? 0) + .limit(limit ?? 10); +} + +export async function countGeneralCheckList( + tx: PgTransaction<any, any, any>, + where?: any +) { + const result = await tx + .select({ count: count() }) + .from(generalEvaluations) + .where(where); + + return result[0]?.count ?? 0; +}
\ No newline at end of file diff --git a/lib/general-check-list/service.ts b/lib/general-check-list/service.ts new file mode 100644 index 00000000..fde756ea --- /dev/null +++ b/lib/general-check-list/service.ts @@ -0,0 +1,245 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import db from "@/db/db"; + +import { filterColumns } from "@/lib/filter-columns"; + +import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq } from "drizzle-orm"; +import { generalEvaluations} from "@/db/schema"; +import { GetGeneralEvaluationsSchema } from "./validation"; +import { selectGeneralCheckLists , countGeneralCheckList} from "./repository"; + +export async function getGeneralCheckList(input: GetGeneralEvaluationsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + + // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터 + const advancedFilters = input.filters || []; + const advancedJoinOperator = input.joinOperator || "and"; + + + // 고급 필터 조건 생성 + let advancedWhere; + if (advancedFilters.length > 0) { + advancedWhere = filterColumns({ + table: generalEvaluations, + filters: advancedFilters, + joinOperator: advancedJoinOperator, + }); + } + + // 전역 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(generalEvaluations.category, s), + ilike(generalEvaluations.inspectionItem, s), + ilike(generalEvaluations.remarks, s), + ); + } + + // 모든 조건 결합 + let whereConditions = []; + if (advancedWhere) whereConditions.push(advancedWhere); + if (globalWhere) whereConditions.push(globalWhere); + + // 조건이 있을 때만 and() 사용 + const finalWhere = whereConditions.length > 0 + ? and(...whereConditions) + : undefined; + + + // 정렬 조건 - 안전하게 처리 + const orderBy = + input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(generalEvaluations[item.id]) + : asc(generalEvaluations[item.id]) + ) + : [desc(generalEvaluations.updatedAt)] + + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectGeneralCheckLists(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countGeneralCheckList(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + console.error("getRfqs 에러:", err); + + // 에러 세부 정보 더 자세히 로깅 + if (err instanceof Error) { + console.error("에러 메시지:", err.message); + console.error("에러 스택:", err.stack); + + if ('code' in err) { + console.error("SQL 에러 코드:", (err as any).code); + } + } + + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── +export type GeneralEvaluationInput = { + category: string; + inspectionItem: string; + remarks?: string | null; + isActive?: boolean; + }; + + // ───────────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────────── + async function generateSerialNumber(tx: typeof db, category: string) { + const prefix = `GE-${category}-`; + + // 카테고리 내에서 가장 최근 시리얼 찾아서 +1 + const latest = await tx + .select({ serialNumber: generalEvaluations.serialNumber }) + .from(generalEvaluations) + .where(eq(generalEvaluations.category, category)) + .orderBy(desc(generalEvaluations.serialNumber)) + .limit(1); + + let nextSeq = 1; + if (latest.length) { + const parts = latest[0].serialNumber.split("-"); + const last = parts[parts.length - 1]; + const num = parseInt(last, 10); + if (!isNaN(num)) nextSeq = num + 1; + } + + const seqStr = nextSeq.toString().padStart(3, "0"); + return `${prefix}${seqStr}`; + } + // ───────────────────────────────────────────────────────────────────────────── + // CRUD Actions + // ───────────────────────────────────────────────────────────────────────────── + export async function createGeneralEvaluation(input: GeneralEvaluationInput) { + return db.transaction(async (tx) => { + try { + const serialNumber = await generateSerialNumber(tx, input.category); + + const [created] = await tx + .insert(generalEvaluations) + .values({ + serialNumber, + category: input.category, + inspectionItem: input.inspectionItem, + remarks: input.remarks ?? null, + isActive: input.isActive ?? true, + }) + .returning(); + + return { success: true, data: created, message: "체크리스트가 추가되었습니다." }; + } catch (err) { + console.error("createGeneralEvaluation error", err); + return { success: false, message: "추가 중 오류가 발생했습니다." }; + } + }); + } + + export async function updateGeneralEvaluation(id: number, fields: Partial<GeneralEvaluationInput>) { + try { + const [updated] = await db + .update(generalEvaluations) + .set({ ...fields, updatedAt: sql`now()` }) + .where(eq(generalEvaluations.id, id)) + .returning(); + + return { success: true, data: updated, message: "수정되었습니다." }; + } catch (err) { + console.error("updateGeneralEvaluation error", err); + return { success: false, message: "수정 중 오류가 발생했습니다." }; + } + } + + export async function deleteGeneralEvaluations(ids: number[]) { + if (ids.length === 0) return { success: false, message: "삭제할 항목이 없습니다." }; + try { + await db.delete(generalEvaluations).where(inArray(generalEvaluations.id, ids)); + return { success: true, message: `${ids.length}개의 체크리스트가 삭제되었습니다.` }; + } catch (err) { + console.error("deleteGeneralEvaluations error", err); + return { success: false, message: "삭제 중 오류가 발생했습니다." }; + } + } + + // ───────────────────────────────────────────────────────────────────────────── + // Pagination Search (기존 getGeneralCheckList → getGeneralEvaluations) + // ───────────────────────────────────────────────────────────────────────────── + export async function getGeneralEvaluations(input: GetGeneralEvaluationsSchema) { + const offset = (input.page - 1) * input.perPage; + + // 고급 필터 처리 + const advFilters = input.filters ?? []; + const advOperator = input.joinOperator ?? "and"; + const advWhere = advFilters.length + ? filterColumns({ table: generalEvaluations, filters: advFilters, joinOperator: advOperator }) + : undefined; + + // 전역 검색 + const globalWhere = input.search + ? or( + ilike(generalEvaluations.serialNumber, `%${input.search}%`), + ilike(generalEvaluations.category, `%${input.search}%`), + ilike(generalEvaluations.inspectionItem, `%${input.search}%`), + ilike(generalEvaluations.remarks, `%${input.search}%`) + ) + : undefined; + + const whereAll = [advWhere, globalWhere].filter(Boolean); + const finalWhere = whereAll.length ? and(...whereAll) : undefined; + + // 정렬 + const orderBy = + input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(generalEvaluations[item.id]) + : asc(generalEvaluations[item.id]) + ) + : [desc(generalEvaluations.updatedAt)] + + + // 트랜잭션 + const { data, total } = await db.transaction(async (tx) => { + const data = await tx + .select() + .from(generalEvaluations) + .where(finalWhere ?? undefined) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)` }) + .from(generalEvaluations) + .where(finalWhere ?? undefined); + + return { data, total: count }; + }); + + + return { data, pageCount: Math.ceil(total / input.perPage) }; + } +
\ No newline at end of file diff --git a/lib/general-check-list/table/add-check-list-dialog.tsx b/lib/general-check-list/table/add-check-list-dialog.tsx new file mode 100644 index 00000000..5721bd59 --- /dev/null +++ b/lib/general-check-list/table/add-check-list-dialog.tsx @@ -0,0 +1,112 @@ +"use client"; +import * as React from "react"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"; +import { Plus } from "lucide-react"; +import { toast } from "sonner"; +import { createGeneralEvaluation } from "@/lib/general-check-list/service"; +import { useRouter } from "next/navigation"; + +const schema = z.object({ + category: z.string().min(1, "카테고리를 입력하세요"), + inspectionItem: z.string().min(1, "점검 항목을 입력하세요"), + remarks: z.string().optional(), +}); + +type FormValues = z.infer<typeof schema>; + +export function CreateEvaluationDialog({ onSuccess }: { onSuccess?: () => void }) { + const [open, setOpen] = React.useState(false); + const [pending, setPending] = React.useState(false); + const router = useRouter(); // ⬅️ + + const form = useForm<FormValues>({ + resolver: zodResolver(schema), + defaultValues: { category: "", inspectionItem: "", remarks: "" }, + }); + + async function onSubmit(values: FormValues) { + setPending(true); + const res = await createGeneralEvaluation(values); + if (res.success) { + toast.success(res.message); + router.refresh(); // ❷ 새로고침 + + onSuccess?.(); + setOpen(false); + form.reset(); + } else { + toast.error(res.message); + } + setPending(false); + } + + return ( + <Dialog open={open} onOpenChange={(v) => !pending && setOpen(v)}> + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Plus className="size-4" /> 새 항목 + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-[480px]"> + <DialogHeader> + <DialogTitle>새 정기평가 체크리스트</DialogTitle> + <DialogDescription>점검 항목을 추가합니다.</DialogDescription> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>카테고리</FormLabel> + <FormControl> + <Input placeholder="예: 안전" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="inspectionItem" + render={({ field }) => ( + <FormItem> + <FormLabel>점검 항목</FormLabel> + <FormControl> + <Input placeholder="예: 안전모 착용 여부" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="remarks" + render={({ field }) => ( + <FormItem> + <FormLabel>비고 (선택)</FormLabel> + <FormControl> + <Input placeholder="메모" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <DialogFooter> + <Button type="submit" disabled={pending}> + {pending ? "저장중..." : "저장"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/general-check-list/table/delete-check-lists-dialog.tsx b/lib/general-check-list/table/delete-check-lists-dialog.tsx new file mode 100644 index 00000000..b4b4d648 --- /dev/null +++ b/lib/general-check-list/table/delete-check-lists-dialog.tsx @@ -0,0 +1,106 @@ +"use client"; +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from "@/components/ui/dialog"; +import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerFooter, DrawerClose } from "@/components/ui/drawer"; +import { Trash, Loader } from "lucide-react"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { deleteGeneralEvaluations } from "@/lib/general-check-list/service"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; + +export function DeleteEvaluationsDialog({ + evaluations, + showTrigger = true, + onSuccess, + ...props +}: { + evaluations: { id: number; serialNumber: string }[]; + showTrigger?: boolean; + onSuccess?: () => void; + open?: boolean; + onOpenChange?: (v: boolean) => void; +}) { + const [pending, startTransition] = React.useTransition(); + const isDesktop = useMediaQuery("(min-width:640px)"); + const router = useRouter(); + + const deleteText = evaluations.length === 1 ? "항목" : "항목들"; + + const handleDelete = () => { + startTransition(async () => { + const res = await deleteGeneralEvaluations(evaluations.map((e) => e.id)); + if (res.success) { + toast.success(res.message); + router.refresh(); + props.onOpenChange?.(false); + onSuccess?.(); + } else { + toast.error(res.message); + } + }); + }; + + const Content = ( + <> + <DialogHeader> + <DialogTitle>삭제 확인</DialogTitle> + <DialogDescription> + 선택된 {evaluations.length}개의 {deleteText}을(를) 삭제합니다. 이 작업은 되돌릴 수 없습니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button variant="destructive" onClick={handleDelete} disabled={pending}> + {pending && <Loader className="mr-2 size-4 animate-spin" />} 삭제 + </Button> + </DialogFooter> + </> + ); + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger && ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="size-4 mr-2" /> 삭제({evaluations.length}) + </Button> + </DialogTrigger> + )} + <DialogContent>{Content}</DialogContent> + </Dialog> + ); + } + + // Mobile Drawer + return ( + <Drawer {...props}> + {showTrigger && ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="size-4 mr-2" /> 삭제({evaluations.length}) + </Button> + </DrawerTrigger> + )} + <DrawerContent className="p-4 space-y-4"> + <DrawerHeader> + <DrawerTitle>삭제 확인</DrawerTitle> + </DrawerHeader> + <DrawerDescription> + 선택된 {evaluations.length}개의 {deleteText}을(를) 삭제합니다. 이 작업은 되돌릴 수 없습니다. + </DrawerDescription> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button variant="destructive" onClick={handleDelete} disabled={pending}> + {pending && <Loader className="mr-2 size-4 animate-spin" />} 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ); +} diff --git a/lib/general-check-list/table/general-check-list-table.tsx b/lib/general-check-list/table/general-check-list-table.tsx new file mode 100644 index 00000000..34b39830 --- /dev/null +++ b/lib/general-check-list/table/general-check-list-table.tsx @@ -0,0 +1,63 @@ +"use client"; +import * as React from "react"; +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 type { DataTableFilterField, DataTableAdvancedFilterField } from "@/types/table"; +import { getGeneralEvaluationColumns } from "./general-check-table-columns"; +import { CreateEvaluationDialog } from "./add-check-list-dialog"; +import { DeleteEvaluationsDialog } from "./delete-check-lists-dialog"; +import { getGeneralEvaluations } from "@/lib/general-check-list/service"; + + +interface EsgEvaluationsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getGeneralEvaluations>>, + ] + > + } + +export function GeneralEvaluationsTable({ promises }: EsgEvaluationsTableProps) { + + const [{ data, pageCount }]= React.use(promises); + const columns = React.useMemo(() => getGeneralEvaluationColumns(), []); + + // Filters (간단 예시) + const filterFields: DataTableFilterField<any>[] = [ + { id: "serialNumber", label: "시리얼", placeholder: "시리얼 검색" }, + { id: "category", label: "카테고리", placeholder: "카테고리" }, + ]; + const advFilterFields: DataTableAdvancedFilterField<any>[] = [ + { id: "inspectionItem", label: "점검 항목", type: "text" }, + { id: "remarks", label: "비고", type: "text" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enableAdvancedFilter: true, + initialState: { sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] } }, + getRowId: (row) => row.id.toString(), + clearOnDefault: true, + shallow: false, + }); + + // Delete selected + const selectedRows = table.getFilteredSelectedRowModel().rows.map((r) => r.original); + + return ( + <div className="space-y-6"> + <DataTable table={table} className="h-full"> + <DataTableAdvancedToolbar table={table} filterFields={advFilterFields} shallow={false}> + <CreateEvaluationDialog /> + {selectedRows.length > 0 && ( + <DeleteEvaluationsDialog evaluations={selectedRows} /> + )} + </DataTableAdvancedToolbar> + </DataTable> + </div> + ); +} diff --git a/lib/general-check-list/table/general-check-table-columns.tsx b/lib/general-check-list/table/general-check-table-columns.tsx new file mode 100644 index 00000000..c764686d --- /dev/null +++ b/lib/general-check-list/table/general-check-table-columns.tsx @@ -0,0 +1,138 @@ +"use client"; +import * as React from "react"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Ellipsis, Pencil, Trash } from "lucide-react"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import { DeleteEvaluationsDialog } from "./delete-check-lists-dialog"; +import { EditEvaluationSheet } from "./update-check-list-sheet"; + + +export interface GeneralEvaluationRow { + id: number; + serialNumber: string; + category: string; + inspectionItem: string; + remarks: string | null; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export function getGeneralEvaluationColumns(): ColumnDef<GeneralEvaluationRow>[] { + return [ + // Checkbox + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // ░░░ Serial Number ░░░ + { + accessorKey: "serialNumber", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리얼" />, + cell: ({ row }) => row.getValue("serialNumber"), + size: 120, + }, + + // ░░░ Category ░░░ + { + accessorKey: "category", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="카테고리" />, + cell: ({ row }) => <Badge>{row.getValue("category")}</Badge>, + size: 120, + }, + + // ░░░ Inspection Item ░░░ + { + accessorKey: "inspectionItem", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="점검 항목" />, + cell: ({ row }) => ( + <div className="truncate max-w-[300px]" title={row.getValue<string>("inspectionItem")!}> + {row.getValue("inspectionItem") as string} + </div> + ), + size: 300, + }, + + // ░░░ Remarks ░░░ + { + accessorKey: "remarks", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />, + cell: ({ row }) => row.getValue("remarks") ?? <span className="text-muted-foreground">-</span>, + size: 200, + }, + + // ░░░ 활성 ░░░ + { + accessorKey: "isActive", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="활성" />, + cell: ({ row }) => (row.getValue("isActive") ? <Badge variant="default">활성</Badge> : <Badge variant="secondary">비활성</Badge>), + size: 80, + }, + + // ░░░ Actions ░░░ + { + id: "actions", + enableHiding: false, + size: 40, + minSize:80, + cell: ({ row }) => { + const record = row.original; + const [openEdit, setOpenEdit] = React.useState(false); + const [openDelete, setOpenDelete] = React.useState(false); + + return ( + <> + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setOpenEdit(true)} + aria-label="edit" + > + <Pencil className="size-4" /> + </Button> + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setOpenDelete(true)} + aria-label="delete" + > + <Trash className="size-4" /> + </Button> + + <EditEvaluationSheet open={openEdit} onOpenChange={setOpenEdit} evaluation={record} /> + <DeleteEvaluationsDialog + open={openDelete} + onOpenChange={setOpenDelete} + evaluations={[record]} + showTrigger={false} + /> + </> + ); + }, + }, + ]; +} diff --git a/lib/general-check-list/table/update-check-list-sheet.tsx b/lib/general-check-list/table/update-check-list-sheet.tsx new file mode 100644 index 00000000..6c845465 --- /dev/null +++ b/lib/general-check-list/table/update-check-list-sheet.tsx @@ -0,0 +1,162 @@ +"use client"; + +import * as React from "react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { + Sheet, // ⬅️ Drawer 대신 Sheet + SheetContent, + SheetHeader, + SheetTitle, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { + Form, FormField, FormItem, FormLabel, + FormControl, FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { updateGeneralEvaluation } from "@/lib/general-check-list/service"; +import { useRouter } from "next/navigation"; + +const schema = z.object({ + category: z.string(), + inspectionItem: z.string(), + remarks: z.string().optional(), + isActive: z.boolean().optional(), +}); + +type Values = z.infer<typeof schema>; + +export function EditEvaluationSheet({ + open, + onOpenChange, + evaluation, + onSuccess, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + evaluation: any; + onSuccess?: () => void; +}) { + const [pending, setPending] = React.useState(false); + const router = useRouter(); // ⬅️ + + const form = useForm<Values>({ + resolver: zodResolver(schema), + defaultValues: { + category: evaluation.category, + inspectionItem: evaluation.inspectionItem, + remarks: evaluation.remarks ?? "", + isActive: evaluation.isActive, + }, + }); + + async function onSubmit(values: Values) { + setPending(true); + const res = await updateGeneralEvaluation(evaluation.id, values); + setPending(false); + + if (res.success) { + toast.success(res.message); + router.refresh(); + + onSuccess?.(); + onOpenChange(false); + } else { + toast.error(res.message); + } + } + + return ( + <Sheet open={open} onOpenChange={(v) => !pending && onOpenChange(v)}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader> + <SheetTitle>항목 수정 – {evaluation.serialNumber}</SheetTitle> + </SheetHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-4"> + {/* 카테고리 */} + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>카테고리</FormLabel> + <FormControl> + <Input {...field} disabled={pending} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 점검 항목 */} + <FormField + control={form.control} + name="inspectionItem" + render={({ field }) => ( + <FormItem> + <FormLabel>점검 항목</FormLabel> + <FormControl> + <Input {...field} disabled={pending} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 비고 */} + <FormField + control={form.control} + name="remarks" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Input {...field} disabled={pending} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 활성 체크 */} + <FormField + control={form.control} + name="isActive" + render={({ field }) => ( + <FormItem className="flex items-center gap-2"> + <FormLabel className="m-0">활성</FormLabel> + <FormControl> + <input + type="checkbox" + checked={field.value} + onChange={(e) => field.onChange(e.target.checked)} + disabled={pending} + /> + </FormControl> + </FormItem> + )} + /> + + <SheetFooter className="pt-4"> + <SheetClose asChild> + <Button variant="outline" disabled={pending}> + 취소 + </Button> + </SheetClose> + <Button type="submit" disabled={pending}> + {pending ? "저장중..." : "저장"} + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ); +} diff --git a/lib/general-check-list/validation.ts b/lib/general-check-list/validation.ts new file mode 100644 index 00000000..d1cd8ab3 --- /dev/null +++ b/lib/general-check-list/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 { GeneralEvaluation } from "@/db/schema"; + + +export const getGenralEvaluationsSchema =createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<GeneralEvaluation>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); + +export type GetGeneralEvaluationsSchema = Awaited<ReturnType<typeof getGenralEvaluationsSchema.parse>>
\ No newline at end of file diff --git a/lib/incoterms/service.ts b/lib/incoterms/service.ts new file mode 100644 index 00000000..116b7f1c --- /dev/null +++ b/lib/incoterms/service.ts @@ -0,0 +1,149 @@ +"use server"; +import db from "@/db/db"; +import { incoterms } from "@/db/schema/procurementRFQ"; +import { GetIncotermsSchema } from "@/lib/incoterms/validations"; +import { filterColumns } from "@/lib/filter-columns"; +import { asc, desc, ilike, and, or, count, eq } from "drizzle-orm"; + +// Incoterms CRUD +export async function getIncoterms(input: GetIncotermsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + // 1. where 절 + let advancedWhere; + try { + advancedWhere = filterColumns({ + table: incoterms, + filters: input.filters, + joinOperator: input.joinOperator, + }); + } catch (whereErr) { + console.error("Error building advanced where:", whereErr); + advancedWhere = undefined; + } + + let globalWhere; + if (input.search) { + try { + const s = `%${input.search}%`; + globalWhere = or( + ilike(incoterms.code, s), + ilike(incoterms.description, s) + ); + } catch (searchErr) { + console.error("Error building search where:", searchErr); + globalWhere = undefined; + } + } + + // 2. where 결합 + let finalWhere; + if (advancedWhere && globalWhere) { + finalWhere = and(advancedWhere, globalWhere); + } else { + finalWhere = advancedWhere || globalWhere; + } + + // 3. order by + let orderBy; + try { + orderBy = + input.sort.length > 0 + ? input.sort + .map((item) => { + if (!item || !item.id || typeof item.id !== "string" || !(item.id in incoterms)) return null; + const col = incoterms[item.id as keyof typeof incoterms]; + return item.desc ? desc(col) : asc(col); + }) + .filter((v): v is Exclude<typeof v, null> => v !== null) + : [asc(incoterms.createdAt)]; + } catch (orderErr) { + console.error("Error building order by:", orderErr); + orderBy = [asc(incoterms.createdAt)]; + } + + // 4. 쿼리 실행 + let data = []; + let total = 0; + + try { + const queryBuilder = db.select().from(incoterms); + + if (finalWhere) { + queryBuilder.where(finalWhere); + } + + if (orderBy && orderBy.length > 0) { + queryBuilder.orderBy(...orderBy); + } + if (typeof offset === "number" && !isNaN(offset)) { + queryBuilder.offset(offset); + } + if (typeof input.perPage === "number" && !isNaN(input.perPage)) { + queryBuilder.limit(input.perPage); + } + + data = await queryBuilder; + + const countBuilder = db + .select({ count: count() }) + .from(incoterms); + + if (finalWhere) { + countBuilder.where(finalWhere); + } + + const countResult = await countBuilder; + total = countResult[0]?.count || 0; + } catch (queryErr) { + console.error("Query execution failed:", queryErr); + throw queryErr; + } + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + console.error("Error in getIncoterms:", err); + if (err instanceof Error) { + console.error("Error message:", err.message); + console.error("Error stack:", err.stack); + } + return { data: [], pageCount: 0 }; + } +} + +export async function createIncoterm(data: Omit<typeof incoterms.$inferInsert, "createdAt">) { + try { + const [created] = await db.insert(incoterms).values(data).returning(); + return { data: created }; + } catch (err) { + console.error("Error creating incoterm:", err); + return { error: "생성 중 오류가 발생했습니다." }; + } +} + +export async function updateIncoterm(code: string, data: Partial<typeof incoterms.$inferInsert>) { + try { + const [updated] = await db + .update(incoterms) + .set(data) + .where(eq(incoterms.code, code)) + .returning(); + return { data: updated }; + } catch (err) { + console.error("Error updating incoterm:", err); + return { error: "수정 중 오류가 발생했습니다." }; + } +} + +export async function deleteIncoterm(code: string) { + try { + await db.delete(incoterms).where(eq(incoterms.code, code)); + return { success: true }; + } catch (err) { + console.error("Error deleting incoterm:", err); + return { error: "삭제 중 오류가 발생했습니다." }; + } +}
\ No newline at end of file diff --git a/lib/incoterms/table/incoterms-add-dialog.tsx b/lib/incoterms/table/incoterms-add-dialog.tsx new file mode 100644 index 00000000..ef378e1e --- /dev/null +++ b/lib/incoterms/table/incoterms-add-dialog.tsx @@ -0,0 +1,162 @@ +"use client"; + +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Plus, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { createIncoterm } from "../service"; +import { toast } from "sonner"; + +const createIncotermSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + description: z.string().min(1, "설명은 필수입니다."), + isActive: z.boolean().default(true), +}); + +type CreateIncotermFormValues = z.infer<typeof createIncotermSchema>; + +interface IncotermsAddDialogProps { + onSuccess?: () => void; +} + +export function IncotermsAddDialog({ onSuccess }: IncotermsAddDialogProps) { + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const form = useForm<CreateIncotermFormValues>({ + resolver: zodResolver(createIncotermSchema), + defaultValues: { + code: "", + description: "", + isActive: true, + }, + }); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + form.reset(); + } + }; + + const handleCancel = () => { + form.reset(); + setOpen(false); + }; + + const onSubmit = async (data: CreateIncotermFormValues) => { + setIsLoading(true); + try { + const result = await createIncoterm(data); + if (result.data) { + toast.success("인코텀즈가 추가되었습니다."); + setOpen(false); + if (onSuccess) { + onSuccess(); + } + } else { + toast.error(result.error || "생성 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("인코텀즈 생성 오류:", error); + toast.error("인코텀즈 생성에 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button size="sm" variant="outline"> + <Plus className="mr-2 h-4 w-4" /> + 인코텀즈 추가 + </Button> + </DialogTrigger> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>인코텀즈 추가</DialogTitle> + <DialogDescription> + 새로운 인코텀즈를 추가합니다. 필수 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel> + 코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="인코텀즈 코드" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel> + 설명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="인코텀즈 설명" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "생성 중..." : "인코텀즈 추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/incoterms/table/incoterms-edit-sheet.tsx b/lib/incoterms/table/incoterms-edit-sheet.tsx new file mode 100644 index 00000000..9cd067c7 --- /dev/null +++ b/lib/incoterms/table/incoterms-edit-sheet.tsx @@ -0,0 +1,146 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { updateIncoterm } from "../service" +import { incoterms } from "@/db/schema/procurementRFQ" + +const updateIncotermSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + description: z.string().min(1, "설명은 필수입니다."), + isActive: z.boolean().default(true), +}) + +type UpdateIncotermSchema = z.infer<typeof updateIncotermSchema> + +interface IncotermsEditSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + data: typeof incoterms.$inferSelect + onSuccess: () => void +} + +export function IncotermsEditSheet({ + open, + onOpenChange, + data, + onSuccess, +}: IncotermsEditSheetProps) { + const form = useForm<UpdateIncotermSchema>({ + resolver: zodResolver(updateIncotermSchema), + defaultValues: { + code: data.code, + description: data.description, + isActive: data.isActive, + }, + mode: "onChange" + }) + + React.useEffect(() => { + if (data) { + form.reset({ + code: data.code, + description: data.description, + isActive: data.isActive, + }) + } + }, [data, form]) + + async function onSubmit(input: UpdateIncotermSchema) { + try { + await updateIncoterm(data.code, input) + toast.success("수정이 완료되었습니다.") + onSuccess() + onOpenChange(false) + } catch { + toast.error("수정 중 오류가 발생했습니다.") + } + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>인코텀즈 수정</SheetTitle> + <SheetDescription> + 인코텀즈 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel>코드</FormLabel> + <FormControl> + <Input {...field} disabled /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="isActive" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel>활성화</FormLabel> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + <div className="flex justify-end space-x-2"> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button type="submit">저장</Button> + </div> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/incoterms/table/incoterms-table-columns.tsx b/lib/incoterms/table/incoterms-table-columns.tsx new file mode 100644 index 00000000..56a44e8b --- /dev/null +++ b/lib/incoterms/table/incoterms-table-columns.tsx @@ -0,0 +1,102 @@ +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Ellipsis } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { incoterms } from "@/db/schema/procurementRFQ"; +import { toast } from "sonner"; +import { deleteIncoterm } from "../service"; + +type Incoterm = typeof incoterms.$inferSelect; + +interface GetColumnsProps { + setRowAction: (action: { type: string; row: Row<Incoterm> }) => void; + onSuccess: () => void; +} + +const handleDelete = async (code: string, onSuccess: () => void) => { + const result = await deleteIncoterm(code); + if (result.success) { + toast.success("삭제 완료"); + onSuccess(); + } else { + toast.error(result.error || "삭제 실패"); + } +}; + +export function getColumns({ setRowAction, onSuccess }: GetColumnsProps): ColumnDef<Incoterm>[] { + return [ + { + id: "code", + header: () => <div>코드</div>, + cell: ({ row }) => <div>{row.original.code}</div>, + enableSorting: true, + enableHiding: false, + }, + { + id: "description", + header: () => <div>설명</div>, + cell: ({ row }) => <div>{row.original.description}</div>, + enableSorting: true, + enableHiding: false, + }, + { + id: "isActive", + header: () => <div>상태</div>, + cell: ({ row }) => ( + <Badge variant={row.original.isActive ? "default" : "secondary"}> + {row.original.isActive ? "활성" : "비활성"} + </Badge> + ), + enableSorting: true, + enableHiding: false, + }, + { + id: "createdAt", + header: () => <div>생성일</div>, + cell: ({ row }) => { + const value = row.original.createdAt; + const date = value ? new Date(value) : null; + return date ? date.toLocaleDateString() : ""; + }, + enableSorting: true, + enableHiding: false, + }, + { + id: "actions", + cell: ({ row }) => ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ type: "edit", row })} + > + 수정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => handleDelete(row.original.code, onSuccess)} + className="text-destructive" + > + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ), + }, + ]; +}
\ No newline at end of file diff --git a/lib/incoterms/table/incoterms-table-toolbar.tsx b/lib/incoterms/table/incoterms-table-toolbar.tsx new file mode 100644 index 00000000..b87982c9 --- /dev/null +++ b/lib/incoterms/table/incoterms-table-toolbar.tsx @@ -0,0 +1,16 @@ +"use client"; + +import * as React from "react"; +import { IncotermsAddDialog } from "./incoterms-add-dialog"; + +interface IncotermsTableToolbarProps { + onSuccess?: () => void; +} + +export function IncotermsTableToolbar({ onSuccess }: IncotermsTableToolbarProps) { + return ( + <div className="flex items-center gap-2"> + <IncotermsAddDialog onSuccess={onSuccess} /> + </div> + ); +}
\ No newline at end of file diff --git a/lib/incoterms/table/incoterms-table.tsx b/lib/incoterms/table/incoterms-table.tsx new file mode 100644 index 00000000..c5b5bba4 --- /dev/null +++ b/lib/incoterms/table/incoterms-table.tsx @@ -0,0 +1,116 @@ +"use client"; +import * as React from "react"; +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 { getColumns } from "./incoterms-table-columns"; +import { incoterms } from "@/db/schema/procurementRFQ"; +import { IncotermsTableToolbar } from "./incoterms-table-toolbar"; +import { toast } from "sonner"; +import { IncotermsEditSheet } from "./incoterms-edit-sheet"; +import { Row } from "@tanstack/react-table"; +import { getIncoterms } from "../service"; + +interface IncotermsTableProps { + promises?: Promise<[{ data: typeof incoterms.$inferSelect[]; pageCount: number }] >; +} + +export function IncotermsTable({ promises }: IncotermsTableProps) { + const [rawData, setRawData] = React.useState<{ data: typeof incoterms.$inferSelect[]; pageCount: number }>({ data: [], pageCount: 0 }); + const [isEditSheetOpen, setIsEditSheetOpen] = React.useState(false); + const [selectedRow, setSelectedRow] = React.useState<typeof incoterms.$inferSelect | null>(null); + + React.useEffect(() => { + if (promises) { + promises.then(([result]) => { + setRawData(result); + }); + } else { + // fallback: 클라이언트에서 직접 fetch (CSR) + (async () => { + try { + const result = await getIncoterms({ + page: 1, + perPage: 10, + search: "", + sort: [{ id: "createdAt", desc: true }], + filters: [], + joinOperator: "and", + flags: ["advancedTable"], + code: "", + description: "", + isActive: "" + }); + setRawData(result); + } catch (error) { + console.error("Error refreshing data:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + } + })(); + } + }, [promises]); + + const refreshData = React.useCallback(async () => { + try { + const result = await getIncoterms({ + page: 1, + perPage: 10, + search: "", + sort: [{ id: "createdAt", desc: true }], + filters: [], + joinOperator: "and", + flags: ["advancedTable"], + code: "", + description: "", + isActive: "" + }); + setRawData(result); + } catch (error) { + console.error("Error refreshing data:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + } + }, []); + + const handleRowAction = async (action: { type: string; row: Row<typeof incoterms.$inferSelect> }) => { + if (action.type === "edit") { + setSelectedRow(action.row.original); + setIsEditSheetOpen(true); + } + }; + + const columns = React.useMemo(() => getColumns({ setRowAction: handleRowAction, onSuccess: refreshData }), [refreshData]); + + const { table } = useDataTable({ + data: rawData.data, + columns, + pageCount: rawData.pageCount, + filterFields: [], + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.code), + shallow: false, + clearOnDefault: true, + }); + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar table={table} filterFields={[]} shallow={false}> + <IncotermsTableToolbar onSuccess={refreshData} /> + </DataTableAdvancedToolbar> + </DataTable> + {isEditSheetOpen && selectedRow && ( + <IncotermsEditSheet + open={isEditSheetOpen} + onOpenChange={setIsEditSheetOpen} + data={selectedRow} + onSuccess={refreshData} + /> + )} + </> + ); +}
\ No newline at end of file diff --git a/lib/incoterms/validations.ts b/lib/incoterms/validations.ts new file mode 100644 index 00000000..3f51dcd6 --- /dev/null +++ b/lib/incoterms/validations.ts @@ -0,0 +1,33 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { incoterms } from "@/db/schema/procurementRFQ"; + +export const SearchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (createdAt 기준 내림차순) + sort: getSortingStateParser<typeof incoterms>().withDefault([ + { id: "createdAt", desc: true }]), + + // 기존 필드 + code: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + isActive: parseAsString.withDefault(""), + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); +export type GetIncotermsSchema = Awaited<ReturnType<typeof SearchParamsCache.parse>>; diff --git a/lib/payment-terms/service.ts b/lib/payment-terms/service.ts new file mode 100644 index 00000000..4ca3efd9 --- /dev/null +++ b/lib/payment-terms/service.ts @@ -0,0 +1,151 @@ +"use server"; +import db from "@/db/db"; +import {paymentTerms} from "@/db/schema/procurementRFQ" +import { GetPaymentTermsSchema } from "./validations"; +import { filterColumns } from "@/lib/filter-columns"; +import { asc, desc, ilike, and, or, count, eq } from "drizzle-orm"; + + +// PaymentTerms CRUD +export async function getPaymentTerms(input: GetPaymentTermsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + // 1. where 절 + let advancedWhere; + try { + advancedWhere = filterColumns({ + table: paymentTerms, + filters: input.filters, + joinOperator: input.joinOperator, + }); + } catch (whereErr) { + console.error("Error building advanced where:", whereErr); + advancedWhere = undefined; + } + + let globalWhere; + if (input.search) { + try { + const s = `%${input.search}%`; + globalWhere = or( + ilike(paymentTerms.code, s), + ilike(paymentTerms.description, s) + ); + } catch (searchErr) { + console.error("Error building search where:", searchErr); + globalWhere = undefined; + } + } + + // 2. where 결합 + let finalWhere; + if (advancedWhere && globalWhere) { + finalWhere = and(advancedWhere, globalWhere); + } else { + finalWhere = advancedWhere || globalWhere; + } + + // 3. order by + let orderBy; + try { + orderBy = + input.sort.length > 0 + ? input.sort + .map((item) => { + if (!item || !item.id || typeof item.id !== "string" || !(item.id in paymentTerms)) return null; + const col = paymentTerms[item.id as keyof typeof paymentTerms]; + return item.desc ? desc(col) : asc(col); + }) + .filter((v): v is Exclude<typeof v, null> => v !== null) + : [asc(paymentTerms.createdAt)]; + } catch (orderErr) { + console.error("Error building order by:", orderErr); + orderBy = [asc(paymentTerms.createdAt)]; + } + + // 4. 쿼리 실행 + let data = []; + let total = 0; + + try { + const queryBuilder = db.select().from(paymentTerms); + + if (finalWhere) { + queryBuilder.where(finalWhere); + } + + if (orderBy && orderBy.length > 0) { + queryBuilder.orderBy(...orderBy); + } + if (typeof offset === "number" && !isNaN(offset)) { + queryBuilder.offset(offset); + } + if (typeof input.perPage === "number" && !isNaN(input.perPage)) { + queryBuilder.limit(input.perPage); + } + + data = await queryBuilder; + + const countBuilder = db + .select({ count: count() }) + .from(paymentTerms); + + if (finalWhere) { + countBuilder.where(finalWhere); + } + + const countResult = await countBuilder; + total = countResult[0]?.count || 0; + } catch (queryErr) { + console.error("Query execution failed:", queryErr); + throw queryErr; + } + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + console.error("Error in getPaymentTerms:", err); + if (err instanceof Error) { + console.error("Error message:", err.message); + console.error("Error stack:", err.stack); + } + return { data: [], pageCount: 0 }; + } +} + +export async function createPaymentTerm(data: Omit<typeof paymentTerms.$inferInsert, "createdAt">) { + try { + const [created] = await db.insert(paymentTerms).values(data).returning(); + return { data: created }; + } catch (err) { + console.error("Error creating payment term:", err); + return { error: "생성 중 오류가 발생했습니다." }; + } +} + +export async function updatePaymentTerm(code: string, data: Partial<typeof paymentTerms.$inferInsert>) { + try { + const [updated] = await db + .update(paymentTerms) + .set(data) + .where(eq(paymentTerms.code, code)) + .returning(); + return { data: updated }; + } catch (err) { + console.error("Error updating payment term:", err); + return { error: "수정 중 오류가 발생했습니다." }; + } +} + +export async function deletePaymentTerm(code: string) { + try { + await db.delete(paymentTerms).where(eq(paymentTerms.code, code)); + return { success: true }; + } catch (err) { + console.error("Error deleting payment term:", err); + return { error: "삭제 중 오류가 발생했습니다." }; + } +} + diff --git a/lib/payment-terms/table/payment-terms-add-dialog.tsx b/lib/payment-terms/table/payment-terms-add-dialog.tsx new file mode 100644 index 00000000..9aa21485 --- /dev/null +++ b/lib/payment-terms/table/payment-terms-add-dialog.tsx @@ -0,0 +1,162 @@ +"use client"; + +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Plus, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { createPaymentTerm } from "../service"; +import { toast } from "sonner"; + +const createPaymentTermSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + description: z.string().min(1, "설명은 필수입니다."), + isActive: z.boolean().default(true), +}); + +type CreatePaymentTermFormValues = z.infer<typeof createPaymentTermSchema>; + +interface PaymentTermsAddDialogProps { + onSuccess?: () => void; +} + +export function PaymentTermsAddDialog({ onSuccess }: PaymentTermsAddDialogProps) { + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const form = useForm<CreatePaymentTermFormValues>({ + resolver: zodResolver(createPaymentTermSchema), + defaultValues: { + code: "", + description: "", + isActive: true, + }, + }); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + form.reset(); + } + }; + + const handleCancel = () => { + form.reset(); + setOpen(false); + }; + + const onSubmit = async (data: CreatePaymentTermFormValues) => { + setIsLoading(true); + try { + const result = await createPaymentTerm(data); + if (result.data) { + toast.success("결제 조건이 추가되었습니다."); + setOpen(false); + if (onSuccess) { + onSuccess(); + } + } else { + toast.error(result.error || "생성 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("결제 조건 생성 오류:", error); + toast.error("결제 조건 생성에 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button size="sm" variant="outline"> + <Plus className="mr-2 h-4 w-4" /> + 결제 조건 추가 + </Button> + </DialogTrigger> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>결제 조건 추가</DialogTitle> + <DialogDescription> + 새로운 결제 조건을 추가합니다. 필수 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel> + 코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="결제 조건 코드" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel> + 설명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="결제 조건 설명" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "생성 중..." : "결제 조건 추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/payment-terms/table/payment-terms-edit-sheet.tsx b/lib/payment-terms/table/payment-terms-edit-sheet.tsx new file mode 100644 index 00000000..b0d105bc --- /dev/null +++ b/lib/payment-terms/table/payment-terms-edit-sheet.tsx @@ -0,0 +1,147 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { updatePaymentTerm } from "../service" +import { paymentTerms } from "@/db/schema/procurementRFQ" + +const updatePaymentTermSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + description: z.string().min(1, "설명은 필수입니다."), + isActive: z.boolean().default(true), +}) + +type UpdatePaymentTermSchema = z.infer<typeof updatePaymentTermSchema> + +interface PaymentTermsEditSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + data: typeof paymentTerms.$inferSelect + onSuccess: () => void +} + +export function PaymentTermsEditSheet({ + open, + onOpenChange, + data, + onSuccess, +}: PaymentTermsEditSheetProps) { + const form = useForm<UpdatePaymentTermSchema>({ + resolver: zodResolver(updatePaymentTermSchema), + defaultValues: { + code: data.code, + description: data.description, + isActive: data.isActive, + }, + mode: "onChange" + }) + + React.useEffect(() => { + if (data) { + form.reset({ + code: data.code, + description: data.description, + isActive: data.isActive, + }) + } + }, [data, form]) + + async function onSubmit(input: UpdatePaymentTermSchema) { + try { + await updatePaymentTerm(data.code, input) + toast.success("수정이 완료되었습니다.") + onSuccess() + onOpenChange(false) + } catch { + toast.error("수정 중 오류가 발생했습니다.") + } + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>결제 조건 수정</SheetTitle> + <SheetDescription> + 결제 조건 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel>코드</FormLabel> + <FormControl> + <Input {...field} disabled /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="isActive" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel>활성화</FormLabel> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + <div className="flex justify-end space-x-2"> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button type="submit">저장</Button> + </div> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/payment-terms/table/payment-terms-table-columns.tsx b/lib/payment-terms/table/payment-terms-table-columns.tsx new file mode 100644 index 00000000..208723f7 --- /dev/null +++ b/lib/payment-terms/table/payment-terms-table-columns.tsx @@ -0,0 +1,102 @@ +import { type ColumnDef, type Row } from "@tanstack/react-table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Ellipsis } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { paymentTerms } from "@/db/schema/procurementRFQ"; +import { toast } from "sonner"; +import { deletePaymentTerm } from "../service"; + +type PaymentTerm = typeof paymentTerms.$inferSelect; + +interface GetColumnsProps { + setRowAction: (action: { type: string; row: Row<PaymentTerm> }) => void; + onSuccess: () => void; +} + +const handleDelete = async (code: string, onSuccess: () => void) => { + const result = await deletePaymentTerm(code); + if (result.success) { + toast.success("삭제 완료"); + onSuccess(); + } else { + toast.error(result.error || "삭제 실패"); + } +}; + +export function getColumns({ setRowAction, onSuccess }: GetColumnsProps): ColumnDef<PaymentTerm>[] { + return [ + { + id: "code", + header: () => <div>코드</div>, + cell: ({ row }) => <div>{row.original.code}</div>, + enableSorting: true, + enableHiding: false, + }, + { + id: "description", + header: () => <div>설명</div>, + cell: ({ row }) => <div>{row.original.description}</div>, + enableSorting: true, + enableHiding: false, + }, + { + id: "isActive", + header: () => <div>상태</div>, + cell: ({ row }) => ( + <Badge variant={row.original.isActive ? "default" : "secondary"}> + {row.original.isActive ? "활성" : "비활성"} + </Badge> + ), + enableSorting: true, + enableHiding: false, + }, + { + id: "createdAt", + header: () => <div>생성일</div>, + cell: ({ row }) => { + const value = row.original.createdAt; + const date = value ? new Date(value) : null; + return date ? date.toLocaleDateString() : ""; + }, + enableSorting: true, + enableHiding: false, + }, + { + id: "actions", + cell: ({ row }) => ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ type: "edit", row })} + > + 수정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => handleDelete(row.original.code, onSuccess)} + className="text-destructive" + > + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ), + }, + ]; +}
\ No newline at end of file diff --git a/lib/payment-terms/table/payment-terms-table-toolbar.tsx b/lib/payment-terms/table/payment-terms-table-toolbar.tsx new file mode 100644 index 00000000..2466a9e4 --- /dev/null +++ b/lib/payment-terms/table/payment-terms-table-toolbar.tsx @@ -0,0 +1,16 @@ +"use client"; + +import * as React from "react"; +import { PaymentTermsAddDialog } from "./payment-terms-add-dialog"; + +interface PaymentTermsTableToolbarProps { + onSuccess?: () => void; +} + +export function PaymentTermsTableToolbar({ onSuccess }: PaymentTermsTableToolbarProps) { + return ( + <div className="flex items-center gap-2"> + <PaymentTermsAddDialog onSuccess={onSuccess} /> + </div> + ); +}
\ No newline at end of file diff --git a/lib/payment-terms/table/payment-terms-table.tsx b/lib/payment-terms/table/payment-terms-table.tsx new file mode 100644 index 00000000..589acb52 --- /dev/null +++ b/lib/payment-terms/table/payment-terms-table.tsx @@ -0,0 +1,127 @@ +"use client"; +import * as React from "react"; +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 { getColumns } from "./payment-terms-table-columns"; +import { paymentTerms } from "@/db/schema/procurementRFQ"; +import { PaymentTermsTableToolbar } from "./payment-terms-table-toolbar"; +import { toast } from "sonner"; +import { PaymentTermsEditSheet } from "./payment-terms-edit-sheet"; +import { Row } from "@tanstack/react-table"; +import { getPaymentTerms } from "../service"; +import { GetPaymentTermsSchema } from "../validations"; + +interface PaymentTermsTableProps { + promises?: Promise<[{ data: typeof paymentTerms.$inferSelect[]; pageCount: number }] >; +} + +export function PaymentTermsTable({ promises }: PaymentTermsTableProps) { + const [rawData, setRawData] = React.useState<{ data: typeof paymentTerms.$inferSelect[]; pageCount: number }>({ data: [], pageCount: 0 }); + const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false); + const [selectedRow, setSelectedRow] = React.useState<typeof paymentTerms.$inferSelect | null>(null); + + React.useEffect(() => { + if (promises) { + promises.then(([result]) => { + setRawData(result); + }); + } else { + // fallback: 클라이언트에서 직접 fetch (CSR) + (async () => { + try { + const result = await getPaymentTerms({ + page: 1, + perPage: 10, + search: "", + sort: [{ id: "createdAt", desc: true }], + filters: [], + joinOperator: "and", + flags: ["advancedTable"], + code: "", + description: "", + isActive: "" + }); + setRawData(result); + } catch (error) { + console.error("Error refreshing data:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + } + })(); + } + }, [promises]); + + const fetchPaymentTerms = React.useCallback(async (params: Record<string, unknown>) => { + try { + const result = await getPaymentTerms(params as GetPaymentTermsSchema); + return result; + } catch (error) { + console.error("Error fetching payment terms:", error); + throw error; + } + }, []); + + const refreshData = React.useCallback(async () => { + try { + const result = await fetchPaymentTerms({ + page: 1, + perPage: 10, + search: "", + sort: [{ id: "createdAt", desc: true }], + filters: [], + joinOperator: "and", + flags: ["advancedTable"], + code: "", + description: "", + isActive: "" + }); + setRawData(result); + } catch (error) { + console.error("Error refreshing data:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + } + }, [fetchPaymentTerms]); + + const handleRowAction = async (action: { type: string; row: Row<typeof paymentTerms.$inferSelect> }) => { + if (action.type === "edit") { + setSelectedRow(action.row.original); + setIsEditDialogOpen(true); + } + }; + + const columns = React.useMemo(() => getColumns({ setRowAction: handleRowAction, onSuccess: refreshData }), [refreshData]); + + const { table } = useDataTable({ + data: rawData.data, + columns, + pageCount: rawData.pageCount, + filterFields: [], + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.code), + shallow: false, + clearOnDefault: true, + }); + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar table={table} filterFields={[]} shallow={false}> + <PaymentTermsTableToolbar onSuccess={refreshData} /> + </DataTableAdvancedToolbar> + </DataTable> + {isEditDialogOpen && selectedRow && ( + <PaymentTermsEditSheet + open={isEditDialogOpen} + onOpenChange={setIsEditDialogOpen} + data={selectedRow} + onSuccess={refreshData} + /> + )} + </> + ); +}
\ No newline at end of file diff --git a/lib/payment-terms/validations.ts b/lib/payment-terms/validations.ts new file mode 100644 index 00000000..6c043d50 --- /dev/null +++ b/lib/payment-terms/validations.ts @@ -0,0 +1,34 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { paymentTerms } from "@/db/schema/procurementRFQ"; + +export const SearchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (createdAt 기준 내림차순) + sort: getSortingStateParser<typeof paymentTerms>().withDefault([ + { id: "createdAt", desc: true }]), + + // 기존 필드 + code: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + isActive: parseAsString.withDefault(""), + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); +export type GetPaymentTermsSchema = Awaited<ReturnType<typeof SearchParamsCache.parse>>; + diff --git a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx index 963c2f85..66bb2613 100644 --- a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx +++ b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx @@ -173,6 +173,8 @@ interface VendorQuotationEditorProps { export default function VendorQuotationEditor({ quotation }: VendorQuotationEditorProps) { + console.log(quotation) + const [activeTab, setActiveTab] = useState("items") const [isSubmitting, setIsSubmitting] = useState(false) const [isSaving, setIsSaving] = useState(false) 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 |
