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/evaluation-target-list | |
| parent | 95bbe9c583ff841220da1267630e7b2025fc36dc (diff) | |
(대표님) 20250620 작업사항
Diffstat (limited to 'lib/evaluation-target-list')
9 files changed, 2521 insertions, 408 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; } |
