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