diff options
| author | joonhoekim <26rote@gmail.com> | 2025-12-01 19:52:06 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-12-01 19:52:06 +0900 |
| commit | 44b74ff4170090673b6eeacd8c528e0abf47b7aa (patch) | |
| tree | 3f3824b4e2cb24536c1677188b4cae5b8909d3da /lib/legal-review | |
| parent | 4953e770929b82ef77da074f77071ebd0f428529 (diff) | |
(김준회) deprecated code 정리
Diffstat (limited to 'lib/legal-review')
| -rw-r--r-- | lib/legal-review/service.ts | 738 | ||||
| -rw-r--r-- | lib/legal-review/status/create-legal-work-dialog.tsx | 506 | ||||
| -rw-r--r-- | lib/legal-review/status/delete-legal-works-dialog.tsx | 152 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-table copy.tsx | 583 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-table.tsx | 546 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-work-detail-dialog.tsx | 409 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-work-filter-sheet.tsx | 897 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-works-columns.tsx | 222 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-works-toolbar-actions.tsx | 286 | ||||
| -rw-r--r-- | lib/legal-review/status/request-review-dialog.tsx | 983 | ||||
| -rw-r--r-- | lib/legal-review/status/update-legal-work-dialog.tsx | 385 | ||||
| -rw-r--r-- | lib/legal-review/validations.ts | 40 |
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 |
