diff options
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 | 501 | ||||
| -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 | 548 | ||||
| -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 | 976 | ||||
| -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, 5737 insertions, 0 deletions
diff --git a/lib/legal-review/service.ts b/lib/legal-review/service.ts new file mode 100644 index 00000000..bc55a1fc --- /dev/null +++ b/lib/legal-review/service.ts @@ -0,0 +1,738 @@ +'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 new file mode 100644 index 00000000..72f2a68b --- /dev/null +++ b/lib/legal-review/status/create-legal-work-dialog.tsx @@ -0,0 +1,501 @@ +"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> + <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 new file mode 100644 index 00000000..665dafc2 --- /dev/null +++ b/lib/legal-review/status/delete-legal-works-dialog.tsx @@ -0,0 +1,152 @@ +"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 new file mode 100644 index 00000000..92abfaf6 --- /dev/null +++ b/lib/legal-review/status/legal-table copy.tsx @@ -0,0 +1,583 @@ +// ============================================================================ +// 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 new file mode 100644 index 00000000..d68ffa4e --- /dev/null +++ b/lib/legal-review/status/legal-table.tsx @@ -0,0 +1,548 @@ +// ============================================================================ +// 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 new file mode 100644 index 00000000..23ceccb2 --- /dev/null +++ b/lib/legal-review/status/legal-work-detail-dialog.tsx @@ -0,0 +1,409 @@ +"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 new file mode 100644 index 00000000..4ac877a9 --- /dev/null +++ b/lib/legal-review/status/legal-work-filter-sheet.tsx @@ -0,0 +1,897 @@ +"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 new file mode 100644 index 00000000..c94b414d --- /dev/null +++ b/lib/legal-review/status/legal-works-columns.tsx @@ -0,0 +1,222 @@ +// 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 new file mode 100644 index 00000000..82fbc80a --- /dev/null +++ b/lib/legal-review/status/legal-works-toolbar-actions.tsx @@ -0,0 +1,286 @@ +"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 new file mode 100644 index 00000000..838752c4 --- /dev/null +++ b/lib/legal-review/status/request-review-dialog.tsx @@ -0,0 +1,976 @@ +"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("") + + // 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 === "준법문의") { + form.setValue("inquiryType", undefined) + // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로) + const currentTitle = form.getValues("title") + if (currentTitle === "기타" || !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 { + // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로) + const currentTitle = form.getValues("title") + if (currentTitle === "기타" || !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() + 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> + {!isTitleOther ? ( + // Select 모드 + <Select + onValueChange={(value) => { + field.onChange(value) + if (value !== "기타") { + // 기타가 아닌 값으로 변경시 해당 값으로 설정 + form.setValue("title", 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) + }} + className="h-6 text-xs" + > + 선택 모드로 돌아가기 + </Button> + </div> + <FormControl> + <Input + placeholder="제목을 직접 입력하세요" + value={field.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 new file mode 100644 index 00000000..d9157d3c --- /dev/null +++ b/lib/legal-review/status/update-legal-work-dialog.tsx @@ -0,0 +1,385 @@ +"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 new file mode 100644 index 00000000..4f41016e --- /dev/null +++ b/lib/legal-review/validations.ts @@ -0,0 +1,40 @@ +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 |
