diff options
Diffstat (limited to 'lib')
26 files changed, 6909 insertions, 470 deletions
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<PeriodicEvaluationFilterFormValues>({ 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({ )} /> - {/* 구분 */} <FormField control={form.control} @@ -472,7 +558,7 @@ export function PeriodicEvaluationFilterSheet({ className="-mr-2 h-4 w-4" onClick={(e) => { 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({ <Button type="submit" variant="samsung" - disabled={isPending || isLoading } + disabled={isPending || isLoading} className="px-4" > <Search className="mr-2 size-4" /> @@ -818,4 +904,4 @@ export function PeriodicEvaluationFilterSheet({ </Form> </div> ); -} +}
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx index 257225c8..4404967a 100644 --- a/lib/evaluation/table/evaluation-table.tsx +++ b/lib/evaluation/table/evaluation-table.tsx @@ -28,7 +28,7 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { cn } from "@/lib/utils" import { useTablePresets } from "@/components/data-table/use-table-presets" import { TablePresetManager } from "@/components/data-table/data-table-preset" -import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet" +import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet" // ✅ 올바른 컴포넌트 이름 import { getPeriodicEvaluationsColumns } from "./evaluation-columns" import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema" import { @@ -300,9 +300,20 @@ export function PeriodicEvaluationsTable({ const [detailedCount, setDetailedCount] = React.useState<number | undefined>(undefined) const [aggregatedCount, setAggregatedCount] = React.useState<number | undefined>(undefined) + // ✅ 외부 필터 상태 (폼에서 전달받은 필터) - 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); + }, []); + // ✅ 뷰 모드 변경 시 URL 업데이트 const handleViewModeChange = React.useCallback((newMode: "detailed" | "aggregated") => { setViewMode(newMode); @@ -321,14 +332,116 @@ export function PeriodicEvaluationsTable({ router.push(`?${newSearchParams.toString()}`, { scroll: false }) }, [router, searchParams]) - 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 패턴과 동일 + const [initialPromiseData] = React.use(promises) + const [tableData, setTableData] = React.useState(initialPromiseData) + const [isDataLoading, setIsDataLoading] = React.useState(false) + + // ✅ URL 필터 변경 감지 및 데이터 새로고침 - EvaluationTargetsTable 패턴과 동일 + 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 currentAggregated = getSearchParam("aggregated") === "true" + + const searchParams = { + filters: currentFilters ? JSON.parse(currentFilters) : [], + joinOperator: currentJoinOperator as "and" | "or", + page: currentPage, + perPage: currentPerPage, + sort: currentSort, + search: currentSearch, + evaluationYear: evaluationYear, + aggregated: currentAggregated + } + + console.log("=== 새 데이터 요청 ===", searchParams) + + // 서버 액션 직접 호출 + const newData = await getPeriodicEvaluationsWithAggregation(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") || + getSearchParam("aggregated") + + if (hasChanges) { + refetchData() + } + }, 300) // 디바운스 시간 단축 + + return () => clearTimeout(timeoutId) + }, [searchString, evaluationYear, 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 currentAggregated = getSearchParam("aggregated") === "true" + + const searchParams = { + filters: currentFilters ? JSON.parse(currentFilters) : [], + joinOperator: currentJoinOperator as "and" | "or", + page: currentPage, + perPage: currentPerPage, + sort: currentSort, + search: currentSearch, + evaluationYear: evaluationYear, + aggregated: currentAggregated + } + + const newData = await getPeriodicEvaluationsWithAggregation(searchParams) + setTableData(newData) + + console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건") + } catch (error) { + console.error("데이터 새로고침 오류:", error) + } finally { + setIsDataLoading(false) + } + }, [evaluationYear, getSearchParam]) + + // 컨테이너 위치 추적 - EvaluationTargetsTable 패턴과 동일 const containerRef = React.useRef<HTMLDivElement>(null) const [containerTop, setContainerTop] = React.useState(0) @@ -347,42 +460,47 @@ export function PeriodicEvaluationsTable({ React.useEffect(() => { updateContainerBounds() - const throttledHandler = () => { - let timeoutId: NodeJS.Timeout - return () => { - clearTimeout(timeoutId) - timeoutId = setTimeout(updateContainerBounds, 16) - } + + const handleResize = () => { + updateContainerBounds() } - - const handler = throttledHandler() - window.addEventListener('resize', updateContainerBounds) - window.addEventListener('scroll', handler) - + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', updateContainerBounds) + return () => { - window.removeEventListener('resize', updateContainerBounds) - window.removeEventListener('scroll', handler) + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', updateContainerBounds) } }, [updateContainerBounds]) - // 데이터 로드 - const [promiseData] = React.use(promises) - const tableData = promiseData + 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: currentParams.page || 1, - perPage: currentParams.perPage || 10, - sort: currentParams.sort || [{ id: "createdAt", desc: true }], - filters: currentParams.filters || [], - joinOperator: currentParams.joinOperator || "and", - search: "", + 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: [] - }), [currentParams]) + }), [getSearchParam, parseSearchParam]) const { presets, @@ -469,11 +587,17 @@ export function PeriodicEvaluationsTable({ const getActiveFilterCount = React.useCallback(() => { try { - return currentParams.filters?.length || 0; + // URL에서 현재 필터 수 확인 + const filtersParam = getSearchParam("filters") + if (filtersParam) { + const filters = JSON.parse(filtersParam) + return Array.isArray(filters) ? filters.length : 0 + } + return 0 } catch { - return 0; + return 0 } - }, [currentParams.filters]); + }, [getSearchParam]) const FILTER_PANEL_WIDTH = 400; @@ -491,14 +615,13 @@ export function PeriodicEvaluationsTable({ height: `calc(100vh - ${containerTop}px)` }} > - <div className="h-full"> - <PeriodicEvaluationFilterSheet - isOpen={isFilterPanelOpen} - onClose={() => setIsFilterPanelOpen(false)} - onFiltersApply={handleFiltersApply} - isLoading={false} - /> - </div> + {/* ✅ 올바른 컴포넌트 사용 */} + <PeriodicEvaluationFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달 + isLoading={false} + /> </div> {/* Main Content Container */} @@ -567,9 +690,18 @@ export function PeriodicEvaluationsTable({ </div> {/* Table Content Area */} - <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 500px)' }}> + <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> + )} <div className="h-full w-full"> <DataTable table={table} className="h-full"> + {/* ✅ EvaluationTargetsTable 패턴과 동일하게 수정 */} <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} @@ -596,7 +728,10 @@ export function PeriodicEvaluationsTable({ onRenamePreset={renamePreset} /> - <PeriodicEvaluationsTableToolbarActions table={table} /> + <PeriodicEvaluationsTableToolbarActions + table={table} + onRefresh={refreshData} + /> </div> </DataTableAdvancedToolbar> </DataTable> diff --git a/lib/forms/services.ts b/lib/forms/services.ts index 269fb4c6..cff23806 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -20,7 +20,7 @@ import { vendorDataReportTemps, VendorDataReportTemps, } from "@/db/schema/vendorData"; -import { eq, and, desc, sql, DrizzleError, inArray, or,type SQL ,type InferSelectModel } from "drizzle-orm"; +import { eq, and, desc, sql, DrizzleError, inArray, or, type SQL, type InferSelectModel } from "drizzle-orm"; import { unstable_cache } from "next/cache"; import { revalidateTag } from "next/cache"; import { getErrorMessage } from "../handle-error"; @@ -53,41 +53,41 @@ export async function getFormsByContractItemId( // `[Forms Service] Fetching forms for contractItemId: ${contractItemId}, mode: ${mode}` // ); - try { - // 쿼리 생성 - let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId)); - - // 모드에 따른 추가 필터 - if (mode === "ENG") { - query = db.select().from(forms).where( - and( - eq(forms.contractItemId, contractItemId), - eq(forms.eng, true) - ) - ); - } else if (mode === "IM") { - query = db.select().from(forms).where( - and( - eq(forms.contractItemId, contractItemId), - eq(forms.im, true) - ) - ); - } - - // 쿼리 실행 - const formRecords = await query; + try { + // 쿼리 생성 + let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId)); - console.log( - `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}, mode: ${mode}` - ); + // 모드에 따른 추가 필터 + if (mode === "ENG") { + query = db.select().from(forms).where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.eng, true) + ) + ); + } else if (mode === "IM") { + query = db.select().from(forms).where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.im, true) + ) + ); + } - return { forms: formRecords }; - } catch (error) { - getErrorMessage( - `Database error for contractItemId ${contractItemId}, mode: ${mode}: ${error}` - ); - throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함 - } + // 쿼리 실행 + const formRecords = await query; + + console.log( + `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}, mode: ${mode}` + ); + + return { forms: formRecords }; + } catch (error) { + getErrorMessage( + `Database error for contractItemId ${contractItemId}, mode: ${mode}: ${error}` + ); + throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함 + } // }, // [cacheKey], // { @@ -109,7 +109,7 @@ export async function getFormsByContractItemId( // 쿼리 생성 let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId)); - + // 모드에 따른 추가 필터 if (mode === "ENG") { query = db.select().from(forms).where( @@ -126,7 +126,7 @@ export async function getFormsByContractItemId( ) ); } - + // 쿼리 실행 const formRecords = await query; @@ -164,7 +164,7 @@ export interface EditableFieldsInfo { // TAG별 편집 가능 필드 조회 함수 async function getEditableFieldsByTag( - contractItemId: number, + contractItemId: number, projectId: number ): Promise<Map<string, string[]>> { try { @@ -232,104 +232,104 @@ export async function getFormData(formCode: string, contractItemId: number) { try { - // 기존 로직으로 projectId, columns, data 가져오기 - const contractItemResult = await db - .select({ - projectId: projects.id - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .innerJoin(projects, eq(contracts.projectId, projects.id)) - .where(eq(contractItems.id, contractItemId)) - .limit(1); - - if (contractItemResult.length === 0) { - console.warn(`[getFormData] No contract item found with ID: ${contractItemId}`); - return { columns: null, data: [], editableFieldsMap: new Map() }; - } + // 기존 로직으로 projectId, columns, data 가져오기 + const contractItemResult = await db + .select({ + projectId: projects.id + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .where(eq(contractItems.id, contractItemId)) + .limit(1); - const projectId = contractItemResult[0].projectId; - - const metaRows = await db - .select() - .from(formMetas) - .where( - and( - eq(formMetas.formCode, formCode), - eq(formMetas.projectId, projectId) - ) - ) - .orderBy(desc(formMetas.updatedAt)) - .limit(1); + if (contractItemResult.length === 0) { + console.warn(`[getFormData] No contract item found with ID: ${contractItemId}`); + return { columns: null, data: [], editableFieldsMap: new Map() }; + } - const meta = metaRows[0] ?? null; - if (!meta) { - console.warn(`[getFormData] No form meta found for formCode: ${formCode} and projectId: ${projectId}`); - return { columns: null, data: [], editableFieldsMap: new Map() }; - } - - const entryRows = await db - .select() - .from(formEntries) - .where( - and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, contractItemId) - ) - ) - .orderBy(desc(formEntries.updatedAt)) - .limit(1); + const projectId = contractItemResult[0].projectId; - const entry = entryRows[0] ?? null; + const metaRows = await db + .select() + .from(formMetas) + .where( + and( + eq(formMetas.formCode, formCode), + eq(formMetas.projectId, projectId) + ) + ) + .orderBy(desc(formMetas.updatedAt)) + .limit(1); - let columns = meta.columns as DataTableColumnJSON[]; - const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; - columns = columns.filter(col => !excludeKeys.includes(col.key)); + const meta = metaRows[0] ?? null; + if (!meta) { + console.warn(`[getFormData] No form meta found for formCode: ${formCode} and projectId: ${projectId}`); + return { columns: null, data: [], editableFieldsMap: new Map() }; + } - + const entryRows = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .orderBy(desc(formEntries.updatedAt)) + .limit(1); - columns.forEach((col) => { - if (!col.displayLabel) { - if (col.uom) { - col.displayLabel = `${col.label} (${col.uom})`; - } else { - col.displayLabel = col.label; - } - } - }); + const entry = entryRows[0] ?? null; - columns.push({ - key:"status", - label:"status", - displayLabel:"Status", - type:"STRING" - }) + let columns = meta.columns as DataTableColumnJSON[]; + const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; + columns = columns.filter(col => !excludeKeys.includes(col.key)); - let data: Array<Record<string, any>> = []; - if (entry) { - if (Array.isArray(entry.data)) { - data = entry.data; - data.sort((a,b) => { - const statusA = a.status || ''; - const statusB = b.status || ''; - return statusB.localeCompare(statusA) - }) - } else { - console.warn("formEntries data was not an array. Using empty array."); - } + columns.forEach((col) => { + if (!col.displayLabel) { + if (col.uom) { + col.displayLabel = `${col.label} (${col.uom})`; + } else { + col.displayLabel = col.label; } + } + }); - // *** 새로 추가: 편집 가능 필드 정보 계산 *** - const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId); + columns.push({ + key: "status", + label: "status", + displayLabel: "Status", + type: "STRING" + }) + + let data: Array<Record<string, any>> = []; + if (entry) { + if (Array.isArray(entry.data)) { + data = entry.data; + + data.sort((a, b) => { + const statusA = a.status || ''; + const statusB = b.status || ''; + return statusB.localeCompare(statusA) + }) + + } else { + console.warn("formEntries data was not an array. Using empty array."); + } + } + + // *** 새로 추가: 편집 가능 필드 정보 계산 *** + const editableFieldsMap = await getEditableFieldsByTag(contractItemId, projectId); + + return { columns, data, editableFieldsMap }; - return { columns, data, editableFieldsMap }; - } catch (cacheError) { console.error(`[getFormData] Cache operation failed:`, cacheError); - + // Fallback logic (기존과 동일하게 editableFieldsMap 추가) try { console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`); @@ -384,7 +384,7 @@ export async function getFormData(formCode: string, contractItemId: number) { const entry = entryRows[0] ?? null; let columns = meta.columns as DataTableColumnJSON[]; - const excludeKeys = [ 'BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; + const excludeKeys = ['BF_TAG_NO', 'TAG_TYPE_ID', 'PIC_NO']; columns = columns.filter(col => !excludeKeys.includes(col.key)); columns.forEach((col) => { @@ -426,7 +426,7 @@ export async function getFormData(formCode: string, contractItemId: number) { export async function findContractItemId(contractId: number, formCode: string): Promise<number | null> { try { console.log(`[findContractItemId] 계약 ID ${contractId}와 formCode ${formCode}에 대한 contractItem 조회 시작`); - + // 1. forms 테이블에서 formCode에 해당하는 모든 레코드 조회 const formsResult = await db .select({ @@ -434,16 +434,16 @@ export async function findContractItemId(contractId: number, formCode: string): }) .from(forms) .where(eq(forms.formCode, formCode)); - + if (formsResult.length === 0) { console.warn(`[findContractItemId] formCode ${formCode}에 해당하는 form을 찾을 수 없습니다.`); return null; } - + // 모든 contractItemId 추출 const contractItemIds = formsResult.map(form => form.contractItemId); console.log(`[findContractItemId] formCode ${formCode}에 해당하는 ${contractItemIds.length}개의 contractItemId 발견`); - + // 2. contractItems 테이블에서 추출한 contractItemId 중에서 // contractId가 일치하는 항목 찾기 const contractItemResult = await db @@ -458,15 +458,15 @@ export async function findContractItemId(contractId: number, formCode: string): ) ) .limit(1); - + if (contractItemResult.length === 0) { console.warn(`[findContractItemId] 계약 ID ${contractId}와 일치하는 contractItemId를 찾을 수 없습니다.`); return null; } - + const contractItemId = contractItemResult[0].id; console.log(`[findContractItemId] 계약 아이템 ID ${contractItemId} 발견`); - + return contractItemId; } catch (error) { console.error(`[findContractItemId] contractItem 조회 중 오류 발생:`, error); @@ -788,7 +788,7 @@ export async function fetchFormMetadata( const rows = await db .select() .from(formMetas) - .where(and(eq(formMetas.formCode, formCode),eq(formMetas.projectId, projectId))) + .where(and(eq(formMetas.formCode, formCode), eq(formMetas.projectId, projectId))) .limit(1); // rows는 배열 @@ -883,8 +883,8 @@ export async function uploadReportTemp( ); } if (file && file.size > 0) { - - const saveResult = await saveFile({file, directory:"vendorFormData",originalName:customFileName}); + + const saveResult = await saveFile({ file, directory: "vendorFormData", originalName: customFileName }); if (!saveResult.success) { return { success: false, error: saveResult.error }; } @@ -897,7 +897,7 @@ export async function uploadReportTemp( contractItemId: packageId, formId: formId, fileName: customFileName, - filePath:saveResult.publicPath!, + filePath: saveResult.publicPath!, }) .returning(); }); @@ -962,7 +962,7 @@ export async function getFormTagTypeMappings(formCode: string, projectId: number eq(tagTypeClassFormMappings.projectId, projectId) ) }); - + return mappings; } catch (error) { console.error("Error fetching form tag type mappings:", error); @@ -984,7 +984,7 @@ export async function getTagTypeByDescription(description: string, projectId: nu eq(tagTypes.projectId, projectId) ) }); - + return tagType; } catch (error) { console.error("Error fetching tag type by description:", error); @@ -1007,7 +1007,7 @@ export async function getSubfieldsByTagTypeForForm(tagTypeCode: string, projectI ), orderBy: tagSubfields.sortOrder }); - + const subfieldsWithOptions = await Promise.all( subfields.map(async (subfield) => { const options = await db.query.tagSubfieldOptions.findMany({ @@ -1016,7 +1016,7 @@ export async function getSubfieldsByTagTypeForForm(tagTypeCode: string, projectI eq(tagSubfieldOptions.projectId, projectId) ) }); - + return { name: subfield.attributesId, label: subfield.attributesDescription, @@ -1027,7 +1027,7 @@ export async function getSubfieldsByTagTypeForForm(tagTypeCode: string, projectI }; }) ); - + return { subFields: subfieldsWithOptions }; } catch (error) { console.error("Error fetching subfields for form:", error); @@ -1043,13 +1043,13 @@ interface SEDPAttribute { NAME: string; VALUE: any; UOM: string; - UOM_ID?: string; + UOM_ID?: string; } interface SEDPDataItem { TAG_NO: string; TAG_DESC: string; - CLS_ID:string; + CLS_ID: string; ATTRIBUTES: SEDPAttribute[]; SCOPE: string; TOOLID: string; @@ -1081,37 +1081,37 @@ async function transformDataToSEDPFormat( columnsJSON.forEach(col => { columnsMap.set(col.key, col); }); - + // Current timestamp for CRTE_DTM and CHGE_DTM const currentTimestamp = new Date().toISOString(); - + // Define the API base URL const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; - + // Get the token const apiKey = await getSEDPToken(); - + // Cache for UOM factors to avoid duplicate API calls const uomFactorCache = new Map<string, number>(); - + // Cache for packageCode to avoid duplicate DB queries for same tag const packageCodeCache = new Map<string, string>(); - + // Cache for tagClass code to avoid duplicate DB queries for same tag const tagClassCodeCache = new Map<string, string>(); - + // Transform each row const transformedItems = []; - + for (const row of tableData) { // Get packageCode for this specific tag let packageCode = formCode; // fallback to formCode let tagClassCode = ""; // for CLS_ID - + if (row.TAG_NO && contractItemId) { // Check cache first const cacheKey = `${contractItemId}-${row.TAG_NO}`; - + if (packageCodeCache.has(cacheKey)) { packageCode = packageCodeCache.get(cacheKey)!; } else { @@ -1123,7 +1123,7 @@ async function transformDataToSEDPFormat( eq(tags.tagNo, row.TAG_NO) ) }); - + if (tagResult) { // Get tagClass code if tagClassId exists if (tagResult.tagClassId) { @@ -1134,30 +1134,30 @@ async function transformDataToSEDPFormat( const tagClassResult = await db.query.tagClasses.findFirst({ where: eq(tagClasses.id, tagResult.tagClassId) }); - + if (tagClassResult) { tagClassCode = tagClassResult.code; console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`); } else { console.warn(`No tagClass found for tagClassId: ${tagResult.tagClassId}`); } - + // Cache the tagClass code result tagClassCodeCache.set(cacheKey, tagClassCode); } } - + // Get the contract item const contractItemResult = await db.query.contractItems.findFirst({ where: eq(contractItems.id, tagResult.contractItemId) }); - + if (contractItemResult) { // Get the first item with this itemId const itemResult = await db.query.items.findFirst({ where: eq(items.id, contractItemResult.itemId) }); - + if (itemResult && itemResult.packageCode) { packageCode = itemResult.packageCode; console.log(`Found packageCode for tag ${row.TAG_NO}: ${packageCode}`); @@ -1170,7 +1170,7 @@ async function transformDataToSEDPFormat( } else { console.warn(`No tag found for contractItemId: ${contractItemId}, tagNo: ${row.TAG_NO}, using fallback`); } - + // Cache the result (even if it's the fallback value) packageCodeCache.set(cacheKey, packageCode); } catch (error) { @@ -1179,7 +1179,7 @@ async function transformDataToSEDPFormat( packageCodeCache.set(cacheKey, packageCode); } } - + // Get tagClass code if not already retrieved above if (!tagClassCode && tagClassCodeCache.has(cacheKey)) { tagClassCode = tagClassCodeCache.get(cacheKey)!; @@ -1191,18 +1191,18 @@ async function transformDataToSEDPFormat( eq(tags.tagNo, row.TAG_NO) ) }); - + if (tagResult && tagResult.tagClassId) { const tagClassResult = await db.query.tagClasses.findFirst({ where: eq(tagClasses.id, tagResult.tagClassId) }); - + if (tagClassResult) { tagClassCode = tagClassResult.code; console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`); } } - + // Cache the tagClass code result tagClassCodeCache.set(cacheKey, tagClassCode); } catch (error) { @@ -1212,7 +1212,7 @@ async function transformDataToSEDPFormat( } } } - + // Create base SEDP item with required fields const sedpItem: SEDPDataItem = { TAG_NO: row.TAG_NO || "", @@ -1235,20 +1235,20 @@ async function transformDataToSEDPFormat( CHGE_DTM: currentTimestamp, _id: "" }; - + // Convert all other fields (except TAG_NO and TAG_DESC) to ATTRIBUTES for (const key in row) { if (key !== "TAG_NO" && key !== "TAG_DESC") { const column = columnsMap.get(key); let value = row[key]; - + // Only process non-empty values if (value !== undefined && value !== null && value !== "") { // Check if we need to apply UOM conversion if (column?.uomId) { // First check cache to avoid duplicate API calls let factor = uomFactorCache.get(column.uomId); - + // If not in cache, make API call to get the factor if (factor === undefined) { try { @@ -1269,7 +1269,7 @@ async function transformDataToSEDPFormat( }) } ); - + if (response.ok) { const uomData = await response.json(); if (uomData && uomData.FACTOR !== undefined && uomData.FACTOR !== null) { @@ -1284,33 +1284,33 @@ async function transformDataToSEDPFormat( console.error(`Error fetching UOM data for ${column.uomId}:`, error); } } - + // Apply the factor if we got one if (factor !== undefined && typeof value === 'number') { value = value * factor; } } - + const attribute: SEDPAttribute = { NAME: key, VALUE: String(value), // 모든 값을 문자열로 변환 UOM: column?.uom || "", - CLS_ID:tagClassCode || "", + CLS_ID: tagClassCode || "", }; - + // Add UOM_ID if present in column definition if (column?.uomId) { attribute.UOM_ID = column.uomId; } - + sedpItem.ATTRIBUTES.push(attribute); } } } - + transformedItems.push(sedpItem); } - + return transformedItems; } @@ -1343,11 +1343,11 @@ export async function getProjectCodeById(projectId: number): Promise<string> { .from(projects) .where(eq(projects.id, projectId)) .limit(1); - + if (!projectRecord || projectRecord.length === 0) { throw new Error(`Project not found with ID: ${projectId}`); } - + return projectRecord[0].code; } @@ -1361,12 +1361,12 @@ export async function sendDataToSEDP( try { // Get the token const apiKey = await getSEDPToken(); - + // Define the API base URL const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; - + console.log("Sending data to SEDP:", JSON.stringify(sedpData, null, 2)); - + // Make the API call const response = await fetch( `${SEDP_API_BASE_URL}/AdapterData/Create`, @@ -1381,12 +1381,12 @@ export async function sendDataToSEDP( body: JSON.stringify(sedpData) } ); - + if (!response.ok) { const errorText = await response.text(); throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); } - + const data = await response.json(); return data; } catch (error: any) { @@ -1408,7 +1408,7 @@ export async function sendFormDataToSEDP( try { // 1. Get project code const projectCode = await getProjectCodeById(projectId); - + // 2. Get class mapping const mappingsResult = await db.query.tagTypeClassFormMappings.findFirst({ where: and( @@ -1416,13 +1416,13 @@ export async function sendFormDataToSEDP( eq(tagTypeClassFormMappings.projectId, projectId) ) }); - + // Check if mappings is an array or a single object and handle accordingly const mappings = Array.isArray(mappingsResult) ? mappingsResult[0] : mappingsResult; - + // Default object code to fallback value if we can't find it let objectCode = ""; // Default fallback - + if (mappings && mappings.classLabel) { const objectCodeResult = await db.query.tagClasses.findFirst({ where: and( @@ -1430,10 +1430,10 @@ export async function sendFormDataToSEDP( eq(tagClasses.projectId, projectId) ) }); - + // Check if result is an array or a single object const objectCodeRecord = Array.isArray(objectCodeResult) ? objectCodeResult[0] : objectCodeResult; - + if (objectCodeRecord && objectCodeRecord.code) { objectCode = objectCodeRecord.code; } else { @@ -1442,7 +1442,7 @@ export async function sendFormDataToSEDP( } else { console.warn(`No mapping found for formCode ${formCode} in project ${projectId}, using default object code`); } - + // 3. Transform data to SEDP format const sedpData = await transformFormDataToSEDP( formData, @@ -1452,10 +1452,10 @@ export async function sendFormDataToSEDP( projectCode, contractItemId // Add contractItemId parameter ); - + // 4. Send to SEDP API const result = await sendDataToSEDP(projectCode, sedpData); - + // 5. SEDP 전송 성공 후 formEntries에 status 업데이트 try { // Get the current formEntries data @@ -1473,7 +1473,7 @@ export async function sendFormDataToSEDP( if (entries && entries.length > 0) { const entry = entries[0]; const dataArray = entry.data as Array<Record<string, any>>; - + if (Array.isArray(dataArray)) { // Extract TAG_NO list from formData const sentTagNumbers = new Set( @@ -1481,7 +1481,7 @@ export async function sendFormDataToSEDP( .map(item => item.TAG_NO) .filter(tagNo => tagNo) // Remove null/undefined values ); - + // Update status for sent tags const updatedDataArray = dataArray.map(item => { if (item.TAG_NO && sentTagNumbers.has(item.TAG_NO)) { @@ -1492,7 +1492,7 @@ export async function sendFormDataToSEDP( } return item; }); - + // Update the database await db .update(formEntries) @@ -1501,7 +1501,7 @@ export async function sendFormDataToSEDP( updatedAt: new Date() }) .where(eq(formEntries.id, entry.id)); - + console.log(`Updated status for ${sentTagNumbers.size} tags to "Sent to S-EDP"`); } } else { @@ -1511,7 +1511,7 @@ export async function sendFormDataToSEDP( // Status 업데이트 실패는 경고로만 처리 (SEDP 전송은 성공했으므로) console.warn("Failed to update status after SEDP send:", statusUpdateError); } - + return { success: true, message: "Data successfully sent to SEDP", @@ -1535,7 +1535,7 @@ export async function deleteFormDataByTags({ formCode: string contractItemId: number tagNos: string[] -}): Promise<{ +}): Promise<{ error?: string success?: boolean deletedCount?: number @@ -1576,7 +1576,7 @@ export async function deleteFormDataByTags({ console.log(`[DELETE ACTION] Current data count: ${currentData.length}`) // 2. 삭제할 항목들 필터링 (formEntries에서) - const updatedData = currentData.filter((item: any) => + const updatedData = currentData.filter((item: any) => !tagNos.includes(item.TAG_NO) ) @@ -1630,7 +1630,7 @@ export async function deleteFormDataByTags({ const cacheKey = `form-data-${formCode}-${contractItemId}` revalidateTag(cacheKey) revalidateTag(`tags-${contractItemId}`) - + // 페이지 재검증 (필요한 경우) console.log(`[DELETE ACTION] Transaction completed successfully`) diff --git a/lib/incoterms/validations.ts b/lib/incoterms/validations.ts index 3f51dcd6..d36f0e71 100644 --- a/lib/incoterms/validations.ts +++ b/lib/incoterms/validations.ts @@ -22,10 +22,6 @@ export const SearchParamsCache = createSearchParamsCache({ sort: getSortingStateParser<typeof incoterms>().withDefault([ { id: "createdAt", desc: true }]), - // 기존 필드 - code: parseAsString.withDefault(""), - description: parseAsString.withDefault(""), - isActive: parseAsString.withDefault(""), filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), search: parseAsString.withDefault(""), 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 diff --git a/lib/polices/service.ts b/lib/polices/service.ts new file mode 100644 index 00000000..33cd592c --- /dev/null +++ b/lib/polices/service.ts @@ -0,0 +1,341 @@ +'use server' + +import db from '@/db/db' +import { policyVersions, userConsents, consentLogs } from '@/db/schema' +import { eq, desc, and } from 'drizzle-orm' +import { revalidatePath } from 'next/cache' + +// 정책 버전 생성 +export async function createPolicyVersion(data: { + policyType: 'privacy_policy' | 'terms_of_service' + version: string + content: string + effectiveDate: Date +}) { + try { + // 트랜잭션으로 처리 + const result = await db.transaction(async (tx) => { + // 기존의 현재 정책들을 비활성화 + await tx + .update(policyVersions) + .set({ isCurrent: false }) + .where(eq(policyVersions.policyType, data.policyType)) + + // 새 정책 버전 생성 + const [newPolicy] = await tx + .insert(policyVersions) + .values({ + policyType: data.policyType, + version: data.version, + content: data.content, + effectiveDate: data.effectiveDate, + isCurrent: true, + }) + .returning() + + return newPolicy + }) + + revalidatePath('/evcp/policies') + return { success: true, policy: result } + } catch (error) { + console.error('Error creating policy version:', error) + return { + success: false, + error: error instanceof Error ? error.message : '정책 생성에 실패했습니다.' + } + } +} + +// 정책 버전 활성화 +export async function activatePolicyVersion(policyId: number) { + try { + // 먼저 해당 정책의 타입을 조회 + const targetPolicy = await db + .select() + .from(policyVersions) + .where(eq(policyVersions.id, policyId)) + .limit(1) + + if (targetPolicy.length === 0) { + return { success: false, error: '정책을 찾을 수 없습니다.' } + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 해당 정책 타입의 모든 버전을 비활성화 + await tx + .update(policyVersions) + .set({ isCurrent: false }) + .where(eq(policyVersions.policyType, targetPolicy[0].policyType)) + + // 선택한 버전만 활성화 + await tx + .update(policyVersions) + .set({ isCurrent: true }) + .where(eq(policyVersions.id, policyId)) + }) + + revalidatePath('/admin/policies') + return { success: true } + } catch (error) { + console.error('Error activating policy version:', error) + return { + success: false, + error: error instanceof Error ? error.message : '정책 활성화에 실패했습니다.' + } + } +} + +// 정책 히스토리 조회 +export async function getPolicyHistory(policyType: 'privacy_policy' | 'terms_of_service') { + try { + const history = await db + .select() + .from(policyVersions) + .where(eq(policyVersions.policyType, policyType)) + .orderBy(desc(policyVersions.createdAt)) + + return { success: true, data: history } + } catch (error) { + console.error('Error fetching policy history:', error) + return { + success: false, + error: error instanceof Error ? error.message : '히스토리 조회에 실패했습니다.' + } + } +} + +// 현재 활성 정책들 조회 +export async function getCurrentPolicies() { + try { + const currentPolicies = await db + .select() + .from(policyVersions) + .where(eq(policyVersions.isCurrent, true)) + + // 정책 타입별로 그룹화 + const grouped = currentPolicies.reduce((acc, policy) => { + acc[policy.policyType] = policy + return acc + }, {} as Record<string, any>) + + return { success: true, data: grouped } + } catch (error) { + console.error('Error fetching current policies:', error) + return { + success: false, + error: error instanceof Error ? error.message : '정책 조회에 실패했습니다.' + } + } +} + +// 모든 정책 조회 (관리자용) +export async function getAllPolicies() { + try { + const allPolicies = await db + .select() + .from(policyVersions) + .orderBy(desc(policyVersions.createdAt)) + + // 정책 타입별로 그룹화 + const grouped = allPolicies.reduce((acc, policy) => { + if (!acc[policy.policyType]) { + acc[policy.policyType] = [] + } + acc[policy.policyType].push(policy) + return acc + }, {} as Record<string, any[]>) + + return { success: true, data: grouped } + } catch (error) { + console.error('Error fetching all policies:', error) + return { + success: false, + error: error instanceof Error ? error.message : '정책 조회에 실패했습니다.' + } + } +} + +// 정책 통계 조회 +export async function getPolicyStats() { + try { + // 각 정책별 버전 수와 최신 업데이트 날짜 + const stats = await db + .select({ + policyType: policyVersions.policyType, + totalVersions: 'COUNT(*)', + latestUpdate: 'MAX(created_at)', + currentVersion: policyVersions.version, + }) + .from(policyVersions) + .where(eq(policyVersions.isCurrent, true)) + .groupBy(policyVersions.policyType, policyVersions.version) + + return { success: true, data: stats } + } catch (error) { + console.error('Error fetching policy stats:', error) + return { + success: false, + error: error instanceof Error ? error.message : '통계 조회에 실패했습니다.' + } + } +} + +// 사용자 동의 기록 +export async function recordUserConsent(data: { + userId: number + consents: Array<{ + consentType: 'privacy_policy' | 'terms_of_service' | 'marketing' | 'optional' + consentStatus: boolean + policyVersion: string + }> + ipAddress?: string + userAgent?: string +}) { + try { + const result = await db.transaction(async (tx) => { + const consentRecords = [] + const logRecords = [] + + for (const consent of data.consents) { + // 사용자 동의 기록 + const [consentRecord] = await tx + .insert(userConsents) + .values({ + userId: data.userId, + consentType: consent.consentType, + consentStatus: consent.consentStatus, + policyVersion: consent.policyVersion, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + }) + .returning() + + consentRecords.push(consentRecord) + + // 동의 로그 기록 + const [logRecord] = await tx + .insert(consentLogs) + .values({ + userId: data.userId, + consentType: consent.consentType, + action: 'consent', + oldStatus: null, + newStatus: consent.consentStatus, + policyVersion: consent.policyVersion, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + }) + .returning() + + logRecords.push(logRecord) + } + + return { consents: consentRecords, logs: logRecords } + }) + + return { success: true, data: result } + } catch (error) { + console.error('Error recording user consent:', error) + return { + success: false, + error: error instanceof Error ? error.message : '동의 기록에 실패했습니다.' + } + } +} + +// 사용자 동의 상태 조회 +export async function getUserConsentStatus(userId: number) { + try { + const consents = await db + .select() + .from(userConsents) + .where(eq(userConsents.userId, userId)) + .orderBy(desc(userConsents.consentedAt)) + + // 각 동의 타입별 최신 상태만 반환 + const latestConsents = consents.reduce((acc, consent) => { + if (!acc[consent.consentType] || + new Date(consent.consentedAt) > new Date(acc[consent.consentType].consentedAt)) { + acc[consent.consentType] = consent + } + return acc + }, {} as Record<string, any>) + + return { success: true, data: latestConsents } + } catch (error) { + console.error('Error fetching user consent status:', error) + return { + success: false, + error: error instanceof Error ? error.message : '동의 상태 조회에 실패했습니다.' + } + } +} + +// 사용자 동의 철회 +export async function revokeUserConsent(data: { + userId: number + consentType: 'privacy_policy' | 'terms_of_service' | 'marketing' | 'optional' + revokeReason?: string + ipAddress?: string + userAgent?: string +}) { + try { + // 현재 동의 상태 조회 + const currentConsent = await db + .select() + .from(userConsents) + .where( + and( + eq(userConsents.userId, data.userId), + eq(userConsents.consentType, data.consentType) + ) + ) + .orderBy(desc(userConsents.consentedAt)) + .limit(1) + + if (currentConsent.length === 0) { + return { success: false, error: '동의 기록을 찾을 수 없습니다.' } + } + + const result = await db.transaction(async (tx) => { + // 동의 철회 기록 + const [updatedConsent] = await tx + .update(userConsents) + .set({ + consentStatus: false, + revokedAt: new Date(), + revokeReason: data.revokeReason, + }) + .where(eq(userConsents.id, currentConsent[0].id)) + .returning() + + // 철회 로그 기록 + const [logRecord] = await tx + .insert(consentLogs) + .values({ + userId: data.userId, + consentType: data.consentType, + action: 'revoke', + oldStatus: currentConsent[0].consentStatus, + newStatus: false, + policyVersion: currentConsent[0].policyVersion, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + additionalData: data.revokeReason ? { revokeReason: data.revokeReason } : null, + }) + .returning() + + return { consent: updatedConsent, log: logRecord } + }) + + return { success: true, data: result } + } catch (error) { + console.error('Error revoking user consent:', error) + return { + success: false, + error: error instanceof Error ? error.message : '동의 철회에 실패했습니다.' + } + } +}
\ No newline at end of file diff --git a/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx b/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx index d38981f6..e73c2163 100644 --- a/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx +++ b/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx @@ -47,11 +47,21 @@ export interface AcceptedQuotationItem { vendorCode: string | null
vendorEmail: string | null
vendorCountry: string | null
+ vendorFlags: {
+ isCustomerPreferred?: boolean;
+ isNewDiscovery?: boolean;
+ isProjectApproved?: boolean;
+ isShiProposal?: boolean;
+ } | null // 벤더 플래그 (JSON)
// Project 정보
projNm: string | null
pspid: string | null
sector: string | null
+ kunnrNm: string | null // 선주명
+ ptype: string | null // 선종코드
+ ptypeNm: string | null // 선종명
+ pjtType: string | null // 프로젝트 타입
// RFQ 아이템 정보
rfqItems: RfqItemInfo[]
@@ -101,7 +111,24 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] { // 3) 데이터 컬럼들 정의
// ----------------------------------------------------------------
const dataColumns: ColumnDef<AcceptedQuotationItem>[] = [
- // 프로젝트 관련 컬럼
+ // 프로젝트 타입
+ {
+ accessorKey: "pjtType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트 타입" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">
+ {row.original.pjtType || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "프로젝트 타입",
+ },
+ },
+ // 프로젝트 코드
{
accessorKey: "pspid",
header: ({ column }) => (
@@ -118,6 +145,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] { excelHeader: "프로젝트 코드",
},
},
+ // 프로젝트명
{
accessorKey: "projNm",
header: ({ column }) => (
@@ -134,7 +162,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] { excelHeader: "프로젝트명",
},
},
- // RFQ quotation 관련 컬럼
+ // RFQ 코드
{
accessorKey: "rfqCode",
header: ({ column }) => (
@@ -151,6 +179,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] { excelHeader: "RFQ 코드",
},
},
+ // RFQ 제목
{
accessorKey: "description",
header: ({ column }) => (
@@ -164,111 +193,10 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] { enableSorting: true,
enableHiding: true,
meta: {
- excelHeader: "RFQ 설명",
- },
- },
- {
- accessorKey: "rfqType",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 타입" />
- ),
- cell: ({ row }) => (
- <div>
- {row.original.rfqType || "-"}
- </div>
- ),
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "RFQ 타입",
- },
- },
- {
- accessorKey: "totalPrice",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="총 금액" />
- ),
- cell: ({ row }) => {
- const price = row.original.totalPrice;
- const currency = row.original.currency || "USD";
- return (
- <div className="text-right font-medium">
- {price ? `${Number(price).toLocaleString()} ${currency}` : "-"}
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "총 금액",
- },
- },
- {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="상태" />
- ),
- cell: ({ row }) => (
- <Badge variant="default" className="bg-green-100 text-green-800">
- {row.original.status}
- </Badge>
- ),
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "상태",
+ excelHeader: "RFQ 제목",
},
},
- // 협력업체 관련 컬럼
- {
- accessorKey: "vendorName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="업체명" />
- ),
- cell: ({ row }) => (
- <div className="font-medium">
- {row.original.vendorName}
- </div>
- ),
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "업체명",
- },
- },
- {
- accessorKey: "vendorCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="업체 코드" />
- ),
- cell: ({ row }) => (
- <div>
- {row.original.vendorCode || "-"}
- </div>
- ),
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "업체 코드",
- },
- },
- {
- accessorKey: "vendorCountry",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="업체 국가" />
- ),
- cell: ({ row }) => (
- <div>
- {row.original.vendorCountry || "-"}
- </div>
- ),
- enableSorting: true,
- enableHiding: true,
- meta: {
- excelHeader: "업체 국가",
- },
- },
- // 아이템 관련 컬럼
+ // 자재그룹
{
accessorKey: "materialGroup",
header: ({ column }) => (
@@ -287,6 +215,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] { excelHeader: "자재그룹",
},
},
+ // 공종
{
accessorKey: "workType",
header: ({ column }) => (
@@ -305,6 +234,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] { excelHeader: "공종",
},
},
+ // 선종
{
accessorKey: "shipType",
header: ({ column }) => (
@@ -323,6 +253,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] { excelHeader: "선종",
},
},
+ // 자재명
{
accessorKey: "itemName",
header: ({ column }) => (
@@ -341,6 +272,7 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] { excelHeader: "자재명",
},
},
+ // 자재명(상세)
{
accessorKey: "itemDetail",
header: ({ column }) => (
@@ -359,37 +291,175 @@ export function getColumns(): ColumnDef<AcceptedQuotationItem>[] { excelHeader: "자재명(상세)",
},
},
- // metadata 관련 컬럼
+ // 협력업체명
{
- accessorKey: "dueDate",
+ accessorKey: "vendorName",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ <DataTableColumnHeaderSimple column={column} title="협력업체명" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">
+ {row.original.vendorName}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "협력업체명",
+ },
+ },
+ // 벤더 유형
+ {
+ accessorKey: "vendorFlags",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 유형" />
+ ),
+ cell: ({ row }) => {
+ const vendorFlags = row.original.vendorFlags;
+
+ if (!vendorFlags) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ const activeFlags = [];
+
+ if (vendorFlags.isCustomerPreferred) {
+ activeFlags.push({ key: "isCustomerPreferred", label: "고객선호", variant: "outline" as const });
+ }
+ if (vendorFlags.isNewDiscovery) {
+ activeFlags.push({ key: "isNewDiscovery", label: "신규발굴", variant: "outline" as const });
+ }
+ if (vendorFlags.isProjectApproved) {
+ activeFlags.push({ key: "isProjectApproved", label: "프로젝트승인", variant: "outline" as const });
+ }
+ if (vendorFlags.isShiProposal) {
+ activeFlags.push({ key: "isShiProposal", label: "SHI제안", variant: "outline" as const });
+ }
+
+ if (activeFlags.length === 0) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {activeFlags.map((flag) => (
+ <Badge key={flag.key} variant={flag.variant} className="text-xs">
+ {flag.label}
+ </Badge>
+ ))}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "협력업체 유형",
+ },
+ },
+ // 협력업체 코드
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체 코드" />
),
cell: ({ row }) => (
<div>
- {row.original.dueDate ? formatDate(row.original.dueDate) : "-"}
+ {row.original.vendorCode || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "협력업체 코드",
+ },
+ },
+ // 협력업체 국가
+ {
+ accessorKey: "vendorCountry",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체 국가" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.vendorCountry || "-"}
</div>
),
enableSorting: true,
enableHiding: true,
meta: {
- excelHeader: "RFQ 마감일",
+ excelHeader: "협력업체 국가",
},
},
+ // 총 금액
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="총 금액" />
+ ),
+ cell: ({ row }) => {
+ const price = row.original.totalPrice;
+ const currency = row.original.currency || "USD";
+ return (
+ <div className="text-right font-medium">
+ {price ? `${Number(price).toLocaleString()} ${currency}` : "-"}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "총 금액",
+ },
+ },
+ // 상태
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.status;
+ let badgeVariant: "default" | "secondary" | "destructive" | "outline" = "default";
+ let badgeClassName = "";
+
+ if (status === "Submitted") {
+ badgeVariant = "secondary";
+ badgeClassName = "bg-blue-100 text-blue-800";
+ } else if (status === "Accepted") {
+ badgeVariant = "default";
+ badgeClassName = "bg-green-100 text-green-800";
+ } else {
+ badgeVariant = "outline";
+ badgeClassName = "bg-gray-100 text-gray-800";
+ }
+
+ return (
+ <Badge variant={badgeVariant} className={badgeClassName}>
+ {status}
+ </Badge>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "상태",
+ },
+ },
+ // RFQ 제출일
{
- accessorKey: "acceptedAt",
+ accessorKey: "submittedAt",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 승인일" />
+ <DataTableColumnHeaderSimple column={column} title="RFQ 제출일" />
),
cell: ({ row }) => (
<div>
- {row.original.acceptedAt ? formatDate(row.original.acceptedAt) : "-"}
+ {row.original.submittedAt ? formatDate(row.original.submittedAt) : "-"}
</div>
),
enableSorting: true,
enableHiding: true,
meta: {
- excelHeader: "RFQ 승인일",
+ excelHeader: "RFQ 제출일",
},
},
]
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 348d31ff..0d00a4e2 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -37,6 +37,14 @@ import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema import { techVendors, techVendorPossibleItems, techVendorContacts } from "@/db/schema/techVendors";
import { deleteFile, saveDRMFile, saveFile } from "@/lib/file-stroage";
import { decryptWithServerAction } from "@/components/drm/drmUtils";
+// RFQ 아이템 정보 타입
+interface RfqItemInfo {
+ itemCode: string;
+ workType: string;
+ itemList: string;
+ subItemList: string;
+ shipTypes: string;
+}
// 정렬 타입 정의
// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
@@ -3555,7 +3563,6 @@ export async function getAcceptedTechSalesVendorQuotations(input: { try {
const offset = (input.page - 1) * input.perPage;
- // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
const baseConditions = [or(
eq(techSalesVendorQuotations.status, 'Submitted'),
eq(techSalesVendorQuotations.status, 'Accepted')
@@ -3664,11 +3671,16 @@ export async function getAcceptedTechSalesVendorQuotations(input: { vendorCode: sql<string | null>`vendors.vendor_code`,
vendorEmail: sql<string | null>`vendors.email`,
vendorCountry: sql<string | null>`vendors.country`,
+ vendorFlags: techSalesVendorQuotations.vendorFlags, // 벤더 플래그 (JSON)
// Project 정보
projNm: biddingProjects.projNm,
pspid: biddingProjects.pspid,
sector: biddingProjects.sector,
+ kunnrNm: biddingProjects.kunnrNm, // 선주명
+ ptype: biddingProjects.ptype, // 선종코드
+ ptypeNm: biddingProjects.ptypeNm, // 선종명
+ pjtType: biddingProjects.pjtType, // 프로젝트 타입
})
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
@@ -3758,36 +3770,61 @@ export async function getAcceptedTechSalesVendorQuotations(input: { });
}
- // 각 RFQ-벤더 조합에 대해 아이템별로 별도 행 생성
+ // 아이템별 벤더 순서로 데이터 확장
const expandedData: any[] = [];
+ // RFQ별로 아이템을 그룹화
+ const rfqItemsByRfq = new Map();
data.forEach(item => {
const rfqItems = rfqItemsMap.get(item.rfqId) || [];
+ if (!rfqItemsByRfq.has(item.rfqId)) {
+ rfqItemsByRfq.set(item.rfqId, []);
+ }
+ rfqItemsByRfq.get(item.rfqId).push({
+ ...item,
+ rfqItems: rfqItems,
+ vendorQuotation: item
+ });
+ });
+
+ // 각 RFQ의 아이템별로 벤더별 행 생성
+ rfqItemsByRfq.forEach((vendorQuotations) => {
+ const firstQuotation = vendorQuotations[0];
+ const rfqItems = firstQuotation.rfqItems;
if (rfqItems.length === 0) {
- // 아이템이 없는 경우 기본 행 하나만 추가
- expandedData.push({
- ...item,
- rfqItems: [],
- itemIndex: 0,
- totalItems: 0,
- isExpanded: false,
+ // 아이템이 없는 경우 각 벤더별로 행 생성
+ vendorQuotations.forEach((quotation) => {
+ expandedData.push({
+ ...quotation.vendorQuotation,
+ rfqItems: [],
+ itemIndex: 0,
+ totalItems: 0,
+ isExpanded: false,
+ itemCode: '',
+ workType: '',
+ itemList: '',
+ subItemList: '',
+ shipTypes: '',
+ });
});
} else {
- // 각 아이템별로 별도 행 생성
- rfqItems.forEach((rfqItem, index) => {
- expandedData.push({
- ...item,
- rfqItems: [rfqItem], // 단일 아이템만 포함
- itemIndex: index,
- totalItems: rfqItems.length,
- isExpanded: index === 0, // 첫 번째 아이템만 확장된 것으로 표시
- // 아이템 정보를 직접 포함
- itemCode: rfqItem.itemCode,
- workType: rfqItem.workType,
- itemList: rfqItem.itemList,
- subItemList: rfqItem.subItemList,
- shipTypes: rfqItem.shipTypes,
+ // 각 아이템별로 벤더별 행 생성
+ rfqItems.forEach((rfqItem: RfqItemInfo, itemIndex: number) => {
+ vendorQuotations.forEach((quotation: { vendorQuotation: any; rfqItems: RfqItemInfo[] }, vendorIndex: number) => {
+ expandedData.push({
+ ...quotation.vendorQuotation,
+ rfqItems: [rfqItem], // 단일 아이템만 포함
+ itemIndex: itemIndex,
+ totalItems: rfqItems.length,
+ isExpanded: vendorIndex === 0, // 첫 번째 벤더만 확장된 것으로 표시
+ // 아이템 정보를 직접 포함
+ itemCode: rfqItem.itemCode,
+ workType: rfqItem.workType,
+ itemList: rfqItem.itemList,
+ subItemList: rfqItem.subItemList,
+ shipTypes: rfqItem.shipTypes,
+ });
});
});
}
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx index 65f11d0e..ded0c116 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx @@ -212,7 +212,7 @@ export function getRfqDetailColumns({ {
id: "vendorFlags",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 구분자" />
+ <DataTableColumnHeaderSimple column={column} title="벤더 유형" />
),
cell: ({ row }) => {
const vendorFlags = row.original.vendorFlags;
@@ -224,16 +224,16 @@ export function getRfqDetailColumns({ const activeFlags = [];
if (vendorFlags.isCustomerPreferred) {
- activeFlags.push({ key: "isCustomerPreferred", label: "고객(선주) 선호 벤더", variant: "default" as const });
+ activeFlags.push({ key: "isCustomerPreferred", label: "고객(선주) 선호 벤더", variant: "outline" as const });
}
if (vendorFlags.isNewDiscovery) {
- activeFlags.push({ key: "isNewDiscovery", label: "신규 발굴 벤더", variant: "secondary" as const });
+ activeFlags.push({ key: "isNewDiscovery", label: "신규 발굴 벤더", variant: "outline" as const });
}
if (vendorFlags.isProjectApproved) {
activeFlags.push({ key: "isProjectApproved", label: "Project Approved Vendor", variant: "outline" as const });
}
if (vendorFlags.isShiProposal) {
- activeFlags.push({ key: "isShiProposal", label: "SHI Proposal Vendor", variant: "destructive" as const });
+ activeFlags.push({ key: "isShiProposal", label: "SHI Proposal Vendor", variant: "outline" as const });
}
if (activeFlags.length === 0) {
diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx index fbaf000e..c04aad64 100644 --- a/lib/vendor-investigation/table/update-investigation-sheet.tsx +++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx @@ -613,7 +613,19 @@ export function UpdateVendorInvestigationSheet({ <FormItem> <FormLabel>실사 방법</FormLabel> <FormControl> - <Input placeholder="실사 방법을 입력하세요..." {...field} /> + <Select value={field.value || ""} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="실사 방법을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem> + <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem> + <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem> + <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> </FormControl> <FormMessage /> </FormItem> diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index e3309786..7c8df1a6 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -1841,7 +1841,8 @@ export async function requestPQVendors(input: ApproveVendorsInput & { revalidateTag("vendors"); revalidateTag("vendor-status-counts"); revalidateTag("vendor-pq-submissions"); - + revalidateTag("pq-submissions"); + if (input.projectId) { revalidateTag(`project-${input.projectId}`); revalidateTag(`project-pq-submissions-${input.projectId}`); |
