summaryrefslogtreecommitdiff
path: root/lib/legal-review
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-12-01 19:52:06 +0900
committerjoonhoekim <26rote@gmail.com>2025-12-01 19:52:06 +0900
commit44b74ff4170090673b6eeacd8c528e0abf47b7aa (patch)
tree3f3824b4e2cb24536c1677188b4cae5b8909d3da /lib/legal-review
parent4953e770929b82ef77da074f77071ebd0f428529 (diff)
(김준회) deprecated code 정리
Diffstat (limited to 'lib/legal-review')
-rw-r--r--lib/legal-review/service.ts738
-rw-r--r--lib/legal-review/status/create-legal-work-dialog.tsx506
-rw-r--r--lib/legal-review/status/delete-legal-works-dialog.tsx152
-rw-r--r--lib/legal-review/status/legal-table copy.tsx583
-rw-r--r--lib/legal-review/status/legal-table.tsx546
-rw-r--r--lib/legal-review/status/legal-work-detail-dialog.tsx409
-rw-r--r--lib/legal-review/status/legal-work-filter-sheet.tsx897
-rw-r--r--lib/legal-review/status/legal-works-columns.tsx222
-rw-r--r--lib/legal-review/status/legal-works-toolbar-actions.tsx286
-rw-r--r--lib/legal-review/status/request-review-dialog.tsx983
-rw-r--r--lib/legal-review/status/update-legal-work-dialog.tsx385
-rw-r--r--lib/legal-review/validations.ts40
12 files changed, 0 insertions, 5747 deletions
diff --git a/lib/legal-review/service.ts b/lib/legal-review/service.ts
deleted file mode 100644
index bc55a1fc..00000000
--- a/lib/legal-review/service.ts
+++ /dev/null
@@ -1,738 +0,0 @@
-'use server'
-
-import { revalidatePath, unstable_noStore } from "next/cache";
-import db from "@/db/db";
-import { legalWorks, legalWorkRequests, legalWorkResponses, legalWorkAttachments, vendors, legalWorksDetailView } from "@/db/schema";
-import { and, asc, count, desc, eq, ilike, or, SQL, inArray } from "drizzle-orm";
-import { CreateLegalWorkData, GetLegalWorksSchema, createLegalWorkSchema } from "./validations";
-import { filterColumns } from "@/lib/filter-columns";
-import { saveFile } from "../file-stroage";
-
-interface CreateLegalWorkResult {
- success: boolean;
- data?: {
- id: number;
- message: string;
- };
- error?: string;
-}
-
-
-
-export async function createLegalWork(
- data: CreateLegalWorkData
-): Promise<CreateLegalWorkResult> {
- unstable_noStore();
-
- try {
- // 1. 입력 데이터 검증
- const validatedData = createLegalWorkSchema.parse(data);
-
- // 2. 벤더 정보 조회
- const vendor = await db
- .select({
- id: vendors.id,
- vendorCode: vendors.vendorCode,
- vendorName: vendors.vendorName,
- })
- .from(vendors)
- .where(eq(vendors.id, validatedData.vendorId))
- .limit(1);
-
- if (!vendor.length) {
- return {
- success: false,
- error: "선택한 벤더를 찾을 수 없습니다.",
- };
- }
-
- const selectedVendor = vendor[0];
-
- // 3. 트랜잭션으로 데이터 삽입
- const result = await db.transaction(async (tx) => {
- // 3-1. legal_works 테이블에 메인 데이터 삽입
- const [legalWorkResult] = await tx
- .insert(legalWorks)
- .values({
- category: validatedData.category,
- status: "신규등록", // 초기 상태
- vendorId: validatedData.vendorId,
- vendorCode: selectedVendor.vendorCode,
- vendorName: selectedVendor.vendorName,
- isUrgent: validatedData.isUrgent,
- requestDate: validatedData.requestDate,
- consultationDate: new Date().toISOString().split('T')[0], // 오늘 날짜
- hasAttachment: false, // 초기값
- reviewer: validatedData.reviewer, // 추후 할당
- legalResponder: null, // 추후 할당
- })
- .returning({ id: legalWorks.id });
-
- const legalWorkId = legalWorkResult.id;
-
-
-
- return { legalWorkId };
- });
-
- // 4. 캐시 재검증
- revalidatePath("/legal-works");
-
- return {
- success: true,
- data: {
- id: result.legalWorkId,
- message: "법무업무가 성공적으로 등록되었습니다.",
- },
- };
-
- } catch (error) {
- console.error("createLegalWork 오류:", error);
-
- // 데이터베이스 오류 처리
- if (error instanceof Error) {
- // 외래키 제약 조건 오류
- if (error.message.includes('foreign key constraint')) {
- return {
- success: false,
- error: "선택한 벤더가 유효하지 않습니다.",
- };
- }
-
- // 중복 키 오류 등 기타 DB 오류
- return {
- success: false,
- error: "데이터베이스 오류가 발생했습니다.",
- };
- }
-
- return {
- success: false,
- error: "알 수 없는 오류가 발생했습니다.",
- };
- }
-}
-
-// 법무업무 상태 업데이트 함수 (보너스)
-export async function updateLegalWorkStatus(
- legalWorkId: number,
- status: string,
- reviewer?: string,
- legalResponder?: string
-): Promise<CreateLegalWorkResult> {
- unstable_noStore();
-
- try {
- const updateData: Partial<typeof legalWorks.$inferInsert> = {
- status,
- updatedAt: new Date(),
- };
-
- if (reviewer) updateData.reviewer = reviewer;
- if (legalResponder) updateData.legalResponder = legalResponder;
-
- await db
- .update(legalWorks)
- .set(updateData)
- .where(eq(legalWorks.id, legalWorkId));
-
- revalidatePath("/legal-works");
-
- return {
- success: true,
- data: {
- id: legalWorkId,
- message: "상태가 성공적으로 업데이트되었습니다.",
- },
- };
-
- } catch (error) {
- console.error("updateLegalWorkStatus 오류:", error);
- return {
- success: false,
- error: "상태 업데이트 중 오류가 발생했습니다.",
- };
- }
-}
-
-// 법무업무 삭제 함수 (보너스)
-export async function deleteLegalWork(legalWorkId: number): Promise<CreateLegalWorkResult> {
- unstable_noStore();
-
- try {
- await db.transaction(async (tx) => {
- // 관련 요청 데이터 먼저 삭제
- await tx
- .delete(legalWorkRequests)
- .where(eq(legalWorkRequests.legalWorkId, legalWorkId));
-
- // 메인 법무업무 데이터 삭제
- await tx
- .delete(legalWorks)
- .where(eq(legalWorks.id, legalWorkId));
- });
-
- revalidatePath("/legal-works");
-
- return {
- success: true,
- data: {
- id: legalWorkId,
- message: "법무업무가 성공적으로 삭제되었습니다.",
- },
- };
-
- } catch (error) {
- console.error("deleteLegalWork 오류:", error);
- return {
- success: false,
- error: "삭제 중 오류가 발생했습니다.",
- };
- }
-}
-
-
-export async function getLegalWorks(input: GetLegalWorksSchema) {
- unstable_noStore(); // ✅ 1. 캐싱 방지 추가
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // ✅ 2. 안전한 필터 처리 (getEvaluationTargets와 동일)
- let advancedWhere: SQL<unknown> | undefined = undefined;
-
- if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) {
- console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`));
-
- try {
- advancedWhere = filterColumns({
- table: legalWorksDetailView,
- filters: input.filters,
- joinOperator: input.joinOperator || 'and',
- });
-
- console.log("필터 조건 생성 완료");
- } catch (error) {
- console.error("필터 조건 생성 오류:", error);
- // ✅ 필터 오류 시에도 전체 데이터 반환
- advancedWhere = undefined;
- }
- }
-
- // ✅ 3. 안전한 글로벌 검색 처리
- let globalWhere: SQL<unknown> | undefined = undefined;
- if (input.search) {
- const searchTerm = `%${input.search}%`;
-
- const searchConditions: SQL<unknown>[] = [
- ilike(legalWorksDetailView.vendorCode, searchTerm),
- ilike(legalWorksDetailView.vendorName, searchTerm),
- ilike(legalWorksDetailView.title, searchTerm),
- ilike(legalWorksDetailView.requestContent, searchTerm),
- ilike(legalWorksDetailView.reviewer, searchTerm),
- ilike(legalWorksDetailView.legalResponder, searchTerm)
- ].filter(Boolean);
-
- if (searchConditions.length > 0) {
- globalWhere = or(...searchConditions);
- }
- }
-
- // ✅ 4. 안전한 WHERE 조건 결합
- const whereConditions: SQL<unknown>[] = [];
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (globalWhere) whereConditions.push(globalWhere);
-
- const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
- // ✅ 5. 전체 데이터 수 조회
- const totalResult = await db
- .select({ count: count() })
- .from(legalWorksDetailView)
- .where(finalWhere);
-
- const total = totalResult[0]?.count || 0;
-
- if (total === 0) {
- return { data: [], pageCount: 0, total: 0 };
- }
-
- console.log("총 데이터 수:", total);
-
- // ✅ 6. 정렬 및 페이징 처리
- const orderByColumns = input.sort.map((sort) => {
- const column = sort.id as keyof typeof legalWorksDetailView.$inferSelect;
- return sort.desc
- ? desc(legalWorksDetailView[column])
- : asc(legalWorksDetailView[column]);
- });
-
- if (orderByColumns.length === 0) {
- orderByColumns.push(desc(legalWorksDetailView.createdAt));
- }
-
- const legalWorksData = await db
- .select()
- .from(legalWorksDetailView)
- .where(finalWhere)
- .orderBy(...orderByColumns)
- .limit(input.perPage)
- .offset(offset);
-
- const pageCount = Math.ceil(total / input.perPage);
-
- console.log("반환 데이터 수:", legalWorksData.length);
-
- return { data: legalWorksData, pageCount, total };
- } catch (err) {
- console.error("getLegalWorks 오류:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
-}
-// 특정 법무업무 상세 조회
-export async function getLegalWorkById(id: number) {
- unstable_noStore();
-
- try {
- const result = await db
- .select()
- .from(legalWorksDetailView)
- .where(eq(legalWorksDetailView.id , id))
- .limit(1);
-
- return result[0] || null;
- } catch (error) {
- console.error("getLegalWorkById 오류:", error);
- return null;
- }
-}
-
-// 법무업무 통계 (뷰 테이블 사용)
-export async function getLegalWorksStats() {
- unstable_noStore();
- try {
- // 전체 통계
- const totalStats = await db
- .select({
- total: count(),
- category: legalWorksDetailView.category,
- status: legalWorksDetailView.status,
- isUrgent: legalWorksDetailView.isUrgent,
- })
- .from(legalWorksDetailView);
-
- // 통계 데이터 가공
- const stats = {
- total: totalStats.length,
- byCategory: {} as Record<string, number>,
- byStatus: {} as Record<string, number>,
- urgent: 0,
- };
-
- totalStats.forEach(stat => {
- // 카테고리별 집계
- if (stat.category) {
- stats.byCategory[stat.category] = (stats.byCategory[stat.category] || 0) + 1;
- }
-
- // 상태별 집계
- if (stat.status) {
- stats.byStatus[stat.status] = (stats.byStatus[stat.status] || 0) + 1;
- }
-
- // 긴급 건수
- if (stat.isUrgent) {
- stats.urgent++;
- }
- });
-
- return stats;
- } catch (error) {
- console.error("getLegalWorksStatsSimple 오류:", error);
- return {
- total: 0,
- byCategory: {},
- byStatus: {},
- urgent: 0,
- };
- }
-}
-
-// 검토요청 폼 데이터 타입
-interface RequestReviewData {
- // 기본 설정
- dueDate: string
- assignee?: string
- notificationMethod: "email" | "internal" | "both"
-
- // 법무업무 상세 정보
- reviewDepartment: "준법문의" | "법무검토"
- inquiryType?: "국내계약" | "국내자문" | "해외계약" | "해외자문"
-
- // 공통 필드
- title: string
- requestContent: string
-
- // 준법문의 전용 필드
- isPublic?: boolean
-
- // 법무검토 전용 필드들
- contractProjectName?: string
- contractType?: string
- contractCounterparty?: string
- counterpartyType?: "법인" | "개인"
- contractPeriod?: string
- contractAmount?: string
- factualRelation?: string
- projectNumber?: string
- shipownerOrderer?: string
- projectType?: string
- governingLaw?: string
-}
-
-// 첨부파일 업로드 함수
-async function uploadAttachment(file: File, legalWorkId: number, userId?: string) {
- try {
- console.log(`📎 첨부파일 업로드 시작: ${file.name} (${file.size} bytes)`)
-
- const result = await saveFile({
- file,
- directory: "legal-works",
- originalName: file.name,
- userId: userId || "system"
- })
-
- if (!result.success) {
- throw new Error(result.error || "파일 업로드 실패")
- }
-
- console.log(`✅ 첨부파일 업로드 성공: ${result.fileName}`)
-
- return {
- fileName: result.fileName!,
- originalFileName: result.originalName!,
- filePath: result.publicPath!,
- fileSize: result.fileSize!,
- mimeType: file.type,
- securityChecks: result.securityChecks
- }
- } catch (error) {
- console.error(`❌ 첨부파일 업로드 실패: ${file.name}`, error)
- throw error
- }
-}
-
-
-export async function requestReview(
- legalWorkId: number,
- formData: RequestReviewData,
- attachments: File[] = [],
- userId?: string
-) {
- try {
- console.log(`🚀 검토요청 처리 시작 - 법무업무 #${legalWorkId}`)
-
- // 트랜잭션 시작
- const result = await db.transaction(async (tx) => {
- // 1. legal_works 테이블 업데이트
- const [updatedWork] = await tx
- .update(legalWorks)
- .set({
- status: "검토요청",
- expectedAnswerDate: formData.dueDate,
- hasAttachment: attachments.length > 0,
- updatedAt: new Date(),
- })
- .where(eq(legalWorks.id, legalWorkId))
- .returning()
-
- if (!updatedWork) {
- throw new Error("법무업무를 찾을 수 없습니다.")
- }
-
- console.log(`📝 법무업무 상태 업데이트 완료: ${updatedWork.status}`)
-
- // 2. legal_work_requests 테이블에 데이터 삽입
- const [createdRequest] = await tx
- .insert(legalWorkRequests)
- .values({
- legalWorkId: legalWorkId,
- reviewDepartment: formData.reviewDepartment,
- inquiryType: formData.inquiryType || null,
- title: formData.title,
- requestContent: formData.requestContent,
-
- // 준법문의 관련 필드
- isPublic: formData.reviewDepartment === "준법문의" ? (formData.isPublic || false) : null,
-
- // 법무검토 관련 필드들
- contractProjectName: formData.contractProjectName || null,
- contractType: formData.contractType || null,
- contractAmount: formData.contractAmount ? parseFloat(formData.contractAmount) : null,
-
- // 국내계약 전용 필드들
- contractCounterparty: formData.contractCounterparty || null,
- counterpartyType: formData.counterpartyType || null,
- contractPeriod: formData.contractPeriod || null,
-
- // 자문 관련 필드
- factualRelation: formData.factualRelation || null,
-
- // 해외 관련 필드들
- projectNumber: formData.projectNumber || null,
- shipownerOrderer: formData.shipownerOrderer || null,
- governingLaw: formData.governingLaw || null,
- projectType: formData.projectType || null,
- })
- .returning()
-
- console.log(`📋 검토요청 정보 저장 완료: ${createdRequest.reviewDepartment}`)
-
- // 3. 첨부파일 처리
- const uploadedFiles = []
- const failedFiles = []
-
- if (attachments.length > 0) {
- console.log(`📎 첨부파일 처리 시작: ${attachments.length}개`)
-
- for (const file of attachments) {
- try {
- const uploadResult = await uploadAttachment(file, legalWorkId, userId)
-
- // DB에 첨부파일 정보 저장
- const [attachmentRecord] = await tx
- .insert(legalWorkAttachments)
- .values({
- legalWorkId: legalWorkId,
- fileName: uploadResult.fileName,
- originalFileName: uploadResult.originalFileName,
- filePath: uploadResult.filePath,
- fileSize: uploadResult.fileSize,
- mimeType: uploadResult.mimeType,
- attachmentType: 'request',
- isAutoGenerated: false,
- })
- .returning()
-
- uploadedFiles.push({
- id: attachmentRecord.id,
- name: uploadResult.originalFileName,
- size: uploadResult.fileSize,
- securityChecks: uploadResult.securityChecks
- })
-
- } catch (fileError) {
- console.error(`❌ 파일 업로드 실패: ${file.name}`, fileError)
- failedFiles.push({
- name: file.name,
- error: fileError instanceof Error ? fileError.message : "업로드 실패"
- })
- }
- }
-
- console.log(`✅ 파일 업로드 완료: 성공 ${uploadedFiles.length}개, 실패 ${failedFiles.length}개`)
- }
-
- return {
- updatedWork,
- createdRequest,
- uploadedFiles,
- failedFiles,
- totalFiles: attachments.length,
- }
- })
-
- // 페이지 재검증
- revalidatePath("/legal-works")
-
- // 성공 메시지 구성
- let message = `검토요청이 성공적으로 발송되었습니다.`
-
- if (result.totalFiles > 0) {
- message += ` (첨부파일: 성공 ${result.uploadedFiles.length}개`
- if (result.failedFiles.length > 0) {
- message += `, 실패 ${result.failedFiles.length}개`
- }
- message += `)`
- }
-
- console.log(`🎉 검토요청 처리 완료 - 법무업무 #${legalWorkId}`)
-
- return {
- success: true,
- data: {
- message,
- legalWorkId: legalWorkId,
- requestId: result.createdRequest.id,
- uploadedFiles: result.uploadedFiles,
- failedFiles: result.failedFiles,
- }
- }
-
- } catch (error) {
- console.error(`💥 검토요청 처리 중 오류 - 법무업무 #${legalWorkId}:`, error)
-
- return {
- success: false,
- error: error instanceof Error ? error.message : "검토요청 처리 중 오류가 발생했습니다."
- }
- }
-}
-
-
-// FormData를 사용하는 버전 (파일 업로드용)
-export async function requestReviewWithFiles(formData: FormData) {
- try {
- // 기본 데이터 추출
- const legalWorkId = parseInt(formData.get("legalWorkId") as string)
-
- const requestData: RequestReviewData = {
- dueDate: formData.get("dueDate") as string,
- assignee: formData.get("assignee") as string || undefined,
- notificationMethod: formData.get("notificationMethod") as "email" | "internal" | "both",
- reviewDepartment: formData.get("reviewDepartment") as "준법문의" | "법무검토",
- inquiryType: formData.get("inquiryType") as "국내계약" | "국내자문" | "해외계약" | "해외자문" || undefined,
- title: formData.get("title") as string,
- requestContent: formData.get("requestContent") as string,
- isPublic: formData.get("isPublic") === "true",
-
- // 법무검토 관련 필드들
- contractProjectName: formData.get("contractProjectName") as string || undefined,
- contractType: formData.get("contractType") as string || undefined,
- contractCounterparty: formData.get("contractCounterparty") as string || undefined,
- counterpartyType: formData.get("counterpartyType") as "법인" | "개인" || undefined,
- contractPeriod: formData.get("contractPeriod") as string || undefined,
- contractAmount: formData.get("contractAmount") as string || undefined,
- factualRelation: formData.get("factualRelation") as string || undefined,
- projectNumber: formData.get("projectNumber") as string || undefined,
- shipownerOrderer: formData.get("shipownerOrderer") as string || undefined,
- projectType: formData.get("projectType") as string || undefined,
- governingLaw: formData.get("governingLaw") as string || undefined,
- }
-
- // 첨부파일 추출
- const attachments: File[] = []
- for (const [key, value] of formData.entries()) {
- if (key.startsWith("attachment_") && value instanceof File && value.size > 0) {
- attachments.push(value)
- }
- }
-
- return await requestReview(legalWorkId, requestData, attachments)
-
- } catch (error) {
- console.error("FormData 처리 중 오류:", error)
- return {
- success: false,
- error: "요청 데이터 처리 중 오류가 발생했습니다."
- }
- }
-}
-
-// 검토요청 가능 여부 확인
-export async function canRequestReview(legalWorkId: number) {
- try {
- const [work] = await db
- .select({ status: legalWorks.status })
- .from(legalWorks)
- .where(eq(legalWorks.id, legalWorkId))
- .limit(1)
-
- if (!work) {
- return { canRequest: false, reason: "법무업무를 찾을 수 없습니다." }
- }
-
- if (work.status !== "신규등록") {
- return {
- canRequest: false,
- reason: `현재 상태(${work.status})에서는 검토요청을 할 수 없습니다. 신규등록 상태에서만 가능합니다.`
- }
- }
-
- return { canRequest: true }
-
- } catch (error) {
- console.error("검토요청 가능 여부 확인 중 오류:", error)
- return {
- canRequest: false,
- reason: "상태 확인 중 오류가 발생했습니다."
- }
- }
-}
-
-// 삭제 요청 타입
-interface RemoveLegalWorksInput {
- ids: number[]
-}
-
-// 응답 타입
-interface RemoveLegalWorksResponse {
- error?: string
- success?: boolean
-}
-
-/**
- * 법무업무 삭제 서버 액션
- */
-export async function removeLegalWorks({
- ids,
-}: RemoveLegalWorksInput): Promise<RemoveLegalWorksResponse> {
- try {
- // 유효성 검사
- if (!ids || ids.length === 0) {
- return {
- error: "삭제할 법무업무를 선택해주세요.",
- }
- }
-
- // 삭제 가능한 상태인지 확인 (선택적)
- const existingWorks = await db
- .select({ id: legalWorks.id, status: legalWorks.status })
- .from(legalWorks)
- .where(inArray(legalWorks.id, ids))
-
- // 삭제 불가능한 상태 체크 (예: 진행중인 업무는 삭제 불가)
- const nonDeletableWorks = existingWorks.filter(
- work => work.status === "검토중" || work.status === "담당자배정"
- )
-
- if (nonDeletableWorks.length > 0) {
- return {
- error: "진행중인 법무업무는 삭제할 수 없습니다.",
- }
- }
-
- // 실제 삭제 실행
- const result = await db
- .delete(legalWorks)
- .where(inArray(legalWorks.id, ids))
-
- // 결과 확인
- if (result.changes === 0) {
- return {
- error: "삭제할 법무업무를 찾을 수 없습니다.",
- }
- }
-
- // 캐시 재검증
- revalidatePath("/legal-works") // 실제 경로에 맞게 수정
-
- return {
- success: true,
- }
-
- } catch (error) {
- console.error("법무업무 삭제 중 오류 발생:", error)
-
- return {
- error: "법무업무 삭제 중 오류가 발생했습니다. 다시 시도해주세요.",
- }
- }
-}
-
-/**
- * 단일 법무업무 삭제 (선택적)
- */
-export async function removeLegalWork(id: number): Promise<RemoveLegalWorksResponse> {
- return removeLegalWorks({ ids: [id] })
-} \ No newline at end of file
diff --git a/lib/legal-review/status/create-legal-work-dialog.tsx b/lib/legal-review/status/create-legal-work-dialog.tsx
deleted file mode 100644
index 0ee1c430..00000000
--- a/lib/legal-review/status/create-legal-work-dialog.tsx
+++ /dev/null
@@ -1,506 +0,0 @@
-"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 { Loader2, Check, ChevronsUpDown, Calendar, User } from "lucide-react"
-import { toast } from "sonner"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Switch } from "@/components/ui/switch"
-import { cn } from "@/lib/utils"
-import { getVendorsForSelection } from "@/lib/b-rfq/service"
-import { createLegalWork } from "../service"
-import { useSession } from "next-auth/react"
-
-interface CreateLegalWorkDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- onSuccess?: () => void
- onDataChange?: () => void
-}
-
-// legalWorks 테이블에 맞춘 단순화된 폼 스키마
-const createLegalWorkSchema = z.object({
- category: z.enum(["CP", "GTC", "기타"]),
- vendorId: z.number().min(1, "벤더를 선택해주세요"),
- isUrgent: z.boolean().default(false),
- requestDate: z.string().min(1, "답변요청일을 선택해주세요"),
- expectedAnswerDate: z.string().optional(),
- reviewer: z.string().min(1, "검토요청자를 입력해주세요"),
-})
-
-type CreateLegalWorkFormValues = z.infer<typeof createLegalWorkSchema>
-
-interface Vendor {
- id: number
- vendorName: string
- vendorCode: string
- country: string
- taxId: string
- status: string
-}
-
-export function CreateLegalWorkDialog({
- open,
- onOpenChange,
- onSuccess,
- onDataChange
-}: CreateLegalWorkDialogProps) {
- const router = useRouter()
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [vendors, setVendors] = React.useState<Vendor[]>([])
- const [vendorsLoading, setVendorsLoading] = React.useState(false)
- const [vendorOpen, setVendorOpen] = React.useState(false)
- const { data: session } = useSession()
-
- const userName = React.useMemo(() => {
- return session?.user?.name || "";
- }, [session]);
-
- const userEmail = React.useMemo(() => {
- return session?.user?.email || "";
- }, [session]);
-
- const defaultReviewer = React.useMemo(() => {
- if (userName && userEmail) {
- return `${userName} (${userEmail})`;
- } else if (userName) {
- return userName;
- } else if (userEmail) {
- return userEmail;
- }
- return "";
- }, [userName, userEmail]);
-
- const loadVendors = React.useCallback(async () => {
- setVendorsLoading(true)
- try {
- const vendorList = await getVendorsForSelection()
- setVendors(vendorList)
- } catch (error) {
- console.error("Failed to load vendors:", error)
- toast.error("벤더 목록을 불러오는데 실패했습니다.")
- } finally {
- setVendorsLoading(false)
- }
- }, [])
-
- // 오늘 날짜 + 7일 후를 기본 답변요청일로 설정
- const getDefaultRequestDate = () => {
- const date = new Date()
- date.setDate(date.getDate() + 7)
- return date.toISOString().split('T')[0]
- }
-
- // 답변요청일 + 3일 후를 기본 답변예정일로 설정
- const getDefaultExpectedDate = (requestDate: string) => {
- if (!requestDate) return ""
- const date = new Date(requestDate)
- date.setDate(date.getDate() + 3)
- return date.toISOString().split('T')[0]
- }
-
- const form = useForm<CreateLegalWorkFormValues>({
- resolver: zodResolver(createLegalWorkSchema),
- defaultValues: {
- category: "CP",
- vendorId: 0,
- isUrgent: false,
- requestDate: getDefaultRequestDate(),
- expectedAnswerDate: "",
- reviewer: defaultReviewer,
- },
- })
-
- React.useEffect(() => {
- if (open) {
- loadVendors()
- }
- }, [open, loadVendors])
-
- // 세션 정보가 로드되면 검토요청자 필드 업데이트
- React.useEffect(() => {
- if (defaultReviewer) {
- form.setValue("reviewer", defaultReviewer)
- }
- }, [defaultReviewer, form])
-
- // 답변요청일 변경시 답변예정일 자동 설정
- const requestDate = form.watch("requestDate")
- React.useEffect(() => {
- if (requestDate) {
- const expectedDate = getDefaultExpectedDate(requestDate)
- form.setValue("expectedAnswerDate", expectedDate)
- }
- }, [requestDate, form])
-
- // 폼 제출 - 서버 액션 적용
- async function onSubmit(data: CreateLegalWorkFormValues) {
- console.log("Form submitted with data:", data)
- setIsSubmitting(true)
-
- try {
- // legalWorks 테이블에 맞춘 데이터 구조
- const legalWorkData = {
- ...data,
- // status는 서버에서 "검토요청"으로 설정
- // consultationDate는 서버에서 오늘 날짜로 설정
- // hasAttachment는 서버에서 false로 설정
- }
-
- const result = await createLegalWork(legalWorkData)
-
- if (result.success) {
- toast.success(result.data?.message || "법무업무가 성공적으로 등록되었습니다.")
- onOpenChange(false)
- form.reset({
- category: "CP",
- vendorId: 0,
- isUrgent: false,
- requestDate: getDefaultRequestDate(),
- expectedAnswerDate: "",
- reviewer: defaultReviewer,
- })
- onSuccess?.()
- onDataChange?.()
- router.refresh()
- } else {
- toast.error(result.error || "등록 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("Error creating legal work:", error)
- toast.error("등록 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 다이얼로그 닫기 핸들러
- const handleOpenChange = (open: boolean) => {
- onOpenChange(open)
- if (!open) {
- form.reset({
- category: "CP",
- vendorId: 0,
- isUrgent: false,
- requestDate: getDefaultRequestDate(),
- expectedAnswerDate: "",
- reviewer: defaultReviewer,
- })
- }
- }
-
- // 선택된 벤더 정보
- const selectedVendor = vendors.find(v => v.id === form.watch("vendorId"))
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="max-w-2xl h-[80vh] p-0 flex flex-col">
- {/* 고정 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle>법무업무 신규 등록</DialogTitle>
- <DialogDescription>
- 새로운 법무업무를 등록합니다. 상세한 검토 요청은 등록 후 별도로 진행할 수 있습니다.
- </DialogDescription>
- </DialogHeader>
- </div>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1 min-h-0"
- >
- {/* 스크롤 가능한 콘텐츠 영역 */}
- <div className="flex-1 overflow-y-auto p-6">
- <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">
- {/* 구분 */}
- <FormField
- control={form.control}
- name="category"
- render={({ field }) => (
- <FormItem>
- <FormLabel>구분</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="구분 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="CP">CP</SelectItem>
- <SelectItem value="GTC">GTC</SelectItem>
- <SelectItem value="기타">기타</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 긴급여부 */}
- <FormField
- control={form.control}
- name="isUrgent"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">긴급 요청</FormLabel>
- <div className="text-sm text-muted-foreground">
- 긴급 처리가 필요한 경우 체크
- </div>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- </div>
-
- {/* 벤더 선택 */}
- <FormField
- control={form.control}
- name="vendorId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>벤더</FormLabel>
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className="w-full justify-between"
- >
- {selectedVendor ? (
- <span className="flex items-center gap-2">
- <Badge variant="outline">{selectedVendor.vendorCode}</Badge>
- {selectedVendor.vendorName}
- </span>
- ) : (
- "벤더 선택..."
- )}
- <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="벤더 검색..." />
- <CommandList
- onWheel={(e) => {
- e.stopPropagation(); // 이벤트 전파 차단
- const target = e.currentTarget;
- target.scrollTop += e.deltaY; // 직접 스크롤 처리
- }}>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {vendors.map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorCode} ${vendor.vendorName}`}
- onSelect={() => {
- field.onChange(vendor.id)
- setVendorOpen(false)
- }}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- vendor.id === field.value ? "opacity-100" : "opacity-0"
- )}
- />
- <div className="flex items-center gap-2">
- <Badge variant="outline">{vendor.vendorCode}</Badge>
- <span>{vendor.vendorName}</span>
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
-
- {/* 담당자 및 일정 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <Calendar className="h-5 w-5" />
- 담당자 및 일정
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {/* 검토요청자 */}
- <FormField
- control={form.control}
- name="reviewer"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center gap-2">
- <User className="h-4 w-4" />
- 검토요청자
- </FormLabel>
- <FormControl>
- <Input
- placeholder={defaultReviewer || "검토요청자 이름을 입력하세요"}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- {/* 답변요청일 */}
- <FormField
- control={form.control}
- name="requestDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>답변요청일</FormLabel>
- <FormControl>
- <Input
- type="date"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 답변예정일 */}
- <FormField
- control={form.control}
- name="expectedAnswerDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>답변예정일 (선택사항)</FormLabel>
- <FormControl>
- <Input
- type="date"
- {...field}
- />
- </FormControl>
- <div className="text-xs text-muted-foreground">
- 답변요청일 기준으로 자동 설정됩니다
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </CardContent>
- </Card>
-
- {/* 안내 메시지 */}
- <Card className="bg-blue-50 border-blue-200">
- <CardContent className="pt-6">
- <div className="flex items-start gap-3">
- <div className="h-2 w-2 rounded-full bg-blue-500 mt-2"></div>
- <div className="space-y-1">
- <p className="text-sm font-medium text-blue-900">
- 법무업무 등록 안내
- </p>
- <p className="text-sm text-blue-700">
- 기본 정보 등록 후, 목록에서 해당 업무를 선택하여 상세한 검토 요청을 진행할 수 있습니다.
- </p>
- <p className="text-xs text-blue-600">
- • 상태: "검토요청"으로 자동 설정<br/>
- • 의뢰일: 오늘 날짜로 자동 설정<br/>
- • 법무답변자: 나중에 배정
- </p>
- </div>
- </div>
- </CardContent>
- </Card>
- </div>
- </div>
-
- {/* 고정 버튼 영역 */}
- <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>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/status/delete-legal-works-dialog.tsx b/lib/legal-review/status/delete-legal-works-dialog.tsx
deleted file mode 100644
index 665dafc2..00000000
--- a/lib/legal-review/status/delete-legal-works-dialog.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type LegalWorksDetailView } from "@/db/schema"
-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 { useRouter } from "next/navigation"
-
-import { removeLegalWorks } from "../service"
-
-interface DeleteLegalWorksDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- legalWorks: Row<LegalWorksDetailView>["original"][]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function DeleteLegalWorksDialog({
- legalWorks,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteLegalWorksDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
- const router = useRouter()
-
- function onDelete() {
- startDeleteTransition(async () => {
- const { error } = await removeLegalWorks({
- ids: legalWorks.map((work) => work.id),
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- props.onOpenChange?.(false)
- router.refresh()
- toast.success("법무업무가 삭제되었습니다")
- onSuccess?.()
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({legalWorks.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
- <DialogDescription>
- 이 작업은 되돌릴 수 없습니다. 선택한{" "}
- <span className="font-medium">{legalWorks.length}</span>
- 건의 법무업무가 완전히 삭제됩니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="Delete selected legal works"
- 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" />
- 삭제 ({legalWorks.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
- <DrawerDescription>
- 이 작업은 되돌릴 수 없습니다. 선택한{" "}
- <span className="font-medium">{legalWorks.length}</span>
- 건의 법무업무가 완전히 삭제됩니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="Delete selected legal works"
- 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/legal-review/status/legal-table copy.tsx b/lib/legal-review/status/legal-table copy.tsx
deleted file mode 100644
index 92abfaf6..00000000
--- a/lib/legal-review/status/legal-table copy.tsx
+++ /dev/null
@@ -1,583 +0,0 @@
-// ============================================================================
-// legal-works-table.tsx - EvaluationTargetsTable을 정확히 복사해서 수정
-// ============================================================================
-"use client";
-
-import * as React from "react";
-import { 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 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 { getLegalWorks } from "../service";
-import { cn } from "@/lib/utils";
-import { useTablePresets } from "@/components/data-table/use-table-presets";
-import { TablePresetManager } from "@/components/data-table/data-table-preset";
-import { getLegalWorksColumns } from "./legal-works-columns";
-import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions";
-import { LegalWorkFilterSheet } from "./legal-work-filter-sheet";
-import { LegalWorksDetailView } from "@/db/schema";
-import { EditLegalWorkSheet } from "./update-legal-work-dialog";
-import { LegalWorkDetailDialog } from "./legal-work-detail-dialog";
-import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog";
-
-/* -------------------------------------------------------------------------- */
-/* Stats Card */
-/* -------------------------------------------------------------------------- */
-function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) {
- const stats = React.useMemo(() => {
- const total = data.length;
- const pending = data.filter(item => item.status === '검토요청').length;
- const assigned = data.filter(item => item.status === '담당자배정').length;
- const inProgress = data.filter(item => item.status === '검토중').length;
- const completed = data.filter(item => item.status === '답변완료').length;
- const urgent = data.filter(item => item.isUrgent).length;
-
- return { total, pending, assigned, inProgress, completed, urgent };
- }, [data]);
-
- if (stats.total === 0) {
- return (
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6">
- <Card className="col-span-full">
- <CardContent className="pt-6 text-center text-sm text-muted-foreground">
- 등록된 법무업무가 없습니다.
- </CardContent>
- </Card>
- </div>
- );
- }
-
- return (
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 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">전체</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- 긴급 {stats.urgent}건
- </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">{stats.pending.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.pending / stats.total) * 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-yellow-600">{stats.assigned.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.assigned / stats.total) * 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-orange-600">{stats.inProgress.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.inProgress / stats.total) * 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="default">완료</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total
- </div>
- </CardContent>
- </Card>
- </div>
- );
-}
-
-/* -------------------------------------------------------------------------- */
-/* LegalWorksTable */
-/* -------------------------------------------------------------------------- */
-interface LegalWorksTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>;
- currentYear?: number; // ✅ EvaluationTargetsTable의 evaluationYear와 동일한 역할
- className?: string;
-}
-
-export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null);
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false);
- const searchParams = useSearchParams();
-
- // ✅ EvaluationTargetsTable과 정확히 동일한 외부 필터 상태
- const [externalFilters, setExternalFilters] = React.useState<any[]>([]);
- const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and");
-
- // ✅ EvaluationTargetsTable과 정확히 동일한 필터 핸들러
- const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => {
- console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator);
- setExternalFilters(filters);
- setExternalJoinOperator(joinOperator);
- setIsFilterPanelOpen(false);
- }, []);
-
- const searchString = React.useMemo(
- () => searchParams.toString(),
- [searchParams]
- );
-
- const getSearchParam = React.useCallback(
- (key: string, def = "") =>
- new URLSearchParams(searchString).get(key) ?? def,
- [searchString]
- );
-
- // ✅ EvaluationTargetsTable과 정확히 동일한 URL 필터 변경 감지 및 데이터 새로고침
- React.useEffect(() => {
- const refetchData = async () => {
- try {
- setIsDataLoading(true);
-
- // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성
- const currentFilters = getSearchParam("filters");
- const currentJoinOperator = getSearchParam("joinOperator", "and");
- const currentPage = parseInt(getSearchParam("page", "1"));
- const currentPerPage = parseInt(getSearchParam("perPage", "10"));
- const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
- const currentSearch = getSearchParam("search", "");
-
- const searchParams = {
- filters: currentFilters ? JSON.parse(currentFilters) : [],
- joinOperator: currentJoinOperator as "and" | "or",
- page: currentPage,
- perPage: currentPerPage,
- sort: currentSort,
- search: currentSearch,
- // ✅ currentYear 추가 (EvaluationTargetsTable의 evaluationYear와 동일)
- currentYear: currentYear
- };
-
- console.log("=== 새 데이터 요청 ===", searchParams);
-
- // 서버 액션 직접 호출
- const newData = await getLegalWorks(searchParams);
- setTableData(newData);
-
- console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건");
- } catch (error) {
- console.error("데이터 새로고침 오류:", error);
- } finally {
- setIsDataLoading(false);
- }
- };
-
- // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용)
- const timeoutId = setTimeout(() => {
- // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침
- const hasChanges = getSearchParam("filters") ||
- getSearchParam("search") ||
- getSearchParam("page") !== "1" ||
- getSearchParam("perPage") !== "10" ||
- getSearchParam("sort");
-
- if (hasChanges) {
- refetchData();
- }
- }, 300); // 디바운스 시간 단축
-
- return () => clearTimeout(timeoutId);
- }, [searchString, currentYear, getSearchParam]); // ✅ EvaluationTargetsTable과 정확히 동일한 의존성
-
- const refreshData = React.useCallback(async () => {
- try {
- setIsDataLoading(true);
-
- // 현재 URL 파라미터로 데이터 새로고침
- const currentFilters = getSearchParam("filters");
- const currentJoinOperator = getSearchParam("joinOperator", "and");
- const currentPage = parseInt(getSearchParam("page", "1"));
- const currentPerPage = parseInt(getSearchParam("perPage", "10"));
- const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
- const currentSearch = getSearchParam("search", "");
-
- const searchParams = {
- filters: currentFilters ? JSON.parse(currentFilters) : [],
- joinOperator: currentJoinOperator as "and" | "or",
- page: currentPage,
- perPage: currentPerPage,
- sort: currentSort,
- search: currentSearch,
- currentYear: currentYear
- };
-
- const newData = await getLegalWorks(searchParams);
- setTableData(newData);
-
- console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건");
- } catch (error) {
- console.error("데이터 새로고침 오류:", error);
- } finally {
- setIsDataLoading(false);
- }
- }, [currentYear, getSearchParam]); // ✅ EvaluationTargetsTable과 동일한 의존성
-
- /* --------------------------- layout refs --------------------------- */
- 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) { // 1px 이상 차이날 때만 업데이트
- return newTop
- }
- return prevTop
- })
- }
- }, [])
-
- React.useEffect(() => {
- updateContainerBounds();
-
- const handleResize = () => {
- updateContainerBounds();
- };
-
- window.addEventListener('resize', handleResize);
- window.addEventListener('scroll', updateContainerBounds);
-
- return () => {
- window.removeEventListener('resize', handleResize);
- window.removeEventListener('scroll', updateContainerBounds);
- };
- }, [updateContainerBounds]);
-
- /* ---------------------- 데이터 상태 관리 ---------------------- */
- // 초기 데이터 설정
- const [initialPromiseData] = React.use(promises);
-
- // ✅ 테이블 데이터 상태 추가
- const [tableData, setTableData] = React.useState(initialPromiseData);
- const [isDataLoading, setIsDataLoading] = React.useState(false);
-
- const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
- try {
- const value = getSearchParam(key);
- return value ? JSON.parse(value) : defaultValue;
- } catch {
- return defaultValue;
- }
- }, [getSearchParam]);
-
- const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
- return parseSearchParamHelper(key, defaultValue);
- };
-
- /* ---------------------- 초기 설정 ---------------------------- */
- const initialSettings = React.useMemo(() => ({
- page: parseInt(getSearchParam("page", "1")),
- perPage: parseInt(getSearchParam("perPage", "10")),
- sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
- filters: parseSearchParam("filters", []),
- joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
- search: getSearchParam("search", ""),
- columnVisibility: {},
- columnOrder: [],
- pinnedColumns: { left: [], right: ["actions"] },
- groupBy: [],
- expandedRows: [],
- }), [getSearchParam, parseSearchParam]);
-
- /* --------------------- 프리셋 훅 ------------------------------ */
- const {
- presets,
- activePresetId,
- hasUnsavedChanges,
- isLoading: presetsLoading,
- createPreset,
- applyPreset,
- updatePreset,
- deletePreset,
- setDefaultPreset,
- renamePreset,
- getCurrentSettings,
- } = useTablePresets<LegalWorksDetailView>(
- "legal-works-table",
- initialSettings
- );
-
- /* --------------------- 컬럼 ------------------------------ */
- const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]);
-
- /* 기본 필터 */
- const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [
- { id: "vendorCode", label: "벤더 코드" },
- { id: "vendorName", label: "벤더명" },
- { id: "status", label: "상태" },
- ];
-
- /* 고급 필터 */
- const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [
- {
- id: "category", label: "구분", type: "select", options: [
- { label: "CP", value: "CP" },
- { label: "GTC", value: "GTC" },
- { label: "기타", value: "기타" }
- ]
- },
- {
- id: "status", label: "상태", type: "select", options: [
- { label: "검토요청", value: "검토요청" },
- { label: "담당자배정", value: "담당자배정" },
- { label: "검토중", value: "검토중" },
- { label: "답변완료", value: "답변완료" },
- { label: "재검토요청", value: "재검토요청" },
- { label: "보류", value: "보류" },
- { label: "취소", value: "취소" }
- ]
- },
- { id: "vendorCode", label: "벤더 코드", type: "text" },
- { id: "vendorName", label: "벤더명", type: "text" },
- {
- id: "isUrgent", label: "긴급여부", type: "select", options: [
- { label: "긴급", value: "true" },
- { label: "일반", value: "false" }
- ]
- },
- {
- id: "reviewDepartment", label: "검토부문", type: "select", options: [
- { label: "준법문의", value: "준법문의" },
- { label: "법무검토", value: "법무검토" }
- ]
- },
- {
- id: "inquiryType", label: "문의종류", type: "select", options: [
- { label: "국내계약", value: "국내계약" },
- { label: "국내자문", value: "국내자문" },
- { label: "해외계약", value: "해외계약" },
- { label: "해외자문", value: "해외자문" }
- ]
- },
- { id: "reviewer", label: "검토요청자", type: "text" },
- { id: "legalResponder", label: "법무답변자", type: "text" },
- { id: "requestDate", label: "답변요청일", type: "date" },
- { id: "consultationDate", label: "의뢰일", type: "date" },
- { id: "expectedAnswerDate", label: "답변예정일", type: "date" },
- { id: "legalCompletionDate", label: "법무완료일", type: "date" },
- { id: "createdAt", label: "생성일", type: "date" },
- ];
-
- /* current settings */
- const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
-
- const initialState = React.useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(sortItem => {
- const columnExists = columns.some(col => col.accessorKey === sortItem.id)
- return columnExists
- }) as any,
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [currentSettings, initialSettings.sort, columns])
-
- /* ----------------------- useDataTable ------------------------ */
- const { table } = useDataTable({
- data: tableData.data,
- columns,
- pageCount: tableData.pageCount,
- rowCount: tableData.total || tableData.data.length,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState,
- getRowId: (row) => String(row.id),
- shallow: false,
- clearOnDefault: true,
- });
-
- /* ---------------------- helper ------------------------------ */
- const getActiveFilterCount = React.useCallback(() => {
- try {
- // URL에서 현재 필터 수 확인
- const filtersParam = getSearchParam("filters");
- if (filtersParam) {
- const filters = JSON.parse(filtersParam);
- return Array.isArray(filters) ? filters.length : 0;
- }
- return 0;
- } catch {
- return 0;
- }
- }, [getSearchParam]);
-
- const FILTER_PANEL_WIDTH = 400;
-
- /* ---------------------------- JSX ---------------------------- */
- 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)`
- }}
- >
- <LegalWorkFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onFiltersApply={handleFiltersApply}
- isLoading={false}
- />
- </div>
-
- {/* Main 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 */}
- <div className="flex items-center justify-between p-4 bg-background shrink-0">
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
- {getActiveFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveFilterCount()}
- </span>
- )}
- </Button>
- <div className="text-sm text-muted-foreground">
- 총 {tableData.total || tableData.data.length}건
- </div>
- </div>
-
- {/* Stats */}
- <div className="px-4">
- <LegalWorksStats data={tableData.data} />
- </div>
-
- {/* Table */}
- <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}>
- {isDataLoading && (
- <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center">
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
- 필터링 중...
- </div>
- </div>
- )}
- <DataTable table={table} className="h-full">
- {/* ✅ EvaluationTargetsTable과 정확히 동일한 DataTableAdvancedToolbar */}
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- debounceMs={300}
- shallow={false}
- externalFilters={externalFilters}
- externalJoinOperator={externalJoinOperator}
- onFiltersChange={(filters, joinOperator) => {
- console.log("=== 필터 변경 감지 ===", filters, joinOperator);
- }}
- >
- <div className="flex items-center gap-2">
- <TablePresetManager<LegalWorksDetailView>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- <LegalWorksTableToolbarActions table={table} onRefresh={refreshData} />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 편집 다이얼로그 */}
- <EditLegalWorkSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- work={rowAction?.row.original ?? null}
- onSuccess={() => {
- rowAction?.row.toggleSelected(false);
- refreshData();
- }}
- />
-
- <LegalWorkDetailDialog
- open={rowAction?.type === "view"}
- onOpenChange={(open) => !open && setRowAction(null)}
- work={rowAction?.row.original || null}
- />
-
- <DeleteLegalWorksDialog
- open={rowAction?.type === "delete"}
- onOpenChange={(open) => !open && setRowAction(null)}
- legalWorks={rowAction?.row.original ? [rowAction.row.original] : []}
- showTrigger={false}
- onSuccess={() => {
- setRowAction(null);
- refreshData();
- }}
- />
- </div>
- </div>
- </div>
- </div>
- </>
- );
-} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-table.tsx b/lib/legal-review/status/legal-table.tsx
deleted file mode 100644
index 4df3568c..00000000
--- a/lib/legal-review/status/legal-table.tsx
+++ /dev/null
@@ -1,546 +0,0 @@
-// ============================================================================
-// components/evaluation-targets-table.tsx (CLIENT COMPONENT)
-// ─ 정리된 버전 ─
-// ============================================================================
-"use client";
-
-import * as React from "react";
-import { useSearchParams } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import { HelpCircle, 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 { LegalWorksDetailView } from "@/db/schema";
-import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions";
-import { getLegalWorks } from "../service";
-import { getLegalWorksColumns } from "./legal-works-columns";
-import { LegalWorkFilterSheet } from "./legal-work-filter-sheet";
-import { EditLegalWorkSheet } from "./update-legal-work-dialog";
-import { LegalWorkDetailDialog } from "./legal-work-detail-dialog";
-import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog";
-
-
-/* -------------------------------------------------------------------------- */
-/* Stats Card */
-/* -------------------------------------------------------------------------- */
-function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) {
- const stats = React.useMemo(() => {
- const total = data.length;
- const pending = data.filter(item => item.status === '검토요청').length;
- const assigned = data.filter(item => item.status === '담당자배정').length;
- const inProgress = data.filter(item => item.status === '검토중').length;
- const completed = data.filter(item => item.status === '답변완료').length;
- const urgent = data.filter(item => item.isUrgent).length;
-
- return { total, pending, assigned, inProgress, completed, urgent };
- }, [data]);
-
- if (stats.total === 0) {
- return (
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6">
- <Card className="col-span-full">
- <CardContent className="pt-6 text-center text-sm text-muted-foreground">
- 등록된 법무업무가 없습니다.
- </CardContent>
- </Card>
- </div>
- );
- }
-
- return (
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 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">전체</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- 긴급 {stats.urgent}건
- </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">{stats.pending.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.pending / stats.total) * 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-yellow-600">{stats.assigned.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.assigned / stats.total) * 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-orange-600">{stats.inProgress.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.inProgress / stats.total) * 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="default">완료</Badge>
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div>
- <div className="text-xs text-muted-foreground mt-1">
- {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total
- </div>
- </CardContent>
- </Card>
- </div>
- );
-}
-
-/* -------------------------------------------------------------------------- */
-/* EvaluationTargetsTable */
-/* -------------------------------------------------------------------------- */
-interface LegalWorksTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>;
- currentYear: number;
- className?: string;
-}
-
-export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null);
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false);
- const searchParams = useSearchParams();
-
- // ✅ 외부 필터 상태 (폼에서 전달받은 필터)
- const [externalFilters, setExternalFilters] = React.useState<any[]>([]);
- const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and");
-
- // ✅ 폼에서 전달받은 필터를 처리하는 핸들러
- const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => {
- console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator);
- setExternalFilters(filters);
- setExternalJoinOperator(joinOperator);
- // 필터 적용 후 패널 닫기
- setIsFilterPanelOpen(false);
- }, []);
-
-
- const searchString = React.useMemo(
- () => searchParams.toString(),
- [searchParams]
- );
-
- const getSearchParam = React.useCallback(
- (key: string, def = "") =>
- new URLSearchParams(searchString).get(key) ?? def,
- [searchString]
- );
-
-
- // ✅ URL 필터 변경 감지 및 데이터 새로고침
- React.useEffect(() => {
- const refetchData = async () => {
- try {
- setIsDataLoading(true);
-
- // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성
- const currentFilters = getSearchParam("filters");
- const currentJoinOperator = getSearchParam("joinOperator", "and");
- const currentPage = parseInt(getSearchParam("page", "1"));
- const currentPerPage = parseInt(getSearchParam("perPage", "10"));
- const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
- const currentSearch = getSearchParam("search", "");
-
- const searchParams = {
- filters: currentFilters ? JSON.parse(currentFilters) : [],
- joinOperator: currentJoinOperator as "and" | "or",
- page: currentPage,
- perPage: currentPerPage,
- sort: currentSort,
- search: currentSearch,
- currentYear: currentYear
- };
-
- console.log("=== 새 데이터 요청 ===", searchParams);
-
- // 서버 액션 직접 호출
- const newData = await getLegalWorks(searchParams);
- setTableData(newData);
-
- console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건");
- } catch (error) {
- console.error("데이터 새로고침 오류:", error);
- } finally {
- setIsDataLoading(false);
- }
- };
-
- /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */
-
- // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용)
- const timeoutId = setTimeout(() => {
- // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침
- const hasChanges = getSearchParam("filters") ||
- getSearchParam("search") ||
- getSearchParam("page") !== "1" ||
- getSearchParam("perPage") !== "10" ||
- getSearchParam("sort");
-
- if (hasChanges) {
- refetchData();
- }
- }, 300); // 디바운스 시간 단축
-
- return () => clearTimeout(timeoutId);
- }, [searchString, currentYear, getSearchParam]);
-
- const refreshData = React.useCallback(async () => {
- try {
- setIsDataLoading(true);
-
- // 현재 URL 파라미터로 데이터 새로고침
- const currentFilters = getSearchParam("filters");
- const currentJoinOperator = getSearchParam("joinOperator", "and");
- const currentPage = parseInt(getSearchParam("page", "1"));
- const currentPerPage = parseInt(getSearchParam("perPage", "10"));
- const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
- const currentSearch = getSearchParam("search", "");
-
- const searchParams = {
- filters: currentFilters ? JSON.parse(currentFilters) : [],
- joinOperator: currentJoinOperator as "and" | "or",
- page: currentPage,
- perPage: currentPerPage,
- sort: currentSort,
- search: currentSearch,
- currentYear: currentYear
- };
-
- const newData = await getLegalWorks(searchParams);
- setTableData(newData);
-
- console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건");
- } catch (error) {
- console.error("데이터 새로고침 오류:", error);
- } finally {
- setIsDataLoading(false);
- }
- }, [currentYear, getSearchParam]);
-
- /* --------------------------- layout refs --------------------------- */
- 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) { // 1px 이상 차이날 때만 업데이트
- return newTop
- }
- return prevTop
- })
- }
- }, [])
- React.useEffect(() => {
- updateContainerBounds();
-
- const handleResize = () => {
- updateContainerBounds();
- };
-
- window.addEventListener('resize', handleResize);
- window.addEventListener('scroll', updateContainerBounds);
-
- return () => {
- window.removeEventListener('resize', handleResize);
- window.removeEventListener('scroll', updateContainerBounds);
- };
- }, [updateContainerBounds]);
-
- /* ---------------------- 데이터 상태 관리 ---------------------- */
- // 초기 데이터 설정
- const [initialPromiseData] = React.use(promises);
-
- // ✅ 테이블 데이터 상태 추가
- const [tableData, setTableData] = React.useState(initialPromiseData);
- const [isDataLoading, setIsDataLoading] = React.useState(false);
-
- const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
- try {
- const value = getSearchParam(key);
- return value ? JSON.parse(value) : defaultValue;
- } catch {
- return defaultValue;
- }
- }, [getSearchParam]);
-
- const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
- return parseSearchParamHelper(key, defaultValue);
- };
-
- /* ---------------------- 초기 설정 ---------------------------- */
- const initialSettings = React.useMemo(() => ({
- page: parseInt(getSearchParam("page", "1")),
- perPage: parseInt(getSearchParam("perPage", "10")),
- sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
- filters: parseSearchParam("filters", []),
- joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
- search: getSearchParam("search", ""),
- columnVisibility: {},
- columnOrder: [],
- pinnedColumns: { left: [], right: ["actions"] },
- groupBy: [],
- expandedRows: [],
- }), [getSearchParam, parseSearchParam]);
-
- /* --------------------- 프리셋 훅 ------------------------------ */
- const {
- presets,
- activePresetId,
- hasUnsavedChanges,
- isLoading: presetsLoading,
- createPreset,
- applyPreset,
- updatePreset,
- deletePreset,
- setDefaultPreset,
- renamePreset,
- getCurrentSettings,
- } = useTablePresets<LegalWorksDetailView>(
- "legal-review-table",
- initialSettings
- );
-
-
-
- /* --------------------- 컬럼 ------------------------------ */
- const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]);
-
- /* 기본 필터 */
- const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [
- { id: "vendorCode", label: "벤더 코드" },
- { id: "vendorName", label: "벤더명" },
- { id: "status", label: "상태" },
- ];
-
- /* 고급 필터 */
- const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [
- ];
-
- /* current settings */
- const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
-
- const initialState = React.useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(sortItem => {
- const columnExists = columns.some(col => col.accessorKey === sortItem.id)
- return columnExists
- }) as any,
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [currentSettings, initialSettings.sort, columns])
-
- /* ----------------------- useDataTable ------------------------ */
- const { table } = useDataTable({
- data: tableData.data,
- columns,
- pageCount: tableData.pageCount,
- rowCount: tableData.total || tableData.data.length,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState,
- getRowId: (row) => String(row.id),
- shallow: false,
- clearOnDefault: true,
- });
-
- /* ---------------------- helper ------------------------------ */
- const getActiveFilterCount = React.useCallback(() => {
- try {
- // URL에서 현재 필터 수 확인
- const filtersParam = getSearchParam("filters");
- if (filtersParam) {
- const filters = JSON.parse(filtersParam);
- return Array.isArray(filters) ? filters.length : 0;
- }
- return 0;
- } catch {
- return 0;
- }
- }, [getSearchParam]);
-
- const FILTER_PANEL_WIDTH = 400;
-
- /* ---------------------------- JSX ---------------------------- */
- 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)`
- }}
- >
- <LegalWorkFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달
- isLoading={false}
- />
- </div>
-
- {/* Main 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 */}
- <div className="flex items-center justify-between p-4 bg-background shrink-0">
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
- {getActiveFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveFilterCount()}
- </span>
- )}
- </Button>
- <div className="text-sm text-muted-foreground">
- 총 {tableData.total || tableData.data.length}건
- </div>
- </div>
-
- {/* Stats */}
- <div className="px-4">
- <LegalWorksStats data={tableData.data} />
-
- </div>
-
- {/* Table */}
- <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}>
- {isDataLoading && (
- <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center">
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
- 필터링 중...
- </div>
- </div>
- )}
- <DataTable table={table} className="h-full">
- {/* ✅ 확장된 DataTableAdvancedToolbar 사용 */}
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- debounceMs={300}
- shallow={false}
- externalFilters={externalFilters}
- externalJoinOperator={externalJoinOperator}
- onFiltersChange={(filters, joinOperator) => {
- console.log("=== 필터 변경 감지 ===", filters, joinOperator);
- }}
- >
- <div className="flex items-center gap-2">
- <TablePresetManager<LegalWorksDetailView>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- <LegalWorksTableToolbarActions table={table}onRefresh={refreshData} />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* 다이얼로그들 */}
- <EditLegalWorkSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- work={rowAction?.row.original || null}
- onSuccess={() => {
- rowAction?.row.toggleSelected(false);
- refreshData();
- }}
- />
-
- <LegalWorkDetailDialog
- open={rowAction?.type === "view"}
- onOpenChange={(open) => !open && setRowAction(null)}
- work={rowAction?.row.original || null}
- />
-
- <DeleteLegalWorksDialog
- open={rowAction?.type === "delete"}
- onOpenChange={(open) => !open && setRowAction(null)}
- legalWorks={rowAction?.row.original ? [rowAction.row.original] : []}
- showTrigger={false}
- onSuccess={() => {
- setRowAction(null);
- refreshData();
- }}
- />
-
- </div>
- </div>
- </div>
- </div>
- </>
- );
-} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-work-detail-dialog.tsx b/lib/legal-review/status/legal-work-detail-dialog.tsx
deleted file mode 100644
index 23ceccb2..00000000
--- a/lib/legal-review/status/legal-work-detail-dialog.tsx
+++ /dev/null
@@ -1,409 +0,0 @@
-"use client";
-
-import * as React from "react";
-import {
- Eye,
- FileText,
- Building,
- User,
- Calendar,
- Clock,
- MessageSquare,
- CheckCircle,
- ShieldCheck,
-} from "lucide-react";
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Badge } from "@/components/ui/badge";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import { formatDate } from "@/lib/utils";
-import { LegalWorksDetailView } from "@/db/schema";
-
-// -----------------------------------------------------------------------------
-// TYPES
-// -----------------------------------------------------------------------------
-
-type LegalWorkData = LegalWorksDetailView;
-
-interface LegalWorkDetailDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- work: LegalWorkData | null;
-}
-
-// -----------------------------------------------------------------------------
-// HELPERS
-// -----------------------------------------------------------------------------
-
-// 상태별 배지 스타일
-const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case "검토요청":
- return "bg-blue-100 text-blue-800 border-blue-200";
- case "담당자배정":
- return "bg-yellow-100 text-yellow-800 border-yellow-200";
- case "검토중":
- return "bg-orange-100 text-orange-800 border-orange-200";
- case "답변완료":
- return "bg-green-100 text-green-800 border-green-200";
- case "재검토요청":
- return "bg-purple-100 text-purple-800 border-purple-200";
- case "보류":
- return "bg-gray-100 text-gray-800 border-gray-200";
- case "취소":
- return "bg-red-100 text-red-800 border-red-200";
- default:
- return "bg-gray-100 text-gray-800 border-gray-200";
- }
-};
-
-export function LegalWorkDetailDialog({
- open,
- onOpenChange,
- work,
-}: LegalWorkDetailDialogProps) {
- if (!work) return null;
-
- // ---------------------------------------------------------------------------
- // CONDITIONAL FLAGS
- // ---------------------------------------------------------------------------
-
- const isLegalReview = work.reviewDepartment === "법무검토";
- const isCompliance = work.reviewDepartment === "준법문의";
-
- const isDomesticContract = work.inquiryType === "국내계약";
- const isDomesticAdvisory = work.inquiryType === "국내자문";
- const isOverseasContract = work.inquiryType === "해외계약";
- const isOverseasAdvisory = work.inquiryType === "해외자문";
-
- const isContractTypeActive =
- isDomesticContract || isOverseasContract || isOverseasAdvisory;
- const isDomesticContractFieldsActive = isDomesticContract;
- const isFactualRelationActive = isDomesticAdvisory || isOverseasAdvisory;
- const isOverseasFieldsActive = isOverseasContract || isOverseasAdvisory;
-
- // ---------------------------------------------------------------------------
- // RENDER
- // ---------------------------------------------------------------------------
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl h-[90vh] p-0 flex flex-col">
- {/* 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Eye className="h-5 w-5" /> 법무업무 상세보기
- </DialogTitle>
- <DialogDescription>
- 법무업무 #{work.id}의 상세 정보를 확인합니다.
- </DialogDescription>
- </DialogHeader>
- </div>
-
- {/* 본문 */}
- <ScrollArea className="flex-1 p-6">
- <div className="space-y-6">
- {/* 1. 기본 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-lg">
- <FileText className="h-5 w-5" /> 기본 정보
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-2 gap-6 text-sm">
- <div className="space-y-4">
- <div className="flex items-center gap-2">
- <span className="font-medium text-muted-foreground">업무 ID:</span>
- <Badge variant="outline">#{work.id}</Badge>
- </div>
- <div className="flex items-center gap-2">
- <span className="font-medium text-muted-foreground">구분:</span>
- <Badge
- variant={
- work.category === "CP"
- ? "default"
- : work.category === "GTC"
- ? "secondary"
- : "outline"
- }
- >
- {work.category}
- </Badge>
- {work.isUrgent && (
- <Badge variant="destructive" className="text-xs">
- 긴급
- </Badge>
- )}
- </div>
- <div className="flex items-center gap-2">
- <span className="font-medium text-muted-foreground">상태:</span>
- <Badge
- className={getStatusBadgeVariant(work.status)}
- variant="outline"
- >
- {work.status}
- </Badge>
- </div>
- </div>
- <div className="space-y-4">
- <div className="flex items-center gap-2">
- <Building className="h-4 w-4 text-muted-foreground" />
- <span className="font-medium text-muted-foreground">벤더:</span>
- <span>
- {work.vendorCode} - {work.vendorName}
- </span>
- </div>
- <div className="flex items-center gap-2">
- <Calendar className="h-4 w-4 text-muted-foreground" />
- <span className="font-medium text-muted-foreground">의뢰일:</span>
- <span>{formatDate(work.consultationDate, "KR")}</span>
- </div>
- <div className="flex items-center gap-2">
- <Clock className="h-4 w-4 text-muted-foreground" />
- <span className="font-medium text-muted-foreground">답변요청일:</span>
- <span>{formatDate(work.requestDate, "KR")}</span>
- </div>
- </div>
- </div>
- </CardContent>
- </Card>
-
- {/* 2. 담당자 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-lg">
- <User className="h-5 w-5" /> 담당자 정보
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-2 gap-6 text-sm">
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">검토요청자</span>
- <p>{work.reviewer || "미지정"}</p>
- </div>
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">법무답변자</span>
- <p>{work.legalResponder || "미배정"}</p>
- </div>
- {work.expectedAnswerDate && (
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">답변예정일</span>
- <p>{formatDate(work.expectedAnswerDate, "KR")}</p>
- </div>
- )}
- {work.legalCompletionDate && (
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">법무완료일</span>
- <p>{formatDate(work.legalCompletionDate, "KR")}</p>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
-
- {/* 3. 법무업무 상세 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-lg">
- <ShieldCheck className="h-5 w-5" /> 법무업무 상세 정보
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4 text-sm">
- <div className="grid grid-cols-2 gap-6">
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">검토부문</span>
- <Badge variant="outline">{work.reviewDepartment}</Badge>
- </div>
- {work.inquiryType && (
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">문의종류</span>
- <Badge variant="secondary">{work.inquiryType}</Badge>
- </div>
- )}
- {isCompliance && (
- <div className="space-y-2 col-span-2">
- <span className="font-medium text-muted-foreground">공개여부</span>
- <Badge variant={work.isPublic ? "default" : "outline"}>
- {work.isPublic ? "공개" : "비공개"}
- </Badge>
- </div>
- )}
- </div>
-
- {/* 법무검토 전용 필드 */}
- {isLegalReview && (
- <>
- {work.contractProjectName && (
- <>
- <Separator className="my-2" />
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">
- 계약명 / 프로젝트명
- </span>
- <p>{work.contractProjectName}</p>
- </div>
- </>
- )}
-
- {/* 계약서 종류 */}
- {isContractTypeActive && work.contractType && (
- <div className="space-y-2">
- <span className="font-medium text-muted-foreground">계약서 종류</span>
- <Badge variant="outline" className="max-w-max">
- {work.contractType}
- </Badge>
- </div>
- )}
-
- {/* 국내계약 전용 필드 */}
- {isDomesticContractFieldsActive && (
- <div className="grid grid-cols-2 gap-6 mt-4">
- {work.contractCounterparty && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">
- 계약상대방
- </span>
- <p>{work.contractCounterparty}</p>
- </div>
- )}
- {work.counterpartyType && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">
- 계약상대방 구분
- </span>
- <p>{work.counterpartyType}</p>
- </div>
- )}
- {work.contractPeriod && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">계약기간</span>
- <p>{work.contractPeriod}</p>
- </div>
- )}
- {work.contractAmount && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">계약금액</span>
- <p>{work.contractAmount}</p>
- </div>
- )}
- </div>
- )}
-
- {/* 사실관계 */}
- {isFactualRelationActive && work.factualRelation && (
- <div className="space-y-2 mt-4">
- <span className="font-medium text-muted-foreground">사실관계</span>
- <p className="whitespace-pre-wrap">{work.factualRelation}</p>
- </div>
- )}
-
- {/* 해외 전용 필드 */}
- {isOverseasFieldsActive && (
- <div className="grid grid-cols-2 gap-6 mt-4">
- {work.projectNumber && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">프로젝트번호</span>
- <p>{work.projectNumber}</p>
- </div>
- )}
- {work.shipownerOrderer && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">선주 / 발주처</span>
- <p>{work.shipownerOrderer}</p>
- </div>
- )}
- {work.projectType && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">프로젝트종류</span>
- <p>{work.projectType}</p>
- </div>
- )}
- {work.governingLaw && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">준거법</span>
- <p>{work.governingLaw}</p>
- </div>
- )}
- </div>
- )}
- </>
- )}
- </CardContent>
- </Card>
-
- {/* 4. 요청 내용 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-lg">
- <MessageSquare className="h-5 w-5" /> 요청 내용
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4 text-sm">
- {work.title && (
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">제목</span>
- <p className="font-medium">{work.title}</p>
- </div>
- )}
- <Separator />
- <div className="space-y-1">
- <span className="font-medium text-muted-foreground">상세 내용</span>
- <div className="bg-muted/30 rounded-lg p-4">
- {work.requestContent ? (
- <div className="prose prose-sm max-w-none">
- <div
- dangerouslySetInnerHTML={{ __html: work.requestContent }}
- />
- </div>
- ) : (
- <p className="italic text-muted-foreground">요청 내용이 없습니다.</p>
- )}
- </div>
- </div>
- {work.attachmentCount > 0 && (
- <div className="flex items-center gap-2">
- <FileText className="h-4 w-4" /> 첨부파일 {work.attachmentCount}개
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 5. 답변 내용 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2 text-lg">
- <CheckCircle className="h-5 w-5" /> 답변 내용
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4 text-sm">
- <div className="bg-green-50 border border-green-200 rounded-lg p-4">
- {work.responseContent ? (
- <div className="prose prose-sm max-w-none">
- <div
- dangerouslySetInnerHTML={{ __html: work.responseContent }}
- />
- </div>
- ) : (
- <p className="italic text-muted-foreground">
- 아직 답변이 등록되지 않았습니다.
- </p>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- </ScrollArea>
- </DialogContent>
- </Dialog>
- );
-}
diff --git a/lib/legal-review/status/legal-work-filter-sheet.tsx b/lib/legal-review/status/legal-work-filter-sheet.tsx
deleted file mode 100644
index 4ac877a9..00000000
--- a/lib/legal-review/status/legal-work-filter-sheet.tsx
+++ /dev/null
@@ -1,897 +0,0 @@
-"use client"
-
-import { useTransition, useState } from "react"
-import { useRouter } 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 { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { cn } from "@/lib/utils"
-import { LEGAL_WORK_FILTER_OPTIONS } from "@/types/legal"
-
-// nanoid 생성기
-const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
-
-// 법무업무 필터 스키마 정의
-const legalWorkFilterSchema = z.object({
- category: z.string().optional(),
- status: z.string().optional(),
- isUrgent: z.string().optional(),
- reviewDepartment: z.string().optional(),
- inquiryType: z.string().optional(),
- reviewer: z.string().optional(),
- legalResponder: z.string().optional(),
- vendorCode: z.string().optional(),
- vendorName: z.string().optional(),
- requestDateFrom: z.string().optional(),
- requestDateTo: z.string().optional(),
- consultationDateFrom: z.string().optional(),
- consultationDateTo: z.string().optional(),
-})
-
-type LegalWorkFilterFormValues = z.infer<typeof legalWorkFilterSchema>
-
-interface LegalWorkFilterSheetProps {
- isOpen: boolean;
- onClose: () => void;
- onFiltersApply: (filters: any[], joinOperator: "and" | "or") => void;
- isLoading?: boolean;
-}
-
-export function LegalWorkFilterSheet({
- isOpen,
- onClose,
- onFiltersApply,
- isLoading = false
-}: LegalWorkFilterSheetProps) {
- const router = useRouter()
- const [isPending, startTransition] = useTransition()
- const [joinOperator, setJoinOperator] = useState<"and" | "or">("and")
-
- // 폼 상태 초기화
- const form = useForm<LegalWorkFilterFormValues>({
- resolver: zodResolver(legalWorkFilterSchema),
- defaultValues: {
- category: "",
- status: "",
- isUrgent: "",
- reviewDepartment: "",
- inquiryType: "",
- reviewer: "",
- legalResponder: "",
- vendorCode: "",
- vendorName: "",
- requestDateFrom: "",
- requestDateTo: "",
- consultationDateFrom: "",
- consultationDateTo: "",
- },
- })
-
- // ✅ 폼 제출 핸들러 - 필터 배열 생성 및 전달
- async function onSubmit(data: LegalWorkFilterFormValues) {
- startTransition(async () => {
- try {
- const newFilters = []
-
- // 구분 필터
- if (data.category?.trim()) {
- newFilters.push({
- id: "category",
- value: data.category.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.isUrgent?.trim()) {
- newFilters.push({
- id: "isUrgent",
- value: data.isUrgent.trim() === "true",
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 검토부문 필터
- if (data.reviewDepartment?.trim()) {
- newFilters.push({
- id: "reviewDepartment",
- value: data.reviewDepartment.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 문의종류 필터
- if (data.inquiryType?.trim()) {
- newFilters.push({
- id: "inquiryType",
- value: data.inquiryType.trim(),
- type: "select",
- operator: "eq",
- rowId: generateId()
- })
- }
-
- // 요청자 필터
- if (data.reviewer?.trim()) {
- newFilters.push({
- id: "reviewer",
- value: data.reviewer.trim(),
- type: "text",
- operator: "iLike",
- rowId: generateId()
- })
- }
-
- // 법무답변자 필터
- if (data.legalResponder?.trim()) {
- newFilters.push({
- id: "legalResponder",
- value: data.legalResponder.trim(),
- type: "text",
- operator: "iLike",
- 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.requestDateFrom?.trim() && data.requestDateTo?.trim()) {
- // 범위 필터 (시작일과 종료일 모두 있는 경우)
- newFilters.push({
- id: "requestDate",
- value: [data.requestDateFrom.trim(), data.requestDateTo.trim()],
- type: "date",
- operator: "between",
- rowId: generateId()
- })
- } else if (data.requestDateFrom?.trim()) {
- // 시작일만 있는 경우 (이후 날짜)
- newFilters.push({
- id: "requestDate",
- value: data.requestDateFrom.trim(),
- type: "date",
- operator: "gte",
- rowId: generateId()
- })
- } else if (data.requestDateTo?.trim()) {
- // 종료일만 있는 경우 (이전 날짜)
- newFilters.push({
- id: "requestDate",
- value: data.requestDateTo.trim(),
- type: "date",
- operator: "lte",
- rowId: generateId()
- })
- }
-
- // 의뢰일 범위 필터
- if (data.consultationDateFrom?.trim() && data.consultationDateTo?.trim()) {
- // 범위 필터 (시작일과 종료일 모두 있는 경우)
- newFilters.push({
- id: "consultationDate",
- value: [data.consultationDateFrom.trim(), data.consultationDateTo.trim()],
- type: "date",
- operator: "between",
- rowId: generateId()
- })
- } else if (data.consultationDateFrom?.trim()) {
- // 시작일만 있는 경우 (이후 날짜)
- newFilters.push({
- id: "consultationDate",
- value: data.consultationDateFrom.trim(),
- type: "date",
- operator: "gte",
- rowId: generateId()
- })
- } else if (data.consultationDateTo?.trim()) {
- // 종료일만 있는 경우 (이전 날짜)
- newFilters.push({
- id: "consultationDate",
- value: data.consultationDateTo.trim(),
- type: "date",
- operator: "lte",
- rowId: generateId()
- })
- }
-
- console.log("=== 생성된 필터들 ===", newFilters);
- console.log("=== 조인 연산자 ===", joinOperator);
-
- // ✅ 부모 컴포넌트에 필터 전달
- onFiltersApply(newFilters, joinOperator);
-
- console.log("=== 필터 적용 완료 ===");
- } catch (error) {
- console.error("법무업무 필터 적용 오류:", error);
- }
- })
- }
-
- // ✅ 필터 초기화 핸들러
- function handleReset() {
- // 1. 폼 초기화
- form.reset({
- category: "",
- status: "",
- isUrgent: "",
- reviewDepartment: "",
- inquiryType: "",
- reviewer: "",
- legalResponder: "",
- vendorCode: "",
- vendorName: "",
- requestDateFrom: "",
- requestDateTo: "",
- consultationDateFrom: "",
- consultationDateTo: "",
- });
-
- // 2. 조인 연산자 초기화
- setJoinOperator("and");
-
- // 3. URL 파라미터 초기화 (필터를 빈 배열로 설정)
- const currentUrl = new URL(window.location.href);
- const newSearchParams = new URLSearchParams(currentUrl.search);
-
- // 필터 관련 파라미터 초기화
- newSearchParams.set("filters", JSON.stringify([]));
- newSearchParams.set("joinOperator", "and");
- newSearchParams.set("page", "1");
- newSearchParams.delete("search"); // 검색어 제거
-
- // URL 업데이트
- router.replace(`${currentUrl.pathname}?${newSearchParams.toString()}`);
-
- // 4. 빈 필터 배열 전달 (즉시 UI 업데이트를 위해)
- onFiltersApply([], "and");
-
- console.log("=== 필터 완전 초기화 완료 ===");
- }
-
- if (!isOpen) {
- return null;
- }
-
- return (
- <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
- {/* 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>
- <Button
- variant="ghost"
- size="icon"
- onClick={onClose}
- className="h-8 w-8"
- >
- <X className="size-4" />
- </Button>
- </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)}
- >
- <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="category"
- render={({ field }) => (
- <FormItem>
- <FormLabel>구분</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <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("category", "");
- }}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {LEGAL_WORK_FILTER_OPTIONS.categories.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}
- >
- <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", "");
- }}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {LEGAL_WORK_FILTER_OPTIONS.statuses.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 긴급여부 */}
- <FormField
- control={form.control}
- name="isUrgent"
- render={({ field }) => (
- <FormItem>
- <FormLabel>긴급여부</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <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("isUrgent", "");
- }}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="true">긴급</SelectItem>
- <SelectItem value="false">일반</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 검토부문 */}
- <FormField
- control={form.control}
- name="reviewDepartment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>검토부문</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <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("reviewDepartment", "");
- }}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {LEGAL_WORK_FILTER_OPTIONS.reviewDepartments.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 문의종류 */}
- <FormField
- control={form.control}
- name="inquiryType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>문의종류</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <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("inquiryType", "");
- }}
- >
- <X className="size-3" />
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {LEGAL_WORK_FILTER_OPTIONS.inquiryTypes.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 요청자 */}
- <FormField
- control={form.control}
- name="reviewer"
- render={({ field }) => (
- <FormItem>
- <FormLabel>요청자</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="요청자명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {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("reviewer", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 법무답변자 */}
- <FormField
- control={form.control}
- name="legalResponder"
- render={({ field }) => (
- <FormItem>
- <FormLabel>법무답변자</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder="법무답변자명 입력"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {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("legalResponder", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <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")}
- />
- {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", "");
- }}
- >
- <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")}
- />
- {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", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 검토 요청일 범위 */}
- <div className="space-y-2">
- <label className="text-sm font-medium">검토 요청일</label>
-
- {/* 시작일 */}
- <FormField
- control={form.control}
- name="requestDateFrom"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs text-muted-foreground">시작일</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- type="date"
- placeholder="시작일 선택"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {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("requestDateFrom", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 종료일 */}
- <FormField
- control={form.control}
- name="requestDateTo"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs text-muted-foreground">종료일</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- type="date"
- placeholder="종료일 선택"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {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("requestDateTo", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* 의뢰일 범위 */}
- <div className="space-y-2">
- <label className="text-sm font-medium">의뢰일</label>
-
- {/* 시작일 */}
- <FormField
- control={form.control}
- name="consultationDateFrom"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs text-muted-foreground">시작일</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- type="date"
- placeholder="시작일 선택"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {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("consultationDateFrom", "");
- }}
- >
- <X className="size-3.5" />
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 종료일 */}
- <FormField
- control={form.control}
- name="consultationDateTo"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="text-xs text-muted-foreground">종료일</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- type="date"
- placeholder="종료일 선택"
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- />
- {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("consultationDateTo", "");
- }}
- >
- <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}
- className="px-4"
- >
- 초기화
- </Button>
- <Button
- type="submit"
- variant="default"
- disabled={isPending || isLoading}
- className="px-4 bg-blue-600 hover:bg-blue-700 text-white"
- >
- <Search className="size-4 mr-2" />
- {isPending || isLoading ? "조회 중..." : "조회"}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/status/legal-works-columns.tsx b/lib/legal-review/status/legal-works-columns.tsx
deleted file mode 100644
index c94b414d..00000000
--- a/lib/legal-review/status/legal-works-columns.tsx
+++ /dev/null
@@ -1,222 +0,0 @@
-// components/legal-works/legal-works-columns.tsx
-"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 {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Ellipsis, Paperclip } from "lucide-react";
-
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
-import type { DataTableRowAction } from "@/types/table";
-import { formatDate } from "@/lib/utils";
-import { LegalWorksDetailView } from "@/db/schema";
-
-// ────────────────────────────────────────────────────────────────────────────
-// 타입
-// ────────────────────────────────────────────────────────────────────────────
-interface GetColumnsProps {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<LegalWorksDetailView> | null>
- >;
-}
-
-// ────────────────────────────────────────────────────────────────────────────
-// 헬퍼
-// ────────────────────────────────────────────────────────────────────────────
-const statusVariant = (status: string) => {
- const map: Record<string, string> = {
- 검토요청: "bg-blue-100 text-blue-800 border-blue-200",
- 담당자배정: "bg-yellow-100 text-yellow-800 border-yellow-200",
- 검토중: "bg-orange-100 text-orange-800 border-orange-200",
- 답변완료: "bg-green-100 text-green-800 border-green-200",
- 재검토요청: "bg-purple-100 text-purple-800 border-purple-200",
- 보류: "bg-gray-100 text-gray-800 border-gray-200",
- 취소: "bg-red-100 text-red-800 border-red-200",
- };
- return map[status] ?? "bg-gray-100 text-gray-800 border-gray-200";
-};
-
-const categoryBadge = (category: string) => (
- <Badge
- variant={
- category === "CP" ? "default" : category === "GTC" ? "secondary" : "outline"
- }
- >
- {category}
- </Badge>
-);
-
-const urgentBadge = (isUrgent: boolean) =>
- isUrgent ? (
- <Badge variant="destructive" className="text-xs px-1 py-0">
- 긴급
- </Badge>
- ) : null;
-
-const header = (title: string) =>
- ({ column }: { column: any }) =>
- <DataTableColumnHeaderSimple column={column} title={title} />;
-
-// ────────────────────────────────────────────────────────────────────────────
-// 기본 컬럼
-// ────────────────────────────────────────────────────────────────────────────
-const BASE_COLUMNS: ColumnDef<LegalWorksDetailView>[] = [
- // 선택 체크박스
- {
- 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"
- />
- ),
- enableSorting: false,
- enableHiding: false,
- size: 40,
- },
-
- // 번호, 구분, 상태
- {
- accessorKey: "id",
- header: header("No."),
- cell: ({ row }) => (
- <div className="w-[60px] text-center font-medium">{row.getValue("id")}</div>
- ),
- size: 80,
- },
- {
- accessorKey: "category",
- header: header("구분"),
- cell: ({ row }) => categoryBadge(row.getValue("category")),
- size: 80,
- },
- {
- accessorKey: "status",
- header: header("상태"),
- cell: ({ row }) => (
- <Badge className={statusVariant(row.getValue("status"))} variant="outline">
- {row.getValue("status")}
- </Badge>
- ),
- size: 120,
- },
-
- // 벤더 코드·이름
- {
- accessorKey: "vendorCode",
- header: header("벤더 코드"),
- cell: ({ row }) => <span className="font-mono text-sm">{row.getValue("vendorCode")}</span>,
- size: 120,
- },
- {
- accessorKey: "vendorName",
- header: header("벤더명"),
- cell: ({ row }) => {
- const name = row.getValue<string>("vendorName");
- return (
- <div className="flex items-center gap-2 truncate max-w-[200px]" title={name}>
- {urgentBadge(row.original.isUrgent)}
- {name}
- </div>
- );
- },
- size: 200,
- },
-
- // 날짜·첨부
- {
- accessorKey: "requestDate",
- header: header("답변요청일"),
- cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.getValue("requestDate"), "KR")}</span>
- ),
- size: 100,
- },
- {
- accessorKey: "hasAttachment",
- header: header("첨부"),
- cell: ({ row }) =>
- row.getValue<boolean>("hasAttachment") ? (
- <Paperclip className="h-4 w-4 text-muted-foreground" />
- ) : (
- <span className="text-muted-foreground">-</span>
- ),
- size: 60,
- enableSorting: false,
- },
-];
-
-// ────────────────────────────────────────────────────────────────────────────
-// 액션 컬럼
-// ────────────────────────────────────────────────────────────────────────────
-const createActionsColumn = (
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<LegalWorksDetailView> | null>
- >
-): ColumnDef<LegalWorksDetailView> => ({
- id: "actions",
- enableHiding: false,
- size: 40,
- minSize: 40,
- cell: ({ row }) => (
- <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" />
- </Button>
- </DropdownMenuTrigger>
-
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem onSelect={() => setRowAction({ row, type: "view" })}>
- 상세보기
- </DropdownMenuItem>
- {row.original.status === "신규등록" && (
- <>
- <DropdownMenuItem onSelect={() => setRowAction({ row, type: "update" })}>
- 편집
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem onSelect={() => setRowAction({ row, type: "delete" })}>
- 삭제하기
- </DropdownMenuItem>
- </>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- ),
-});
-
-// ────────────────────────────────────────────────────────────────────────────
-// 메인 함수
-// ────────────────────────────────────────────────────────────────────────────
-export function getLegalWorksColumns({
- setRowAction,
-}: GetColumnsProps): ColumnDef<LegalWorksDetailView>[] {
- return [...BASE_COLUMNS, createActionsColumn(setRowAction)];
-}
diff --git a/lib/legal-review/status/legal-works-toolbar-actions.tsx b/lib/legal-review/status/legal-works-toolbar-actions.tsx
deleted file mode 100644
index 82fbc80a..00000000
--- a/lib/legal-review/status/legal-works-toolbar-actions.tsx
+++ /dev/null
@@ -1,286 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import {
- Plus,
- Send,
- Download,
- RefreshCw,
- FileText,
- MessageSquare
-} from "lucide-react"
-import { toast } from "sonner"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { CreateLegalWorkDialog } from "./create-legal-work-dialog"
-import { RequestReviewDialog } from "./request-review-dialog"
-import { exportTableToExcel } from "@/lib/export"
-import { getLegalWorks } from "../service"
-import { LegalWorksDetailView } from "@/db/schema"
-import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog"
-
-type LegalWorkData = LegalWorksDetailView
-
-interface LegalWorksTableToolbarActionsProps {
- table: Table<LegalWorkData>
- onRefresh?: () => void
-}
-
-export function LegalWorksTableToolbarActions({
- table,
- onRefresh
-}: LegalWorksTableToolbarActionsProps) {
- const [isLoading, setIsLoading] = React.useState(false)
- const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
- const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false)
- const router = useRouter()
- const { data: session } = useSession()
-
- // 사용자 ID 가져오기
- const userId = React.useMemo(() => {
- return session?.user?.id ? Number(session.user.id) : 1
- }, [session])
-
- // 선택된 행들 - 단일 선택만 허용
- const selectedRows = table.getFilteredSelectedRowModel().rows
- const hasSelection = selectedRows.length > 0
- const isSingleSelection = selectedRows.length === 1
- const isMultipleSelection = selectedRows.length > 1
-
- // 선택된 단일 work
- const selectedWork = isSingleSelection ? selectedRows[0].original : null
-
- // const canDeleateReview = selectedRows.filter(v=>v.status === '신규등록')
-
-
- const deletableRows = React.useMemo(() => {
- return selectedRows.filter(row => {
- const status = row.original.status
- return status ==="신규등록"
- })
- }, [selectedRows])
-
- const hasDeletableRows = deletableRows.length > 0
-
- // 선택된 work의 상태 확인
- const canRequestReview = selectedWork?.status === "신규등록"
- const canAssign = selectedWork?.status === "신규등록"
-
- // ----------------------------------------------------------------
- // 신규 생성
- // ----------------------------------------------------------------
- const handleCreateNew = React.useCallback(() => {
- setCreateDialogOpen(true)
- }, [])
-
- // ----------------------------------------------------------------
- // 검토 요청 (단일 선택만)
- // ----------------------------------------------------------------
- const handleRequestReview = React.useCallback(() => {
- if (!isSingleSelection) {
- toast.error("검토요청은 한 건씩만 가능합니다. 하나의 항목만 선택해주세요.")
- return
- }
-
- if (!canRequestReview) {
- toast.error("신규등록 상태인 항목만 검토요청이 가능합니다.")
- return
- }
-
- setReviewDialogOpen(true)
- }, [isSingleSelection, canRequestReview])
-
- // ----------------------------------------------------------------
- // 다이얼로그 성공 핸들러
- // ----------------------------------------------------------------
- const handleActionSuccess = React.useCallback(() => {
- table.resetRowSelection()
- onRefresh?.()
- router.refresh()
- }, [table, onRefresh, router])
-
- // ----------------------------------------------------------------
- // 내보내기 핸들러
- // ----------------------------------------------------------------
- const handleExport = React.useCallback(() => {
- exportTableToExcel(table, {
- filename: "legal-works-list",
- excludeColumns: ["select", "actions"],
- })
- }, [table])
-
- // ----------------------------------------------------------------
- // 새로고침 핸들러
- // ----------------------------------------------------------------
- const handleRefresh = React.useCallback(async () => {
- setIsLoading(true)
- try {
- onRefresh?.()
- toast.success("데이터를 새로고침했습니다.")
- } catch (error) {
- console.error("새로고침 오류:", error)
- toast.error("새로고침 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- }
- }, [onRefresh])
-
- return (
- <>
- <div className="flex items-center gap-2">
-
- {hasDeletableRows&&(
- <DeleteLegalWorksDialog
- legalWorks={deletableRows.map(row => row.original)}
- showTrigger={hasDeletableRows}
- onSuccess={() => {
- table.toggleAllRowsSelected(false)
- // onRefresh?.()
- }}
- />
- )}
- {/* 신규 생성 버튼 */}
- <Button
- variant="default"
- size="sm"
- className="gap-2"
- onClick={handleCreateNew}
- disabled={isLoading}
- >
- <Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">신규 등록</span>
- </Button>
-
- {/* 유틸리티 버튼들 */}
- <div className="flex items-center gap-1 border-l pl-2 ml-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefresh}
- disabled={isLoading}
- className="gap-2"
- >
- <RefreshCw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
- <span className="hidden sm:inline">새로고침</span>
- </Button>
-
- <Button
- variant="outline"
- size="sm"
- onClick={handleExport}
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">내보내기</span>
- </Button>
- </div>
-
- {/* 선택된 항목 액션 버튼들 */}
- {hasSelection && (
- <div className="flex items-center gap-1 border-l pl-2 ml-2">
- {/* 다중 선택 경고 메시지 */}
- {isMultipleSelection && (
- <div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded border border-amber-200">
- 검토요청은 한 건씩만 가능합니다
- </div>
- )}
-
- {/* 검토 요청 버튼 (단일 선택시만) */}
- {isSingleSelection && (
- <Button
- variant="default"
- size="sm"
- className="gap-2 bg-blue-600 hover:bg-blue-700"
- onClick={handleRequestReview}
- disabled={isLoading || !canRequestReview}
- >
- <Send className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- {canRequestReview ? "검토요청" : "검토불가"}
- </span>
- </Button>
- )}
-
- {/* 추가 액션 드롭다운 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- disabled={isLoading}
- >
- <MessageSquare className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">추가 작업</span>
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={() => toast.info("담당자 배정 기능을 준비 중입니다.")}
- disabled={!isSingleSelection || !canAssign}
- >
- <FileText className="size-4 mr-2" />
- 담당자 배정
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- onClick={() => toast.info("상태 변경 기능을 준비 중입니다.")}
- disabled={!isSingleSelection}
- >
- <RefreshCw className="size-4 mr-2" />
- 상태 변경
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- )}
-
- {/* 선택된 항목 정보 표시 */}
- {hasSelection && (
- <div className="flex items-center gap-1 border-l pl-2 ml-2">
- <div className="text-xs text-muted-foreground">
- {isSingleSelection ? (
- <>
- 선택: #{selectedWork?.id} ({selectedWork?.category})
- {selectedWork?.vendorName && ` | ${selectedWork.vendorName}`}
- {selectedWork?.status && ` | ${selectedWork.status}`}
- </>
- ) : (
- `선택: ${selectedRows.length}건 (개별 처리 필요)`
- )}
- </div>
- </div>
- )}
- </div>
-
- {/* 다이얼로그들 */}
- {/* 신규 생성 다이얼로그 */}
- <CreateLegalWorkDialog
- open={createDialogOpen}
- onOpenChange={setCreateDialogOpen}
- onSuccess={handleActionSuccess}
- onDataChange={onRefresh}
- />
-
- {/* 검토 요청 다이얼로그 - 단일 work 전달 */}
- {selectedWork && (
- <RequestReviewDialog
- open={reviewDialogOpen}
- onOpenChange={setReviewDialogOpen}
- work={selectedWork} // 단일 객체로 변경
- onSuccess={handleActionSuccess}
- />
- )}
- </>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/status/request-review-dialog.tsx b/lib/legal-review/status/request-review-dialog.tsx
deleted file mode 100644
index d99fc0e3..00000000
--- a/lib/legal-review/status/request-review-dialog.tsx
+++ /dev/null
@@ -1,983 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import * as z from "zod"
-import { Loader2, Send, FileText, Clock, Upload, X, Building, User, Calendar } from "lucide-react"
-import { toast } from "sonner"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-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 { Switch } from "@/components/ui/switch"
-import TiptapEditor from "@/components/qna/tiptap-editor"
-import { canRequestReview, requestReview } from "../service"
-import { LegalWorksDetailView } from "@/db/schema"
-
-type LegalWorkData = LegalWorksDetailView
-
-interface RequestReviewDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- work: LegalWorkData | null
- onSuccess?: () => void
-}
-
-// 검토요청 폼 스키마
-const requestReviewSchema = z.object({
- // 기본 검토 설정
- dueDate: z.string().min(1, "검토 완료 희망일을 선택해주세요"),
- assignee: z.string().optional(),
- notificationMethod: z.enum(["email", "internal", "both"]).default("both"),
-
- // 법무업무 상세 정보
- reviewDepartment: z.enum(["준법문의", "법무검토"]),
- inquiryType: z.enum(["국내계약", "국내자문", "해외계약", "해외자문"]).optional(),
-
- // 공통 필드
- title: z.string().min(1, "제목을 선택해주세요"),
- requestContent: z.string().min(1, "요청내용을 입력해주세요"),
-
- // 준법문의 전용 필드
- isPublic: z.boolean().default(false),
-
- // 법무검토 전용 필드들
- contractProjectName: z.string().optional(),
- contractType: z.string().optional(),
- contractCounterparty: z.string().optional(),
- counterpartyType: z.enum(["법인", "개인"]).optional(),
- contractPeriod: z.string().optional(),
- contractAmount: z.string().optional(),
- factualRelation: z.string().optional(),
- projectNumber: z.string().optional(),
- shipownerOrderer: z.string().optional(),
- projectType: z.string().optional(),
- governingLaw: z.string().optional(),
-}).refine((data) => {
- // 법무검토 선택시 문의종류 필수
- if (data.reviewDepartment === "법무검토" && !data.inquiryType) {
- return false;
- }
- return true;
-}, {
- message: "법무검토 선택시 문의종류를 선택해주세요",
- path: ["inquiryType"]
-});
-
-type RequestReviewFormValues = z.infer<typeof requestReviewSchema>
-
-export function RequestReviewDialog({
- open,
- onOpenChange,
- work,
- onSuccess
-}: RequestReviewDialogProps) {
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [attachments, setAttachments] = React.useState<File[]>([])
- const [editorContent, setEditorContent] = React.useState("")
- const [canRequest, setCanRequest] = React.useState(true)
- const [requestCheckMessage, setRequestCheckMessage] = React.useState("")
- const [isCustomTitle, setIsCustomTitle] = React.useState(false)
-
- // work의 category에 따라 기본 reviewDepartment 결정
- const getDefaultReviewDepartment = () => {
- return work?.category === "CP" ? "준법문의" : "법무검토"
- }
-
- const form = useForm<RequestReviewFormValues>({
- resolver: zodResolver(requestReviewSchema),
- defaultValues: {
- dueDate: "",
- assignee: "",
- notificationMethod: "both",
- reviewDepartment: getDefaultReviewDepartment(),
- title: getDefaultReviewDepartment() === "준법문의" ? "CP검토" : "GTC검토",
- requestContent: "",
- isPublic: false,
- },
- })
-
- // work 변경시 검토요청 가능 여부 확인
- React.useEffect(() => {
- if (work && open) {
- canRequestReview(work.id).then((result) => {
- setCanRequest(result.canRequest)
- setRequestCheckMessage(result.reason || "")
- })
-
- const defaultDepartment = work.category === "CP" ? "준법문의" : "법무검토"
- form.setValue("reviewDepartment", defaultDepartment)
- }
- }, [work, open, form])
-
- // 검토부문 감시
- const reviewDepartment = form.watch("reviewDepartment")
- const inquiryType = form.watch("inquiryType")
- const titleValue = form.watch("title")
-
- // 조건부 필드 활성화 로직
- const isContractTypeActive = inquiryType && ["국내계약", "해외계약", "해외자문"].includes(inquiryType)
- const isDomesticContractFieldsActive = inquiryType === "국내계약"
- const isFactualRelationActive = inquiryType && ["국내자문", "해외자문"].includes(inquiryType)
- const isOverseasFieldsActive = inquiryType && ["해외계약", "해외자문"].includes(inquiryType)
-
- // 제목 "기타" 선택 여부 확인
- // const isTitleOther = titleValue === "기타"
-
- // 검토부문 변경시 관련 필드 초기화
- React.useEffect(() => {
- if (reviewDepartment === "준법문의") {
- setIsCustomTitle(false)
- form.setValue("inquiryType", undefined)
- // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로)
- const currentTitle = form.getValues("title")
- if (!currentTitle || currentTitle === "GTC검토") {
- form.setValue("title", "CP검토")
- }
- // 법무검토 전용 필드들 초기화
- form.setValue("contractProjectName", "")
- form.setValue("contractType", "")
- form.setValue("contractCounterparty", "")
- form.setValue("counterpartyType", undefined)
- form.setValue("contractPeriod", "")
- form.setValue("contractAmount", "")
- form.setValue("factualRelation", "")
- form.setValue("projectNumber", "")
- form.setValue("shipownerOrderer", "")
- form.setValue("projectType", "")
- form.setValue("governingLaw", "")
- } else {
- setIsCustomTitle(false)
- // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로)
- const currentTitle = form.getValues("title")
- if (!currentTitle || currentTitle === "CP검토") {
- form.setValue("title", "GTC검토")
- }
- form.setValue("isPublic", false)
- }
- }, [reviewDepartment, form])
-
- // 문의종류 변경시 관련 필드 초기화
- React.useEffect(() => {
- if (inquiryType) {
- // 계약서 종류 초기화 (옵션이 달라지므로)
- form.setValue("contractType", "")
-
- // 조건에 맞지 않는 필드들 초기화
- if (!isDomesticContractFieldsActive) {
- form.setValue("contractCounterparty", "")
- form.setValue("counterpartyType", undefined)
- form.setValue("contractPeriod", "")
- form.setValue("contractAmount", "")
- }
-
- if (!isFactualRelationActive) {
- form.setValue("factualRelation", "")
- }
-
- if (!isOverseasFieldsActive) {
- form.setValue("projectNumber", "")
- form.setValue("shipownerOrderer", "")
- form.setValue("projectType", "")
- form.setValue("governingLaw", "")
- }
- }
- }, [inquiryType, isDomesticContractFieldsActive, isFactualRelationActive, isOverseasFieldsActive, form])
-
- // 에디터 내용이 변경될 때 폼에 반영
- React.useEffect(() => {
- form.setValue("requestContent", editorContent)
- }, [editorContent, form])
-
- // 첨부파일 처리
- const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
- const files = Array.from(event.target.files || [])
- setAttachments(prev => [...prev, ...files])
- }
-
- const removeAttachment = (index: number) => {
- setAttachments(prev => prev.filter((_, i) => i !== index))
- }
-
- // 폼 제출
- async function onSubmit(data: RequestReviewFormValues) {
- if (!work) return
-
- console.log("Request review data:", data)
- console.log("Work to review:", work)
- console.log("Attachments:", attachments)
- setIsSubmitting(true)
-
- try {
- const result = await requestReview(work.id, data, attachments)
-
- if (result.success) {
- toast.success(result.data?.message || `법무업무 #${work.id}에 대한 검토요청이 완료되었습니다.`)
- onOpenChange(false)
- handleReset()
- onSuccess?.()
- } else {
- toast.error(result.error || "검토요청 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("Error requesting review:", error)
- toast.error("검토요청 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 폼 리셋 함수
- const handleReset = () => {
- const defaultDepartment = getDefaultReviewDepartment()
- setIsCustomTitle(false) // 추가
-
- form.reset({
- dueDate: "",
- assignee: "",
- notificationMethod: "both",
- reviewDepartment: defaultDepartment,
- title: defaultDepartment === "준법문의" ? "CP검토" : "GTC검토",
- requestContent: "",
- isPublic: false,
- })
- setAttachments([])
- setEditorContent("")
- }
-
- // 다이얼로그 닫기 핸들러
- const handleOpenChange = (open: boolean) => {
- onOpenChange(open)
- if (!open) {
- handleReset()
- }
- }
-
- // 제목 옵션 (검토부문에 따라 다름)
- const getTitleOptions = () => {
- if (reviewDepartment === "준법문의") {
- return [
- { value: "CP검토", label: "CP검토" },
- { value: "기타", label: "기타 (직접입력)" }
- ]
- } else {
- return [
- { value: "GTC검토", label: "GTC검토" },
- { value: "기타", label: "기타 (직접입력)" }
- ]
- }
- }
-
- // 계약서 종류 옵션 (문의종류에 따라 다름)
- const getContractTypeOptions = () => {
- if (inquiryType === "국내계약") {
- return [
- { value: "공사도급계약", label: "공사도급계약" },
- { value: "제작납품계약", label: "제작납품계약" },
- { value: "자재매매계약", label: "자재매매계약" },
- { value: "용역위탁계약", label: "용역위탁계약" },
- { value: "기술사용 및 개발계약", label: "기술사용 및 개발계약" },
- { value: "운송 및 자재관리 계약", label: "운송 및 자재관리 계약" },
- { value: "자문 등 위임계약", label: "자문 등 위임계약" },
- { value: "양해각서", label: "양해각서" },
- { value: "양수도 계약", label: "양수도 계약" },
- { value: "합의서", label: "합의서" },
- { value: "공동도급(운영)협약서", label: "공동도급(운영)협약서" },
- { value: "협정서", label: "협정서" },
- { value: "약정서", label: "약정서" },
- { value: "협의서", label: "협의서" },
- { value: "기타", label: "기타" },
- { value: "비밀유지계약서", label: "비밀유지계약서" },
- { value: "분양계약서", label: "분양계약서" },
- ]
- } else {
- // 해외계약/해외자문
- return [
- { value: "Shipbuilding Contract", label: "Shipbuilding Contract" },
- { value: "Offshore Contract (EPCI, FEED)", label: "Offshore Contract (EPCI, FEED)" },
- { value: "Supplementary / Addendum", label: "Supplementary / Addendum" },
- { value: "Subcontract / GTC / PTC / PO", label: "Subcontract / GTC / PTC / PO" },
- { value: "Novation / Assignment", label: "Novation / Assignment" },
- { value: "NDA (Confidential, Secrecy)", label: "NDA (Confidential, Secrecy)" },
- { value: "Warranty", label: "Warranty" },
- { value: "Waiver and Release", label: "Waiver and Release" },
- { value: "Bond (PG, RG, Advanced Payment)", label: "Bond (PG, RG, Advanced Payment)" },
- { value: "MOU / LOI / LOA", label: "MOU / LOI / LOA" },
- { value: "Power of Attorney (POA)", label: "Power of Attorney (POA)" },
- { value: "Commission Agreement", label: "Commission Agreement" },
- { value: "Consortium Agreement", label: "Consortium Agreement" },
- { value: "JV / JDP Agreement", label: "JV / JDP Agreement" },
- { value: "Engineering Service Contract", label: "Engineering Service Contract" },
- { value: "Consultancy Service Agreement", label: "Consultancy Service Agreement" },
- { value: "Purchase / Lease Agreement", label: "Purchase / Lease Agreement" },
- { value: "Financial / Loan / Covenant", label: "Financial / Loan / Covenant" },
- { value: "Other Contract / Agreement", label: "Other Contract / Agreement" },
- ]
- }
- }
-
- // 프로젝트 종류 옵션
- const getProjectTypeOptions = () => {
- return [
- { value: "BARGE VESSEL", label: "BARGE VESSEL" },
- { value: "BULK CARRIER", label: "BULK CARRIER" },
- { value: "CHEMICAL CARRIER", label: "CHEMICAL CARRIER" },
- { value: "FULL CONTAINER", label: "FULL CONTAINER" },
- { value: "CRUDE OIL TANKER", label: "CRUDE OIL TANKER" },
- { value: "CRUISE SHIP", label: "CRUISE SHIP" },
- { value: "DRILL SHIP", label: "DRILL SHIP" },
- { value: "FIELD DEVELOPMENT SHIP", label: "FIELD DEVELOPMENT SHIP" },
- { value: "FLOATING PRODUCTION STORAGE OFFLOADING", label: "FLOATING PRODUCTION STORAGE OFFLOADING" },
- { value: "CAR-FERRY & PASSENGER VESSEL", label: "CAR-FERRY & PASSENGER VESSEL" },
- { value: "FLOATING STORAGE OFFLOADING", label: "FLOATING STORAGE OFFLOADING" },
- { value: "HEAVY DECK CARGO", label: "HEAVY DECK CARGO" },
- { value: "PRODUCT OIL TANKER", label: "PRODUCT OIL TANKER" },
- { value: "HIGH SPEED LINER", label: "HIGH SPEED LINER" },
- { value: "JACK-UP", label: "JACK-UP" },
- { value: "LIQUEFIED NATURAL GAS CARRIER", label: "LIQUEFIED NATURAL GAS CARRIER" },
- { value: "LIQUEFIED PETROLEUM GAS CARRIER", label: "LIQUEFIED PETROLEUM GAS CARRIER" },
- { value: "MULTIPURPOSE CARGO CARRIER", label: "MULTIPURPOSE CARGO CARRIER" },
- { value: "ORE-BULK-OIL CARRIER", label: "ORE-BULK-OIL CARRIER" },
- { value: "OIL TANKER", label: "OIL TANKER" },
- { value: "OTHER VESSEL", label: "OTHER VESSEL" },
- { value: "PURE CAR CARRIER", label: "PURE CAR CARRIER" },
- { value: "PRODUCT CARRIER", label: "PRODUCT CARRIER" },
- { value: "PLATFORM", label: "PLATFORM" },
- { value: "PUSHER", label: "PUSHER" },
- { value: "REEFER TRANSPORT VESSEL", label: "REEFER TRANSPORT VESSEL" },
- { value: "ROLL-ON ROLL-OFF VESSEL", label: "ROLL-ON ROLL-OFF VESSEL" },
- { value: "SEMI RIG", label: "SEMI RIG" },
- { value: "SUPPLY ANCHOR HANDLING VESSEL", label: "SUPPLY ANCHOR HANDLING VESSEL" },
- { value: "SHUTTLE TANKER", label: "SHUTTLE TANKER" },
- { value: "SUPPLY VESSEL", label: "SUPPLY VESSEL" },
- { value: "TOPSIDE", label: "TOPSIDE" },
- { value: "TUG SUPPLY VESSEL", label: "TUG SUPPLY VESSEL" },
- { value: "VERY LARGE CRUDE OIL CARRIER", label: "VERY LARGE CRUDE OIL CARRIER" },
- { value: "WELL INTERVENTION SHIP", label: "WELL INTERVENTION SHIP" },
- { value: "WIND TURBINE INSTALLATION VESSEL", label: "WIND TURBINE INSTALLATION VESSEL" },
- { value: "기타", label: "기타" },
- ]
- }
-
- if (!work) {
- return null
- }
-
- // 검토요청 불가능한 경우 안내 메시지
- if (!canRequest) {
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-md">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2 text-amber-600">
- <FileText className="h-5 w-5" />
- 검토요청 불가
- </DialogTitle>
- <DialogDescription className="pt-4">
- {requestCheckMessage}
- </DialogDescription>
- </DialogHeader>
- <div className="flex justify-end pt-4">
- <Button onClick={() => onOpenChange(false)}>확인</Button>
- </div>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="max-w-4xl h-[90vh] p-0 flex flex-col">
- {/* 고정 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Send className="h-5 w-5" />
- 검토요청 발송
- </DialogTitle>
- <DialogDescription>
- 법무업무 #{work.id}에 대한 상세한 검토를 요청합니다.
- </DialogDescription>
- </DialogHeader>
- </div>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1 min-h-0"
- >
- {/* 스크롤 가능한 콘텐츠 영역 */}
- <div className="flex-1 overflow-y-auto p-6">
- <div className="space-y-6">
- {/* 선택된 업무 정보 */}
- <Card className="bg-blue-50 border-blue-200">
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 검토 대상 업무
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div className="space-y-2">
- <div className="flex items-center gap-2">
- <span className="font-medium">업무 ID:</span>
- <Badge variant="outline">#{work.id}</Badge>
- </div>
- <div className="flex items-center gap-2">
- <span className="font-medium">구분:</span>
- <Badge variant={work.category === "CP" ? "default" : "secondary"}>
- {work.category}
- </Badge>
- {work.isUrgent && (
- <Badge variant="destructive" className="text-xs">
- 긴급
- </Badge>
- )}
- </div>
- <div className="flex items-center gap-2">
- <Building className="h-4 w-4" />
- <span className="font-medium">벤더:</span>
- <span>{work.vendorCode} - {work.vendorName}</span>
- </div>
- </div>
- <div className="space-y-2">
- <div className="flex items-center gap-2">
- <User className="h-4 w-4" />
- <span className="font-medium">요청자:</span>
- <span>{work.reviewer || "미지정"}</span>
- </div>
- <div className="flex items-center gap-2">
- <Calendar className="h-4 w-4" />
- <span className="font-medium">답변요청일:</span>
- <span>{work.requestDate || "미설정"}</span>
- </div>
- <div className="flex items-center gap-2">
- <span className="font-medium">상태:</span>
- <Badge variant="outline">{work.status}</Badge>
- </div>
- </div>
- </div>
- </CardContent>
- </Card>
-
- {/* 기본 설정 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">기본 설정</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {/* 검토 완료 희망일 */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center gap-2">
- <Clock className="h-4 w-4" />
- 검토 완료 희망일
- </FormLabel>
- <FormControl>
- <Input type="date" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
-
- {/* 법무업무 상세 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">법무업무 상세 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {/* 검토부문 */}
- <FormField
- control={form.control}
- name="reviewDepartment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>검토부문</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="검토부문 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="준법문의">준법문의</SelectItem>
- <SelectItem value="법무검토">법무검토</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 문의종류 (법무검토 선택시만) */}
- {reviewDepartment === "법무검토" && (
- <FormField
- control={form.control}
- name="inquiryType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>문의종류</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="문의종류 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="국내계약">국내계약</SelectItem>
- <SelectItem value="국내자문">국내자문</SelectItem>
- <SelectItem value="해외계약">해외계약</SelectItem>
- <SelectItem value="해외자문">해외자문</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- {/* 제목 - 조건부 렌더링 */}
- <FormField
- control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem>
- <FormLabel>제목</FormLabel>
- {!isCustomTitle ? (
- // Select 모드
- <Select
- onValueChange={(value) => {
- if (value === "기타") {
- setIsCustomTitle(true)
- field.onChange("") // 빈 값으로 초기화
- } else {
- field.onChange(value)
- }
- }}
- value={field.value}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="제목 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {getTitleOptions().map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- ) : (
- // Input 모드 (기타 선택시)
- <div className="space-y-2">
- <div className="flex items-center gap-2">
- <Badge variant="outline" className="text-xs">기타</Badge>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => {
- const defaultTitle = reviewDepartment === "준법문의" ? "CP검토" : "GTC검토"
- form.setValue("title", defaultTitle)
- setIsCustomTitle(false) // 상태 초기화
- }}
- className="h-6 text-xs"
- >
- 선택 모드로 돌아가기
- </Button>
- </div>
- <FormControl>
- <Input
- placeholder="제목을 직접 입력하세요"
- value={field.value}
- onChange={(e) => field.onChange(e.target.value)}
- autoFocus
- />
- </FormControl>
- </div>
- )}
- <FormMessage />
- </FormItem>
- )}
-/>
-
- {/* 준법문의 전용 필드들 */}
- {reviewDepartment === "준법문의" && (
- <FormField
- control={form.control}
- name="isPublic"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">공개여부</FormLabel>
- <div className="text-sm text-muted-foreground">
- 준법문의 공개 설정
- </div>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- )}
-
- {/* 법무검토 전용 필드들 */}
- {reviewDepartment === "법무검토" && (
- <div className="space-y-4">
- {/* 계약명/프로젝트명 */}
- <FormField
- control={form.control}
- name="contractProjectName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약명/프로젝트명</FormLabel>
- <FormControl>
- <Input placeholder="계약명 또는 프로젝트명 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약서 종류 - 조건부 활성화 */}
- {isContractTypeActive && (
- <FormField
- control={form.control}
- name="contractType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약서 종류</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="계약서 종류 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {getContractTypeOptions().map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- {/* 국내계약 전용 필드들 */}
- {isDomesticContractFieldsActive && (
- <div className="grid grid-cols-2 gap-4">
- {/* 계약상대방 */}
- <FormField
- control={form.control}
- name="contractCounterparty"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약상대방</FormLabel>
- <FormControl>
- <Input placeholder="계약상대방 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약상대방 구분 */}
- <FormField
- control={form.control}
- name="counterpartyType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약상대방 구분</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="구분 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="법인">법인</SelectItem>
- <SelectItem value="개인">개인</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약기간 */}
- <FormField
- control={form.control}
- name="contractPeriod"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약기간</FormLabel>
- <FormControl>
- <Input placeholder="계약기간 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약금액 */}
- <FormField
- control={form.control}
- name="contractAmount"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약금액</FormLabel>
- <FormControl>
- <Input placeholder="계약금액 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- )}
-
- {/* 사실관계 - 조건부 활성화 */}
- {isFactualRelationActive && (
- <FormField
- control={form.control}
- name="factualRelation"
- render={({ field }) => (
- <FormItem>
- <FormLabel>사실관계</FormLabel>
- <FormControl>
- <Textarea
- placeholder="사실관계를 상세히 입력해주세요"
- className="min-h-[80px]"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
-
- {/* 해외 관련 필드들 - 조건부 활성화 */}
- {isOverseasFieldsActive && (
- <div className="grid grid-cols-2 gap-4">
- {/* 프로젝트번호 */}
- <FormField
- control={form.control}
- name="projectNumber"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트번호</FormLabel>
- <FormControl>
- <Input placeholder="프로젝트번호 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 선주/발주처 */}
- <FormField
- control={form.control}
- name="shipownerOrderer"
- render={({ field }) => (
- <FormItem>
- <FormLabel>선주/발주처</FormLabel>
- <FormControl>
- <Input placeholder="선주/발주처 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 프로젝트종류 */}
- <FormField
- control={form.control}
- name="projectType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>프로젝트종류</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="프로젝트종류 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {getProjectTypeOptions().map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 준거법 */}
- <FormField
- control={form.control}
- name="governingLaw"
- render={({ field }) => (
- <FormItem>
- <FormLabel>준거법</FormLabel>
- <FormControl>
- <Input placeholder="준거법 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- )}
- </div>
- )}
-
- {/* 요청내용 - TiptapEditor로 교체 */}
- <FormField
- control={form.control}
- name="requestContent"
- render={({ field }) => (
- <FormItem>
- <FormLabel>요청내용</FormLabel>
- <FormControl>
- <div className="min-h-[250px]">
- <TiptapEditor
- content={editorContent}
- setContent={setEditorContent}
- disabled={isSubmitting}
- height="250px"
- />
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 첨부파일 */}
- <div className="space-y-2">
- <FormLabel>첨부파일</FormLabel>
- <div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-4">
- <input
- type="file"
- multiple
- onChange={handleFileChange}
- className="hidden"
- id="file-upload"
- />
- <label
- htmlFor="file-upload"
- className="flex flex-col items-center justify-center cursor-pointer"
- >
- <Upload className="h-8 w-8 text-muted-foreground mb-2" />
- <span className="text-sm text-muted-foreground">
- 파일을 선택하거나 여기로 드래그하세요
- </span>
- </label>
- </div>
-
- {/* 선택된 파일 목록 */}
- {attachments.length > 0 && (
- <div className="space-y-2">
- {attachments.map((file, index) => (
- <div key={index} className="flex items-center justify-between bg-muted/50 p-2 rounded">
- <span className="text-sm truncate">{file.name}</span>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeAttachment(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- </div>
-
- {/* 고정 버튼 영역 */}
- <div className="flex-shrink-0 border-t 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}
- className="bg-blue-600 hover:bg-blue-700"
- >
- {isSubmitting ? (
- <>
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
- 발송 중...
- </>
- ) : (
- <>
- <Send className="mr-2 h-4 w-4" />
- 검토요청 발송
- </>
- )}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/status/update-legal-work-dialog.tsx b/lib/legal-review/status/update-legal-work-dialog.tsx
deleted file mode 100644
index d9157d3c..00000000
--- a/lib/legal-review/status/update-legal-work-dialog.tsx
+++ /dev/null
@@ -1,385 +0,0 @@
-"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 { Loader2, Check, ChevronsUpDown, Edit } from "lucide-react"
-import { toast } from "sonner"
-
-import { Button } from "@/components/ui/button"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Switch } from "@/components/ui/switch"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { cn } from "@/lib/utils"
-import { getVendorsForSelection } from "@/lib/b-rfq/service"
-import { LegalWorksDetailView } from "@/db/schema"
-// import { updateLegalWork } from "../service"
-
-type LegalWorkData = LegalWorksDetailView
-
-interface EditLegalWorkSheetProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- work: LegalWorkData | null
- onSuccess?: () => void
- onDataChange?: () => void
-}
-
-// 편집용 폼 스키마 (신규등록 상태에서만 기본 정보만 편집)
-const editLegalWorkSchema = z.object({
- category: z.enum(["CP", "GTC", "기타"]),
- vendorId: z.number().min(1, "벤더를 선택해주세요"),
- isUrgent: z.boolean().default(false),
- requestDate: z.string().min(1, "답변요청일을 선택해주세요"),
-})
-
-type EditLegalWorkFormValues = z.infer<typeof editLegalWorkSchema>
-
-interface Vendor {
- id: number
- vendorName: string
- vendorCode: string
- country: string
- taxId: string
- status: string
-}
-
-export function EditLegalWorkSheet({
- open,
- onOpenChange,
- work,
- onSuccess,
- onDataChange
-}: EditLegalWorkSheetProps) {
- const router = useRouter()
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [vendors, setVendors] = React.useState<Vendor[]>([])
- const [vendorsLoading, setVendorsLoading] = React.useState(false)
- const [vendorOpen, setVendorOpen] = React.useState(false)
-
- const loadVendors = React.useCallback(async () => {
- setVendorsLoading(true)
- try {
- const vendorList = await getVendorsForSelection()
- setVendors(vendorList)
- } catch (error) {
- console.error("Failed to load vendors:", error)
- toast.error("벤더 목록을 불러오는데 실패했습니다.")
- } finally {
- setVendorsLoading(false)
- }
- }, [])
-
- const form = useForm<EditLegalWorkFormValues>({
- resolver: zodResolver(editLegalWorkSchema),
- defaultValues: {
- category: "CP",
- vendorId: 0,
- isUrgent: false,
- requestDate: "",
- },
- })
-
- // work 데이터가 변경될 때 폼 값 업데이트
- React.useEffect(() => {
- if (work && open) {
- form.reset({
- category: work.category as "CP" | "GTC" | "기타",
- vendorId: work.vendorId || 0,
- isUrgent: work.isUrgent || false,
- requestDate: work.requestDate ? new Date(work.requestDate).toISOString().split('T')[0] : "",
- })
- }
- }, [work, open, form])
-
- React.useEffect(() => {
- if (open) {
- loadVendors()
- }
- }, [open, loadVendors])
-
- // 폼 제출
- async function onSubmit(data: EditLegalWorkFormValues) {
- if (!work) return
-
- console.log("Updating legal work with data:", data)
- setIsSubmitting(true)
-
- try {
- const result = await updateLegalWork(work.id, data)
-
- if (result.success) {
- toast.success(result.data?.message || "법무업무가 성공적으로 수정되었습니다.")
- onOpenChange(false)
- onSuccess?.()
- onDataChange?.()
- router.refresh()
- } else {
- toast.error(result.error || "수정 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("Error updating legal work:", error)
- toast.error("수정 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 시트 닫기 핸들러
- const handleOpenChange = (openState: boolean) => {
- onOpenChange(openState)
- if (!openState) {
- form.reset()
- }
- }
-
- // 선택된 벤더 정보
- const selectedVendor = vendors.find(v => v.id === form.watch("vendorId"))
-
- if (!work) {
- return null
- }
-
- return (
- <Sheet open={open} onOpenChange={handleOpenChange}>
- <SheetContent className="w-[600px] sm:w-[800px] p-0 flex flex-col" style={{maxWidth:900}}>
- {/* 고정 헤더 */}
- <SheetHeader className="flex-shrink-0 p-6 border-b">
- <SheetTitle className="flex items-center gap-2">
- <Edit className="h-5 w-5" />
- 법무업무 편집
- </SheetTitle>
- <SheetDescription>
- 법무업무 #{work.id}의 기본 정보를 수정합니다. (신규등록 상태에서만 편집 가능)
- </SheetDescription>
- </SheetHeader>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1 min-h-0"
- >
- {/* 스크롤 가능한 콘텐츠 영역 */}
- <ScrollArea className="flex-1 p-6">
- <div className="space-y-6">
- {/* 기본 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">기본 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {/* 구분 */}
- <FormField
- control={form.control}
- name="category"
- render={({ field }) => (
- <FormItem>
- <FormLabel>구분</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="구분 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="CP">CP</SelectItem>
- <SelectItem value="GTC">GTC</SelectItem>
- <SelectItem value="기타">기타</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 긴급여부 */}
- <FormField
- control={form.control}
- name="isUrgent"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">긴급 요청</FormLabel>
- <div className="text-sm text-muted-foreground">
- 긴급 처리가 필요한 경우 체크
- </div>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
-
- {/* 벤더 선택 */}
- <FormField
- control={form.control}
- name="vendorId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>벤더</FormLabel>
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className="w-full justify-between"
- >
- {selectedVendor ? (
- <span className="flex items-center gap-2">
- <Badge variant="outline">{selectedVendor.vendorCode}</Badge>
- {selectedVendor.vendorName}
- </span>
- ) : (
- "벤더 선택..."
- )}
- <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="벤더 검색..." />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {vendors.map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorCode} ${vendor.vendorName}`}
- onSelect={() => {
- field.onChange(vendor.id)
- setVendorOpen(false)
- }}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- vendor.id === field.value ? "opacity-100" : "opacity-0"
- )}
- />
- <div className="flex items-center gap-2">
- <Badge variant="outline">{vendor.vendorCode}</Badge>
- <span>{vendor.vendorName}</span>
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 답변요청일 */}
- <FormField
- control={form.control}
- name="requestDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>답변요청일</FormLabel>
- <FormControl>
- <Input type="date" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </CardContent>
- </Card>
-
- {/* 안내 메시지 */}
- <Card className="bg-blue-50 border-blue-200">
- <CardContent className="pt-6">
- <div className="flex items-start gap-3">
- <div className="h-2 w-2 rounded-full bg-blue-500 mt-2"></div>
- <div className="space-y-1">
- <p className="text-sm font-medium text-blue-900">
- 편집 제한 안내
- </p>
- <p className="text-sm text-blue-700">
- 기본 정보는 '신규등록' 상태에서만 편집할 수 있습니다. 검토요청이 발송된 후에는 담당자를 통해 변경해야 합니다.
- </p>
- </div>
- </div>
- </CardContent>
- </Card>
- </div>
- </ScrollArea>
-
- {/* 고정 버튼 영역 */}
- <SheetFooter className="flex-shrink-0 border-t bg-background p-6">
- <div className="flex justify-end gap-3 w-full">
- <SheetClose asChild>
- <Button
- type="button"
- variant="outline"
- disabled={isSubmitting}
- >
- 취소
- </Button>
- </SheetClose>
- <Button
- type="submit"
- disabled={isSubmitting}
- >
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 저장
- </Button>
- </div>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/legal-review/validations.ts b/lib/legal-review/validations.ts
deleted file mode 100644
index 4f41016e..00000000
--- a/lib/legal-review/validations.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import {
- createSearchParamsCache,
- parseAsArrayOf,
- parseAsInteger,
- parseAsString,
- parseAsStringEnum,
-} from "nuqs/server";
-import * as z from "zod";
-import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
-import { legalWorksDetailView } from "@/db/schema";
-
-export const SearchParamsCacheLegalWorks = createSearchParamsCache({
- // UI 모드나 플래그 관련
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 (createdAt 기준 내림차순)
- sort: getSortingStateParser<typeof legalWorksDetailView>().withDefault([
- { id: "createdAt", desc: true }]),
-
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- search: parseAsString.withDefault(""),
-});
-export type GetLegalWorksSchema = Awaited<ReturnType<typeof SearchParamsCacheLegalWorks.parse>>;
-
-export const createLegalWorkSchema = z.object({
- category: z.enum(["CP", "GTC", "기타"]),
- vendorId: z.number().min(1, "벤더를 선택해주세요"),
- isUrgent: z.boolean().default(false),
- requestDate: z.string().min(1, "답변요청일을 선택해주세요"),
- expectedAnswerDate: z.string().optional(),
- reviewer: z.string().min(1, "검토요청자를 입력해주세요"),
- });
-
-export type CreateLegalWorkData = z.infer<typeof createLegalWorkSchema>;
- \ No newline at end of file