diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-20 11:37:31 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-20 11:37:31 +0000 |
| commit | aa86729f9a2ab95346a2851e3837de1c367aae17 (patch) | |
| tree | b601b18b6724f2fb449c7fa9ea50cbd652a8077d /lib | |
| parent | 95bbe9c583ff841220da1267630e7b2025fc36dc (diff) | |
(대표님) 20250620 작업사항
Diffstat (limited to 'lib')
40 files changed, 7838 insertions, 733 deletions
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts index 62f0f0ef..572b468d 100644 --- a/lib/evaluation-target-list/service.ts +++ b/lib/evaluation-target-list/service.ts @@ -1,13 +1,13 @@ 'use server' -import { and, or, desc, asc, ilike, eq, isNull, sql, count } from "drizzle-orm"; +import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray } from "drizzle-orm"; import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; import { filterColumns } from "@/lib/filter-columns"; import db from "@/db/db"; -import { - evaluationTargets, - evaluationTargetReviewers, +import { + evaluationTargets, + evaluationTargetReviewers, evaluationTargetReviews, users, vendors, @@ -21,7 +21,9 @@ import { } from "@/db/schema"; import { GetEvaluationTargetsSchema } from "./validation"; import { PgTransaction } from "drizzle-orm/pg-core"; - +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { sendEmail } from "../mail/sendEmail"; export async function selectEvaluationTargetsFromView( tx: PgTransaction<any, any, any>, @@ -52,7 +54,7 @@ export async function countEvaluationTargetsFromView( .select({ count: count() }) .from(evaluationTargetsWithDepartments) .where(where); - + return res[0]?.count ?? 0; } @@ -102,9 +104,9 @@ export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) { // 정렬 (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); - }) + const column = evaluationTargetsWithDepartments[item.id as keyof typeof evaluationTargetsWithDepartments]; + return item.desc ? desc(column) : asc(column); + }) : [desc(evaluationTargetsWithDepartments.createdAt)]; // 데이터 조회 - View 테이블 사용 @@ -196,7 +198,7 @@ export interface CreateEvaluationTargetInput { // service.ts 파일의 CreateEvaluationTargetInput 타입 수정 export interface CreateEvaluationTargetInput { evaluationYear: number - division: "OCEAN" | "SHIPYARD" + division: "PLANT" | "SHIP" vendorId: number materialType: "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK" adminComment?: string @@ -216,17 +218,16 @@ export async function createEvaluationTarget( input: CreateEvaluationTargetInput, createdBy: number ) { - - console.log(input,"input") + 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, + country: vendors.country, }) .from(vendors) .where(eq(vendors.id, input.vendorId)) @@ -238,7 +239,7 @@ export async function createEvaluationTarget( const vendorInfo = vendor[0]; - // 중복 체크 (기존과 동일) + // 중복 체크 const existing = await tx .select({ id: evaluationTargets.id }) .from(evaluationTargets) @@ -255,51 +256,57 @@ export async function createEvaluationTarget( throw new Error("이미 동일한 평가 대상이 존재합니다."); } - // 평가 대상 생성 (기존과 동일) + // 🔧 수정: 타입 추론 문제 해결 + const targetValues: typeof evaluationTargets.$inferInsert = { + 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, + // 🔧 수정: decimal 타입은 숫자로 처리 + ldClaimAmount: input.ldClaimAmount?.toString() ?? '0', + ldClaimCurrency: input.ldClaimCurrency ?? 'KRW', + } + + console.log(targetValues) + + // 평가 대상 생성 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", - }) + .values(targetValues) .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); + + // 🔧 수정: SQL 배열 처리 개선 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, - }; - }); + .where(inArray(users.id, reviewerIds)); // sql 대신 inArray 사용 + + const reviewerAssignments: typeof evaluationTargetReviewers.$inferInsert[] = + input.reviewers.map(r => { + const info = reviewerInfos.find(i => i.id === r.reviewerUserId); + return { + evaluationTargetId, + departmentCode: r.departmentCode, + departmentNameFrom: info?.departmentName ?? "TEST 부서", + reviewerUserId: r.reviewerUserId, + assignedBy: createdBy, + }; + }); await tx.insert(evaluationTargetReviewers).values(reviewerAssignments); } @@ -319,6 +326,253 @@ export async function createEvaluationTarget( } } +//업데이트 입력 타입 정의 +export interface UpdateEvaluationTargetInput { + id: number + adminComment?: string + consolidatedComment?: string + ldClaimCount?: number + ldClaimAmount?: number + ldClaimCurrency?: "KRW" | "USD" | "EUR" | "JPY" + consensusStatus?: boolean | null + orderIsApproved?: boolean | null + procurementIsApproved?: boolean | null + qualityIsApproved?: boolean | null + designIsApproved?: boolean | null + csIsApproved?: boolean | null + // 담당자 이메일 변경 + orderReviewerEmail?: string + procurementReviewerEmail?: string + qualityReviewerEmail?: string + designReviewerEmail?: string + csReviewerEmail?: string +} + +export interface UpdateEvaluationTargetInput { + id: number + // 기본 정보 + adminComment?: string + consolidatedComment?: string + ldClaimCount?: number + ldClaimAmount?: number + ldClaimCurrency?: "KRW" | "USD" | "EUR" | "JPY" + consensusStatus?: boolean | null + + // 각 부서별 평가 결과 + orderIsApproved?: boolean | null + procurementIsApproved?: boolean | null + qualityIsApproved?: boolean | null + designIsApproved?: boolean | null + csIsApproved?: boolean | null + + // 담당자 이메일 (사용자 ID로 변환됨) + orderReviewerEmail?: string + procurementReviewerEmail?: string + qualityReviewerEmail?: string + designReviewerEmail?: string + csReviewerEmail?: string +} + +export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) { + console.log(input, "update input") + + try { + const session = await auth() + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + return await db.transaction(async (tx) => { + // 평가 대상 존재 확인 + const existing = await tx + .select({ id: evaluationTargets.id }) + .from(evaluationTargets) + .where(eq(evaluationTargets.id, input.id)) + .limit(1) + + if (!existing.length) { + throw new Error("평가 대상을 찾을 수 없습니다.") + } + + // 1. 기본 정보 업데이트 + const updateFields: Partial<typeof evaluationTargets.$inferInsert> = {} + + if (input.adminComment !== undefined) { + updateFields.adminComment = input.adminComment + } + if (input.consolidatedComment !== undefined) { + updateFields.consolidatedComment = input.consolidatedComment + } + if (input.ldClaimCount !== undefined) { + updateFields.ldClaimCount = input.ldClaimCount + } + if (input.ldClaimAmount !== undefined) { + updateFields.ldClaimAmount = input.ldClaimAmount.toString() + } + if (input.ldClaimCurrency !== undefined) { + updateFields.ldClaimCurrency = input.ldClaimCurrency + } + if (input.consensusStatus !== undefined) { + updateFields.consensusStatus = input.consensusStatus + } + + // 기본 정보가 있으면 업데이트 + if (Object.keys(updateFields).length > 0) { + updateFields.updatedAt = new Date() + + await tx + .update(evaluationTargets) + .set(updateFields) + .where(eq(evaluationTargets.id, input.id)) + } + + // 2. 담당자 정보 업데이트 + const reviewerUpdates = [ + { departmentCode: EVALUATION_DEPARTMENT_CODES.ORDER_EVAL, email: input.orderReviewerEmail }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.PROCUREMENT_EVAL, email: input.procurementReviewerEmail }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.QUALITY_EVAL, email: input.qualityReviewerEmail }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.DESIGN_EVAL, email: input.designReviewerEmail }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.CS_EVAL, email: input.csReviewerEmail }, + ] + + for (const update of reviewerUpdates) { + if (update.email !== undefined) { + // 기존 담당자 제거 + await tx + .delete(evaluationTargetReviewers) + .where( + and( + eq(evaluationTargetReviewers.evaluationTargetId, input.id), + eq(evaluationTargetReviewers.departmentCode, update.departmentCode) + ) + ) + + // 새 담당자 추가 (이메일이 있는 경우만) + if (update.email) { + // 이메일로 사용자 ID 조회 + const user = await tx + .select({ id: users.id }) + .from(users) + .where(eq(users.email, update.email)) + .limit(1) + + if (user.length > 0) { + await tx + .insert(evaluationTargetReviewers) + .values({ + evaluationTargetId: input.id, + departmentCode: update.departmentCode, + reviewerUserId: user[0].id, + assignedBy: session.user.id, + }) + } + } + } + } + + // 3. 평가 결과 업데이트 + const reviewUpdates = [ + { departmentCode: EVALUATION_DEPARTMENT_CODES.ORDER_EVAL, isApproved: input.orderIsApproved }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.PROCUREMENT_EVAL, isApproved: input.procurementIsApproved }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.QUALITY_EVAL, isApproved: input.qualityIsApproved }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.DESIGN_EVAL, isApproved: input.designIsApproved }, + { departmentCode: EVALUATION_DEPARTMENT_CODES.CS_EVAL, isApproved: input.csIsApproved }, + ] + + for (const review of reviewUpdates) { + if (review.isApproved !== undefined) { + // 해당 부서의 담당자 조회 + const reviewer = await tx + .select({ + reviewerUserId: evaluationTargetReviewers.reviewerUserId + }) + .from(evaluationTargetReviewers) + .where( + and( + eq(evaluationTargetReviewers.evaluationTargetId, input.id), + eq(evaluationTargetReviewers.departmentCode, review.departmentCode) + ) + ) + .limit(1) + + if (reviewer.length > 0) { + // 기존 평가 결과 삭제 + await tx + .delete(evaluationTargetReviews) + .where( + and( + eq(evaluationTargetReviews.evaluationTargetId, input.id), + eq(evaluationTargetReviews.reviewerUserId, reviewer[0].reviewerUserId) + ) + ) + + // 새 평가 결과 추가 (null이 아닌 경우만) + if (review.isApproved !== null) { + await tx + .insert(evaluationTargetReviews) + .values({ + evaluationTargetId: input.id, + reviewerUserId: reviewer[0].reviewerUserId, + departmentCode: review.departmentCode, + isApproved: review.isApproved, + reviewedAt: new Date(), + }) + } + } + } + } + + // 4. 의견 일치 상태 및 전체 상태 자동 계산 + const currentReviews = await tx + .select({ + isApproved: evaluationTargetReviews.isApproved, + departmentCode: evaluationTargetReviews.departmentCode, + }) + .from(evaluationTargetReviews) + .where(eq(evaluationTargetReviews.evaluationTargetId, input.id)) + + console.log("Current reviews:", currentReviews) + + // 최소 3개 부서에서 평가가 완료된 경우 의견 일치 상태 계산 + if (currentReviews.length >= 3) { + const approvals = currentReviews.map(r => r.isApproved) + const allApproved = approvals.every(approval => approval === true) + const allRejected = approvals.every(approval => approval === false) + const hasConsensus = allApproved || allRejected + + let newStatus: "PENDING" | "CONFIRMED" | "EXCLUDED" = "PENDING" + if (hasConsensus) { + newStatus = allApproved ? "CONFIRMED" : "EXCLUDED" + } + + console.log("Auto-updating status:", { hasConsensus, newStatus, approvals }) + + await tx + .update(evaluationTargets) + .set({ + consensusStatus: hasConsensus, + status: newStatus, + confirmedAt: hasConsensus ? new Date() : null, + confirmedBy: hasConsensus ? session.user.id : null, + updatedAt: new Date() + }) + .where(eq(evaluationTargets.id, input.id)) + } + + return { + success: true, + message: "평가 대상이 성공적으로 수정되었습니다.", + } + }) + } catch (error) { + console.error("Error updating evaluation target:", error) + return { + success: false, + error: error instanceof Error ? error.message : "평가 대상 수정 중 오류가 발생했습니다.", + } + } +} + // 담당자 목록 조회 시 부서 정보도 함께 반환 export async function getAvailableReviewers(departmentCode?: string) { try { @@ -358,9 +612,9 @@ export async function getAvailableVendors(search?: string) { // 검색어가 있으면 적용 search ? or( - ilike(vendors.vendorCode, `%${search}%`), - ilike(vendors.vendorName, `%${search}%`) - ) + ilike(vendors.vendorCode, `%${search}%`), + ilike(vendors.vendorName, `%${search}%`) + ) : undefined ) ) @@ -392,4 +646,266 @@ export async function getDepartmentInfo() { key, }; }); +} + + +export async function confirmEvaluationTargets(targetIds: number[]) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." } + } + + if (targetIds.length === 0) { + return { success: false, error: "선택된 평가 대상이 없습니다." } + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 확정 가능한 대상들 확인 (PENDING 상태이면서 consensusStatus가 true인 것들) + const eligibleTargets = await tx + .select() + .from(evaluationTargets) + .where( + and( + inArray(evaluationTargets.id, targetIds), + eq(evaluationTargets.status, "PENDING"), + eq(evaluationTargets.consensusStatus, true) + ) + ) + + if (eligibleTargets.length === 0) { + throw new Error("확정 가능한 평가 대상이 없습니다. (의견 일치 상태인 대기중 항목만 확정 가능)") + } + + // 상태를 CONFIRMED로 변경 + const confirmedTargetIds = eligibleTargets.map(target => target.id) + await tx + .update(evaluationTargets) + .set({ + status: "CONFIRMED", + confirmedAt: new Date(), + confirmedBy: Number(session.user.id), + updatedAt: new Date() + }) + .where(inArray(evaluationTargets.id, confirmedTargetIds)) + + return confirmedTargetIds + }) + + + return { + success: true, + message: `${targetIds.length}개 평가 대상이 확정되었습니다.`, + confirmedCount: targetIds.length + } + + } catch (error) { + console.error("Error confirming evaluation targets:", error) + return { + success: false, + error: error instanceof Error ? error.message : "확정 처리 중 오류가 발생했습니다." + } + } +} + +export async function excludeEvaluationTargets(targetIds: number[]) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." } + } + + if (targetIds.length === 0) { + return { success: false, error: "선택된 평가 대상이 없습니다." } + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 제외 가능한 대상들 확인 (PENDING 상태인 것들) + const eligibleTargets = await tx + .select() + .from(evaluationTargets) + .where( + and( + inArray(evaluationTargets.id, targetIds), + eq(evaluationTargets.status, "PENDING") + ) + ) + + if (eligibleTargets.length === 0) { + throw new Error("제외 가능한 평가 대상이 없습니다. (대기중 상태인 항목만 제외 가능)") + } + + // 상태를 EXCLUDED로 변경 + const excludedTargetIds = eligibleTargets.map(target => target.id) + await tx + .update(evaluationTargets) + .set({ + status: "EXCLUDED", + updatedAt: new Date() + }) + .where(inArray(evaluationTargets.id, excludedTargetIds)) + + return excludedTargetIds + }) + + + return { + success: true, + message: `${targetIds.length}개 평가 대상이 제외되었습니다.`, + excludedCount: targetIds.length + } + + } catch (error) { + console.error("Error excluding evaluation targets:", error) + return { + success: false, + error: error instanceof Error ? error.message : "제외 처리 중 오류가 발생했습니다." + } + } +} + +export async function requestEvaluationReview(targetIds: number[], message?: string) { + try { + const session = await auth() + if (!session?.user) { + return { success: false, error: "인증이 필요합니다." } + } + + if (targetIds.length === 0) { + return { success: false, error: "선택된 평가 대상이 없습니다." } + } + + // 선택된 평가 대상들과 담당자 정보 조회 + const targetsWithReviewers = await db + .select({ + id: evaluationTargets.id, + vendorCode: evaluationTargets.vendorCode, + vendorName: evaluationTargets.vendorName, + materialType: evaluationTargets.materialType, + evaluationYear: evaluationTargets.evaluationYear, + status: evaluationTargets.status, + reviewerEmail: users.email, + reviewerName: users.name, + departmentCode: evaluationTargetReviewers.departmentCode, + departmentName: evaluationTargetReviewers.departmentNameFrom, + }) + .from(evaluationTargets) + .leftJoin( + evaluationTargetReviewers, + eq(evaluationTargets.id, evaluationTargetReviewers.evaluationTargetId) + ) + .leftJoin( + users, + eq(evaluationTargetReviewers.reviewerUserId, users.id) + ) + .where( + and( + inArray(evaluationTargets.id, targetIds), + eq(evaluationTargets.status, "PENDING") + ) + ) + + if (targetsWithReviewers.length === 0) { + return { success: false, error: "의견 요청 가능한 평가 대상이 없습니다." } + } + + // 평가 대상별로 그룹화 + const targetGroups = targetsWithReviewers.reduce((acc, item) => { + if (!acc[item.id]) { + acc[item.id] = { + id: item.id, + vendorCode: item.vendorCode, + vendorName: item.vendorName, + materialType: item.materialType, + evaluationYear: item.evaluationYear, + reviewers: [] + } + } + + if (item.reviewerEmail) { + acc[item.id].reviewers.push({ + email: item.reviewerEmail, + name: item.reviewerName, + departmentCode: item.departmentCode, + departmentName: item.departmentName + }) + } + + return acc + }, {} as Record<number, any>) + + const targets = Object.values(targetGroups) + + // 모든 담당자 이메일 수집 (중복 제거) + const reviewerEmails = new Set<string>() + const reviewerInfo = new Map<string, { name: string; departments: string[] }>() + + targets.forEach(target => { + target.reviewers.forEach((reviewer: any) => { + if (reviewer.email) { + reviewerEmails.add(reviewer.email) + + if (!reviewerInfo.has(reviewer.email)) { + reviewerInfo.set(reviewer.email, { + name: reviewer.name || reviewer.email, + departments: [] + }) + } + + const info = reviewerInfo.get(reviewer.email)! + if (reviewer.departmentName && !info.departments.includes(reviewer.departmentName)) { + info.departments.push(reviewer.departmentName) + } + } + }) + }) + + if (reviewerEmails.size === 0) { + return { success: false, error: "담당자가 지정되지 않은 평가 대상입니다." } + } + + // 각 담당자에게 이메일 발송 + const emailPromises = Array.from(reviewerEmails).map(email => { + const reviewer = reviewerInfo.get(email)! + + return sendEmail({ + to: email, + subject: `벤더 평가 의견 요청 - ${targets.length}건`, + template: "evaluation-review-request", + context: { + requesterName: session.user.name || session.user.email, + reviewerName: reviewer.name, + targetCount: targets.length, + targets: targets.map(target => ({ + vendorCode: target.vendorCode, + vendorName: target.vendorName, + materialType: target.materialType, + evaluationYear: target.evaluationYear + })), + message: message || "", + reviewUrl: `${process.env.NEXTAUTH_URL}/evaluation-targets`, + requestDate: new Date().toLocaleString('ko-KR') + } + }) + }) + + await Promise.all(emailPromises) + + revalidatePath("/evaluation-targets") + return { + success: true, + message: `${reviewerEmails.size}명의 담당자에게 의견 요청 이메일이 발송되었습니다.`, + emailCount: reviewerEmails.size + } + + } catch (error) { + console.error("Error requesting evaluation review:", error) + return { + success: false, + error: error instanceof Error ? error.message : "의견 요청 중 오류가 발생했습니다." + } + } }
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx b/lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx new file mode 100644 index 00000000..47af419d --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx @@ -0,0 +1,384 @@ +// evaluation-target-action-dialogs.tsx +"use client" + +import * as React from "react" +import { Loader2, AlertTriangle, Check, X, MessageSquare } from "lucide-react" +import { toast } from "sonner" + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" + +import { + confirmEvaluationTargets, + excludeEvaluationTargets, + requestEvaluationReview +} from "../service" +import { EvaluationTargetWithDepartments } from "@/db/schema" + +// ---------------------------------------------------------------- +// 확정 컨펌 다이얼로그 +// ---------------------------------------------------------------- +interface ConfirmTargetsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + targets: EvaluationTargetWithDepartments[] + onSuccess?: () => void +} + +export function ConfirmTargetsDialog({ + open, + onOpenChange, + targets, + onSuccess +}: ConfirmTargetsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + // 확정 가능한 대상들 (consensusStatus가 true인 것들) + const confirmableTargets = targets.filter( + t => t.status === "PENDING" && t.consensusStatus === true + ) + + const handleConfirm = async () => { + if (confirmableTargets.length === 0) return + + setIsLoading(true) + try { + const targetIds = confirmableTargets.map(t => t.id) + const result = await confirmEvaluationTargets(targetIds) + + if (result.success) { + toast.success(result.message) + onSuccess?.() + onOpenChange(false) + } else { + toast.error(result.error) + } + } catch (error) { + toast.error("확정 처리 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + return ( + <AlertDialog open={open} onOpenChange={onOpenChange}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <Check className="h-5 w-5 text-green-600" /> + 평가 대상 확정 + </AlertDialogTitle> + <AlertDialogDescription asChild> + <div className="space-y-3"> + <p> + 선택된 {targets.length}개 항목 중{" "} + <span className="font-semibold text-green-600"> + {confirmableTargets.length}개 항목 + </span> + 을 확정하시겠습니까? + </p> + + {confirmableTargets.length !== targets.length && ( + <div className="p-3 bg-yellow-50 rounded-lg border border-yellow-200"> + <p className="text-sm text-yellow-800"> + <AlertTriangle className="h-4 w-4 inline mr-1" /> + 의견 일치 상태인 대기중 항목만 확정 가능합니다. + ({targets.length - confirmableTargets.length}개 항목 제외됨) + </p> + </div> + )} + + {confirmableTargets.length > 0 && ( + <div className="max-h-32 overflow-y-auto"> + <div className="text-sm space-y-1"> + {confirmableTargets.slice(0, 5).map(target => ( + <div key={target.id} className="flex items-center gap-2"> + <Badge variant="outline" className="text-xs"> + {target.vendorCode} + </Badge> + <span className="text-xs">{target.vendorName}</span> + </div> + ))} + {confirmableTargets.length > 5 && ( + <p className="text-xs text-muted-foreground"> + ...외 {confirmableTargets.length - 5}개 + </p> + )} + </div> + </div> + )} + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleConfirm} + disabled={isLoading || confirmableTargets.length === 0} + className="bg-green-600 hover:bg-green-700" + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 확정 ({confirmableTargets.length}) + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ) +} + +// ---------------------------------------------------------------- +// 제외 컨펌 다이얼로그 +// ---------------------------------------------------------------- +interface ExcludeTargetsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + targets: EvaluationTargetWithDepartments[] + onSuccess?: () => void +} + +export function ExcludeTargetsDialog({ + open, + onOpenChange, + targets, + onSuccess +}: ExcludeTargetsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + // 제외 가능한 대상들 (PENDING 상태인 것들) + const excludableTargets = targets.filter(t => t.status === "PENDING") + + const handleExclude = async () => { + if (excludableTargets.length === 0) return + + setIsLoading(true) + try { + const targetIds = excludableTargets.map(t => t.id) + const result = await excludeEvaluationTargets(targetIds) + + if (result.success) { + toast.success(result.message) + onSuccess?.() + onOpenChange(false) + } else { + toast.error(result.error) + } + } catch (error) { + toast.error("제외 처리 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + return ( + <AlertDialog open={open} onOpenChange={onOpenChange}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <X className="h-5 w-5 text-red-600" /> + 평가 대상 제외 + </AlertDialogTitle> + <AlertDialogDescription asChild> + <div className="space-y-3"> + <p> + 선택된 {targets.length}개 항목 중{" "} + <span className="font-semibold text-red-600"> + {excludableTargets.length}개 항목 + </span> + 을 제외하시겠습니까? + </p> + + {excludableTargets.length !== targets.length && ( + <div className="p-3 bg-yellow-50 rounded-lg border border-yellow-200"> + <p className="text-sm text-yellow-800"> + <AlertTriangle className="h-4 w-4 inline mr-1" /> + 대기중 상태인 항목만 제외 가능합니다. + ({targets.length - excludableTargets.length}개 항목 제외됨) + </p> + </div> + )} + + {excludableTargets.length > 0 && ( + <div className="max-h-32 overflow-y-auto"> + <div className="text-sm space-y-1"> + {excludableTargets.slice(0, 5).map(target => ( + <div key={target.id} className="flex items-center gap-2"> + <Badge variant="outline" className="text-xs"> + {target.vendorCode} + </Badge> + <span className="text-xs">{target.vendorName}</span> + </div> + ))} + {excludableTargets.length > 5 && ( + <p className="text-xs text-muted-foreground"> + ...외 {excludableTargets.length - 5}개 + </p> + )} + </div> + </div> + )} + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleExclude} + disabled={isLoading || excludableTargets.length === 0} + className="bg-red-600 hover:bg-red-700" + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 제외 ({excludableTargets.length}) + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ) +} + +// ---------------------------------------------------------------- +// 의견 요청 다이얼로그 +// ---------------------------------------------------------------- +interface RequestReviewDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + targets: EvaluationTargetWithDepartments[] + onSuccess?: () => void +} + +export function RequestReviewDialog({ + open, + onOpenChange, + targets, + onSuccess +}: RequestReviewDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [message, setMessage] = React.useState("") + + // 의견 요청 가능한 대상들 (PENDING 상태인 것들) + const reviewableTargets = targets.filter(t => t.status === "PENDING") + + // 담당자 이메일들 수집 + const reviewerEmails = React.useMemo(() => { + const emails = new Set<string>() + reviewableTargets.forEach(target => { + if (target.orderReviewerEmail) emails.add(target.orderReviewerEmail) + if (target.procurementReviewerEmail) emails.add(target.procurementReviewerEmail) + if (target.qualityReviewerEmail) emails.add(target.qualityReviewerEmail) + if (target.designReviewerEmail) emails.add(target.designReviewerEmail) + if (target.csReviewerEmail) emails.add(target.csReviewerEmail) + }) + return Array.from(emails) + }, [reviewableTargets]) + + const handleRequestReview = async () => { + if (reviewableTargets.length === 0) return + + setIsLoading(true) + try { + const targetIds = reviewableTargets.map(t => t.id) + const result = await requestEvaluationReview(targetIds, message) + + if (result.success) { + toast.success(result.message) + onSuccess?.() + onOpenChange(false) + setMessage("") + } else { + toast.error(result.error) + } + } catch (error) { + toast.error("의견 요청 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <MessageSquare className="h-5 w-5 text-blue-600" /> + 평가 의견 요청 + </DialogTitle> + <DialogDescription> + 선택된 평가 대상에 대한 의견을 담당자들에게 요청합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 요약 정보 */} + <div className="p-3 bg-blue-50 rounded-lg border border-blue-200"> + <div className="text-sm space-y-1"> + <p> + <span className="font-medium">요청 대상:</span> {reviewableTargets.length}개 평가 항목 + </p> + <p> + <span className="font-medium">받는 사람:</span> {reviewerEmails.length}명의 담당자 + </p> + </div> + </div> + + {/* 메시지 입력 */} + <div className="space-y-2"> + <Label htmlFor="review-message">추가 메시지 (선택사항)</Label> + <Textarea + id="review-message" + placeholder="담당자들에게 전달할 추가 메시지를 입력하세요..." + value={message} + onChange={(e) => setMessage(e.target.value)} + rows={3} + /> + </div> + + {reviewableTargets.length !== targets.length && ( + <div className="p-3 bg-yellow-50 rounded-lg border border-yellow-200"> + <p className="text-sm text-yellow-800"> + <AlertTriangle className="h-4 w-4 inline mr-1" /> + 대기중 상태인 항목만 의견 요청 가능합니다. + ({targets.length - reviewableTargets.length}개 항목 제외됨) + </p> + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleRequestReview} + disabled={isLoading || reviewableTargets.length === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 의견 요청 발송 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ 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 index 15837733..fe0b3188 100644 --- a/lib/evaluation-target-list/table/evaluation-target-table.tsx +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -25,6 +25,7 @@ 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" +import { EditEvaluationTargetSheet } from "./update-evaluation-target" interface EvaluationTargetsTableProps { promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]> @@ -40,13 +41,13 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) React.useEffect(() => { let isMounted = true - + async function fetchStats() { try { setIsLoading(true) setError(null) const statsData = await getEvaluationTargetsStats(evaluationYear) - + if (isMounted) { setStats(statsData) } @@ -186,45 +187,59 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) 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) + // ✅ 스크롤 이벤트 throttling으로 성능 최적화 const updateContainerBounds = React.useCallback(() => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect() - setContainerTop(rect.top) + const newTop = rect.top + + // ✅ 값이 실제로 변경될 때만 상태 업데이트 + setContainerTop(prevTop => { + if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트 + return newTop + } + return prevTop + }) } }, []) + // ✅ throttle 함수 추가 + const throttledUpdateBounds = React.useCallback(() => { + let timeoutId: NodeJS.Timeout + return () => { + clearTimeout(timeoutId) + timeoutId = setTimeout(updateContainerBounds, 16) // ~60fps + } + }, [updateContainerBounds]) + React.useEffect(() => { updateContainerBounds() - + + const throttledHandler = throttledUpdateBounds() + const handleResize = () => { updateContainerBounds() } - + window.addEventListener('resize', handleResize) - window.addEventListener('scroll', updateContainerBounds) - + window.addEventListener('scroll', throttledHandler) // ✅ throttled 함수 사용 + return () => { window.removeEventListener('resize', handleResize) - window.removeEventListener('scroll', updateContainerBounds) + window.removeEventListener('scroll', throttledHandler) } - }, [updateContainerBounds]) + }, [updateContainerBounds, throttledUpdateBounds]) 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] - }) + console.log(tableData) const initialSettings = React.useMemo(() => ({ page: parseInt(searchParams.get('page') || '1'), @@ -232,7 +247,7 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: 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') ? + basicFilters: searchParams.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [], basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", search: searchParams.get('search') || '', @@ -259,8 +274,8 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: } = useTablePresets<EvaluationTargetWithDepartments>('evaluation-targets-table', initialSettings) const columns = React.useMemo( - () => getEvaluationTargetsColumns(), - [] + () => getEvaluationTargetsColumns({ setRowAction }), + [setRowAction] ) const filterFields: DataTableFilterField<EvaluationTargetWithDepartments>[] = [ @@ -271,31 +286,41 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: const advancedFilterFields: DataTableAdvancedFilterField<EvaluationTargetWithDepartments>[] = [ { id: "evaluationYear", label: "평가년도", type: "number" }, - { id: "division", label: "구분", type: "select", options: [ - { label: "해양", value: "OCEAN" }, - { label: "조선", value: "SHIPYARD" }, - ]}, + { + 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: "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" }, @@ -305,17 +330,21 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: const currentSettings = useMemo(() => { return getCurrentSettings() }, [getCurrentSettings]) - + + function getColKey<T>(c: ColumnDef<T>): string | undefined { + if ("accessorKey" in c && c.accessorKey) return c.accessorKey as string + if ("id" in c && c.id) return c.id as string + return undefined + } + 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, + sorting: initialSettings.sort.filter(s => + columns.some(c => getColKey(c) === s.id)), columnVisibility: currentSettings.columnVisibility, columnPinning: currentSettings.pinnedColumns, } - }, [currentSettings, initialSettings.sort, columns]) + }, [columns, currentSettings, initialSettings.sort]) const { table } = useDataTable({ data: tableData.data, @@ -349,12 +378,12 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: return ( <> {/* Filter Panel */} - <div + <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={{ + style={{ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', top: `${containerTop}px`, height: `calc(100vh - ${containerTop}px)` @@ -362,7 +391,7 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: > <div className="h-full"> <EvaluationTargetFilterSheet - isOpen={isFilterPanelOpen} + isOpen={isFilterPanelOpen} onClose={() => setIsFilterPanelOpen(false)} onSearch={handleSearch} isLoading={false} @@ -371,12 +400,12 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: </div> {/* Main Content Container */} - <div + <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)} > <div className="flex w-full h-full"> - <div + <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%', @@ -386,14 +415,14 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: {/* 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" + <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"/>} + {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()} @@ -401,7 +430,7 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: )} </Button> </div> - + <div className="text-sm text-muted-foreground"> {tableData && ( <span>총 {tableData.total || tableData.data.length}건</span> @@ -437,11 +466,18 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: onSetDefaultPreset={setDefaultPreset} onRenamePreset={renamePreset} /> - + <EvaluationTargetsTableToolbarActions table={table} /> </div> </DataTableAdvancedToolbar> </DataTable> + + <EditEvaluationTargetSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + evaluationTarget={rowAction?.row.original ?? null} + /> + </div> </div> </div> diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx index b1e19434..93807ef9 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx @@ -7,6 +7,11 @@ 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"; +import { EditEvaluationTargetSheet } from "./update-evaluation-target"; + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EvaluationTargetWithDepartments> | null>>; +} // 상태별 색상 매핑 const getStatusBadgeVariant = (status: string) => { @@ -36,8 +41,8 @@ const getConsensusBadge = (consensusStatus: boolean | null) => { // 구분 배지 const getDivisionBadge = (division: string) => { return ( - <Badge variant={division === "OCEAN" ? "default" : "secondary"}> - {division === "OCEAN" ? "해양" : "조선"} + <Badge variant={division === "PLANT" ? "default" : "secondary"}> + {division === "PLANT" ? "해양" : "조선"} </Badge> ); }; @@ -46,7 +51,7 @@ const getDivisionBadge = (division: string) => { const getMaterialTypeBadge = (materialType: string) => { const typeMap = { EQUIPMENT: "기자재", - BULK: "벌크", + BULK: "벌크", EQUIPMENT_BULK: "기자재/벌크" }; return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>; @@ -61,8 +66,23 @@ const getDomesticForeignBadge = (domesticForeign: string) => { ); }; -export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDepartments>[] { +// 평가 상태 배지 +const getApprovalBadge = (isApproved: boolean | null) => { + if (isApproved === null) { + return <Badge variant="outline" className="text-xs">대기중</Badge>; + } + if (isApproved === true) { + return <Badge variant="default" className="bg-green-600 text-xs">승인</Badge>; + } + return <Badge variant="destructive" className="text-xs">거부</Badge>; +}; + +export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] { return [ + // ═══════════════════════════════════════════════════════════════ + // 기본 정보 + // ═══════════════════════════════════════════════════════════════ + // Checkbox { id: "select", @@ -102,46 +122,6 @@ export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDep 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="상태" />, @@ -161,6 +141,54 @@ export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDep size: 100, }, + // ░░░ 벤더 코드 ░░░ + + { + header: "협력업체 정보", + columns: [ + { + 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: "consensusStatus", @@ -169,56 +197,235 @@ export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDep 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> - )} + header: "발주 평가 담당자", + columns: [ + { + accessorKey: "orderDepartmentName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />, + cell: ({ row }) => { + const departmentName = row.getValue<string>("orderDepartmentName"); + return departmentName ? ( + <div className="truncate max-w-[120px]" title={departmentName}> + {departmentName} </div> - )} - </div> - ); - }, - size: 120, - enableSorting: false, + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 120, + }, + { + accessorKey: "orderReviewerName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />, + cell: ({ row }) => { + const reviewerName = row.getValue<string>("orderReviewerName"); + return reviewerName ? ( + <div className="truncate max-w-[100px]" title={reviewerName}> + {reviewerName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "orderIsApproved", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />, + cell: ({ row }) => getApprovalBadge(row.getValue("orderIsApproved")), + size: 80, + }, + ], + }, + + // ═══════════════════════════════════════════════════════════════ + // 조달 부서 그룹 + // ═══════════════════════════════════════════════════════════════ + { + header: "조달 평가 담당자", + columns: [ + { + accessorKey: "procurementDepartmentName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />, + cell: ({ row }) => { + const departmentName = row.getValue<string>("procurementDepartmentName"); + return departmentName ? ( + <div className="truncate max-w-[120px]" title={departmentName}> + {departmentName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 120, + }, + { + accessorKey: "procurementReviewerName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />, + cell: ({ row }) => { + const reviewerName = row.getValue<string>("procurementReviewerName"); + return reviewerName ? ( + <div className="truncate max-w-[100px]" title={reviewerName}> + {reviewerName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "procurementIsApproved", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />, + cell: ({ row }) => getApprovalBadge(row.getValue("procurementIsApproved")), + size: 80, + }, + ], }, + // ═══════════════════════════════════════════════════════════════ + // 품질 부서 그룹 + // ═══════════════════════════════════════════════════════════════ + { + header: "품질 평가 담당자", + columns: [ + { + accessorKey: "qualityDepartmentName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />, + cell: ({ row }) => { + const departmentName = row.getValue<string>("qualityDepartmentName"); + return departmentName ? ( + <div className="truncate max-w-[120px]" title={departmentName}> + {departmentName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 120, + }, + { + accessorKey: "qualityReviewerName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />, + cell: ({ row }) => { + const reviewerName = row.getValue<string>("qualityReviewerName"); + return reviewerName ? ( + <div className="truncate max-w-[100px]" title={reviewerName}> + {reviewerName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "qualityIsApproved", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />, + cell: ({ row }) => getApprovalBadge(row.getValue("qualityIsApproved")), + size: 80, + }, + ], + }, + + // ═══════════════════════════════════════════════════════════════ + // 설계 부서 그룹 + // ═══════════════════════════════════════════════════════════════ + { + header: "설계 평가 담당자", + columns: [ + { + accessorKey: "designDepartmentName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />, + cell: ({ row }) => { + const departmentName = row.getValue<string>("designDepartmentName"); + return departmentName ? ( + <div className="truncate max-w-[120px]" title={departmentName}> + {departmentName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 120, + }, + { + accessorKey: "designReviewerName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />, + cell: ({ row }) => { + const reviewerName = row.getValue<string>("designReviewerName"); + return reviewerName ? ( + <div className="truncate max-w-[100px]" title={reviewerName}> + {reviewerName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "designIsApproved", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />, + cell: ({ row }) => getApprovalBadge(row.getValue("designIsApproved")), + size: 80, + }, + ], + }, + + // ═══════════════════════════════════════════════════════════════ + // CS 부서 그룹 + // ═══════════════════════════════════════════════════════════════ + { + header: "CS 평가 담당자", + columns: [ + { + accessorKey: "csDepartmentName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />, + cell: ({ row }) => { + const departmentName = row.getValue<string>("csDepartmentName"); + return departmentName ? ( + <div className="truncate max-w-[120px]" title={departmentName}> + {departmentName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 120, + }, + { + accessorKey: "csReviewerName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />, + cell: ({ row }) => { + const reviewerName = row.getValue<string>("csReviewerName"); + return reviewerName ? ( + <div className="truncate max-w-[100px]" title={reviewerName}> + {reviewerName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "csIsApproved", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />, + cell: ({ row }) => getApprovalBadge(row.getValue("csIsApproved")), + size: 80, + }, + ], + }, + + // ═══════════════════════════════════════════════════════════════ + // 관리 정보 + // ═══════════════════════════════════════════════════════════════ + // ░░░ 관리자 의견 ░░░ { accessorKey: "adminComment", @@ -274,69 +481,47 @@ export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDep size: 100, }, + // ░░░ 생성일 ░░░ + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />, + cell: ({ row }) => { + const createdAt = row.getValue<Date>("createdAt"); + return createdAt ? ( + <span className="text-sm"> + {new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date(createdAt))} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + // ░░░ Actions ░░░ { id: "actions", enableHiding: false, - size: 120, - minSize: 120, + size: 40, + minSize: 40, 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 ( + 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)} + onClick={() => setRowAction({ row, type: "update" })} 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> ); }, diff --git a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx index c14ae83f..502ee974 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx @@ -44,12 +44,17 @@ const evaluationTargetFilterSchema = z.object({ vendorCode: z.string().optional(), vendorName: z.string().optional(), reviewerUserId: z.string().optional(), // 담당자 ID로 필터링 + orderReviewerName: z.string().optional(), // 주문 검토자명 + procurementReviewerName: z.string().optional(), // 조달 검토자명 + qualityReviewerName: z.string().optional(), // 품질 검토자명 + designReviewerName: z.string().optional(), // 설계 검토자명 + csReviewerName: z.string().optional(), // CS 검토자명 }) // 옵션 정의 const divisionOptions = [ - { value: "OCEAN", label: "해양" }, - { value: "SHIPYARD", label: "조선" }, + { value: "PLANT", label: "해양" }, + { value: "SHIP", label: "조선" }, ] const statusOptions = [ @@ -128,6 +133,11 @@ export function EvaluationTargetFilterSheet({ vendorCode: "", vendorName: "", reviewerUserId: "", + orderReviewerName: "", + procurementReviewerName: "", + qualityReviewerName: "", + designReviewerName: "", + csReviewerName: "", }, }) @@ -261,6 +271,57 @@ export function EvaluationTargetFilterSheet({ }) } + // 새로 추가된 검토자명 필터들 + if (data.orderReviewerName?.trim()) { + newFilters.push({ + id: "orderReviewerName", + value: data.orderReviewerName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.procurementReviewerName?.trim()) { + newFilters.push({ + id: "procurementReviewerName", + value: data.procurementReviewerName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.qualityReviewerName?.trim()) { + newFilters.push({ + id: "qualityReviewerName", + value: data.qualityReviewerName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.designReviewerName?.trim()) { + newFilters.push({ + id: "designReviewerName", + value: data.designReviewerName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.csReviewerName?.trim()) { + newFilters.push({ + id: "csReviewerName", + value: data.csReviewerName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + // URL 업데이트 const currentUrl = new URL(window.location.href); const params = new URLSearchParams(currentUrl.search); @@ -313,6 +374,11 @@ export function EvaluationTargetFilterSheet({ vendorCode: "", vendorName: "", reviewerUserId: "", + orderReviewerName: "", + procurementReviewerName: "", + qualityReviewerName: "", + designReviewerName: "", + csReviewerName: "", }); // URL 초기화 @@ -723,6 +789,191 @@ export function EvaluationTargetFilterSheet({ )} /> + {/* 주문 검토자명 */} + <FormField + control={form.control} + name="orderReviewerName" + 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("orderReviewerName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 조달 검토자명 */} + <FormField + control={form.control} + name="procurementReviewerName" + 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("procurementReviewerName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 품질 검토자명 */} + <FormField + control={form.control} + name="qualityReviewerName" + 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("qualityReviewerName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 설계 검토자명 */} + <FormField + control={form.control} + name="designReviewerName" + 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("designReviewerName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* CS 검토자명 */} + <FormField + control={form.control} + name="csReviewerName" + render={({ field }) => ( + <FormItem> + <FormLabel>CS 담당자명</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="CS 담당자명 입력" + {...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("csReviewerName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> </div> diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx index 3fb47771..9043c588 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -24,7 +24,13 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { ManualCreateEvaluationTargetDialog } from "./manual-create-evaluation-target-dialog" +import { + ConfirmTargetsDialog, + ExcludeTargetsDialog, + RequestReviewDialog +} from "./evaluation-target-action-dialogs" import { EvaluationTargetWithDepartments } from "@/db/schema" +import { exportTableToExcel } from "@/lib/export" interface EvaluationTargetsTableToolbarActionsProps { table: Table<EvaluationTargetWithDepartments> @@ -37,6 +43,9 @@ export function EvaluationTargetsTableToolbarActions({ }: EvaluationTargetsTableToolbarActionsProps) { const [isLoading, setIsLoading] = React.useState(false) const [manualCreateDialogOpen, setManualCreateDialogOpen] = React.useState(false) + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [excludeDialogOpen, setExcludeDialogOpen] = React.useState(false) + const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false) const router = useRouter() // 선택된 행들 @@ -91,84 +100,12 @@ export function EvaluationTargetsTableToolbarActions({ } // ---------------------------------------------------------------- - // 선택된 항목들 확정 + // 다이얼로그 성공 핸들러 // ---------------------------------------------------------------- - 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("데이터가 새로고침되었습니다.") + const handleActionSuccess = () => { + table.resetRowSelection() + onRefresh?.() + router.refresh() } return ( @@ -204,22 +141,17 @@ export function EvaluationTargetsTableToolbarActions({ <Button variant="outline" size="sm" - onClick={handleExport} + onClick={() => + exportTableToExcel(table, { + filename: "vendor-target-list", + excludeColumns: ["select", "actions"], + }) + } 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> {/* 선택된 항목 액션 버튼들 */} @@ -231,7 +163,7 @@ export function EvaluationTargetsTableToolbarActions({ variant="default" size="sm" className="gap-2 bg-green-600 hover:bg-green-700" - onClick={handleConfirmSelected} + onClick={() => setConfirmDialogOpen(true)} disabled={isLoading} > <Check className="size-4" aria-hidden="true" /> @@ -247,7 +179,7 @@ export function EvaluationTargetsTableToolbarActions({ variant="destructive" size="sm" className="gap-2" - onClick={handleExcludeSelected} + onClick={() => setExcludeDialogOpen(true)} disabled={isLoading} > <X className="size-4" aria-hidden="true" /> @@ -263,7 +195,7 @@ export function EvaluationTargetsTableToolbarActions({ variant="outline" size="sm" className="gap-2" - onClick={handleRequestReview} + onClick={() => setReviewDialogOpen(true)} disabled={isLoading} > <MessageSquare className="size-4" aria-hidden="true" /> @@ -282,6 +214,30 @@ export function EvaluationTargetsTableToolbarActions({ onOpenChange={setManualCreateDialogOpen} /> + {/* 확정 컨펌 다이얼로그 */} + <ConfirmTargetsDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + targets={selectedTargets} + onSuccess={handleActionSuccess} + /> + + {/* 제외 컨펌 다이얼로그 */} + <ExcludeTargetsDialog + open={excludeDialogOpen} + onOpenChange={setExcludeDialogOpen} + targets={selectedTargets} + onSuccess={handleActionSuccess} + /> + + {/* 의견 요청 다이얼로그 */} + <RequestReviewDialog + open={reviewDialogOpen} + onOpenChange={setReviewDialogOpen} + targets={selectedTargets} + onSuccess={handleActionSuccess} + /> + {/* 선택 정보 표시 */} {hasSelection && ( <div className="text-xs text-muted-foreground"> 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 index 5704cba1..af369ea6 100644 --- a/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx +++ b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx @@ -60,26 +60,7 @@ import { 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 @@ -114,12 +95,49 @@ export function ManualCreateEvaluationTargetDialog({ // 부서 정보 상태 const [departments, setDepartments] = React.useState<Array<{ code: string, name: string, key: string }>>([]) + // 폼 스키마 정의 +const createEvaluationTargetSchema = z.object({ + evaluationYear: z.number().min(2020).max(2030), + division: z.enum(["PLANT", "SHIP"]), + vendorId: z.number().min(0), // 0도 허용, 클라이언트에서 검증 + 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) 제거, 나중에 클라이언트에서 필터링 + }) + ), +}).refine((data) => { + // 벤더가 선택되어야 함 + if (data.vendorId === 0) { + return false; + } + // 최소 1명의 담당자가 지정되어야 함 (reviewerUserId > 0) + const validReviewers = data.reviewers + .filter(r => r.reviewerUserId > 0) + .map((r, i) => ({ + departmentCode: r.departmentCode || departments[i]?.code, // 없으면 보충 + reviewerUserId: r.reviewerUserId, + })); + return validReviewers.length > 0; +}, { + message: "벤더를 선택하고 최소 1명의 담당자를 지정해주세요.", + path: ["vendorId"] +}) +type CreateEvaluationTargetFormValues = z.infer<typeof createEvaluationTargetSchema> + + const form = useForm<CreateEvaluationTargetFormValues>({ resolver: zodResolver(createEvaluationTargetSchema), defaultValues: { evaluationYear: getDefaultEvaluationYear(), - division: "OCEAN", - vendorId: 0, + division: "SHIP", + vendorId: 0, // 임시로 0, 나중에 검증에서 체크 materialType: "EQUIPMENT", adminComment: "", ldClaimCount: 0, @@ -180,17 +198,12 @@ export function ManualCreateEvaluationTargetDialog({ // 부서 정보가 로드되면 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) - } - } + const defaultReviewers = departments.map(dept => ({ + departmentCode: dept.code, // ✅ 반드시 포함 + reviewerUserId: 0, + })); + form.setValue("reviewers", defaultReviewers, { shouldValidate: false }); + } }, [departments, open]) // form 의존성 제거하고 조건 추가 console.log(departments) @@ -234,7 +247,7 @@ export function ManualCreateEvaluationTargetDialog({ // 폼과 상태 초기화 form.reset({ evaluationYear: getDefaultEvaluationYear(), - division: "OCEAN", + division: "SHIP", vendorId: 0, materialType: "EQUIPMENT", adminComment: "", @@ -269,7 +282,7 @@ export function ManualCreateEvaluationTargetDialog({ if (!open) { form.reset({ evaluationYear: getDefaultEvaluationYear(), - division: "OCEAN", + division: "SHIP", vendorId: 0, materialType: "EQUIPMENT", adminComment: "", @@ -326,24 +339,29 @@ export function ManualCreateEvaluationTargetDialog({ return ( <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="max-w-lg flex flex-col h-[90vh]"> + <DialogContent className="max-w-lg h-[90vh] p-0 flex flex-col"> {/* 고정 헤더 */} - <DialogHeader className="flex-shrink-0"> - <DialogTitle>평가 대상 수동 생성</DialogTitle> - <DialogDescription> - 새로운 평가 대상을 수동으로 생성하고 담당자를 지정합니다. - </DialogDescription> - </DialogHeader> + <div className="flex-shrink-0 p-6 border-b"> + <DialogHeader> + <DialogTitle>평가 대상 수동 생성</DialogTitle> + <DialogDescription> + 새로운 평가 대상을 수동으로 생성하고 담당자를 지정합니다. + </DialogDescription> + </DialogHeader> + </div> {/* Form을 전체 콘텐츠를 감싸도록 수정 */} <Form {...form}> <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col flex-1" + onSubmit={form.handleSubmit( + onSubmit, + (errors) => console.log('❌ validation errors:', errors) + )} + className="flex flex-col flex-1 min-h-0" id="evaluation-target-form" > {/* 스크롤 가능한 콘텐츠 영역 */} - <div className="flex-1 overflow-y-auto"> + <div className="flex-1 overflow-y-auto p-6"> <div className="space-y-6"> {/* 기본 정보 */} <Card> @@ -693,9 +711,14 @@ export function ManualCreateEvaluationTargetDialog({ <CommandItem value="선택 안함" onSelect={() => { - field.onChange(0) - setReviewerOpens(prev => ({...prev, [department.code]: false})) - }} + // reviewers[index] 전체를 갱신 + form.setValue( + `reviewers.${index}`, + { departmentCode: department.code, reviewerUserId: reviewer.id }, + { shouldValidate: true } + ); + setReviewerOpens(prev => ({ ...prev, [department.code]: false })); + }} > <Check className={cn( @@ -747,22 +770,24 @@ export function ManualCreateEvaluationTargetDialog({ </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 className="flex-shrink-0 border-t bg-background p-6"> + <div className="flex justify-end gap-3"> + <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> </div> </form> </Form> diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx new file mode 100644 index 00000000..0d56addb --- /dev/null +++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx @@ -0,0 +1,760 @@ +"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, X } from "lucide-react" +import { toast } from "sonner" +import { useSession } from "next-auth/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 { + 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 { + updateEvaluationTarget, + getAvailableReviewers, + getDepartmentInfo, + type UpdateEvaluationTargetInput, +} from "../service" +import { EvaluationTargetWithDepartments } from "@/db/schema" + +// 편집 가능한 필드들에 대한 스키마 +const editEvaluationTargetSchema = z.object({ + adminComment: z.string().optional(), + consolidatedComment: z.string().optional(), + ldClaimCount: z.number().min(0).optional(), + ldClaimAmount: z.number().min(0).optional(), + ldClaimCurrency: z.enum(["KRW", "USD", "EUR", "JPY"]).optional(), + consensusStatus: z.boolean().nullable().optional(), + orderIsApproved: z.boolean().nullable().optional(), + procurementIsApproved: z.boolean().nullable().optional(), + qualityIsApproved: z.boolean().nullable().optional(), + designIsApproved: z.boolean().nullable().optional(), + csIsApproved: z.boolean().nullable().optional(), + // 담당자 정보 수정 + orderReviewerEmail: z.string().optional(), + procurementReviewerEmail: z.string().optional(), + qualityReviewerEmail: z.string().optional(), + designReviewerEmail: z.string().optional(), + csReviewerEmail: z.string().optional(), +}) + +type EditEvaluationTargetFormValues = z.infer<typeof editEvaluationTargetSchema> + +interface EditEvaluationTargetSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluationTarget: EvaluationTargetWithDepartments | null +} + +// 권한 타입 정의 +type PermissionLevel = "none" | "department" | "admin" + +interface UserPermissions { + level: PermissionLevel + editableApprovals: string[] // 편집 가능한 approval 필드들 +} + +export function EditEvaluationTargetSheet({ + open, + onOpenChange, + evaluationTarget, +}: EditEvaluationTargetSheetProps) { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = React.useState(false) + const { data: session } = useSession() + + // 담당자 관련 상태 + 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 userPermissions = React.useMemo((): UserPermissions => { + if (!session?.user || !evaluationTarget) { + return { level: "none", editableApprovals: [] } + } + + const userEmail = session.user.email + const userRole = session.user.role + + // 평가관리자는 모든 권한 + if (userRole === "평가관리자") { + return { + level: "admin", + editableApprovals: [ + "orderIsApproved", + "procurementIsApproved", + "qualityIsApproved", + "designIsApproved", + "csIsApproved" + ] + } + } + + // 부서별 담당자 권한 확인 + const editableApprovals: string[] = [] + + if (evaluationTarget.orderReviewerEmail === userEmail) { + editableApprovals.push("orderIsApproved") + } + if (evaluationTarget.procurementReviewerEmail === userEmail) { + editableApprovals.push("procurementIsApproved") + } + if (evaluationTarget.qualityReviewerEmail === userEmail) { + editableApprovals.push("qualityIsApproved") + } + if (evaluationTarget.designReviewerEmail === userEmail) { + editableApprovals.push("designIsApproved") + } + if (evaluationTarget.csReviewerEmail === userEmail) { + editableApprovals.push("csIsApproved") + } + + return { + level: editableApprovals.length > 0 ? "department" : "none", + editableApprovals + } + }, [session, evaluationTarget]) + + const form = useForm<EditEvaluationTargetFormValues>({ + resolver: zodResolver(editEvaluationTargetSchema), + defaultValues: { + adminComment: "", + consolidatedComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + consensusStatus: null, + orderIsApproved: null, + procurementIsApproved: null, + qualityIsApproved: null, + designIsApproved: null, + csIsApproved: null, + orderReviewerEmail: "", + procurementReviewerEmail: "", + qualityReviewerEmail: "", + designReviewerEmail: "", + csReviewerEmail: "", + }, + }) + + // 부서 정보 로드 + const loadDepartments = React.useCallback(async () => { + try { + const departmentList = await getDepartmentInfo() + setDepartments(departmentList) + } catch (error) { + console.error("Error loading departments:", error) + toast.error("부서 정보를 불러오는데 실패했습니다.") + } + }, []) + + // 담당자 목록 로드 + 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 && evaluationTarget) { + loadDepartments() + loadReviewers() + + // 폼에 기존 데이터 설정 + form.reset({ + adminComment: evaluationTarget.adminComment || "", + consolidatedComment: evaluationTarget.consolidatedComment || "", + ldClaimCount: evaluationTarget.ldClaimCount || 0, + ldClaimAmount: parseFloat(evaluationTarget.ldClaimAmount || "0"), + ldClaimCurrency: evaluationTarget.ldClaimCurrency || "KRW", + consensusStatus: evaluationTarget.consensusStatus, + orderIsApproved: evaluationTarget.orderIsApproved, + procurementIsApproved: evaluationTarget.procurementIsApproved, + qualityIsApproved: evaluationTarget.qualityIsApproved, + designIsApproved: evaluationTarget.designIsApproved, + csIsApproved: evaluationTarget.csIsApproved, + orderReviewerEmail: evaluationTarget.orderReviewerEmail || "", + procurementReviewerEmail: evaluationTarget.procurementReviewerEmail || "", + qualityReviewerEmail: evaluationTarget.qualityReviewerEmail || "", + designReviewerEmail: evaluationTarget.designReviewerEmail || "", + csReviewerEmail: evaluationTarget.csReviewerEmail || "", + }) + } + }, [open, evaluationTarget, form]) + + // 폼 제출 + async function onSubmit(data: EditEvaluationTargetFormValues) { + if (!evaluationTarget) return + + setIsSubmitting(true) + try { + const input: UpdateEvaluationTargetInput = { + id: evaluationTarget.id, + ...data, + } + + console.log("Updating evaluation target:", input) + + const result = await updateEvaluationTarget(input) + + if (result.success) { + toast.success(result.message || "평가 대상이 성공적으로 수정되었습니다.") + onOpenChange(false) + router.refresh() + } else { + toast.error(result.error || "평가 대상 수정에 실패했습니다.") + } + } catch (error) { + console.error("Error updating evaluation target:", error) + toast.error("평가 대상 수정 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 시트 닫기 핸들러 + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + if (!open) { + form.reset() + setReviewerSearches({}) + setReviewerOpens({}) + } + } + + // 담당자 검색 필터링 + 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 canEditField = (fieldName: string): boolean => { + if (userPermissions.level === "admin") return true + if (userPermissions.level === "none") return false + + // 부서 담당자는 자신의 approval만 편집 가능 + return userPermissions.editableApprovals.includes(fieldName) + } + + // 관리자 전용 필드 확인 + const canEditAdminFields = (): boolean => { + return userPermissions.level === "admin" + } + + if (!evaluationTarget) { + return null + } + + // 권한이 없는 경우 + if (userPermissions.level === "none") { + return ( + <Sheet open={open} onOpenChange={handleOpenChange}> + <SheetContent className="sm:max-w-lg overflow-y-auto"> + <SheetHeader> + <SheetTitle>평가 대상 수정</SheetTitle> + <SheetDescription> + 권한이 없어 수정할 수 없습니다. + </SheetDescription> + </SheetHeader> + <div className="mt-6 p-4 bg-muted rounded-lg text-center"> + <p className="text-sm text-muted-foreground"> + 이 평가 대상을 수정할 권한이 없습니다. + </p> + </div> + </SheetContent> + </Sheet> + ) + } + + return ( + <Sheet open={open} onOpenChange={handleOpenChange}> + <SheetContent className="flex flex-col h-full sm:max-w-xl"> + <SheetHeader className="flex-shrink-0 text-left pb-6"> + <SheetTitle>평가 대상 수정</SheetTitle> + <SheetDescription> + 평가 대상 정보를 수정합니다. + {userPermissions.level === "department" && ( + <div className="mt-2 p-2 bg-blue-50 rounded text-sm"> + <strong>부서 담당자 권한:</strong> 해당 부서의 평가 항목만 수정 가능합니다. + </div> + )} + {userPermissions.level === "admin" && ( + <div className="mt-2 p-2 bg-green-50 rounded text-sm"> + <strong>평가관리자 권한:</strong> 모든 항목을 수정할 수 있습니다. + </div> + )} + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + {/* 스크롤 가능한 컨텐츠 영역 */} + <div className="flex-1 overflow-y-auto pr-2 -mr-2"> + <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 text-sm"> + <div> + <span className="font-medium">평가년도:</span> {evaluationTarget.evaluationYear} + </div> + <div> + <span className="font-medium">구분:</span> {evaluationTarget.division === "PLANT" ? "해양" : "조선"} + </div> + <div> + <span className="font-medium">벤더 코드:</span> {evaluationTarget.vendorCode} + </div> + <div> + <span className="font-medium">벤더명:</span> {evaluationTarget.vendorName} + </div> + <div> + <span className="font-medium">자재구분:</span> {evaluationTarget.materialType} + </div> + <div> + <span className="font-medium">상태:</span> {evaluationTarget.status} + </div> + </div> + </CardContent> + </Card> + + {/* L/D 클레임 정보 (관리자만) */} + {canEditAdminFields() && ( + <Card> + <CardHeader> + <CardTitle className="text-lg">L/D 클레임 정보</CardTitle> + </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" + {...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" + {...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} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue /> + </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> + </CardHeader> + <CardContent className="space-y-4"> + + {/* 의견 일치 여부 (관리자만) */} + {canEditAdminFields() && ( + <FormField + control={form.control} + name="consensusStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>의견 일치 여부</FormLabel> + <Select + onValueChange={(value) => { + if (value === "null") field.onChange(null) + else field.onChange(value === "true") + }} + value={field.value === null ? "null" : field.value.toString()} + > + <FormControl> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="null">검토 중</SelectItem> + <SelectItem value="true">의견 일치</SelectItem> + <SelectItem value="false">의견 불일치</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 각 부서별 평가 */} + <div className="grid grid-cols-1 gap-4"> + {[ + { key: "orderIsApproved", label: "주문 부서 평가", email: evaluationTarget.orderReviewerEmail }, + { key: "procurementIsApproved", label: "조달 부서 평가", email: evaluationTarget.procurementReviewerEmail }, + { key: "qualityIsApproved", label: "품질 부서 평가", email: evaluationTarget.qualityReviewerEmail }, + { key: "designIsApproved", label: "설계 부서 평가", email: evaluationTarget.designReviewerEmail }, + { key: "csIsApproved", label: "CS 부서 평가", email: evaluationTarget.csReviewerEmail }, + ].map(({ key, label, email }) => ( + <FormField + key={key} + control={form.control} + name={key as keyof EditEvaluationTargetFormValues} + render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center justify-between"> + <span>{label}</span> + {email && ( + <span className="text-xs text-muted-foreground"> + 담당자: {email} + </span> + )} + </FormLabel> + <Select + onValueChange={(value) => { + if (value === "null") field.onChange(null) + else field.onChange(value === "true") + }} + value={field.value === null ? "null" : field.value?.toString()} + disabled={!canEditField(key)} + > + <FormControl> + <SelectTrigger className={!canEditField(key) ? "opacity-50" : ""}> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="null">대기중</SelectItem> + <SelectItem value="true">평가대상 맞음</SelectItem> + <SelectItem value="false">평가대상 아님</SelectItem> + </SelectContent> + </Select> + {!canEditField(key) && ( + <p className="text-xs text-muted-foreground"> + 편집 권한이 없습니다. + </p> + )} + <FormMessage /> + </FormItem> + )} + /> + ))} + </div> + </CardContent> + </Card> + + {/* 의견 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">의견</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 관리자 의견 (관리자만) */} + {canEditAdminFields() && ( + <FormField + control={form.control} + name="adminComment" + render={({ field }) => ( + <FormItem> + <FormLabel>관리자 의견</FormLabel> + <FormControl> + <Textarea + placeholder="관리자 의견을 입력하세요..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 종합 의견 (모든 권한자) */} + <FormField + control={form.control} + name="consolidatedComment" + render={({ field }) => ( + <FormItem> + <FormLabel>종합 의견</FormLabel> + <FormControl> + <Textarea + placeholder="종합 의견을 입력하세요..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* 담당자 변경 (관리자만) */} + {canEditAdminFields() && departments.length > 0 && ( + <Card> + <CardHeader> + <CardTitle className="text-lg">담당자 변경</CardTitle> + <p className="text-sm text-muted-foreground"> + 각 부서별 담당자를 변경할 수 있습니다. + </p> + </CardHeader> + <CardContent className="space-y-4"> + {[ + { key: "orderReviewerEmail", label: "주문 부서 담당자", current: evaluationTarget.orderReviewerEmail }, + { key: "procurementReviewerEmail", label: "조달 부서 담당자", current: evaluationTarget.procurementReviewerEmail }, + { key: "qualityReviewerEmail", label: "품질 부서 담당자", current: evaluationTarget.qualityReviewerEmail }, + { key: "designReviewerEmail", label: "설계 부서 담당자", current: evaluationTarget.designReviewerEmail }, + { key: "csReviewerEmail", label: "CS 부서 담당자", current: evaluationTarget.csReviewerEmail }, + ].map(({ key, label, current }) => { + const selectedReviewer = reviewers.find(r => r.email === form.watch(key as keyof EditEvaluationTargetFormValues)) + const filteredReviewers = getFilteredReviewers(reviewerSearches[key] || "") + + return ( + <FormField + key={key} + control={form.control} + name={key as keyof EditEvaluationTargetFormValues} + render={({ field }) => ( + <FormItem> + <FormLabel> + {label} + {current && ( + <span className="text-xs text-muted-foreground ml-2"> + (현재: {current}) + </span> + )} + </FormLabel> + <Popover + open={reviewerOpens[key] || false} + onOpenChange={(open) => setReviewerOpens(prev => ({...prev, [key]: open}))} + > + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={reviewerOpens[key]} + className="w-full justify-between" + > + {selectedReviewer ? ( + <span className="flex items-center gap-2"> + <span>{selectedReviewer.name}</span> + <span className="text-xs text-muted-foreground"> + ({selectedReviewer.email}) + </span> + </span> + ) : field.value ? ( + field.value + ) : ( + "담당자 선택..." + )} + <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[key] || ""} + onValueChange={(value) => setReviewerSearches(prev => ({...prev, [key]: 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("") + setReviewerOpens(prev => ({ ...prev, [key]: false })) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + !field.value ? "opacity-100" : "opacity-0" + )} + /> + 선택 안함 + </CommandItem> + {filteredReviewers.map((reviewer) => ( + <CommandItem + key={reviewer.id} + value={`${reviewer.name} ${reviewer.email}`} + onSelect={() => { + field.onChange(reviewer.email) + setReviewerOpens(prev => ({...prev, [key]: false})) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + reviewer.email === 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> + )} + /> + ) + })} + </CardContent> + </Card> + )} + </div> + </div> + + {/* 고정된 버튼 영역 */} + <div className="flex-shrink-0 flex justify-end gap-3 pt-6 mt-6 border-t bg-background"> + <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> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/validation.ts b/lib/evaluation-target-list/validation.ts index e42f536b..ce5604be 100644 --- a/lib/evaluation-target-list/validation.ts +++ b/lib/evaluation-target-list/validation.ts @@ -46,7 +46,7 @@ import { >; export type EvaluationTargetStatus = "PENDING" | "CONFIRMED" | "EXCLUDED"; - export type Division = "OCEAN" | "SHIPYARD"; + export type Division = "PLANT" | "SHIP"; export type MaterialType = "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK"; export type DomesticForeign = "DOMESTIC" | "FOREIGN"; @@ -54,8 +54,8 @@ import { export const EVALUATION_TARGET_FILTER_OPTIONS = { DIVISIONS: [ - { value: "OCEAN", label: "해양" }, - { value: "SHIPYARD", label: "조선" }, + { value: "PLANT", label: "해양" }, + { value: "SHIP", label: "조선" }, ], STATUSES: [ { value: "PENDING", label: "검토 중" }, @@ -86,7 +86,7 @@ import { } export function validateDivision(division: string): division is Division { - return ["OCEAN", "SHIPYARD"].includes(division); + return ["PLANT", "SHIP"].includes(division); } export function validateStatus(status: string): status is EvaluationTargetStatus { @@ -142,8 +142,8 @@ import { // 구분별 라벨 반환 export function getDivisionLabel(division: Division): string { const divisionMap = { - OCEAN: "해양", - SHIPYARD: "조선" + PLANT: "해양", + SHIP: "조선" }; return divisionMap[division] || division; } diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/lib/evaluation/service.ts diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx new file mode 100644 index 00000000..0c207a53 --- /dev/null +++ b/lib/evaluation/table/evaluation-columns.tsx @@ -0,0 +1,441 @@ +// ================================================================ +// 1. PERIODIC EVALUATIONS COLUMNS +// ================================================================ + +"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, Clock, FileText } from "lucide-react"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import { PeriodicEvaluationView } from "@/db/schema"; + + + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PeriodicEvaluationView> | null>>; +} + +// 상태별 색상 매핑 +const getStatusBadgeVariant = (status: string) => { + switch (status) { + case "PENDING_SUBMISSION": + return "outline"; + case "SUBMITTED": + return "secondary"; + case "IN_REVIEW": + return "default"; + case "REVIEW_COMPLETED": + return "default"; + case "FINALIZED": + return "default"; + default: + return "outline"; + } +}; + +const getStatusLabel = (status: string) => { + const statusMap = { + PENDING_SUBMISSION: "제출대기", + SUBMITTED: "제출완료", + IN_REVIEW: "검토중", + REVIEW_COMPLETED: "검토완료", + FINALIZED: "최종확정" + }; + return statusMap[status] || status; +}; + +// 등급별 색상 +const getGradeBadgeVariant = (grade: string | null) => { + if (!grade) return "outline"; + switch (grade) { + case "S": + return "default"; + case "A": + return "secondary"; + case "B": + return "outline"; + case "C": + return "destructive"; + case "D": + return "destructive"; + default: + return "outline"; + } +}; + +// 구분 배지 +const getDivisionBadge = (division: string) => { + return ( + <Badge variant={division === "PLANT" ? "default" : "secondary"}> + {division === "PLANT" ? "해양" : "조선"} + </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> + ); +}; + +// 진행률 배지 +const getProgressBadge = (completed: number, total: number) => { + if (total === 0) return <Badge variant="outline">-</Badge>; + + const percentage = Math.round((completed / total) * 100); + const variant = percentage === 100 ? "default" : percentage >= 50 ? "secondary" : "destructive"; + + return <Badge variant={variant}>{completed}/{total} ({percentage}%)</Badge>; +}; + +export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ColumnDef<PeriodicEvaluationWithRelations>[] { + 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: "evaluationTarget.evaluationYear", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />, + cell: ({ row }) => <span className="font-medium">{row.original.evaluationTarget?.evaluationYear}</span>, + size: 100, + }, + + // ░░░ 평가기간 ░░░ + { + accessorKey: "evaluationPeriod", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가기간" />, + cell: ({ row }) => ( + <Badge variant="outline">{row.getValue("evaluationPeriod")}</Badge> + ), + size: 100, + }, + + // ░░░ 구분 ░░░ + { + accessorKey: "evaluationTarget.division", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />, + cell: ({ row }) => getDivisionBadge(row.original.evaluationTarget?.division || ""), + size: 80, + }, + + // ═══════════════════════════════════════════════════════════════ + // 협력업체 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "협력업체 정보", + columns: [ + { + accessorKey: "evaluationTarget.vendorCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더 코드" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.evaluationTarget?.vendorCode}</span> + ), + size: 120, + }, + + { + accessorKey: "evaluationTarget.vendorName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />, + cell: ({ row }) => ( + <div className="truncate max-w-[200px]" title={row.original.evaluationTarget?.vendorName}> + {row.original.evaluationTarget?.vendorName} + </div> + ), + size: 200, + }, + + { + accessorKey: "evaluationTarget.domesticForeign", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />, + cell: ({ row }) => getDomesticForeignBadge(row.original.evaluationTarget?.domesticForeign || ""), + size: 80, + }, + + { + accessorKey: "evaluationTarget.materialType", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />, + cell: ({ row }) => getMaterialTypeBadge(row.original.evaluationTarget?.materialType || ""), + size: 120, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 제출 현황 + // ═══════════════════════════════════════════════════════════════ + { + header: "제출 현황", + columns: [ + { + accessorKey: "documentsSubmitted", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="문서제출" />, + cell: ({ row }) => { + const submitted = row.getValue<boolean>("documentsSubmitted"); + return ( + <Badge variant={submitted ? "default" : "destructive"}> + {submitted ? "제출완료" : "미제출"} + </Badge> + ); + }, + size: 100, + }, + + { + accessorKey: "submissionDate", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="제출일" />, + cell: ({ row }) => { + const submissionDate = row.getValue<Date>("submissionDate"); + return submissionDate ? ( + <span className="text-sm"> + {new Intl.DateTimeFormat("ko-KR", { + month: "2-digit", + day: "2-digit", + }).format(new Date(submissionDate))} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 80, + }, + + { + accessorKey: "submissionDeadline", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="마감일" />, + cell: ({ row }) => { + const deadline = row.getValue<Date>("submissionDeadline"); + if (!deadline) return <span className="text-muted-foreground">-</span>; + + const now = new Date(); + const isOverdue = now > deadline; + + return ( + <span className={`text-sm ${isOverdue ? "text-red-600" : ""}`}> + {new Intl.DateTimeFormat("ko-KR", { + month: "2-digit", + day: "2-digit", + }).format(new Date(deadline))} + </span> + ); + }, + size: 80, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 평가 점수 + // ═══════════════════════════════════════════════════════════════ + { + header: "평가 점수", + columns: [ + { + accessorKey: "totalScore", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="총점" />, + cell: ({ row }) => { + const score = row.getValue<number>("totalScore"); + return score ? ( + <span className="font-medium">{score.toFixed(1)}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 80, + }, + + { + accessorKey: "evaluationGrade", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등급" />, + cell: ({ row }) => { + const grade = row.getValue<string>("evaluationGrade"); + return grade ? ( + <Badge variant={getGradeBadgeVariant(grade)}>{grade}</Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 60, + }, + + { + accessorKey: "finalScore", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종점수" />, + cell: ({ row }) => { + const finalScore = row.getValue<number>("finalScore"); + return finalScore ? ( + <span className="font-bold text-green-600">{finalScore.toFixed(1)}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 90, + }, + + { + accessorKey: "finalGrade", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종등급" />, + cell: ({ row }) => { + const finalGrade = row.getValue<string>("finalGrade"); + return finalGrade ? ( + <Badge variant={getGradeBadgeVariant(finalGrade)} className="bg-green-600"> + {finalGrade} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 90, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 진행 현황 + // ═══════════════════════════════════════════════════════════════ + { + header: "진행 현황", + columns: [ + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />, + cell: ({ row }) => { + const status = row.getValue<string>("status"); + return ( + <Badge variant={getStatusBadgeVariant(status)}> + {getStatusLabel(status)} + </Badge> + ); + }, + size: 100, + }, + + { + id: "reviewProgress", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리뷰진행" />, + cell: ({ row }) => { + const stats = row.original.reviewerStats; + if (!stats) return <span className="text-muted-foreground">-</span>; + + return getProgressBadge(stats.completedReviewers, stats.totalReviewers); + }, + size: 120, + }, + + { + accessorKey: "reviewCompletedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="검토완료일" />, + cell: ({ row }) => { + const completedAt = row.getValue<Date>("reviewCompletedAt"); + return completedAt ? ( + <span className="text-sm"> + {new Intl.DateTimeFormat("ko-KR", { + month: "2-digit", + day: "2-digit", + }).format(new Date(completedAt))} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + + { + accessorKey: "finalizedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />, + cell: ({ row }) => { + const finalizedAt = row.getValue<Date>("finalizedAt"); + return finalizedAt ? ( + <span className="text-sm font-medium"> + {new Intl.DateTimeFormat("ko-KR", { + month: "2-digit", + day: "2-digit", + }).format(new Date(finalizedAt))} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 80, + }, + ] + }, + + // ░░░ Actions ░░░ + { + id: "actions", + enableHiding: false, + size: 40, + minSize: 40, + cell: ({ row }) => { + return ( + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setRowAction({ row, type: "view" })} + aria-label="상세보기" + title="상세보기" + > + <Eye className="size-4" /> + </Button> + + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setRowAction({ row, type: "update" })} + aria-label="수정" + title="수정" + > + <Pencil className="size-4" /> + </Button> + </div> + ); + }, + }, + ]; +}
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx new file mode 100644 index 00000000..7cda4989 --- /dev/null +++ b/lib/evaluation/table/evaluation-filter-sheet.tsx @@ -0,0 +1,1031 @@ +// ================================================================ +// 2. PERIODIC EVALUATIONS FILTER SHEET +// ================================================================ + +"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 periodicEvaluationFilterSchema = z.object({ + evaluationYear: z.string().optional(), + evaluationPeriod: z.string().optional(), + division: z.string().optional(), + status: z.string().optional(), + domesticForeign: z.string().optional(), + materialType: z.string().optional(), + vendorCode: z.string().optional(), + vendorName: z.string().optional(), + documentsSubmitted: z.string().optional(), + evaluationGrade: z.string().optional(), + finalGrade: z.string().optional(), + minTotalScore: z.string().optional(), + maxTotalScore: z.string().optional(), +}) + +// 옵션 정의 +const evaluationPeriodOptions = [ + { value: "상반기", label: "상반기" }, + { value: "하반기", label: "하반기" }, + { value: "연간", label: "연간" }, +] + +const divisionOptions = [ + { value: "PLANT", label: "해양" }, + { value: "SHIP", label: "조선" }, +] + +const statusOptions = [ + { value: "PENDING_SUBMISSION", label: "제출대기" }, + { value: "SUBMITTED", label: "제출완료" }, + { value: "IN_REVIEW", label: "검토중" }, + { value: "REVIEW_COMPLETED", label: "검토완료" }, + { value: "FINALIZED", label: "최종확정" }, +] + +const domesticForeignOptions = [ + { value: "DOMESTIC", label: "내자" }, + { value: "FOREIGN", label: "외자" }, +] + +const materialTypeOptions = [ + { value: "EQUIPMENT", label: "기자재" }, + { value: "BULK", label: "벌크" }, + { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, +] + +const documentsSubmittedOptions = [ + { value: "true", label: "제출완료" }, + { value: "false", label: "미제출" }, +] + +const gradeOptions = [ + { value: "S", label: "S등급" }, + { value: "A", label: "A등급" }, + { value: "B", label: "B등급" }, + { value: "C", label: "C등급" }, + { value: "D", label: "D등급" }, +] + +type PeriodicEvaluationFilterFormValues = z.infer<typeof periodicEvaluationFilterSchema> + +interface PeriodicEvaluationFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +export function PeriodicEvaluationFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: PeriodicEvaluationFilterSheetProps) { + const router = useRouter() + const params = useParams(); + + const [isPending, startTransition] = useTransition() + const [isInitializing, setIsInitializing] = useState(false) + const lastAppliedFilters = useRef<string>("") + + // nuqs로 URL 상태 관리 + const [filters, setFilters] = useQueryState( + "basicFilters", + getFiltersStateParser().withDefault([]) + ) + + const [joinOperator, setJoinOperator] = useQueryState( + "basicJoinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and") + ) + + // 폼 상태 초기화 + const form = useForm<PeriodicEvaluationFilterFormValues>({ + resolver: zodResolver(periodicEvaluationFilterSchema), + defaultValues: { + evaluationYear: new Date().getFullYear().toString(), + evaluationPeriod: "", + division: "", + status: "", + domesticForeign: "", + materialType: "", + vendorCode: "", + vendorName: "", + documentsSubmitted: "", + evaluationGrade: "", + finalGrade: "", + minTotalScore: "", + maxTotalScore: "", + }, + }) + + // 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: PeriodicEvaluationFilterFormValues) { + 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.evaluationPeriod?.trim()) { + newFilters.push({ + id: "evaluationPeriod", + value: data.evaluationPeriod.trim(), + type: "select", + 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.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.documentsSubmitted?.trim()) { + newFilters.push({ + id: "documentsSubmitted", + value: data.documentsSubmitted.trim() === "true", + type: "boolean", + operator: "eq", + rowId: generateId() + }) + } + + if (data.evaluationGrade?.trim()) { + newFilters.push({ + id: "evaluationGrade", + value: data.evaluationGrade.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.finalGrade?.trim()) { + newFilters.push({ + id: "finalGrade", + value: data.finalGrade.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.minTotalScore?.trim()) { + newFilters.push({ + id: "totalScore", + value: parseFloat(data.minTotalScore.trim()), + type: "number", + operator: "gte", + rowId: generateId() + }) + } + + if (data.maxTotalScore?.trim()) { + newFilters.push({ + id: "totalScore", + value: parseFloat(data.maxTotalScore.trim()), + type: "number", + operator: "lte", + 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()}`; + window.location.href = newUrl; + + lastAppliedFilters.current = JSON.stringify(newFilters); + + if (onSearch) { + onSearch(); + } + } catch (error) { + console.error("정기평가 필터 적용 오류:", error); + } + }) + } + + // 필터 초기화 핸들러 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + evaluationYear: new Date().getFullYear().toString(), + evaluationPeriod: "", + division: "", + status: "", + domesticForeign: "", + materialType: "", + vendorCode: "", + vendorName: "", + documentsSubmitted: "", + evaluationGrade: "", + finalGrade: "", + minTotalScore: "", + maxTotalScore: "", + }); + + 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"> + {/* 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="evaluationPeriod" + 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("evaluationPeriod", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {evaluationPeriodOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <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="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> + )} + /> + + {/* 문서제출여부 */} + <FormField + control={form.control} + name="documentsSubmitted" + 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("documentsSubmitted", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {documentsSubmittedOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 평가등급 */} + <FormField + control={form.control} + name="evaluationGrade" + 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("evaluationGrade", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {gradeOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 최종등급 */} + <FormField + control={form.control} + name="finalGrade" + 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("finalGrade", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {gradeOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 점수 범위 */} + <div className="grid grid-cols-2 gap-2"> + <FormField + control={form.control} + name="minTotalScore" + render={({ field }) => ( + <FormItem> + <FormLabel>최소점수</FormLabel> + <FormControl> + <div className="relative"> + <Input + type="number" + step="0.1" + 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("minTotalScore", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="maxTotalScore" + render={({ field }) => ( + <FormItem> + <FormLabel>최대점수</FormLabel> + <FormControl> + <div className="relative"> + <Input + type="number" + step="0.1" + 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("maxTotalScore", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + </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/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx new file mode 100644 index 00000000..16f70592 --- /dev/null +++ b/lib/evaluation/table/evaluation-table.tsx @@ -0,0 +1,462 @@ +"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 { 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 { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet" +import { getPeriodicEvaluationsColumns } from "./evaluation-columns" +import { PeriodicEvaluationView } from "@/db/schema" + +interface PeriodicEvaluationsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]> + evaluationYear: number + className?: string +} + +// 통계 카드 컴포넌트 +function PeriodicEvaluationsStats({ 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) + // TODO: getPeriodicEvaluationsStats 구현 필요 + const statsData = { + total: 150, + pendingSubmission: 25, + submitted: 45, + inReview: 30, + reviewCompleted: 35, + finalized: 15, + averageScore: 82.5, + completionRate: 75 + } + + if (isMounted) { + setStats(statsData) + } + } catch (err) { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to fetch stats') + console.error('Error fetching periodic evaluations 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 || !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"> + {error ? `통계 데이터를 불러올 수 없습니다: ${error}` : "통계 데이터가 없습니다."} + </div> + </CardContent> + </Card> + </div> + ) + } + + const totalEvaluations = stats.total || 0 + const pendingSubmission = stats.pendingSubmission || 0 + const inProgress = (stats.submitted || 0) + (stats.inReview || 0) + (stats.reviewCompleted || 0) + const finalized = stats.finalized || 0 + const completionRate = stats.completionRate || 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">{totalEvaluations.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + 평균점수 {stats.averageScore?.toFixed(1) || 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="outline">대기</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{pendingSubmission.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {totalEvaluations > 0 ? Math.round((pendingSubmission / totalEvaluations) * 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="secondary">진행</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-blue-600">{inProgress.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {totalEvaluations > 0 ? Math.round((inProgress / totalEvaluations) * 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={completionRate >= 80 ? "default" : completionRate >= 60 ? "secondary" : "destructive"}> + {completionRate}% + </Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{finalized.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + 최종확정 완료 + </div> + </CardContent> + </Card> + </div> + ) +} + +export function PeriodicEvaluationsTable({ promises, evaluationYear, className }: PeriodicEvaluationsTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<PeriodicEvaluationView> | null>(null) + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + 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() + const newTop = rect.top + + setContainerTop(prevTop => { + if (Math.abs(prevTop - newTop) > 1) { + return newTop + } + return prevTop + }) + } + }, []) + + const throttledUpdateBounds = React.useCallback(() => { + let timeoutId: NodeJS.Timeout + return () => { + clearTimeout(timeoutId) + timeoutId = setTimeout(updateContainerBounds, 16) + } + }, [updateContainerBounds]) + + React.useEffect(() => { + updateContainerBounds() + + const throttledHandler = throttledUpdateBounds() + + const handleResize = () => { + updateContainerBounds() + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', throttledHandler) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', throttledHandler) + } + }, [updateContainerBounds, throttledUpdateBounds]) + + const [promiseData] = React.use(promises) + const tableData = promiseData + + 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<PeriodicEvaluationView>('periodic-evaluations-table', initialSettings) + + const columns = React.useMemo( + () => getPeriodicEvaluationsColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField<PeriodicEvaluationView>[] = [ + { id: "evaluationTarget.vendorCode", label: "벤더 코드" }, + { id: "evaluationTarget.vendorName", label: "벤더명" }, + { id: "status", label: "진행상태" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView>[] = [ + { id: "evaluationTarget.evaluationYear", label: "평가년도", type: "number" }, + { id: "evaluationPeriod", label: "평가기간", type: "text" }, + { + id: "evaluationTarget.division", label: "구분", type: "select", options: [ + { label: "해양", value: "PLANT" }, + { label: "조선", value: "SHIP" }, + ] + }, + { id: "evaluationTarget.vendorCode", label: "벤더 코드", type: "text" }, + { id: "evaluationTarget.vendorName", label: "벤더명", type: "text" }, + { + id: "status", label: "진행상태", type: "select", options: [ + { label: "제출대기", value: "PENDING_SUBMISSION" }, + { label: "제출완료", value: "SUBMITTED" }, + { label: "검토중", value: "IN_REVIEW" }, + { label: "검토완료", value: "REVIEW_COMPLETED" }, + { label: "최종확정", value: "FINALIZED" }, + ] + }, + { + id: "documentsSubmitted", label: "문서제출", type: "select", options: [ + { label: "제출완료", value: "true" }, + { label: "미제출", value: "false" }, + ] + }, + { id: "totalScore", label: "총점", type: "number" }, + { id: "finalScore", label: "최종점수", type: "number" }, + { id: "submissionDate", label: "제출일", type: "date" }, + { id: "reviewCompletedAt", label: "검토완료일", type: "date" }, + { id: "finalizedAt", label: "최종확정일", type: "date" }, + ] + + const currentSettings = useMemo(() => { + return getCurrentSettings() + }, [getCurrentSettings]) + + function getColKey<T>(c: ColumnDef<T>): string | undefined { + if ("accessorKey" in c && c.accessorKey) return c.accessorKey as string + if ("id" in c && c.id) return c.id as string + return undefined + } + + const initialState = useMemo(() => { + return { + sorting: initialSettings.sort.filter(s => + columns.some(c => getColKey(c) === s.id)), + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [columns, currentSettings, initialSettings.sort]) + + 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"> + <PeriodicEvaluationFilterSheet + 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"> + <PeriodicEvaluationsStats 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<PeriodicEvaluationView> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + {/* TODO: PeriodicEvaluationsTableToolbarActions 구현 */} + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* TODO: 수정/상세보기 모달 구현 */} + + </div> + </div> + </div> + </div> + </div> + </> + ) +}
\ No newline at end of file diff --git a/lib/evaluation/validation.ts b/lib/evaluation/validation.ts new file mode 100644 index 00000000..9179f585 --- /dev/null +++ b/lib/evaluation/validation.ts @@ -0,0 +1,46 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + } from "nuqs/server"; + import * as z from "zod"; + + import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { periodicEvaluations } from "@/db/schema"; + + // ============= 메인 검색 파라미터 스키마 ============= + + export const searchParamsEvaluationsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<typeof periodicEvaluations>().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 GetEvaluationsSchema = Awaited< + ReturnType<typeof searchParamsEvaluationsCache.parse> + >; diff --git a/lib/forms/services.ts b/lib/forms/services.ts index 021bb767..0558e83f 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -27,6 +27,7 @@ import { getErrorMessage } from "../handle-error"; import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; import { contractItems, contracts, items, projects } from "@/db/schema"; import { getSEDPToken } from "../sedp/sedp-token"; +import { decryptWithServerAction } from "@/components/drm/drmUtils"; export type FormInfo = InferSelectModel<typeof forms>; @@ -47,9 +48,9 @@ export async function getFormsByContractItemId( try { // return unstable_cache( // async () => { - console.log( - `[Forms Service] Fetching forms for contractItemId: ${contractItemId}, mode: ${mode}` - ); + // console.log( + // `[Forms Service] Fetching forms for contractItemId: ${contractItemId}, mode: ${mode}` + // ); try { // 쿼리 생성 @@ -227,10 +228,9 @@ async function getEditableFieldsByTag( * 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱. */ export async function getFormData(formCode: string, contractItemId: number) { - const cacheKey = `form-data-${formCode}-${contractItemId}`; - console.log(cacheKey, "getFormData") try { + // 기존 로직으로 projectId, columns, data 가져오기 const contractItemResult = await db .select({ @@ -285,6 +285,8 @@ export async function getFormData(formCode: string, contractItemId: number) { const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; columns = columns.filter(col => !excludeKeys.includes(col.key)); + + columns.forEach((col) => { if (!col.displayLabel) { if (col.uom) { @@ -295,25 +297,24 @@ export async function getFormData(formCode: string, contractItemId: number) { } }); - // status 컬럼 추가 columns.push({ - key: "status", - label: "status", - displayLabel: "Status", - type: "STRING" - }); + key:"status", + label:"status", + displayLabel:"Status", + type:"STRING" + }) let data: Array<Record<string, any>> = []; if (entry) { if (Array.isArray(entry.data)) { data = entry.data; - data.sort((a, b) => { + data.sort((a,b) => { const statusA = a.status || ''; const statusB = b.status || ''; - return statusB.localeCompare(statusA); - }); - + return statusB.localeCompare(statusA) + }) + } else { console.warn("formEntries data was not an array. Using empty array."); } @@ -382,7 +383,7 @@ export async function getFormData(formCode: string, contractItemId: number) { const entry = entryRows[0] ?? null; let columns = meta.columns as DataTableColumnJSON[]; - const excludeKeys = ['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) => { @@ -693,7 +694,7 @@ export async function updateFormDataInDB( ...oldItem, ...newData, TAG_NO: oldItem.TAG_NO, // TAG_NO 변경 불가 시 유지 - status: "Imported from EXCEL" // Excel에서 가져온 데이터임을 표시 + status: "Updated" // Excel에서 가져온 데이터임을 표시 }; const updatedArray = [...dataArray]; @@ -894,7 +895,8 @@ export async function uploadReportTemp( const savePath = path.join(baseDir, uniqueName); - const arrayBuffer = await file.arrayBuffer(); + // const arrayBuffer = await file.arrayBuffer(); + const arrayBuffer = await decryptWithServerAction(file); const buffer = Buffer.from(arrayBuffer); await fs.mkdir(baseDir, { recursive: true }); diff --git a/lib/incoterms/table/delete-incoterms-dialog.tsx b/lib/incoterms/table/delete-incoterms-dialog.tsx new file mode 100644 index 00000000..8b91033c --- /dev/null +++ b/lib/incoterms/table/delete-incoterms-dialog.tsx @@ -0,0 +1,154 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { deleteIncoterm } from "../service" +import { incoterms } from "@/db/schema/procurementRFQ" + +interface DeleteIncotermsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + incoterms: Row<typeof incoterms.$inferSelect>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteIncotermsDialog({ + incoterms, + showTrigger = true, + onSuccess, + ...props +}: DeleteIncotermsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + try { + // 각 인코텀즈를 순차적으로 삭제 + for (const incoterm of incoterms) { + const result = await deleteIncoterm(incoterm.code) + if (!result.success) { + toast.error(`인코텀즈 ${incoterm.code} 삭제 실패: ${result.error}`) + return + } + } + + props.onOpenChange?.(false) + toast.success("인코텀즈가 성공적으로 삭제되었습니다.") + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("인코텀즈 삭제 중 오류가 발생했습니다.") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + delete ({incoterms.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{incoterms.length}</span> + 개의 인코텀즈를 서버에서 영구적으로 삭제합니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="선택된 행 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + delete ({incoterms.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{incoterms.length}</span> + 개의 인코텀즈를 서버에서 영구적으로 삭제합니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="선택된 행 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/incoterms/table/incoterms-add-dialog.tsx b/lib/incoterms/table/incoterms-add-dialog.tsx index ef378e1e..0f7384d6 100644 --- a/lib/incoterms/table/incoterms-add-dialog.tsx +++ b/lib/incoterms/table/incoterms-add-dialog.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import * as z from "zod"; import { Plus, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -70,7 +70,8 @@ export function IncotermsAddDialog({ onSuccess }: IncotermsAddDialogProps) { try { const result = await createIncoterm(data); if (result.data) { - toast.success("인코텀즈가 추가되었습니다."); + toast.success("인코텀즈가 성공적으로 추가되었습니다."); + form.reset(); setOpen(false); if (onSuccess) { onSuccess(); @@ -89,16 +90,17 @@ export function IncotermsAddDialog({ onSuccess }: IncotermsAddDialogProps) { return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button size="sm" variant="outline"> + <Button variant="outline" size="sm"> <Plus className="mr-2 h-4 w-4" /> 인코텀즈 추가 </Button> </DialogTrigger> <DialogContent className="max-w-md"> <DialogHeader> - <DialogTitle>인코텀즈 추가</DialogTitle> + <DialogTitle>새 인코텀즈 추가</DialogTitle> <DialogDescription> 새로운 인코텀즈를 추가합니다. 필수 정보를 입력해주세요. + <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> </DialogDescription> </DialogHeader> @@ -153,7 +155,7 @@ export function IncotermsAddDialog({ onSuccess }: IncotermsAddDialogProps) { disabled={isLoading} > {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {isLoading ? "생성 중..." : "인코텀즈 추가"} + {isLoading ? "생성 중..." : "추가"} </Button> </DialogFooter> </DialogContent> diff --git a/lib/incoterms/table/incoterms-edit-sheet.tsx b/lib/incoterms/table/incoterms-edit-sheet.tsx index 9cd067c7..1ae6e902 100644 --- a/lib/incoterms/table/incoterms-edit-sheet.tsx +++ b/lib/incoterms/table/incoterms-edit-sheet.tsx @@ -5,6 +5,8 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { toast } from "sonner" import * as z from "zod" +import { Loader } from "lucide-react" + import { Button } from "@/components/ui/button" import { Form, @@ -16,8 +18,10 @@ import { } from "@/components/ui/form" import { Sheet, + SheetClose, SheetContent, SheetDescription, + SheetFooter, SheetHeader, SheetTitle, } from "@/components/ui/sheet" @@ -37,7 +41,7 @@ type UpdateIncotermSchema = z.infer<typeof updateIncotermSchema> interface IncotermsEditSheetProps { open: boolean onOpenChange: (open: boolean) => void - data: typeof incoterms.$inferSelect + data: typeof incoterms.$inferSelect | null onSuccess: () => void } @@ -47,12 +51,14 @@ export function IncotermsEditSheet({ data, onSuccess, }: IncotermsEditSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const form = useForm<UpdateIncotermSchema>({ resolver: zodResolver(updateIncotermSchema), defaultValues: { - code: data.code, - description: data.description, - isActive: data.isActive, + code: data?.code ?? "", + description: data?.description ?? "", + isActive: data?.isActive ?? true, }, mode: "onChange" }) @@ -68,14 +74,19 @@ export function IncotermsEditSheet({ }, [data, form]) async function onSubmit(input: UpdateIncotermSchema) { - try { - await updateIncoterm(data.code, input) - toast.success("수정이 완료되었습니다.") - onSuccess() - onOpenChange(false) - } catch { - toast.error("수정 중 오류가 발생했습니다.") - } + if (!data) return + + startUpdateTransition(async () => { + try { + await updateIncoterm(data.code, input) + toast.success("인코텀즈가 성공적으로 수정되었습니다.") + onSuccess() + onOpenChange(false) + } catch (error) { + console.error("Update error:", error) + toast.error("인코텀즈 수정 중 오류가 발생했습니다.") + } + }) } return ( @@ -96,7 +107,7 @@ export function IncotermsEditSheet({ <FormItem> <FormLabel>코드</FormLabel> <FormControl> - <Input {...field} disabled /> + <Input {...field} /> </FormControl> <FormMessage /> </FormItem> @@ -132,12 +143,25 @@ export function IncotermsEditSheet({ </FormItem> )} /> - <div className="flex justify-end space-x-2"> - <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> - 취소 + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + disabled={isUpdatePending || !form.formState.isValid} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 </Button> - <Button type="submit">저장</Button> - </div> + </SheetFooter> </form> </Form> </SheetContent> diff --git a/lib/incoterms/table/incoterms-table-columns.tsx b/lib/incoterms/table/incoterms-table-columns.tsx index 56a44e8b..91ce4482 100644 --- a/lib/incoterms/table/incoterms-table-columns.tsx +++ b/lib/incoterms/table/incoterms-table-columns.tsx @@ -1,76 +1,71 @@ -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"; +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" + +import { formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { incoterms } from "@/db/schema/procurementRFQ"; -import { toast } from "sonner"; -import { deleteIncoterm } from "../service"; +} from "@/components/ui/dropdown-menu" -type Incoterm = typeof incoterms.$inferSelect; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { incoterms } from "@/db/schema/procurementRFQ" interface GetColumnsProps { - setRowAction: (action: { type: string; row: Row<Incoterm> }) => void; - onSuccess: () => void; + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof incoterms.$inferSelect> | null>> } -const handleDelete = async (code: string, onSuccess: () => void) => { - const result = await deleteIncoterm(code); - if (result.success) { - toast.success("삭제 완료"); - onSuccess(); - } else { - toast.error(result.error || "삭제 실패"); +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof incoterms.$inferSelect>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<typeof incoterms.$inferSelect> = { + 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" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, } -}; -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 }) => ( + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<typeof incoterms.$inferSelect> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button @@ -83,20 +78,99 @@ export function getColumns({ setRowAction, onSuccess }: GetColumnsProps): Column </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-40"> <DropdownMenuItem - onSelect={() => setRowAction({ type: "edit", row })} + onSelect={() => setRowAction({ row, type: "update" })} > - 수정 + Edit </DropdownMenuItem> + <DropdownMenuSeparator /> <DropdownMenuItem - onSelect={() => handleDelete(row.original.code, onSuccess)} - className="text-destructive" + onSelect={() => setRowAction({ row, type: "delete" })} > - 삭제 + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> + ) + }, + maxSize: 30, + } + + // ---------------------------------------------------------------- + // 3) 데이터 컬럼들 + // ---------------------------------------------------------------- + const dataColumns: ColumnDef<typeof incoterms.$inferSelect>[] = [ + { + accessorKey: "code", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="코드" /> + ), + meta: { + excelHeader: "코드", + type: "text", + }, + cell: ({ row }) => row.getValue("code") ?? "", + minSize: 80 + }, + { + accessorKey: "description", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="설명" /> ), + meta: { + excelHeader: "설명", + type: "text", + }, + cell: ({ row }) => row.getValue("description") ?? "", + minSize: 80 }, - ]; + { + accessorKey: "isActive", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + meta: { + excelHeader: "상태", + type: "boolean", + }, + cell: ({ row }) => { + const isActive = row.getValue("isActive") as boolean + return ( + <Badge variant={isActive ? "default" : "secondary"}> + {isActive ? "활성" : "비활성"} + </Badge> + ) + }, + minSize: 80 + }, + { + accessorKey: "createdAt", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + meta: { + excelHeader: "생성일", + type: "date", + }, + cell: ({ row }) => { + const dateVal = row.getValue("createdAt") as Date + return formatDateTime(dateVal) + }, + minSize: 80 + } + ] + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, dataColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...dataColumns, + actionsColumn, + ] }
\ No newline at end of file diff --git a/lib/incoterms/table/incoterms-table-toolbar.tsx b/lib/incoterms/table/incoterms-table-toolbar.tsx index b87982c9..698acf59 100644 --- a/lib/incoterms/table/incoterms-table-toolbar.tsx +++ b/lib/incoterms/table/incoterms-table-toolbar.tsx @@ -1,16 +1,53 @@ "use client"; import * as React from "react"; +import { type Table } from "@tanstack/react-table"; +import { Download } from "lucide-react"; + +import { exportTableToExcel } from "@/lib/export"; +import { Button } from "@/components/ui/button"; +import { DeleteIncotermsDialog } from "./delete-incoterms-dialog"; import { IncotermsAddDialog } from "./incoterms-add-dialog"; +import { incoterms } from "@/db/schema/procurementRFQ"; -interface IncotermsTableToolbarProps { +interface IncotermsTableToolbarActionsProps { + table: Table<typeof incoterms.$inferSelect>; onSuccess?: () => void; } -export function IncotermsTableToolbar({ onSuccess }: IncotermsTableToolbarProps) { +export function IncotermsTableToolbarActions({ table, onSuccess }: IncotermsTableToolbarActionsProps) { return ( <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteIncotermsDialog + incoterms={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => { + table.toggleAllRowsSelected(false); + onSuccess?.(); + }} + /> + ) : null} + <IncotermsAddDialog onSuccess={onSuccess} /> + + {/** 3) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "incoterms-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> </div> ); }
\ No newline at end of file diff --git a/lib/incoterms/table/incoterms-table.tsx b/lib/incoterms/table/incoterms-table.tsx index c5b5bba4..c98de810 100644 --- a/lib/incoterms/table/incoterms-table.tsx +++ b/lib/incoterms/table/incoterms-table.tsx @@ -3,13 +3,16 @@ 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 { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table" +import { getIncoterms } from "../service"; import { getColumns } from "./incoterms-table-columns"; -import { incoterms } from "@/db/schema/procurementRFQ"; -import { IncotermsTableToolbar } from "./incoterms-table-toolbar"; -import { toast } from "sonner"; +import { DeleteIncotermsDialog } from "./delete-incoterms-dialog"; import { IncotermsEditSheet } from "./incoterms-edit-sheet"; -import { Row } from "@tanstack/react-table"; -import { getIncoterms } from "../service"; +import { IncotermsTableToolbarActions } from "./incoterms-table-toolbar"; +import { incoterms } from "@/db/schema/procurementRFQ"; interface IncotermsTableProps { promises?: Promise<[{ data: typeof incoterms.$inferSelect[]; pageCount: number }] >; @@ -17,8 +20,7 @@ interface IncotermsTableProps { 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); + const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof incoterms.$inferSelect> | null>(null); React.useEffect(() => { if (promises) { @@ -44,7 +46,6 @@ export function IncotermsTable({ promises }: IncotermsTableProps) { setRawData(result); } catch (error) { console.error("Error refreshing data:", error); - toast.error("데이터를 불러오는 중 오류가 발생했습니다."); } })(); } @@ -67,50 +68,71 @@ export function IncotermsTable({ promises }: IncotermsTableProps) { 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 }), + [setRowAction] + ) - const columns = React.useMemo(() => getColumns({ setRowAction: handleRowAction, onSuccess: refreshData }), [refreshData]); + // 고급 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<typeof incoterms.$inferSelect>[] = [ + { id: "code", label: "코드", type: "text" }, + { + id: "isActive", label: "상태", type: "select", options: [ + { label: "활성", value: "true" }, + { label: "비활성", value: "false" }, + ] + }, + { id: "description", label: "설명", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + ]; 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, - }); + data: rawData.data, + columns, + pageCount: rawData.pageCount, + 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 + table={table} + filterFields={advancedFilterFields} + > + <IncotermsTableToolbarActions table={table} onSuccess={refreshData} /> </DataTableAdvancedToolbar> </DataTable> - {isEditSheetOpen && selectedRow && ( - <IncotermsEditSheet - open={isEditSheetOpen} - onOpenChange={setIsEditSheetOpen} - data={selectedRow} - onSuccess={refreshData} - /> - )} + + <DeleteIncotermsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + incoterms={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => { + rowAction?.row.toggleSelected(false) + refreshData() + }} + /> + + <IncotermsEditSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + data={rowAction?.row.original ?? null} + onSuccess={refreshData} + /> </> ); }
\ No newline at end of file diff --git a/lib/mail/templates/evaluation-review-request.hbs b/lib/mail/templates/evaluation-review-request.hbs new file mode 100644 index 00000000..022f438b --- /dev/null +++ b/lib/mail/templates/evaluation-review-request.hbs @@ -0,0 +1,162 @@ +<!-- evaluation-review-request.hbs --> +<!DOCTYPE html> +<html lang="ko"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>벤더 평가 의견 요청</title> + <style> + body { + font-family: 'Malgun Gothic', '맑은 고딕', Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 600px; + margin: 0 auto; + padding: 20px; + background-color: #f5f5f5; + } + .container { + background-color: #ffffff; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + .header { + text-align: center; + border-bottom: 2px solid #e9ecef; + padding-bottom: 20px; + margin-bottom: 30px; + } + .header h1 { + color: #2563eb; + font-size: 24px; + margin: 0; + } + .content { + margin-bottom: 30px; + } + .info-box { + background-color: #f8f9fa; + border-left: 4px solid #2563eb; + padding: 15px; + margin: 20px 0; + border-radius: 4px; + } + .target-list { + background-color: #f8f9fa; + border-radius: 6px; + padding: 15px; + margin: 15px 0; + } + .target-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #e9ecef; + } + .target-item:last-child { + border-bottom: none; + } + .vendor-code { + background-color: #e9ecef; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: bold; + } + .button { + display: inline-block; + background-color: #2563eb; + color: white; + padding: 12px 24px; + text-decoration: none; + border-radius: 6px; + font-weight: bold; + text-align: center; + margin: 20px auto; + } + .button:hover { + background-color: #1d4ed8; + } + .message-box { + background-color: #fef3c7; + border: 1px solid #f59e0b; + border-radius: 6px; + padding: 15px; + margin: 20px 0; + } + .footer { + border-top: 1px solid #e9ecef; + padding-top: 20px; + margin-top: 30px; + text-align: center; + color: #6b7280; + font-size: 14px; + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <h1>🔍 벤더 평가 의견 요청</h1> + </div> + + <div class="content"> + <p>안녕하세요{{#if reviewerName}}, <strong>{{reviewerName}}</strong>님{{/if}}</p> + + <p><strong>{{requesterName}}</strong>님이 벤더 평가에 대한 의견을 요청하셨습니다.</p> + + <div class="info-box"> + <p><strong>📋 요청 정보</strong></p> + <ul style="margin: 10px 0;"> + <li>요청 일시: {{requestDate}}</li> + <li>평가 대상: {{targetCount}}개 벤더</li> + </ul> + </div> + + {{#if message}} + <div class="message-box"> + <p><strong>💬 요청자 메시지:</strong></p> + <p style="margin: 8px 0; white-space: pre-line;">{{message}}</p> + </div> + {{/if}} + + <div class="target-list"> + <p><strong>📄 평가 대상 목록:</strong></p> + {{#each targets}} + <div class="target-item"> + <div> + <span class="vendor-code">{{this.vendorCode}}</span> + <span style="margin-left: 8px;">{{this.vendorName}}</span> + </div> + <div style="font-size: 12px; color: #6b7280;"> + {{this.materialType}} ({{this.evaluationYear}}년) + </div> + </div> + {{/each}} + </div> + + <div style="text-align: center;"> + <a href="{{reviewUrl}}" class="button"> + 📝 평가 의견 작성하기 + </a> + </div> + + <div class="info-box"> + <p><strong>💡 참고사항:</strong></p> + <ul style="margin: 10px 0;"> + <li>평가 시스템에 로그인하여 각 벤더에 대한 의견을 입력해주세요.</li> + <li>평가 여부(여/부)와 함께 종합 의견도 작성 가능합니다.</li> + <li>궁금한 사항이 있으시면 요청자에게 직접 문의해주세요.</li> + </ul> + </div> + </div> + + <div class="footer"> + <p>이 메일은 벤더 평가 시스템에서 자동으로 발송되었습니다.</p> + <p>문의사항이 있으시면 시스템 관리자에게 연락해주세요.</p> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/payment-terms/table/delete-payment-terms-dialog.tsx b/lib/payment-terms/table/delete-payment-terms-dialog.tsx new file mode 100644 index 00000000..3e955fce --- /dev/null +++ b/lib/payment-terms/table/delete-payment-terms-dialog.tsx @@ -0,0 +1,154 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { deletePaymentTerm } from "../service" +import { paymentTerms } from "@/db/schema/procurementRFQ" + +interface DeletePaymentTermsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + paymentTerms: Row<typeof paymentTerms.$inferSelect>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeletePaymentTermsDialog({ + paymentTerms, + showTrigger = true, + onSuccess, + ...props +}: DeletePaymentTermsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + try { + // 각 결제 조건을 순차적으로 삭제 + for (const paymentTerm of paymentTerms) { + const result = await deletePaymentTerm(paymentTerm.code) + if (!result.success) { + toast.error(`결제 조건 ${paymentTerm.code} 삭제 실패: ${result.error}`) + return + } + } + + props.onOpenChange?.(false) + toast.success("결제 조건이 성공적으로 삭제되었습니다.") + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("결제 조건 삭제 중 오류가 발생했습니다.") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + delete ({paymentTerms.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{paymentTerms.length}</span> + 개의 결제 조건을 서버에서 영구적으로 삭제합니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="선택된 행 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + delete ({paymentTerms.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{paymentTerms.length}</span> + 개의 결제 조건을 서버에서 영구적으로 삭제합니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="선택된 행 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/payment-terms/table/payment-terms-add-dialog.tsx b/lib/payment-terms/table/payment-terms-add-dialog.tsx index 9aa21485..49819f87 100644 --- a/lib/payment-terms/table/payment-terms-add-dialog.tsx +++ b/lib/payment-terms/table/payment-terms-add-dialog.tsx @@ -70,7 +70,8 @@ export function PaymentTermsAddDialog({ onSuccess }: PaymentTermsAddDialogProps) try { const result = await createPaymentTerm(data); if (result.data) { - toast.success("결제 조건이 추가되었습니다."); + toast.success("결제 조건이 성공적으로 추가되었습니다."); + form.reset(); setOpen(false); if (onSuccess) { onSuccess(); @@ -89,16 +90,17 @@ export function PaymentTermsAddDialog({ onSuccess }: PaymentTermsAddDialogProps) return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button size="sm" variant="outline"> + <Button variant="outline" size="sm"> <Plus className="mr-2 h-4 w-4" /> 결제 조건 추가 </Button> </DialogTrigger> <DialogContent className="max-w-md"> <DialogHeader> - <DialogTitle>결제 조건 추가</DialogTitle> + <DialogTitle>새 결제 조건 추가</DialogTitle> <DialogDescription> 새로운 결제 조건을 추가합니다. 필수 정보를 입력해주세요. + <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> </DialogDescription> </DialogHeader> @@ -153,7 +155,7 @@ export function PaymentTermsAddDialog({ onSuccess }: PaymentTermsAddDialogProps) disabled={isLoading} > {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {isLoading ? "생성 중..." : "결제 조건 추가"} + {isLoading ? "생성 중..." : "추가"} </Button> </DialogFooter> </DialogContent> diff --git a/lib/payment-terms/table/payment-terms-edit-sheet.tsx b/lib/payment-terms/table/payment-terms-edit-sheet.tsx index b0d105bc..48d79c21 100644 --- a/lib/payment-terms/table/payment-terms-edit-sheet.tsx +++ b/lib/payment-terms/table/payment-terms-edit-sheet.tsx @@ -5,6 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { toast } from "sonner" import * as z from "zod" +import { Loader } from "lucide-react" import { Button } from "@/components/ui/button" import { @@ -17,8 +18,10 @@ import { } from "@/components/ui/form" import { Sheet, + SheetClose, SheetContent, SheetDescription, + SheetFooter, SheetHeader, SheetTitle, } from "@/components/ui/sheet" @@ -38,7 +41,7 @@ type UpdatePaymentTermSchema = z.infer<typeof updatePaymentTermSchema> interface PaymentTermsEditSheetProps { open: boolean onOpenChange: (open: boolean) => void - data: typeof paymentTerms.$inferSelect + data: typeof paymentTerms.$inferSelect | null onSuccess: () => void } @@ -48,12 +51,14 @@ export function PaymentTermsEditSheet({ data, onSuccess, }: PaymentTermsEditSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const form = useForm<UpdatePaymentTermSchema>({ resolver: zodResolver(updatePaymentTermSchema), defaultValues: { - code: data.code, - description: data.description, - isActive: data.isActive, + code: data?.code ?? "", + description: data?.description ?? "", + isActive: data?.isActive ?? true, }, mode: "onChange" }) @@ -69,14 +74,19 @@ export function PaymentTermsEditSheet({ }, [data, form]) async function onSubmit(input: UpdatePaymentTermSchema) { - try { - await updatePaymentTerm(data.code, input) - toast.success("수정이 완료되었습니다.") - onSuccess() - onOpenChange(false) - } catch { - toast.error("수정 중 오류가 발생했습니다.") - } + if (!data) return + + startUpdateTransition(async () => { + try { + await updatePaymentTerm(data.code, input) + toast.success("결제 조건이 성공적으로 수정되었습니다.") + onSuccess() + onOpenChange(false) + } catch (error) { + console.error("Update error:", error) + toast.error("결제 조건 수정 중 오류가 발생했습니다.") + } + }) } return ( @@ -97,7 +107,7 @@ export function PaymentTermsEditSheet({ <FormItem> <FormLabel>코드</FormLabel> <FormControl> - <Input {...field} disabled /> + <Input {...field} /> </FormControl> <FormMessage /> </FormItem> @@ -133,12 +143,25 @@ export function PaymentTermsEditSheet({ </FormItem> )} /> - <div className="flex justify-end space-x-2"> - <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> - 취소 + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + disabled={isUpdatePending || !form.formState.isValid} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 </Button> - <Button type="submit">저장</Button> - </div> + </SheetFooter> </form> </Form> </SheetContent> diff --git a/lib/payment-terms/table/payment-terms-table-columns.tsx b/lib/payment-terms/table/payment-terms-table-columns.tsx index 208723f7..08d30482 100644 --- a/lib/payment-terms/table/payment-terms-table-columns.tsx +++ b/lib/payment-terms/table/payment-terms-table-columns.tsx @@ -1,76 +1,71 @@ -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"; +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" + +import { formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { paymentTerms } from "@/db/schema/procurementRFQ"; -import { toast } from "sonner"; -import { deletePaymentTerm } from "../service"; +} from "@/components/ui/dropdown-menu" -type PaymentTerm = typeof paymentTerms.$inferSelect; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { paymentTerms } from "@/db/schema/procurementRFQ" interface GetColumnsProps { - setRowAction: (action: { type: string; row: Row<PaymentTerm> }) => void; - onSuccess: () => void; + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof paymentTerms.$inferSelect> | null>> } -const handleDelete = async (code: string, onSuccess: () => void) => { - const result = await deletePaymentTerm(code); - if (result.success) { - toast.success("삭제 완료"); - onSuccess(); - } else { - toast.error(result.error || "삭제 실패"); +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof paymentTerms.$inferSelect>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<typeof paymentTerms.$inferSelect> = { + 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" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, } -}; -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 }) => ( + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<typeof paymentTerms.$inferSelect> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button @@ -83,20 +78,99 @@ export function getColumns({ setRowAction, onSuccess }: GetColumnsProps): Column </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-40"> <DropdownMenuItem - onSelect={() => setRowAction({ type: "edit", row })} + onSelect={() => setRowAction({ row, type: "update" })} > - 수정 + Edit </DropdownMenuItem> + <DropdownMenuSeparator /> <DropdownMenuItem - onSelect={() => handleDelete(row.original.code, onSuccess)} - className="text-destructive" + onSelect={() => setRowAction({ row, type: "delete" })} > - 삭제 + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> + ) + }, + maxSize: 30, + } + + // ---------------------------------------------------------------- + // 3) 데이터 컬럼들 + // ---------------------------------------------------------------- + const dataColumns: ColumnDef<typeof paymentTerms.$inferSelect>[] = [ + { + accessorKey: "code", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="코드" /> + ), + meta: { + excelHeader: "코드", + type: "text", + }, + cell: ({ row }) => row.getValue("code") ?? "", + minSize: 80 + }, + { + accessorKey: "description", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="설명" /> ), + meta: { + excelHeader: "설명", + type: "text", + }, + cell: ({ row }) => row.getValue("description") ?? "", + minSize: 80 }, - ]; + { + accessorKey: "isActive", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + meta: { + excelHeader: "상태", + type: "boolean", + }, + cell: ({ row }) => { + const isActive = row.getValue("isActive") as boolean + return ( + <Badge variant={isActive ? "default" : "secondary"}> + {isActive ? "활성" : "비활성"} + </Badge> + ) + }, + minSize: 80 + }, + { + accessorKey: "createdAt", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + meta: { + excelHeader: "생성일", + type: "date", + }, + cell: ({ row }) => { + const dateVal = row.getValue("createdAt") as Date + return formatDateTime(dateVal) + }, + minSize: 80 + } + ] + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, dataColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...dataColumns, + actionsColumn, + ] }
\ 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 index 2466a9e4..51ac9b93 100644 --- a/lib/payment-terms/table/payment-terms-table-toolbar.tsx +++ b/lib/payment-terms/table/payment-terms-table-toolbar.tsx @@ -1,16 +1,53 @@ "use client"; import * as React from "react"; +import { type Table } from "@tanstack/react-table"; +import { Download } from "lucide-react"; + +import { exportTableToExcel } from "@/lib/export"; +import { Button } from "@/components/ui/button"; +import { DeletePaymentTermsDialog } from "./delete-payment-terms-dialog"; import { PaymentTermsAddDialog } from "./payment-terms-add-dialog"; +import { paymentTerms } from "@/db/schema/procurementRFQ"; -interface PaymentTermsTableToolbarProps { +interface PaymentTermsTableToolbarActionsProps { + table: Table<typeof paymentTerms.$inferSelect>; onSuccess?: () => void; } -export function PaymentTermsTableToolbar({ onSuccess }: PaymentTermsTableToolbarProps) { +export function PaymentTermsTableToolbarActions({ table, onSuccess }: PaymentTermsTableToolbarActionsProps) { return ( <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeletePaymentTermsDialog + paymentTerms={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => { + table.toggleAllRowsSelected(false); + onSuccess?.(); + }} + /> + ) : null} + <PaymentTermsAddDialog onSuccess={onSuccess} /> + + {/** 3) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "payment-terms-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> </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 index 589acb52..ddf270ce 100644 --- a/lib/payment-terms/table/payment-terms-table.tsx +++ b/lib/payment-terms/table/payment-terms-table.tsx @@ -3,13 +3,16 @@ 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 { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table" +import { getPaymentTerms } from "../service"; 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 { DeletePaymentTermsDialog } from "./delete-payment-terms-dialog"; import { PaymentTermsEditSheet } from "./payment-terms-edit-sheet"; -import { Row } from "@tanstack/react-table"; -import { getPaymentTerms } from "../service"; +import { PaymentTermsTableToolbarActions } from "./payment-terms-table-toolbar"; +import { paymentTerms } from "@/db/schema/procurementRFQ"; import { GetPaymentTermsSchema } from "../validations"; interface PaymentTermsTableProps { @@ -18,8 +21,7 @@ interface PaymentTermsTableProps { 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); + const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof paymentTerms.$inferSelect> | null>(null); React.useEffect(() => { if (promises) { @@ -45,7 +47,6 @@ export function PaymentTermsTable({ promises }: PaymentTermsTableProps) { setRawData(result); } catch (error) { console.error("Error refreshing data:", error); - toast.error("데이터를 불러오는 중 오류가 발생했습니다."); } })(); } @@ -78,50 +79,71 @@ export function PaymentTermsTable({ promises }: PaymentTermsTableProps) { 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 }), + [setRowAction] + ) - const columns = React.useMemo(() => getColumns({ setRowAction: handleRowAction, onSuccess: refreshData }), [refreshData]); + // 고급 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<typeof paymentTerms.$inferSelect>[] = [ + { id: "code", label: "코드", type: "text" }, + { + id: "isActive", label: "상태", type: "select", options: [ + { label: "활성", value: "true" }, + { label: "비활성", value: "false" }, + ] + }, + { id: "description", label: "설명", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + ]; 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, - }); + data: rawData.data, + columns, + pageCount: rawData.pageCount, + 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 + table={table} + filterFields={advancedFilterFields} + > + <PaymentTermsTableToolbarActions table={table} onSuccess={refreshData} /> </DataTableAdvancedToolbar> </DataTable> - {isEditDialogOpen && selectedRow && ( - <PaymentTermsEditSheet - open={isEditDialogOpen} - onOpenChange={setIsEditDialogOpen} - data={selectedRow} - onSuccess={refreshData} - /> - )} + + <DeletePaymentTermsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + paymentTerms={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => { + rowAction?.row.toggleSelected(false) + refreshData() + }} + /> + + <PaymentTermsEditSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + data={rowAction?.row.original ?? null} + onSuccess={refreshData} + /> </> ); }
\ No newline at end of file diff --git a/lib/project-gtc/service.ts b/lib/project-gtc/service.ts new file mode 100644 index 00000000..c65d9364 --- /dev/null +++ b/lib/project-gtc/service.ts @@ -0,0 +1,389 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import db from "@/db/db"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { asc, desc, ilike, or, eq, count, and, ne, sql } from "drizzle-orm"; +import { + projectGtcFiles, + projectGtcView, + type ProjectGtcFile, + projects, +} from "@/db/schema"; +import { promises as fs } from "fs"; +import path from "path"; +import crypto from "crypto"; +import { revalidatePath } from 'next/cache'; + +// Project GTC 목록 조회 +export async function getProjectGtcList( + input: { + page: number; + perPage: number; + search?: string; + sort: Array<{ id: string; desc: boolean }>; + filters?: Record<string, unknown>; + } +) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + const { data, total } = await db.transaction(async (tx) => { + let whereCondition = undefined; + + // GTC 파일이 있는 프로젝트만 필터링 + const gtcFileCondition = sql`${projectGtcView.gtcFileId} IS NOT NULL`; + + if (input.search) { + const s = `%${input.search}%`; + const searchCondition = or( + ilike(projectGtcView.code, s), + ilike(projectGtcView.name, s), + ilike(projectGtcView.type, s), + ilike(projectGtcView.originalFileName, s) + ); + whereCondition = and(gtcFileCondition, searchCondition); + } else { + whereCondition = gtcFileCondition; + } + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc( + projectGtcView[ + item.id as keyof typeof projectGtcView + ] as never + ) + : asc( + projectGtcView[ + item.id as keyof typeof projectGtcView + ] as never + ) + ) + : [desc(projectGtcView.projectCreatedAt)]; + + const dataResult = await tx + .select() + .from(projectGtcView) + .where(whereCondition) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + const totalCount = await tx + .select({ count: count() }) + .from(projectGtcView) + .where(whereCondition); + + return { + data: dataResult, + total: totalCount[0]?.count || 0, + }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { + data, + pageCount, + }; + } catch (error) { + console.error("getProjectGtcList 에러:", error); + throw new Error("Project GTC 목록을 가져오는 중 오류가 발생했습니다."); + } + }, + [`project-gtc-list-${JSON.stringify(input)}`], + { + tags: ["project-gtc"], + revalidate: false, + } + )(); +} + +// Project GTC 파일 업로드 +export async function uploadProjectGtcFile( + projectId: number, + file: File +): Promise<{ success: boolean; data?: ProjectGtcFile; error?: string }> { + try { + // 유효성 검사 + if (!projectId) { + return { success: false, error: "프로젝트 ID는 필수입니다." }; + } + + if (!file) { + return { success: false, error: "파일은 필수입니다." }; + } + + // 허용된 파일 타입 검사 + const allowedTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain' + ]; + + if (!allowedTypes.includes(file.type)) { + return { success: false, error: "PDF, Word, 또는 텍스트 파일만 업로드 가능합니다." }; + } + + // 원본 파일 이름과 확장자 분리 + const originalFileName = file.name; + const fileExtension = path.extname(originalFileName); + const fileNameWithoutExt = path.basename(originalFileName, fileExtension); + + // 해시된 파일 이름 생성 + const timestamp = Date.now(); + const randomHash = crypto.createHash('md5') + .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`) + .digest('hex') + .substring(0, 8); + + const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`; + + // 저장 디렉토리 설정 + const uploadDir = path.join(process.cwd(), "public", "project-gtc"); + + // 디렉토리가 없으면 생성 + try { + await fs.mkdir(uploadDir, { recursive: true }); + } catch (err) { + console.log("Directory already exists or creation failed:", err); + } + + // 파일 경로 설정 + const filePath = path.join(uploadDir, hashedFileName); + const publicFilePath = `/project-gtc/${hashedFileName}`; + + // 파일을 ArrayBuffer로 변환 + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // 파일 저장 + await fs.writeFile(filePath, buffer); + + // 기존 파일이 있으면 삭제 + const existingFile = await db.query.projectGtcFiles.findFirst({ + where: eq(projectGtcFiles.projectId, projectId) + }); + + if (existingFile) { + // 기존 파일 삭제 + try { + const filePath = path.join(process.cwd(), "public", existingFile.filePath); + await fs.unlink(filePath); + } catch { + console.error("파일 삭제 실패"); + } + + // DB에서 기존 파일 정보 삭제 + await db.delete(projectGtcFiles) + .where(eq(projectGtcFiles.id, existingFile.id)); + } + + // DB에 새 파일 정보 저장 + const newFile = await db.insert(projectGtcFiles).values({ + projectId, + fileName: hashedFileName, + filePath: publicFilePath, + originalFileName, + fileSize: file.size, + mimeType: file.type, + }).returning(); + + revalidateTag("project-gtc"); + revalidatePath("/evcp/project-gtc"); + + return { success: true, data: newFile[0] }; + + } catch (error) { + console.error("Project GTC 파일 업로드 에러:", error); + return { + success: false, + error: error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다." + }; + } +} + +// Project GTC 파일 삭제 +export async function deleteProjectGtcFile( + projectId: number +): Promise<{ success: boolean; error?: string }> { + try { + return await db.transaction(async (tx) => { + const existingFile = await tx.query.projectGtcFiles.findFirst({ + where: eq(projectGtcFiles.projectId, projectId) + }); + + if (!existingFile) { + return { success: false, error: "삭제할 파일이 없습니다." }; + } + + // 파일 시스템에서 파일 삭제 + try { + const filePath = path.join(process.cwd(), "public", existingFile.filePath); + await fs.unlink(filePath); + } catch (error) { + console.error("파일 시스템에서 파일 삭제 실패:", error); + throw new Error("파일 시스템에서 파일 삭제에 실패했습니다."); + } + + // DB에서 파일 정보 삭제 + await tx.delete(projectGtcFiles) + .where(eq(projectGtcFiles.id, existingFile.id)); + + return { success: true }; + }); + + } catch (error) { + console.error("Project GTC 파일 삭제 에러:", error); + return { + success: false, + error: error instanceof Error ? error.message : "파일 삭제 중 오류가 발생했습니다." + }; + } finally { + // 트랜잭션 성공/실패와 관계없이 캐시 무효화 + revalidateTag("project-gtc"); + revalidatePath("/evcp/project-gtc"); + } +} + +// 프로젝트별 GTC 파일 정보 조회 +export async function getProjectGtcFile(projectId: number): Promise<ProjectGtcFile | null> { + try { + const file = await db.query.projectGtcFiles.findFirst({ + where: eq(projectGtcFiles.projectId, projectId) + }); + + return file || null; + } catch (error) { + console.error("Project GTC 파일 조회 에러:", error); + return null; + } +} + +// 프로젝트 생성 서버 액션 +export async function createProject( + input: { + code: string; + name: string; + type: string; + } +): Promise<{ success: boolean; data?: typeof projects.$inferSelect; error?: string }> { + try { + // 유효성 검사 + if (!input.code?.trim()) { + return { success: false, error: "프로젝트 코드는 필수입니다." }; + } + + if (!input.name?.trim()) { + return { success: false, error: "프로젝트명은 필수입니다." }; + } + + if (!input.type?.trim()) { + return { success: false, error: "프로젝트 타입은 필수입니다." }; + } + + // 프로젝트 코드 중복 검사 + const existingProject = await db.query.projects.findFirst({ + where: eq(projects.code, input.code.trim()) + }); + + if (existingProject) { + return { success: false, error: "이미 존재하는 프로젝트 코드입니다." }; + } + + // 프로젝트 생성 + const newProject = await db.insert(projects).values({ + code: input.code.trim(), + name: input.name.trim(), + type: input.type.trim(), + }).returning(); + + revalidateTag("project-gtc"); + revalidatePath("/evcp/project-gtc"); + + return { success: true, data: newProject[0] }; + + } catch (error) { + console.error("프로젝트 생성 에러:", error); + return { + success: false, + error: error instanceof Error ? error.message : "프로젝트 생성 중 오류가 발생했습니다." + }; + } +} + +// 프로젝트 정보 수정 서버 액션 +export async function updateProject( + input: { + id: number; + code: string; + name: string; + type: string; + } +): Promise<{ success: boolean; error?: string }> { + try { + if (!input.id) { + return { success: false, error: "프로젝트 ID는 필수입니다." }; + } + if (!input.code?.trim()) { + return { success: false, error: "프로젝트 코드는 필수입니다." }; + } + if (!input.name?.trim()) { + return { success: false, error: "프로젝트명은 필수입니다." }; + } + if (!input.type?.trim()) { + return { success: false, error: "프로젝트 타입은 필수입니다." }; + } + + // 프로젝트 코드 중복 검사 (본인 제외) + const existingProject = await db.query.projects.findFirst({ + where: and( + eq(projects.code, input.code.trim()), + ne(projects.id, input.id) + ) + }); + if (existingProject) { + return { success: false, error: "이미 존재하는 프로젝트 코드입니다." }; + } + + // 업데이트 + await db.update(projects) + .set({ + code: input.code.trim(), + name: input.name.trim(), + type: input.type.trim(), + }) + .where(eq(projects.id, input.id)); + + revalidateTag("project-gtc"); + revalidatePath("/evcp/project-gtc"); + + return { success: true }; + } catch (error) { + console.error("프로젝트 수정 에러:", error); + return { + success: false, + error: error instanceof Error ? error.message : "프로젝트 수정 중 오류가 발생했습니다." + }; + } +} + +// 이미 GTC 파일이 등록된 프로젝트 ID 목록 조회 +export async function getProjectsWithGtcFiles(): Promise<number[]> { + try { + const result = await db + .select({ projectId: projectGtcFiles.projectId }) + .from(projectGtcFiles); + + return result.map(row => row.projectId); + } catch (error) { + console.error("GTC 파일이 등록된 프로젝트 조회 에러:", error); + return []; + } +}
\ No newline at end of file diff --git a/lib/project-gtc/table/add-project-dialog.tsx b/lib/project-gtc/table/add-project-dialog.tsx new file mode 100644 index 00000000..616ab950 --- /dev/null +++ b/lib/project-gtc/table/add-project-dialog.tsx @@ -0,0 +1,296 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { Upload, X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { ProjectSelector } from "@/components/ProjectSelector" +import { uploadProjectGtcFile, getProjectsWithGtcFiles } from "../service" +import { type Project } from "@/lib/rfqs/service" + +const addProjectSchema = z.object({ + projectId: z.number().min(1, "프로젝트 선택은 필수입니다."), + gtcFile: z.instanceof(File, { message: "GTC 파일은 필수입니다." }).optional(), +}) + +type AddProjectFormValues = z.infer<typeof addProjectSchema> + +interface AddProjectDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess?: () => void +} + +export function AddProjectDialog({ + open, + onOpenChange, + onSuccess, +}: AddProjectDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) + const [selectedFile, setSelectedFile] = React.useState<File | null>(null) + const [excludedProjectIds, setExcludedProjectIds] = React.useState<number[]>([]) + + const form = useForm<AddProjectFormValues>({ + resolver: zodResolver(addProjectSchema), + defaultValues: { + projectId: 0, + gtcFile: undefined, + }, + }) + + // 이미 GTC 파일이 등록된 프로젝트 ID 목록 로드 + React.useEffect(() => { + async function loadExcludedProjects() { + try { + const excludedIds = await getProjectsWithGtcFiles(); + setExcludedProjectIds(excludedIds); + } catch (error) { + console.error("제외할 프로젝트 목록 로드 오류:", error); + } + } + + if (open) { + loadExcludedProjects(); + } + }, [open]); + + // 프로젝트 선택 시 폼에 자동으로 채우기 + const handleProjectSelect = (project: Project) => { + // 이미 GTC 파일이 등록된 프로젝트인지 확인 + if (excludedProjectIds.includes(project.id)) { + toast.error("이미 GTC 파일이 등록된 프로젝트입니다."); + // 선택된 프로젝트 정보 초기화 + setSelectedProject(null); + form.setValue("projectId", 0); + return; + } + + setSelectedProject(project) + form.setValue("projectId", project.id) + } + + // 파일 선택 처리 + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + // PDF 파일만 허용 + if (file.type !== 'application/pdf') { + toast.error("PDF 파일만 업로드 가능합니다.") + return + } + + setSelectedFile(file) + form.setValue("gtcFile", file) + } + } + + // 파일 제거 + const handleRemoveFile = () => { + setSelectedFile(null) + form.setValue("gtcFile", undefined) + // input 요소의 value도 초기화 + const fileInput = document.getElementById('gtc-file-input') as HTMLInputElement + if (fileInput) { + fileInput.value = '' + } + } + + const onSubmit = async (data: AddProjectFormValues) => { + // 프로젝트가 선택되지 않았으면 에러 + if (!selectedProject) { + toast.error("프로젝트를 선택해주세요.") + return + } + + // 이미 GTC 파일이 등록된 프로젝트인지 다시 한번 확인 + if (excludedProjectIds.includes(selectedProject.id)) { + toast.error("이미 GTC 파일이 등록된 프로젝트입니다.") + return + } + + // GTC 파일이 없으면 에러 + if (!data.gtcFile) { + toast.error("GTC 파일은 필수입니다.") + return + } + + setIsLoading(true) + try { + // GTC 파일 업로드 + const fileResult = await uploadProjectGtcFile(selectedProject.id, data.gtcFile) + + if (!fileResult.success) { + toast.error(fileResult.error || "GTC 파일 업로드에 실패했습니다.") + return + } + + toast.success("GTC 파일이 성공적으로 업로드되었습니다.") + form.reset() + setSelectedProject(null) + setSelectedFile(null) + onOpenChange(false) + onSuccess?.() + } catch (error) { + console.error("GTC 파일 업로드 오류:", error) + toast.error("GTC 파일 업로드 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + form.reset() + setSelectedProject(null) + setSelectedFile(null) + } + onOpenChange(newOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>GTC 파일 추가</DialogTitle> + <DialogDescription> + 기존 프로젝트를 선택하고 GTC 파일을 업로드합니다. (이미 GTC 파일이 등록된 프로젝트는 제외됩니다) + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 프로젝트 선택 (필수) */} + <FormField + control={form.control} + name="projectId" + render={() => ( + <FormItem> + <FormLabel>프로젝트 선택 *</FormLabel> + <FormControl> + <ProjectSelector + selectedProjectId={selectedProject?.id} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 선택하세요..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 프로젝트 정보 표시 (읽기 전용) */} + {selectedProject && ( + <div className="p-4 bg-muted rounded-lg space-y-2"> + <h4 className="font-medium text-sm">선택된 프로젝트 정보</h4> + <div className="space-y-1 text-sm"> + <div className="flex justify-between"> + <span className="text-muted-foreground">프로젝트 코드:</span> + <span className="font-medium">{selectedProject.projectCode}</span> + </div> + <div className="flex justify-between"> + <span className="text-muted-foreground">프로젝트명:</span> + <span className="font-medium">{selectedProject.projectName}</span> + </div> + </div> + </div> + )} + + {/* GTC 파일 업로드 */} + <FormField + control={form.control} + name="gtcFile" + render={() => ( + <FormItem> + <FormLabel>GTC 파일 *</FormLabel> + <div className="space-y-2"> + {!selectedFile ? ( + <div className="flex items-center justify-center w-full"> + <label + htmlFor="gtc-file-input" + className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100" + > + <div className="flex flex-col items-center justify-center pt-5 pb-6"> + <Upload className="w-8 h-8 mb-4 text-gray-500" /> + <p className="mb-2 text-sm text-gray-500"> + <span className="font-semibold">클릭하여 파일 선택</span> 또는 드래그 앤 드롭 + </p> + <p className="text-xs text-gray-500"> + PDF 파일만 + </p> + </div> + <input + id="gtc-file-input" + type="file" + className="hidden" + accept=".pdf" + onChange={handleFileSelect} + disabled={isLoading} + /> + </label> + </div> + ) : ( + <div className="flex items-center justify-between p-3 border rounded-lg bg-gray-50"> + <div className="flex items-center space-x-2"> + <Upload className="w-4 h-4 text-gray-500" /> + <span className="text-sm font-medium">{selectedFile.name}</span> + <span className="text-xs text-gray-500"> + ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB) + </span> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={handleRemoveFile} + disabled={isLoading} + > + <X className="w-4 h-4" /> + </Button> + </div> + )} + </div> + <FormMessage /> + </FormItem> + )} + /> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => handleOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button type="submit" disabled={isLoading || !selectedProject}> + {isLoading ? "업로드 중..." : "GTC 파일 업로드"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/project-gtc/table/delete-gtc-file-dialog.tsx b/lib/project-gtc/table/delete-gtc-file-dialog.tsx new file mode 100644 index 00000000..d64be529 --- /dev/null +++ b/lib/project-gtc/table/delete-gtc-file-dialog.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { deleteProjectGtcFile } from "../service" +import { ProjectGtcView } from "@/db/schema" + +interface DeleteGtcFileDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + projects: Row<ProjectGtcView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteGtcFileDialog({ + projects, + showTrigger = true, + onSuccess, + ...props +}: DeleteGtcFileDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + try { + // 각 프로젝트의 GTC 파일을 삭제 + const deletePromises = projects.map(project => + deleteProjectGtcFile(project.id) + ) + + const results = await Promise.all(deletePromises) + + // 성공/실패 확인 + const successCount = results.filter(result => result.success).length + const failureCount = results.length - successCount + + if (failureCount > 0) { + toast.error(`${failureCount}개 파일 삭제에 실패했습니다.`) + return + } + + props.onOpenChange?.(false) + toast.success(`${successCount}개 GTC 파일이 성공적으로 삭제되었습니다.`) + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("파일 삭제 중 오류가 발생했습니다.") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({projects.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{projects.length}</span> + {projects.length === 1 ? " Project GTC" : " Project GTCs"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({projects.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete{" "} + <span className="font-medium">{projects.length}</span> + {projects.length === 1 ? " Project GTC" : " Project GTCs"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/project-gtc/table/project-gtc-table-columns.tsx b/lib/project-gtc/table/project-gtc-table-columns.tsx new file mode 100644 index 00000000..dfdf1921 --- /dev/null +++ b/lib/project-gtc/table/project-gtc-table-columns.tsx @@ -0,0 +1,364 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, Paperclip, FileText } from "lucide-react" +import { toast } from "sonner" + +import { formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { ProjectGtcView } from "@/db/schema" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProjectGtcView> | null>> +} + +/** + * 파일 다운로드 함수 + */ +const handleFileDownload = async (projectId: number, fileName: string) => { + try { + // API를 통해 파일 다운로드 + const response = await fetch(`/api/project-gtc?action=download&projectId=${projectId}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('파일 다운로드에 실패했습니다.'); + } + + // 파일 blob 생성 + const blob = await response.blob(); + + // 다운로드 링크 생성 + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 메모리 정리 + window.URL.revokeObjectURL(url); + + toast.success("파일 다운로드를 시작합니다."); + } catch (error) { + console.error("파일 다운로드 오류:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } +}; + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ProjectGtcView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<ProjectGtcView> = { + 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" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 파일 다운로드 컬럼 (아이콘) + // ---------------------------------------------------------------- + const downloadColumn: ColumnDef<ProjectGtcView> = { + id: "download", + header: "", + cell: ({ row }) => { + const project = row.original; + + if (!project.filePath || !project.originalFileName) { + return null; + } + + return ( + <Button + variant="ghost" + size="icon" + onClick={() => handleFileDownload(project.id, project.originalFileName!)} + title={`${project.originalFileName} 다운로드`} + className="hover:bg-muted" + > + <Paperclip className="h-4 w-4" /> + <span className="sr-only">다운로드</span> + </Button> + ); + }, + maxSize: 30, + enableSorting: false, + } + + // ---------------------------------------------------------------- + // 3) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<ProjectGtcView> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + <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: "upload", row })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ type: "delete", row })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + maxSize: 30, + } + + // ---------------------------------------------------------------- + // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 4-1) groupMap: { [groupName]: ColumnDef<ProjectGtcView>[] } + const groupMap: Record<string, ColumnDef<ProjectGtcView>[]> = {} + + // 프로젝트 정보 그룹 + groupMap["기본 정보"] = [ + { + accessorKey: "code", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[500px] truncate font-medium"> + {row.getValue("code")} + </span> + </div> + ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + minSize: 120, + }, + { + accessorKey: "name", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트명" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[500px] truncate"> + {row.getValue("name")} + </span> + </div> + ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + minSize: 200, + }, + { + accessorKey: "type", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 타입" /> + ), + cell: ({ row }) => { + const type = row.getValue("type") as string + return ( + <div className="flex w-[100px] items-center"> + <Badge variant="secondary"> + {type} + </Badge> + </div> + ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + minSize: 100, + }, + ] + + // 파일 정보 그룹 + groupMap["파일 정보"] = [ + { + accessorKey: "originalFileName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="GTC 파일" /> + ), + cell: ({ row }) => { + const fileName = row.getValue("originalFileName") as string | null + const filePath = row.original.filePath + + if (!fileName) { + return ( + <div className="flex items-center text-muted-foreground"> + <FileText className="mr-2 h-4 w-4" /> + <span>파일 없음</span> + </div> + ) + } + + return ( + <div className="flex items-center space-x-2"> + <FileText className="h-4 w-4" /> + <div className="flex flex-col"> + {filePath ? ( + <button + onClick={async (e) => { + e.preventDefault(); + e.stopPropagation(); + try { + // API를 통해 파일 다운로드 + const response = await fetch(`/api/project-gtc?action=download&projectId=${row.original.id}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('파일을 열 수 없습니다.'); + } + + // 파일 blob 생성 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + window.open(url, '_blank'); + } catch (error) { + console.error("파일 미리보기 오류:", error); + toast.error("파일을 열 수 없습니다."); + } + }} + className="font-medium text-left hover:underline cursor-pointer text-blue-600 hover:text-blue-800 transition-colors" + title="클릭하여 파일 열기" + > + {fileName} + </button> + ) : ( + <span className="font-medium">{fileName}</span> + )} + </div> + </div> + ) + }, + minSize: 200, + }, + ] + + // 날짜 정보 그룹 + groupMap["날짜 정보"] = [ + { + accessorKey: "gtcCreatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="GTC 등록일" /> + ), + cell: ({ row }) => { + const date = row.getValue("gtcCreatedAt") as Date | null + if (!date) { + return <span className="text-muted-foreground">-</span> + } + return ( + <div className="flex items-center"> + <span> + {formatDateTime(new Date(date))} + </span> + </div> + ) + }, + minSize: 150, + }, + { + accessorKey: "projectCreatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 생성일" /> + ), + cell: ({ row }) => { + const date = row.getValue("projectCreatedAt") as Date + return ( + <div className="flex items-center"> + <span> + {formatDate(new Date(date))} + </span> + </div> + ) + }, + minSize: 120, + }, + ] + + // ---------------------------------------------------------------- + // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<ProjectGtcView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "프로젝트 정보", "파일 정보", "날짜 정보" 등 + columns: colDefs, + }) + }) + + // ---------------------------------------------------------------- + // 5) 최종 컬럼 배열: select, download, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + downloadColumn, // 다운로드 컬럼 추가 + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/project-gtc/table/project-gtc-table-toolbar-actions.tsx b/lib/project-gtc/table/project-gtc-table-toolbar-actions.tsx new file mode 100644 index 00000000..ec6ba053 --- /dev/null +++ b/lib/project-gtc/table/project-gtc-table-toolbar-actions.tsx @@ -0,0 +1,74 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { type Table } from "@tanstack/react-table" +import { Download, Plus } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { DeleteGtcFileDialog } from "./delete-gtc-file-dialog" +import { AddProjectDialog } from "./add-project-dialog" +import { ProjectGtcView } from "@/db/schema" + +interface ProjectGtcTableToolbarActionsProps { + table: Table<ProjectGtcView> +} + +export function ProjectGtcTableToolbarActions({ table }: ProjectGtcTableToolbarActionsProps) { + const router = useRouter() + const [showAddProjectDialog, setShowAddProjectDialog] = React.useState(false) + + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteGtcFileDialog + projects={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => { + table.toggleAllRowsSelected(false) + router.refresh() + }} + /> + ) : null} + + {/** 2) GTC 추가 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => setShowAddProjectDialog(true)} + className="gap-2" + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">GTC 추가</span> + </Button> + + {/** 3) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "project-gtc-list", + excludeColumns: ["select", "download", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + + {/** 4) 프로젝트 추가 다이얼로그 */} + <AddProjectDialog + open={showAddProjectDialog} + onOpenChange={setShowAddProjectDialog} + onSuccess={() => { + router.refresh() + }} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/project-gtc/table/project-gtc-table.tsx b/lib/project-gtc/table/project-gtc-table.tsx new file mode 100644 index 00000000..6e529ccf --- /dev/null +++ b/lib/project-gtc/table/project-gtc-table.tsx @@ -0,0 +1,100 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { DataTable } from "@/components/data-table/data-table"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table" +import { getProjectGtcList } from "../service"; +import { getColumns } from "./project-gtc-table-columns"; +import { DeleteGtcFileDialog } from "./delete-gtc-file-dialog"; +import { UpdateGtcFileSheet } from "./update-gtc-file-sheet"; +import { ProjectGtcTableToolbarActions } from "./project-gtc-table-toolbar-actions"; +import { ProjectGtcView } from "@/db/schema"; + +interface ProjectGtcTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getProjectGtcList>>, + ] + > +} + +export function ProjectGtcTable({ promises }: ProjectGtcTableProps) { + const router = useRouter(); + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ProjectGtcView> | null>(null) + + const [{ data, pageCount }] = + React.use(promises) + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // config 기반으로 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<ProjectGtcView>[] = [ + { id: "code", label: "프로젝트 코드", type: "text" }, + { id: "name", label: "프로젝트명", type: "text" }, + { + id: "type", label: "프로젝트 타입", type: "select", options: [ + { label: "Ship", value: "ship" }, + { label: "Offshore", value: "offshore" }, + { label: "Other", value: "other" }, + ] + }, + { id: "originalFileName", label: "GTC 파일명", type: "text" }, + { id: "projectCreatedAt", label: "프로젝트 생성일", type: "date" }, + { id: "gtcCreatedAt", label: "GTC 등록일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "projectCreatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <ProjectGtcTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + <DeleteGtcFileDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + projects={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => { + router.refresh(); + }} + /> + + <UpdateGtcFileSheet + open={rowAction?.type === "upload"} + onOpenChange={() => setRowAction(null)} + project={rowAction && rowAction.type === "upload" ? rowAction.row.original : null} + /> + </> + ); +}
\ No newline at end of file diff --git a/lib/project-gtc/table/update-gtc-file-sheet.tsx b/lib/project-gtc/table/update-gtc-file-sheet.tsx new file mode 100644 index 00000000..65a6bb45 --- /dev/null +++ b/lib/project-gtc/table/update-gtc-file-sheet.tsx @@ -0,0 +1,222 @@ +"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 { Upload } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { uploadProjectGtcFile } from "../service" +import type { ProjectGtcView } from "@/db/schema" + +const updateProjectSchema = z.object({ + gtcFile: z.instanceof(File).optional(), +}) + +type UpdateProjectFormValues = z.infer<typeof updateProjectSchema> + +interface UpdateGtcFileSheetProps { + project: ProjectGtcView | null + open: boolean + onOpenChange: (open: boolean) => void +} + +export function UpdateGtcFileSheet({ + project, + open, + onOpenChange, +}: UpdateGtcFileSheetProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [selectedFile, setSelectedFile] = React.useState<File | null>(null) + + const form = useForm<UpdateProjectFormValues>({ + resolver: zodResolver(updateProjectSchema), + defaultValues: { + gtcFile: undefined, + }, + }) + + // 기존 값 세팅 (프로젝트 변경 시) + React.useEffect(() => { + if (project) { + form.reset({ + gtcFile: undefined, + }) + setSelectedFile(null) + } + }, [project, form]) + + // 파일 선택 처리 + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + // PDF 파일만 허용 + if (file.type !== 'application/pdf') { + toast.error("PDF 파일만 업로드 가능합니다.") + return + } + setSelectedFile(file) + form.setValue("gtcFile", file) + } + } + + // 폼 제출 핸들러 + async function onSubmit(data: UpdateProjectFormValues) { + if (!project) { + toast.error("프로젝트 정보를 찾을 수 없습니다.") + return + } + + setIsLoading(true) + try { + // GTC 파일이 있으면 업로드 + if (data.gtcFile) { + const fileResult = await uploadProjectGtcFile(project.id, data.gtcFile) + if (!fileResult.success) { + toast.error(fileResult.error || "GTC 파일 업로드에 실패했습니다.") + return + } + toast.success("GTC 파일이 성공적으로 업로드되었습니다.") + } else { + toast.info("변경사항이 없습니다.") + } + + form.reset() + setSelectedFile(null) + onOpenChange(false) + } catch (error) { + console.error("GTC 파일 업로드 오류:", error) + toast.error("GTC 파일 업로드 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + if (!project) return null + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="flex flex-col gap-6 sm:max-w-xl"> + <SheetHeader className="text-left"> + <SheetTitle>GTC 파일 수정</SheetTitle> + <SheetDescription> + 프로젝트 정보는 수정할 수 없으며, GTC 파일만 업로드할 수 있습니다. + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + {/* 프로젝트 정보 (읽기 전용) */} + <div className="space-y-4"> + <div> + <FormLabel>프로젝트 코드</FormLabel> + <Input + value={project.code} + disabled + className="bg-muted" + /> + </div> + <div> + <FormLabel>프로젝트명</FormLabel> + <Input + value={project.name} + disabled + className="bg-muted" + /> + </div> + <div> + <FormLabel>프로젝트 타입</FormLabel> + <Input + value={project.type} + disabled + className="bg-muted" + /> + </div> + </div> + + {/* GTC 파일 업로드 */} + <FormField + control={form.control} + name="gtcFile" + render={() => ( + <FormItem> + <FormLabel>GTC 파일 (PDF만, 선택 시 기존 파일 교체)</FormLabel> + <div className="space-y-2"> + <label + htmlFor="gtc-file-input" + className="flex flex-col items-center justify-center w-full min-h-[8rem] border-2 border-dashed border-gray-300 rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100" + > + <div className="flex flex-col items-center justify-center p-4 text-center"> + <Upload className="w-8 h-8 mb-2 text-gray-500" /> + <span className="mb-1 text-base font-semibold text-gray-800"> + {selectedFile + ? selectedFile.name + : project.originalFileName + ? `현재 파일: ${project.originalFileName}` + : "현재 파일 없음"} + </span> + {selectedFile ? ( + <span className="text-xs text-gray-500"> + ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB) + </span> + ) : ( + <> + <p className="mb-2 text-sm text-gray-500"> + 또는 클릭하여 파일을 선택하세요 + </p> + <p className="text-xs text-gray-500"> + PDF 파일만 + </p> + </> + )} + </div> + <input + id="gtc-file-input" + type="file" + className="hidden" + accept=".pdf" + onChange={handleFileSelect} + disabled={isLoading} + /> + </label> + </div> + <FormMessage /> + </FormItem> + )} + /> + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button type="submit" disabled={isLoading || !selectedFile}> + {isLoading ? "업로드 중..." : "GTC 파일 업로드"} + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/project-gtc/table/view-gtc-file-dialog.tsx b/lib/project-gtc/table/view-gtc-file-dialog.tsx new file mode 100644 index 00000000..f8cfecd9 --- /dev/null +++ b/lib/project-gtc/table/view-gtc-file-dialog.tsx @@ -0,0 +1,230 @@ +"use client" + +import * as React from "react" +import { Download, FileText, Calendar, HardDrive } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { format } from "date-fns" +import { ko } from "date-fns/locale" +import type { ProjectGtcView } from "@/db/schema" + +interface ViewGtcFileDialogProps { + project: ProjectGtcView | null + open: boolean + onOpenChange: (open: boolean) => void +} + +// 파일 크기 포맷팅 함수 +function formatBytes(bytes: number | null): string { + if (!bytes) return "0 B" + + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +export function ViewGtcFileDialog({ + project, + open, + onOpenChange, +}: ViewGtcFileDialogProps) { + if (!project || !project.gtcFileId) return null + + const handleDownload = async () => { + try { + // API를 통해 파일 다운로드 + const response = await fetch(`/api/project-gtc?action=download&projectId=${project.id}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('파일 다운로드에 실패했습니다.'); + } + + // 파일 blob 생성 + const blob = await response.blob(); + + // 다운로드 링크 생성 + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = project.originalFileName || 'gtc-file'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 메모리 정리 + window.URL.revokeObjectURL(url); + } catch (error) { + console.error("파일 다운로드 오류:", error); + } + } + + const handlePreview = async () => { + try { + // API를 통해 파일 다운로드 + const response = await fetch(`/api/project-gtc?action=download&projectId=${project.id}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error('파일을 열 수 없습니다.'); + } + + // 파일 blob 생성 + const blob = await response.blob(); + + // PDF 파일인 경우 새 탭에서 열기 + if (project.mimeType === 'application/pdf') { + const url = window.URL.createObjectURL(blob); + window.open(url, '_blank'); + // 메모리 정리는 브라우저가 탭을 닫을 때 자동으로 처리됨 + } else { + // 다른 파일 타입은 다운로드 + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = project.originalFileName || 'gtc-file'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } + } catch (error) { + console.error("파일 미리보기 오류:", error); + } + } + + const getFileIcon = () => { + if (project.mimeType?.includes('pdf')) { + return "📄" + } else if (project.mimeType?.includes('word') || project.mimeType?.includes('document')) { + return "📝" + } else if (project.mimeType?.includes('text')) { + return "📃" + } + return "📎" + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>GTC 파일 정보</DialogTitle> + <DialogDescription> + 프로젝트 "{project.name}" ({project.code})의 GTC 파일 정보입니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 프로젝트 정보 */} + <div className="p-4 bg-muted rounded-lg"> + <h4 className="font-medium mb-2">프로젝트 정보</h4> + <div className="space-y-2 text-sm"> + <div className="flex justify-between"> + <span className="text-muted-foreground">프로젝트 코드:</span> + <span className="font-medium">{project.code}</span> + </div> + <div className="flex justify-between"> + <span className="text-muted-foreground">프로젝트명:</span> + <span className="font-medium">{project.name}</span> + </div> + <div className="flex justify-between"> + <span className="text-muted-foreground">프로젝트 타입:</span> + <Badge variant="secondary">{project.type}</Badge> + </div> + </div> + </div> + + {/* 파일 정보 */} + <div className="p-4 bg-muted rounded-lg"> + <h4 className="font-medium mb-2">파일 정보</h4> + <div className="space-y-3"> + <div className="flex items-center space-x-3"> + <span className="text-2xl">{getFileIcon()}</span> + <div className="flex-1"> + <div className="font-medium">{project.originalFileName}</div> + <div className="text-sm text-muted-foreground"> + {project.fileName} + </div> + </div> + </div> + + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="flex items-center space-x-2"> + <HardDrive className="h-4 w-4 text-muted-foreground" /> + <span className="text-muted-foreground">파일 크기:</span> + <span className="font-medium"> + {project.fileSize ? formatBytes(project.fileSize) : '알 수 없음'} + </span> + </div> + <div className="flex items-center space-x-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span className="text-muted-foreground">파일 타입:</span> + <span className="font-medium"> + {project.mimeType || '알 수 없음'} + </span> + </div> + <div className="flex items-center space-x-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span className="text-muted-foreground">업로드일:</span> + <span className="font-medium"> + {project.gtcCreatedAt ? + format(new Date(project.gtcCreatedAt), "yyyy-MM-dd HH:mm", { locale: ko }) : + '알 수 없음' + } + </span> + </div> + <div className="flex items-center space-x-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span className="text-muted-foreground">수정일:</span> + <span className="font-medium"> + {project.gtcUpdatedAt ? + format(new Date(project.gtcUpdatedAt), "yyyy-MM-dd HH:mm", { locale: ko }) : + '알 수 없음' + } + </span> + </div> + </div> + </div> + </div> + </div> + + <DialogFooter className="flex space-x-2"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + 닫기 + </Button> + <Button + type="button" + variant="outline" + onClick={handlePreview} + > + 미리보기 + </Button> + <Button + type="button" + onClick={handleDownload} + > + <Download className="mr-2 h-4 w-4" /> + 다운로드 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/project-gtc/validations.ts b/lib/project-gtc/validations.ts new file mode 100644 index 00000000..963ffdd4 --- /dev/null +++ b/lib/project-gtc/validations.ts @@ -0,0 +1,32 @@ +import * as z from "zod" +import { createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum +} from "nuqs/server" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { ProjectGtcView } from "@/db/schema" + +export const projectGtcSearchParamsSchema = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<ProjectGtcView>().withDefault([ + { id: "projectCreatedAt", desc: true }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +export const projectGtcFileSchema = z.object({ + projectId: z.number().min(1, "프로젝트 ID는 필수입니다."), + file: z.instanceof(File).refine((file) => file.size > 0, "파일은 필수입니다."), +}) + +export type ProjectGtcSearchParams = Awaited<ReturnType<typeof projectGtcSearchParamsSchema.parse>> +export type ProjectGtcFileInput = z.infer<typeof projectGtcFileSchema>
\ No newline at end of file diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index c3c14aff..96d6a3c9 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -11,7 +11,7 @@ import { techSalesRfqItems, biddingProjects } from "@/db/schema"; -import { and, desc, eq, ilike, or, sql, inArray } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql, inArray, count, asc } from "drizzle-orm"; import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import { getErrorMessage } from "@/lib/handle-error"; @@ -3022,9 +3022,9 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType try { // RFQ 타입에 따른 벤더 타입 매핑 - const vendorTypeFilter = rfqType === "SHIP" ? "SHIP" : - rfqType === "TOP" ? "OFFSHORE_TOP" : - rfqType === "HULL" ? "OFFSHORE_HULL" : null; + const vendorTypeFilter = rfqType === "SHIP" ? "조선" : + rfqType === "TOP" ? "해양TOP" : + rfqType === "HULL" ? "해양HULL" : null; const whereConditions = [ eq(techVendors.status, "ACTIVE"), @@ -3034,9 +3034,9 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType ) ]; - // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 + // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 (컴마 구분 문자열에서 검색) if (vendorTypeFilter) { - whereConditions.push(eq(techVendors.techVendorType, vendorTypeFilter)); + whereConditions.push(sql`${techVendors.techVendorType} LIKE ${'%' + vendorTypeFilter + '%'}`); } const results = await db @@ -3058,4 +3058,237 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType console.error("Error searching tech vendors:", err); throw new Error(getErrorMessage(err)); } +} + +/** + * Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함) + */ +export async function getAcceptedTechSalesVendorQuotations(input: { + search?: string; + filters?: Filter<typeof techSalesVendorQuotations>[]; + sort?: { id: string; desc: boolean }[]; + page: number; + perPage: number; + rfqType?: "SHIP" | "TOP" | "HULL"; +}) { + unstable_noStore(); + + try { + const offset = (input.page - 1) * input.perPage; + + // 기본 WHERE 조건: status = 'Accepted'만 조회 + const baseConditions = [ + eq(techSalesVendorQuotations.status, 'Accepted') + ]; + + // 검색 조건 추가 + const searchConditions = []; + if (input.search) { + searchConditions.push( + ilike(techSalesRfqs.rfqCode, `%${input.search}%`), + ilike(techSalesRfqs.description, `%${input.search}%`), + ilike(sql`vendors.vendor_name`, `%${input.search}%`), + ilike(sql`vendors.vendor_code`, `%${input.search}%`) + ); + } + + // 정렬 조건 변환 + const orderByConditions: OrderByType[] = []; + if (input.sort?.length) { + input.sort.forEach((sortItem) => { + switch (sortItem.id) { + case "rfqCode": + orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.rfqCode) : asc(techSalesRfqs.rfqCode)); + break; + case "description": + orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.description) : asc(techSalesRfqs.description)); + break; + case "vendorName": + orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_name`) : asc(sql`vendors.vendor_name`)); + break; + case "vendorCode": + orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_code`) : asc(sql`vendors.vendor_code`)); + break; + case "totalPrice": + orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.totalPrice) : asc(techSalesVendorQuotations.totalPrice)); + break; + case "acceptedAt": + orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.acceptedAt) : asc(techSalesVendorQuotations.acceptedAt)); + break; + default: + orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt)); + } + }); + } else { + orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt)); + } + + // 필터 조건 추가 + const filterConditions = []; + if (input.filters?.length) { + const { filterWhere, joinOperator } = filterColumns({ + table: techSalesVendorQuotations, + filters: input.filters, + joinOperator: input.joinOperator ?? "and", + }); + if (filterWhere) { + filterConditions.push(filterWhere); + } + } + + // RFQ 타입 필터 + if (input.rfqType) { + filterConditions.push(eq(techSalesRfqs.rfqType, input.rfqType)); + } + + // 모든 조건 결합 + const allConditions = [ + ...baseConditions, + ...filterConditions, + ...(searchConditions.length > 0 ? [or(...searchConditions)] : []) + ]; + + const whereCondition = allConditions.length > 1 + ? and(...allConditions) + : allConditions[0]; + + // 데이터 조회 + const data = await db + .select({ + // Quotation 정보 + id: techSalesVendorQuotations.id, + rfqId: techSalesVendorQuotations.rfqId, + vendorId: techSalesVendorQuotations.vendorId, + quotationCode: techSalesVendorQuotations.quotationCode, + quotationVersion: techSalesVendorQuotations.quotationVersion, + totalPrice: techSalesVendorQuotations.totalPrice, + currency: techSalesVendorQuotations.currency, + validUntil: techSalesVendorQuotations.validUntil, + status: techSalesVendorQuotations.status, + remark: techSalesVendorQuotations.remark, + submittedAt: techSalesVendorQuotations.submittedAt, + acceptedAt: techSalesVendorQuotations.acceptedAt, + createdAt: techSalesVendorQuotations.createdAt, + updatedAt: techSalesVendorQuotations.updatedAt, + + // RFQ 정보 + rfqCode: techSalesRfqs.rfqCode, + rfqType: techSalesRfqs.rfqType, + description: techSalesRfqs.description, + dueDate: techSalesRfqs.dueDate, + rfqStatus: techSalesRfqs.status, + materialCode: techSalesRfqs.materialCode, + + // Vendor 정보 + vendorName: sql<string>`vendors.vendor_name`, + vendorCode: sql<string | null>`vendors.vendor_code`, + vendorEmail: sql<string | null>`vendors.email`, + vendorCountry: sql<string | null>`vendors.country`, + + // Project 정보 + projNm: biddingProjects.projNm, + pspid: biddingProjects.pspid, + sector: biddingProjects.sector, + }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`)) + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) + .where(whereCondition) + .orderBy(...orderByConditions) + .limit(input.perPage) + .offset(offset); + + // 총 개수 조회 + const totalCount = await db + .select({ count: count() }) + .from(techSalesVendorQuotations) + .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id)) + .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`)) + .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id)) + .where(whereCondition); + + const total = totalCount[0]?.count ?? 0; + const pageCount = Math.ceil(total / input.perPage); + + return { + data, + pageCount, + total, + }; + + } catch (error) { + console.error("getAcceptedTechSalesVendorQuotations 오류:", error); + throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`); + } +} + +/** + * 벤더 견적서 거절 처리 (벤더가 직접 거절) + */ +export async function rejectTechSalesVendorQuotations(input: { + quotationIds: number[]; + rejectionReason?: string; +}) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + + const result = await db.transaction(async (tx) => { + // 견적서들이 존재하고 벤더가 권한이 있는지 확인 + const quotations = await tx + .select({ + id: techSalesVendorQuotations.id, + status: techSalesVendorQuotations.status, + vendorId: techSalesVendorQuotations.vendorId, + }) + .from(techSalesVendorQuotations) + .where(inArray(techSalesVendorQuotations.id, input.quotationIds)); + + if (quotations.length !== input.quotationIds.length) { + throw new Error("일부 견적서를 찾을 수 없습니다."); + } + + // 이미 거절된 견적서가 있는지 확인 + const alreadyRejected = quotations.filter(q => q.status === "Rejected"); + if (alreadyRejected.length > 0) { + throw new Error("이미 거절된 견적서가 포함되어 있습니다."); + } + + // 승인된 견적서가 있는지 확인 + const alreadyAccepted = quotations.filter(q => q.status === "Accepted"); + if (alreadyAccepted.length > 0) { + throw new Error("이미 승인된 견적서는 거절할 수 없습니다."); + } + + // 견적서 상태를 거절로 변경 + await tx + .update(techSalesVendorQuotations) + .set({ + status: "Rejected", + rejectionReason: input.rejectionReason || null, + updatedBy: parseInt(session.user.id), + updatedAt: new Date(), + }) + .where(inArray(techSalesVendorQuotations.id, input.quotationIds)); + + return { success: true, updatedCount: quotations.length }; + }); + revalidateTag("techSalesRfqs"); + revalidateTag("techSalesVendorQuotations"); + revalidatePath("/partners/techsales/rfq-ship", "page"); + return { + success: true, + message: `${result.updatedCount}개의 견적서가 거절되었습니다.`, + data: result + }; + } catch (error) { + console.error("견적서 거절 오류:", error); + return { + success: false, + error: getErrorMessage(error) + }; + } }
\ No newline at end of file diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx index ddee2317..b89f8953 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx @@ -15,7 +15,8 @@ import { } from "@/components/ui/tooltip" import { TechSalesVendorQuotations, - TECH_SALES_QUOTATION_STATUS_CONFIG + TECH_SALES_QUOTATION_STATUS_CONFIG, + TECH_SALES_QUOTATION_STATUSES } from "@/db/schema" import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" @@ -70,14 +71,21 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge className="translate-y-0.5" /> ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} + cell: ({ row }) => { + const isRejected = row.original.status === TECH_SALES_QUOTATION_STATUSES.REJECTED; + const isAccepted = row.original.status === TECH_SALES_QUOTATION_STATUSES.ACCEPTED; + const isDisabled = isRejected || isAccepted; + + return ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="행 선택" - className="translate-y-0.5" - /> - ), + className="translate-y-0.5" + disabled={isDisabled} + /> + ); + }, enableSorting: false, enableHiding: false, }, @@ -158,33 +166,33 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge // enableSorting: true, // enableHiding: true, // }, - { - accessorKey: "itemName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="자재명" /> - ), - cell: ({ row }) => { - const itemName = row.getValue("itemName") as string; - return ( - <div className="min-w-48 max-w-64"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <span className="truncate block text-sm"> - {itemName || "N/A"} - </span> - </TooltipTrigger> - <TooltipContent> - <p className="max-w-xs">{itemName || "N/A"}</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - ); - }, - enableSorting: true, - enableHiding: true, - }, + // { + // accessorKey: "itemName", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="자재명" /> + // ), + // cell: ({ row }) => { + // const itemName = row.getValue("itemName") as string; + // return ( + // <div className="min-w-48 max-w-64"> + // <TooltipProvider> + // <Tooltip> + // <TooltipTrigger asChild> + // <span className="truncate block text-sm"> + // {itemName || "N/A"} + // </span> + // </TooltipTrigger> + // <TooltipContent> + // <p className="max-w-xs">{itemName || "N/A"}</p> + // </TooltipContent> + // </Tooltip> + // </TooltipProvider> + // </div> + // ); + // }, + // enableSorting: true, + // enableHiding: true, + // }, { accessorKey: "projNm", header: ({ column }) => ( @@ -597,6 +605,9 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge const quotation = row.original; const rfqCode = quotation.rfqCode || "N/A"; const tooltipText = `${rfqCode} 견적서 작성`; + const isRejected = quotation.status === "Rejected"; + const isAccepted = quotation.status === "Accepted"; + const isDisabled = isRejected || isAccepted; return ( <div className="w-16"> @@ -607,16 +618,19 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge variant="ghost" size="icon" onClick={() => { - router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`); + if (!isDisabled) { + router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`); + } }} className="h-8 w-8" + disabled={isDisabled} > <Edit className="h-4 w-4" /> <span className="sr-only">견적서 작성</span> </Button> </TooltipTrigger> <TooltipContent> - <p>{tooltipText}</p> + <p>{isRejected ? "거절된 견적서는 편집할 수 없습니다" : isAccepted ? "승인된 견적서는 편집할 수 없습니다" : tooltipText}</p> </TooltipContent> </Tooltip> </TooltipProvider> diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx index 55dcad92..5e5d4f39 100644 --- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx +++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx @@ -12,9 +12,24 @@ import { useRouter } from "next/navigation" import { getColumns } from "./vendor-quotations-table-columns" import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet" import { RfqItemsViewDialog } from "../../table/rfq-items-view-dialog" -import { getTechSalesRfqAttachments, getVendorQuotations } from "@/lib/techsales-rfq/service" +import { getTechSalesRfqAttachments, getVendorQuotations, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service" import { toast } from "sonner" import { Skeleton } from "@/components/ui/skeleton" +import { Button } from "@/components/ui/button" +import { X } from "lucide-react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" interface QuotationWithRfqCode extends TechSalesVendorQuotations { rfqCode?: string | null; @@ -95,8 +110,6 @@ function TableLoadingSkeleton() { ) } - - export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) { const searchParams = useSearchParams() const router = useRouter() @@ -110,6 +123,11 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<{ id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; } | null>(null) + // 거절 다이얼로그 상태 + const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false) + const [rejectionReason, setRejectionReason] = React.useState("") + const [isRejecting, setIsRejecting] = React.useState(false) + // 데이터 로딩 상태 const [data, setData] = React.useState<QuotationWithRfqCode[]>([]) const [pageCount, setPageCount] = React.useState(0) @@ -248,6 +266,54 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab setSelectedRfqForItems(rfq) setItemsDialogOpen(true) }, []) + + // 거절 처리 함수 + const handleRejectQuotations = React.useCallback(async () => { + if (!table) return; + + const selectedRows = table.getFilteredSelectedRowModel().rows; + const quotationIds = selectedRows.map(row => row.original.id); + + if (quotationIds.length === 0) { + toast.error("거절할 견적서를 선택해주세요."); + return; + } + + // 거절할 수 없는 상태의 견적서가 있는지 확인 + const invalidStatuses = selectedRows.filter(row => + row.original.status === "Accepted" || row.original.status === "Rejected" + ); + + if (invalidStatuses.length > 0) { + toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다."); + return; + } + + setIsRejecting(true); + + try { + const result = await rejectTechSalesVendorQuotations({ + quotationIds, + rejectionReason: rejectionReason.trim() || undefined, + }); + + if (result.success) { + toast.success(result.message); + setRejectDialogOpen(false); + setRejectionReason(""); + table.resetRowSelection(); + // 데이터 다시 로드 + await loadData(); + } else { + toast.error(result.error || "견적서 거절 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("견적서 거절 오류:", error); + toast.error("견적서 거절 중 오류가 발생했습니다."); + } finally { + setIsRejecting(false); + } + }, [rejectionReason, loadData]); // 테이블 컬럼 정의 const columns = React.useMemo(() => getColumns({ @@ -322,6 +388,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab enableAdvancedFilter: true, enableColumnResizing: true, columnResizeMode: 'onChange', + enableRowSelection: true, // 행 선택 활성화 initialState: { sorting: initialSettings.sort, columnPinning: { right: ["actions"] }, @@ -366,6 +433,48 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab filterFields={advancedFilterFields} shallow={false} > + {/* 선택된 행이 있을 때 거절 버튼 표시 */} + {table && table.getFilteredSelectedRowModel().rows.length > 0 && ( + <AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}> + <AlertDialogTrigger asChild> + <Button variant="destructive" size="sm"> + <X className="mr-2 h-4 w-4" /> + 선택한 견적서 거절 ({table.getFilteredSelectedRowModel().rows.length}개) + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>견적서 거절</AlertDialogTitle> + <AlertDialogDescription> + 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까? + 거절된 견적서는 다시 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid gap-2"> + <Label htmlFor="rejection-reason">거절 사유 (선택사항)</Label> + <Textarea + id="rejection-reason" + placeholder="거절 사유를 입력하세요..." + value={rejectionReason} + onChange={(e) => setRejectionReason(e.target.value)} + /> + </div> + </div> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleRejectQuotations} + disabled={isRejecting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isRejecting ? "처리 중..." : "거절"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + )} + {!isInitialLoad && isLoading && ( <div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" /> |
