diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-19 09:44:28 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-19 09:44:28 +0000 |
| commit | 95bbe9c583ff841220da1267630e7b2025fc36dc (patch) | |
| tree | 5e3d5bb3302530bbaa7f7abbe8c9cf8193ccbd4c /lib/esg-check-list | |
| parent | 0eb030580b5cbe5f03d570c3c9d8c519bac3b783 (diff) | |
(대표님) 20250619 1844 KST 작업사항
Diffstat (limited to 'lib/esg-check-list')
| -rw-r--r-- | lib/esg-check-list/repository.ts | 133 | ||||
| -rw-r--r-- | lib/esg-check-list/service.ts | 601 | ||||
| -rw-r--r-- | lib/esg-check-list/table/esg-evaluation-delete-dialog.tsx | 168 | ||||
| -rw-r--r-- | lib/esg-check-list/table/esg-evaluation-details-sheet.tsx | 188 | ||||
| -rw-r--r-- | lib/esg-check-list/table/esg-evaluation-form-sheet.tsx | 492 | ||||
| -rw-r--r-- | lib/esg-check-list/table/esg-evaluations-table-columns.tsx | 357 | ||||
| -rw-r--r-- | lib/esg-check-list/table/esg-evaluations-table-toolbar-actions.tsx | 184 | ||||
| -rw-r--r-- | lib/esg-check-list/table/esg-excel-import.tsx | 399 | ||||
| -rw-r--r-- | lib/esg-check-list/table/esg-table.tsx | 236 | ||||
| -rw-r--r-- | lib/esg-check-list/table/excel-actions.tsx | 233 | ||||
| -rw-r--r-- | lib/esg-check-list/table/excel-utils.tsx | 304 | ||||
| -rw-r--r-- | lib/esg-check-list/validation.ts | 30 |
12 files changed, 3325 insertions, 0 deletions
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 |
