From de2ac5a2860bc25180971e7a11f852d9d44675b7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 6 Aug 2025 04:23:40 +0000 Subject: (대표님) 정기평가, 법적검토, 정책, 가입관련 처리 및 관련 컴포넌트 추가, 메뉴 변경 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/delete-targets-dialog.tsx | 1 - .../table/evaluation-target-table.tsx | 15 +- .../table/evaluation-targets-filter-sheet.tsx | 22 +- lib/evaluation/service.ts | 6 +- lib/evaluation/table/evaluation-filter-sheet.tsx | 234 +++-- lib/evaluation/table/evaluation-table.tsx | 223 ++++- lib/forms/services.ts | 390 ++++---- lib/incoterms/validations.ts | 4 - lib/legal-review/service.ts | 738 ++++++++++++++++ .../status/create-legal-work-dialog.tsx | 501 +++++++++++ .../status/delete-legal-works-dialog.tsx | 152 ++++ lib/legal-review/status/legal-table copy.tsx | 583 ++++++++++++ lib/legal-review/status/legal-table.tsx | 548 ++++++++++++ .../status/legal-work-detail-dialog.tsx | 409 +++++++++ .../status/legal-work-filter-sheet.tsx | 897 +++++++++++++++++++ lib/legal-review/status/legal-works-columns.tsx | 222 +++++ .../status/legal-works-toolbar-actions.tsx | 286 ++++++ lib/legal-review/status/request-review-dialog.tsx | 976 +++++++++++++++++++++ .../status/update-legal-work-dialog.tsx | 385 ++++++++ lib/legal-review/validations.ts | 40 + lib/polices/service.ts | 341 +++++++ .../table/accepted-quotations-table-columns.tsx | 298 ++++--- lib/techsales-rfq/service.ts | 83 +- .../table/detail-table/rfq-detail-column.tsx | 8 +- .../table/update-investigation-sheet.tsx | 14 +- lib/vendors/service.ts | 3 +- 26 files changed, 6909 insertions(+), 470 deletions(-) create mode 100644 lib/legal-review/service.ts create mode 100644 lib/legal-review/status/create-legal-work-dialog.tsx create mode 100644 lib/legal-review/status/delete-legal-works-dialog.tsx create mode 100644 lib/legal-review/status/legal-table copy.tsx create mode 100644 lib/legal-review/status/legal-table.tsx create mode 100644 lib/legal-review/status/legal-work-detail-dialog.tsx create mode 100644 lib/legal-review/status/legal-work-filter-sheet.tsx create mode 100644 lib/legal-review/status/legal-works-columns.tsx create mode 100644 lib/legal-review/status/legal-works-toolbar-actions.tsx create mode 100644 lib/legal-review/status/request-review-dialog.tsx create mode 100644 lib/legal-review/status/update-legal-work-dialog.tsx create mode 100644 lib/legal-review/validations.ts create mode 100644 lib/polices/service.ts (limited to 'lib') diff --git a/lib/evaluation-target-list/table/delete-targets-dialog.tsx b/lib/evaluation-target-list/table/delete-targets-dialog.tsx index 5414d281..5f5493c2 100644 --- a/lib/evaluation-target-list/table/delete-targets-dialog.tsx +++ b/lib/evaluation-target-list/table/delete-targets-dialog.tsx @@ -37,7 +37,6 @@ export function DeleteTargetsDialog({ return targets.filter(target => target.status === "PENDING") }, [targets]) - console.log(pendingTargets,"pendingTargets") const handleDelete = async () => { if (pendingTargets.length === 0) { diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx index 9cc73003..9ca66acb 100644 --- a/lib/evaluation-target-list/table/evaluation-target-table.tsx +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -362,11 +362,16 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: const updateContainerBounds = React.useCallback(() => { if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - setContainerTop(rect.top); + 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(); @@ -439,6 +444,8 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: initialSettings ); + + /* --------------------- 컬럼 ------------------------------ */ const columns = React.useMemo(() => getEvaluationTargetsColumns({ setRowAction }), [setRowAction]); diff --git a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx index 3b6f9fa1..d37591ef 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx @@ -256,6 +256,7 @@ export function EvaluationTargetFilterSheet({ // ✅ 필터 초기화 핸들러 function handleReset() { + // 1. 폼 초기화 form.reset({ evaluationYear: "", division: "", @@ -273,9 +274,26 @@ export function EvaluationTargetFilterSheet({ csReviewerName: "", }); - // 빈 필터 배열 전달 - onFiltersApply([], "and"); + // 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) { diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts index 879876ed..b958e371 100644 --- a/lib/evaluation/service.ts +++ b/lib/evaluation/service.ts @@ -40,7 +40,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { AttachmentDetail, EvaluationDetailResponse } from "@/types/evaluation-form" import { headers } from 'next/headers'; -export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) { +export async function getPeriodicEvaluations(input: GetEvaluationsSchema) { try { const offset = (input.page - 1) * input.perPage; @@ -1300,7 +1300,7 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis } -export async function getPeriodicEvaluationsAggregated(input: GetEvaluationTargetsSchema) { +export async function getPeriodicEvaluationsAggregated(input: GetEvaluationsSchema) { try { const offset = (input.page - 1) * input.perPage; @@ -1398,7 +1398,7 @@ export async function getPeriodicEvaluationsAggregated(input: GetEvaluationTarge } // 기존 함수에 집계 옵션을 추가한 통합 함수 -export async function getPeriodicEvaluationsWithAggregation(input: GetEvaluationTargetsSchema) { +export async function getPeriodicEvaluationsWithAggregation(input: GetEvaluationsSchema) { if (input.aggregated) { return getPeriodicEvaluationsAggregated(input); } else { diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx index b0bf9139..c2dd9734 100644 --- a/lib/evaluation/table/evaluation-filter-sheet.tsx +++ b/lib/evaluation/table/evaluation-filter-sheet.tsx @@ -7,7 +7,6 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Search, X } from "lucide-react"; import { customAlphabet } from "nanoid"; -import { parseAsStringEnum, useQueryState } from "nuqs"; import { Button } from "@/components/ui/button"; import { @@ -28,7 +27,6 @@ import { SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import { getFiltersStateParser } from "@/lib/parsers"; import { EVALUATION_TARGET_FILTER_OPTIONS } from "@/lib/evaluation-target-list/validation"; /***************************************************************************************** @@ -49,7 +47,6 @@ const statusOptions = [ { value: "FINALIZED", label: "결과확정" }, ]; - const documentsSubmittedOptions = [ { value: "true", label: "제출완료" }, { value: "false", label: "미제출" }, @@ -104,14 +101,13 @@ export function PeriodicEvaluationFilterSheet({ isLoading = false, onFiltersApply, }: PeriodicEvaluationFilterSheetProps) { - /** Router (needed only for pathname) */ + /** Router (needed for URL updates) */ const router = useRouter(); /** Track pending state while we update URL */ const [isPending, startTransition] = useTransition(); const [joinOperator, setJoinOperator] = useState<"and" | "or">("and") - /** React‑Hook‑Form */ const form = useForm({ resolver: zodResolver(periodicEvaluationFilterSchema), @@ -131,82 +127,156 @@ export function PeriodicEvaluationFilterSheet({ }, }); - /***************************************************************************************** - * 3️⃣ Submit → build filter array → push to URL (and reset page=1) + * 3️⃣ Submit → build filter array → callback + URL (동기적 처리) *****************************************************************************************/ async function onSubmit(data: PeriodicEvaluationFilterFormValues) { startTransition(() => { try { - const newFilters: any[] = []; - - const pushFilter = ( - id: string, - value: any, - type: "text" | "select" | "number" | "boolean", - operator: "eq" | "iLike" | "gte" | "lte" - ) => { - newFilters.push({ id, value, type, operator, rowId: generateId() }); - }; - - if (data.evaluationYear?.trim()) - pushFilter("evaluationYear", Number(data.evaluationYear), "number", "eq"); - - if (data.division?.trim()) - pushFilter("division", data.division.trim(), "select", "eq"); - - if (data.status?.trim()) - pushFilter("status", data.status.trim(), "select", "eq"); - - if (data.domesticForeign?.trim()) - pushFilter("domesticForeign", data.domesticForeign.trim(), "select", "eq"); - - if (data.materialType?.trim()) - pushFilter("materialType", data.materialType.trim(), "select", "eq"); - - if (data.vendorCode?.trim()) - pushFilter("vendorCode", data.vendorCode.trim(), "text", "iLike"); - - if (data.vendorName?.trim()) - pushFilter("vendorName", data.vendorName.trim(), "text", "iLike"); - - if (data.documentsSubmitted?.trim()) - pushFilter( - "documentsSubmitted", - data.documentsSubmitted.trim() === "true", - "boolean", - "eq" - ); - - if (data.evaluationGrade?.trim()) - pushFilter("evaluationGrade", data.evaluationGrade.trim(), "select", "eq"); - - if (data.finalGrade?.trim()) - pushFilter("finalGrade", data.finalGrade.trim(), "select", "eq"); - - if (data.minTotalScore?.trim()) - pushFilter("totalScore", Number(data.minTotalScore), "number", "gte"); - - if (data.maxTotalScore?.trim()) - pushFilter("totalScore", Number(data.maxTotalScore), "number", "lte"); - - setJoinOperator(joinOperator); - - + const newFilters = [] + + // 필터 생성 로직 + if (data.evaluationYear?.trim()) { + newFilters.push({ + id: "evaluationYear", + value: parseInt(data.evaluationYear.trim()), + type: "number", + operator: "eq", + rowId: generateId() + }) + } + + if (data.division?.trim()) { + newFilters.push({ + id: "division", + value: data.division.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.domesticForeign?.trim()) { + newFilters.push({ + id: "domesticForeign", + value: data.domesticForeign.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.materialType?.trim()) { + newFilters.push({ + id: "materialType", + value: data.materialType.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.vendorCode?.trim()) { + newFilters.push({ + id: "vendorCode", + value: data.vendorCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.vendorName?.trim()) { + newFilters.push({ + id: "vendorName", + value: data.vendorName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.documentsSubmitted?.trim()) { + newFilters.push({ + id: "documentsSubmitted", + value: data.documentsSubmitted.trim() === "true", + type: "boolean", + operator: "eq", + rowId: generateId() + }) + } + + if (data.evaluationGrade?.trim()) { + newFilters.push({ + id: "evaluationGrade", + value: data.evaluationGrade.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.finalGrade?.trim()) { + newFilters.push({ + id: "finalGrade", + value: data.finalGrade.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.minTotalScore?.trim()) { + newFilters.push({ + id: "totalScore", + value: parseFloat(data.minTotalScore.trim()), + type: "number", + operator: "gte", + rowId: generateId() + }) + } + + if (data.maxTotalScore?.trim()) { + newFilters.push({ + id: "totalScore", + value: parseFloat(data.maxTotalScore.trim()), + type: "number", + operator: "lte", + rowId: generateId() + }) + } + + console.log("=== 생성된 필터들 ===", newFilters); + console.log("=== 조인 연산자 ===", joinOperator); + + + // ✅ 부모 컴포넌트에 필터 전달 (동기적으로 즉시 호출) onFiltersApply(newFilters, joinOperator); - } catch (err) { - // eslint-disable-next-line no-console - console.error("정기평가 필터 적용 오류:", err); + + console.log("=== 필터 적용 완료 ==="); + } catch (error) { + console.error("정기평가 필터 적용 오류:", error); } }); } /***************************************************************************************** - * 4️⃣ Reset → clear form & URL + * 4️⃣ Reset → clear form & URL (동기적 처리) *****************************************************************************************/ - async function handleReset() { + function handleReset() { + // 1. 폼 초기화 form.reset({ - evaluationYear: new Date().getFullYear().toString(), + evaluationYear: "", division: "", status: "", domesticForeign: "", @@ -220,9 +290,26 @@ export function PeriodicEvaluationFilterSheet({ maxTotalScore: "", }); - onFiltersApply([], "and"); + // 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("=== 필터 완전 초기화 완료 ==="); } /***************************************************************************************** @@ -306,7 +393,6 @@ export function PeriodicEvaluationFilterSheet({ )} /> - {/* 구분 */} { e.stopPropagation(); - form.setValue("materialType", ""); + form.setValue("materialType", ""); }} disabled={isPending} > @@ -798,7 +884,7 @@ export function PeriodicEvaluationFilterSheet({ type="button" variant="outline" onClick={handleReset} - disabled={isPending } + disabled={isPending} className="px-4" > 초기화 @@ -806,7 +892,7 @@ export function PeriodicEvaluationFilterSheet({ + + + + + + + 검색 결과가 없습니다. + + {vendors.map((vendor) => ( + { + field.onChange(vendor.id) + setVendorOpen(false) + }} + > + +
+ {vendor.vendorCode} + {vendor.vendorName} +
+
+ ))} +
+
+
+
+ + + + )} + /> + + + + {/* 담당자 및 일정 정보 */} + + + + + 담당자 및 일정 + + + + {/* 검토요청자 */} + ( + + + + 검토요청자 + + + + + + + )} + /> + +
+ {/* 답변요청일 */} + ( + + 답변요청일 + + + + + + )} + /> + + {/* 답변예정일 */} + ( + + 답변예정일 (선택사항) + + + +
+ 답변요청일 기준으로 자동 설정됩니다 +
+ +
+ )} + /> +
+
+
+ + {/* 안내 메시지 */} + + +
+
+
+

+ 법무업무 등록 안내 +

+

+ 기본 정보 등록 후, 목록에서 해당 업무를 선택하여 상세한 검토 요청을 진행할 수 있습니다. +

+

+ • 상태: "검토요청"으로 자동 설정
+ • 의뢰일: 오늘 날짜로 자동 설정
+ • 법무답변자: 나중에 배정 +

+
+
+
+
+ + + + {/* 고정 버튼 영역 */} +
+
+ + +
+
+ + + + + ) +} \ 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 { + legalWorks: Row["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 ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + {legalWorks.length} + 건의 법무업무가 완전히 삭제됩니다. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + {legalWorks.length} + 건의 법무업무가 완전히 삭제됩니다. + + + + + + + + + + + ) +} \ 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 ( +
+ + + 등록된 법무업무가 없습니다. + + +
+ ); + } + + return ( +
+ + + 총 건수 + 전체 + + +
{stats.total.toLocaleString()}
+
+ 긴급 {stats.urgent}건 +
+
+
+ + + + 검토요청 + 대기 + + +
{stats.pending.toLocaleString()}
+
+ {stats.total ? Math.round((stats.pending / stats.total) * 100) : 0}% of total +
+
+
+ + + + 담당자배정 + 진행 + + +
{stats.assigned.toLocaleString()}
+
+ {stats.total ? Math.round((stats.assigned / stats.total) * 100) : 0}% of total +
+
+
+ + + + 검토중 + 진행 + + +
{stats.inProgress.toLocaleString()}
+
+ {stats.total ? Math.round((stats.inProgress / stats.total) * 100) : 0}% of total +
+
+
+ + + + 답변완료 + 완료 + + +
{stats.completed.toLocaleString()}
+
+ {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total +
+
+
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* LegalWorksTable */ +/* -------------------------------------------------------------------------- */ +interface LegalWorksTableProps { + promises: Promise<[Awaited>]>; + currentYear?: number; // ✅ EvaluationTargetsTable의 evaluationYear와 동일한 역할 + className?: string; +} + +export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) { + const [rowAction, setRowAction] = React.useState | null>(null); + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); + const searchParams = useSearchParams(); + + // ✅ EvaluationTargetsTable과 정확히 동일한 외부 필터 상태 + const [externalFilters, setExternalFilters] = React.useState([]); + 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(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 = (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( + "legal-works-table", + initialSettings + ); + + /* --------------------- 컬럼 ------------------------------ */ + const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]); + + /* 기본 필터 */ + const filterFields: DataTableFilterField[] = [ + { id: "vendorCode", label: "벤더 코드" }, + { id: "vendorName", label: "벤더명" }, + { id: "status", label: "상태" }, + ]; + + /* 고급 필터 */ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + 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 */} +
+ setIsFilterPanelOpen(false)} + onFiltersApply={handleFiltersApply} + isLoading={false} + /> +
+ + {/* Main Container */} +
+
+
+ {/* Header */} +
+ +
+ 총 {tableData.total || tableData.data.length}건 +
+
+ + {/* Stats */} +
+ +
+ + {/* Table */} +
+ {isDataLoading && ( +
+
+
+ 필터링 중... +
+
+ )} + + {/* ✅ EvaluationTargetsTable과 정확히 동일한 DataTableAdvancedToolbar */} + { + console.log("=== 필터 변경 감지 ===", filters, joinOperator); + }} + > +
+ + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + +
+
+
+ + {/* 편집 다이얼로그 */} + setRowAction(null)} + work={rowAction?.row.original ?? null} + onSuccess={() => { + rowAction?.row.toggleSelected(false); + refreshData(); + }} + /> + + !open && setRowAction(null)} + work={rowAction?.row.original || null} + /> + + !open && setRowAction(null)} + legalWorks={rowAction?.row.original ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null); + refreshData(); + }} + /> +
+
+
+
+ + ); +} \ 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 ( +
+ + + 등록된 법무업무가 없습니다. + + +
+ ); + } + + return ( +
+ + + 총 건수 + 전체 + + +
{stats.total.toLocaleString()}
+
+ 긴급 {stats.urgent}건 +
+
+
+ + + + 검토요청 + 대기 + + +
{stats.pending.toLocaleString()}
+
+ {stats.total ? Math.round((stats.pending / stats.total) * 100) : 0}% of total +
+
+
+ + + + 담당자배정 + 진행 + + +
{stats.assigned.toLocaleString()}
+
+ {stats.total ? Math.round((stats.assigned / stats.total) * 100) : 0}% of total +
+
+
+ + + + 검토중 + 진행 + + +
{stats.inProgress.toLocaleString()}
+
+ {stats.total ? Math.round((stats.inProgress / stats.total) * 100) : 0}% of total +
+
+
+ + + + 답변완료 + 완료 + + +
{stats.completed.toLocaleString()}
+
+ {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total +
+
+
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* EvaluationTargetsTable */ +/* -------------------------------------------------------------------------- */ +interface LegalWorksTableProps { + promises: Promise<[Awaited>]>; + currentYear: number; + className?: string; +} + +export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) { + const [rowAction, setRowAction] = React.useState | null>(null); + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); + const searchParams = useSearchParams(); + + // ✅ 외부 필터 상태 (폼에서 전달받은 필터) + const [externalFilters, setExternalFilters] = React.useState([]); + 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(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 = (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( + "legal-review-table", + initialSettings + ); + + + + /* --------------------- 컬럼 ------------------------------ */ + const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]); + + /* 기본 필터 */ + const filterFields: DataTableFilterField[] = [ + { id: "vendorCode", label: "벤더 코드" }, + { id: "vendorName", label: "벤더명" }, + { id: "status", label: "상태" }, + ]; + + /* 고급 필터 */ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + ]; + + /* 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 */} +
+ setIsFilterPanelOpen(false)} + onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달 + isLoading={false} + /> +
+ + {/* Main Container */} +
+
+
+ {/* Header */} +
+ +
+ 총 {tableData.total || tableData.data.length}건 +
+
+ + {/* Stats */} +
+ + +
+ + {/* Table */} +
+ {isDataLoading && ( +
+
+
+ 필터링 중... +
+
+ )} + + {/* ✅ 확장된 DataTableAdvancedToolbar 사용 */} + { + console.log("=== 필터 변경 감지 ===", filters, joinOperator); + }} + > +
+ + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + +
+
+
+ + {/* 다이얼로그들 */} + setRowAction(null)} + work={rowAction?.row.original || null} + onSuccess={() => { + rowAction?.row.toggleSelected(false); + refreshData(); + }} + /> + + !open && setRowAction(null)} + work={rowAction?.row.original || null} + /> + + !open && setRowAction(null)} + legalWorks={rowAction?.row.original ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null); + refreshData(); + }} + /> + +
+
+
+
+ + ); +} \ 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 ( + + + {/* 헤더 */} +
+ + + 법무업무 상세보기 + + + 법무업무 #{work.id}의 상세 정보를 확인합니다. + + +
+ + {/* 본문 */} + +
+ {/* 1. 기본 정보 */} + + + + 기본 정보 + + + +
+
+
+ 업무 ID: + #{work.id} +
+
+ 구분: + + {work.category} + + {work.isUrgent && ( + + 긴급 + + )} +
+
+ 상태: + + {work.status} + +
+
+
+
+ + 벤더: + + {work.vendorCode} - {work.vendorName} + +
+
+ + 의뢰일: + {formatDate(work.consultationDate, "KR")} +
+
+ + 답변요청일: + {formatDate(work.requestDate, "KR")} +
+
+
+
+
+ + {/* 2. 담당자 정보 */} + + + + 담당자 정보 + + + +
+
+ 검토요청자 +

{work.reviewer || "미지정"}

+
+
+ 법무답변자 +

{work.legalResponder || "미배정"}

+
+ {work.expectedAnswerDate && ( +
+ 답변예정일 +

{formatDate(work.expectedAnswerDate, "KR")}

+
+ )} + {work.legalCompletionDate && ( +
+ 법무완료일 +

{formatDate(work.legalCompletionDate, "KR")}

+
+ )} +
+
+
+ + {/* 3. 법무업무 상세 정보 */} + + + + 법무업무 상세 정보 + + + +
+
+ 검토부문 + {work.reviewDepartment} +
+ {work.inquiryType && ( +
+ 문의종류 + {work.inquiryType} +
+ )} + {isCompliance && ( +
+ 공개여부 + + {work.isPublic ? "공개" : "비공개"} + +
+ )} +
+ + {/* 법무검토 전용 필드 */} + {isLegalReview && ( + <> + {work.contractProjectName && ( + <> + +
+ + 계약명 / 프로젝트명 + +

{work.contractProjectName}

+
+ + )} + + {/* 계약서 종류 */} + {isContractTypeActive && work.contractType && ( +
+ 계약서 종류 + + {work.contractType} + +
+ )} + + {/* 국내계약 전용 필드 */} + {isDomesticContractFieldsActive && ( +
+ {work.contractCounterparty && ( +
+ + 계약상대방 + +

{work.contractCounterparty}

+
+ )} + {work.counterpartyType && ( +
+ + 계약상대방 구분 + +

{work.counterpartyType}

+
+ )} + {work.contractPeriod && ( +
+ 계약기간 +

{work.contractPeriod}

+
+ )} + {work.contractAmount && ( +
+ 계약금액 +

{work.contractAmount}

+
+ )} +
+ )} + + {/* 사실관계 */} + {isFactualRelationActive && work.factualRelation && ( +
+ 사실관계 +

{work.factualRelation}

+
+ )} + + {/* 해외 전용 필드 */} + {isOverseasFieldsActive && ( +
+ {work.projectNumber && ( +
+ 프로젝트번호 +

{work.projectNumber}

+
+ )} + {work.shipownerOrderer && ( +
+ 선주 / 발주처 +

{work.shipownerOrderer}

+
+ )} + {work.projectType && ( +
+ 프로젝트종류 +

{work.projectType}

+
+ )} + {work.governingLaw && ( +
+ 준거법 +

{work.governingLaw}

+
+ )} +
+ )} + + )} +
+
+ + {/* 4. 요청 내용 */} + + + + 요청 내용 + + + + {work.title && ( +
+ 제목 +

{work.title}

+
+ )} + +
+ 상세 내용 +
+ {work.requestContent ? ( +
+
+
+ ) : ( +

요청 내용이 없습니다.

+ )} +
+
+ {work.attachmentCount > 0 && ( +
+ 첨부파일 {work.attachmentCount}개 +
+ )} + + + + {/* 5. 답변 내용 */} + + + + 답변 내용 + + + +
+ {work.responseContent ? ( +
+
+
+ ) : ( +

+ 아직 답변이 등록되지 않았습니다. +

+ )} +
+ + +
+ + +
+ ); +} 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 + +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({ + 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 ( +
+ {/* Filter Panel Header */} +
+

법무업무 검색 필터

+ +
+ + {/* Join Operator Selection */} +
+ + +
+ +
+ + {/* Scrollable content area */} +
+
+ + {/* 구분 */} + ( + + 구분 + + + + )} + /> + + {/* 상태 */} + ( + + 상태 + + + + )} + /> + + {/* 긴급여부 */} + ( + + 긴급여부 + + + + )} + /> + + {/* 검토부문 */} + ( + + 검토부문 + + + + )} + /> + + {/* 문의종류 */} + ( + + 문의종류 + + + + )} + /> + + {/* 요청자 */} + ( + + 요청자 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 법무답변자 */} + ( + + 법무답변자 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 벤더 코드 */} + ( + + 벤더 코드 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 벤더명 */} + ( + + 벤더명 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 검토 요청일 범위 */} +
+ + + {/* 시작일 */} + ( + + 시작일 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 종료일 */} + ( + + 종료일 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> +
+ + {/* 의뢰일 범위 */} +
+ + + {/* 시작일 */} + ( + + 시작일 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 종료일 */} + ( + + 종료일 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> +
+ +
+
+ + {/* Fixed buttons at bottom */} +
+
+ + +
+
+
+ +
+ ) +} \ 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 | null> + >; +} + +// ──────────────────────────────────────────────────────────────────────────── +// 헬퍼 +// ──────────────────────────────────────────────────────────────────────────── +const statusVariant = (status: string) => { + const map: Record = { + 검토요청: "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) => ( + + {category} + +); + +const urgentBadge = (isUrgent: boolean) => + isUrgent ? ( + + 긴급 + + ) : null; + +const header = (title: string) => + ({ column }: { column: any }) => + ; + +// ──────────────────────────────────────────────────────────────────────────── +// 기본 컬럼 +// ──────────────────────────────────────────────────────────────────────────── +const BASE_COLUMNS: ColumnDef[] = [ + // 선택 체크박스 + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + 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 }) => ( +
{row.getValue("id")}
+ ), + size: 80, + }, + { + accessorKey: "category", + header: header("구분"), + cell: ({ row }) => categoryBadge(row.getValue("category")), + size: 80, + }, + { + accessorKey: "status", + header: header("상태"), + cell: ({ row }) => ( + + {row.getValue("status")} + + ), + size: 120, + }, + + // 벤더 코드·이름 + { + accessorKey: "vendorCode", + header: header("벤더 코드"), + cell: ({ row }) => {row.getValue("vendorCode")}, + size: 120, + }, + { + accessorKey: "vendorName", + header: header("벤더명"), + cell: ({ row }) => { + const name = row.getValue("vendorName"); + return ( +
+ {urgentBadge(row.original.isUrgent)} + {name} +
+ ); + }, + size: 200, + }, + + // 날짜·첨부 + { + accessorKey: "requestDate", + header: header("답변요청일"), + cell: ({ row }) => ( + {formatDate(row.getValue("requestDate"), "KR")} + ), + size: 100, + }, + { + accessorKey: "hasAttachment", + header: header("첨부"), + cell: ({ row }) => + row.getValue("hasAttachment") ? ( + + ) : ( + - + ), + size: 60, + enableSorting: false, + }, +]; + +// ──────────────────────────────────────────────────────────────────────────── +// 액션 컬럼 +// ──────────────────────────────────────────────────────────────────────────── +const createActionsColumn = ( + setRowAction: React.Dispatch< + React.SetStateAction | null> + > +): ColumnDef => ({ + id: "actions", + enableHiding: false, + size: 40, + minSize: 40, + cell: ({ row }) => ( + + + + + + + setRowAction({ row, type: "view" })}> + 상세보기 + + {row.original.status === "신규등록" && ( + <> + setRowAction({ row, type: "update" })}> + 편집 + + + setRowAction({ row, type: "delete" })}> + 삭제하기 + + + )} + + + ), +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 메인 함수 +// ──────────────────────────────────────────────────────────────────────────── +export function getLegalWorksColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef[] { + 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 + 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 ( + <> +
+ + {hasDeletableRows&&( + row.original)} + showTrigger={hasDeletableRows} + onSuccess={() => { + table.toggleAllRowsSelected(false) + // onRefresh?.() + }} + /> + )} + {/* 신규 생성 버튼 */} + + + {/* 유틸리티 버튼들 */} +
+ + + +
+ + {/* 선택된 항목 액션 버튼들 */} + {hasSelection && ( +
+ {/* 다중 선택 경고 메시지 */} + {isMultipleSelection && ( +
+ 검토요청은 한 건씩만 가능합니다 +
+ )} + + {/* 검토 요청 버튼 (단일 선택시만) */} + {isSingleSelection && ( + + )} + + {/* 추가 액션 드롭다운 */} + + + + + + toast.info("담당자 배정 기능을 준비 중입니다.")} + disabled={!isSingleSelection || !canAssign} + > + + 담당자 배정 + + + toast.info("상태 변경 기능을 준비 중입니다.")} + disabled={!isSingleSelection} + > + + 상태 변경 + + + +
+ )} + + {/* 선택된 항목 정보 표시 */} + {hasSelection && ( +
+
+ {isSingleSelection ? ( + <> + 선택: #{selectedWork?.id} ({selectedWork?.category}) + {selectedWork?.vendorName && ` | ${selectedWork.vendorName}`} + {selectedWork?.status && ` | ${selectedWork.status}`} + + ) : ( + `선택: ${selectedRows.length}건 (개별 처리 필요)` + )} +
+
+ )} +
+ + {/* 다이얼로그들 */} + {/* 신규 생성 다이얼로그 */} + + + {/* 검토 요청 다이얼로그 - 단일 work 전달 */} + {selectedWork && ( + + )} + + ) +} \ 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 + +export function RequestReviewDialog({ + open, + onOpenChange, + work, + onSuccess +}: RequestReviewDialogProps) { + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [attachments, setAttachments] = React.useState([]) + 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({ + 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) => { + 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 ( + + + + + + 검토요청 불가 + + + {requestCheckMessage} + + +
+ +
+
+
+ ) + } + + return ( + + + {/* 고정 헤더 */} +
+ + + + 검토요청 발송 + + + 법무업무 #{work.id}에 대한 상세한 검토를 요청합니다. + + +
+ +
+ + {/* 스크롤 가능한 콘텐츠 영역 */} +
+
+ {/* 선택된 업무 정보 */} + + + + + 검토 대상 업무 + + + +
+
+
+ 업무 ID: + #{work.id} +
+
+ 구분: + + {work.category} + + {work.isUrgent && ( + + 긴급 + + )} +
+
+ + 벤더: + {work.vendorCode} - {work.vendorName} +
+
+
+
+ + 요청자: + {work.reviewer || "미지정"} +
+
+ + 답변요청일: + {work.requestDate || "미설정"} +
+
+ 상태: + {work.status} +
+
+
+
+
+ + {/* 기본 설정 */} + + + 기본 설정 + + + {/* 검토 완료 희망일 */} + ( + + + + 검토 완료 희망일 + + + + + + + )} + /> + + + + {/* 법무업무 상세 정보 */} + + + 법무업무 상세 정보 + + + {/* 검토부문 */} + ( + + 검토부문 + + + + )} + /> + + {/* 문의종류 (법무검토 선택시만) */} + {reviewDepartment === "법무검토" && ( + ( + + 문의종류 + + + + )} + /> + )} + + {/* 제목 - 조건부 렌더링 */} + ( + + 제목 + {!isTitleOther ? ( + // Select 모드 + + ) : ( + // Input 모드 (기타 선택시) +
+
+ 기타 + +
+ + field.onChange(e.target.value)} + autoFocus + /> + +
+ )} + +
+ )} + /> + + {/* 준법문의 전용 필드들 */} + {reviewDepartment === "준법문의" && ( + ( + +
+ 공개여부 +
+ 준법문의 공개 설정 +
+
+ + + +
+ )} + /> + )} + + {/* 법무검토 전용 필드들 */} + {reviewDepartment === "법무검토" && ( +
+ {/* 계약명/프로젝트명 */} + ( + + 계약명/프로젝트명 + + + + + + )} + /> + + {/* 계약서 종류 - 조건부 활성화 */} + {isContractTypeActive && ( + ( + + 계약서 종류 + + + + )} + /> + )} + + {/* 국내계약 전용 필드들 */} + {isDomesticContractFieldsActive && ( +
+ {/* 계약상대방 */} + ( + + 계약상대방 + + + + + + )} + /> + + {/* 계약상대방 구분 */} + ( + + 계약상대방 구분 + + + + )} + /> + + {/* 계약기간 */} + ( + + 계약기간 + + + + + + )} + /> + + {/* 계약금액 */} + ( + + 계약금액 + + + + + + )} + /> +
+ )} + + {/* 사실관계 - 조건부 활성화 */} + {isFactualRelationActive && ( + ( + + 사실관계 + +