summaryrefslogtreecommitdiff
path: root/lib/evaluation-target-list
diff options
context:
space:
mode:
Diffstat (limited to 'lib/evaluation-target-list')
-rw-r--r--lib/evaluation-target-list/service.ts616
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx384
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table.tsx154
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-columns.tsx459
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx255
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx138
-rw-r--r--lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx151
-rw-r--r--lib/evaluation-target-list/table/update-evaluation-target.tsx760
-rw-r--r--lib/evaluation-target-list/validation.ts12
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;
}