diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:44:45 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:44:45 +0000 |
| commit | 90f79a7a691943a496f67f01c1e493256070e4de (patch) | |
| tree | 37275fde3ae08c2bca384fbbc8eb378de7e39230 /lib/evaluation-target-list | |
| parent | fbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff) | |
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib/evaluation-target-list')
5 files changed, 973 insertions, 457 deletions
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts index bb47fca4..0e209aa2 100644 --- a/lib/evaluation-target-list/service.ts +++ b/lib/evaluation-target-list/service.ts @@ -32,6 +32,7 @@ import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { sendEmail } from "../mail/sendEmail"; import type { SQL } from "drizzle-orm" +import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation"; export async function selectEvaluationTargetsFromView( tx: PgTransaction<any, any, any>, @@ -685,17 +686,10 @@ export async function getAvailableVendors(search?: string) { // 부서 정보 조회 (상수에서) export async function getDepartmentInfo() { return Object.entries(EVALUATION_DEPARTMENT_CODES).map(([key, value]) => { - const departmentNames = { - ORDER_EVAL: "발주 평가 담당", - PROCUREMENT_EVAL: "조달 평가 담당", - QUALITY_EVAL: "품질 평가 담당", - DESIGN_EVAL: "설계 평가 담당", - CS_EVAL: "CS 평가 담당", - }; return { code: value, - name: departmentNames[key as keyof typeof departmentNames], + name: DEPARTMENT_CODE_LABELS[key as keyof typeof DEPARTMENT_CODE_LABELS], key, }; }); @@ -810,44 +804,44 @@ export async function confirmEvaluationTargets( const totalEsgItems = esgItemsCount[0]?.count || 0 // 5. 각 periodicEvaluation에 대해 담당자별 reviewerEvaluations도 생성 - if (periodicEvaluationsToCreate.length > 0) { - // 새로 생성된 periodicEvaluations 조회 - const newPeriodicEvaluations = await tx - .select({ - id: periodicEvaluations.id, - evaluationTargetId: periodicEvaluations.evaluationTargetId - }) - .from(periodicEvaluations) - .where( - and( - inArray(periodicEvaluations.evaluationTargetId, confirmedTargetIds), - eq(periodicEvaluations.evaluationPeriod, currentPeriod) - ) - ) + // if (periodicEvaluationsToCreate.length > 0) { + // // 새로 생성된 periodicEvaluations 조회 + // const newPeriodicEvaluations = await tx + // .select({ + // id: periodicEvaluations.id, + // evaluationTargetId: periodicEvaluations.evaluationTargetId + // }) + // .from(periodicEvaluations) + // .where( + // and( + // inArray(periodicEvaluations.evaluationTargetId, confirmedTargetIds), + // eq(periodicEvaluations.evaluationPeriod, currentPeriod) + // ) + // ) - // 각 평가에 대해 담당자별 reviewerEvaluations 생성 - for (const periodicEval of newPeriodicEvaluations) { - // 해당 evaluationTarget의 담당자들 조회 - const reviewers = await tx - .select() - .from(evaluationTargetReviewers) - .where(eq(evaluationTargetReviewers.evaluationTargetId, periodicEval.evaluationTargetId)) + // // 각 평가에 대해 담당자별 reviewerEvaluations 생성 + // for (const periodicEval of newPeriodicEvaluations) { + // // 해당 evaluationTarget의 담당자들 조회 + // const reviewers = await tx + // .select() + // .from(evaluationTargetReviewers) + // .where(eq(evaluationTargetReviewers.evaluationTargetId, periodicEval.evaluationTargetId)) - if (reviewers.length > 0) { - const reviewerEvaluationsToCreate = reviewers.map(reviewer => ({ - periodicEvaluationId: periodicEval.id, - evaluationTargetReviewerId: reviewer.id, - isCompleted: false, - createdAt: new Date(), - updatedAt: new Date() - })) + // if (reviewers.length > 0) { + // const reviewerEvaluationsToCreate = reviewers.map(reviewer => ({ + // periodicEvaluationId: periodicEval.id, + // evaluationTargetReviewerId: reviewer.id, + // isCompleted: false, + // createdAt: new Date(), + // updatedAt: new Date() + // })) - await tx - .insert(reviewerEvaluations) - .values(reviewerEvaluationsToCreate) - } - } - } + // await tx + // .insert(reviewerEvaluations) + // .values(reviewerEvaluationsToCreate) + // } + // } + // } // 6. 벤더별 evaluationSubmissions 레코드 생성 const evaluationSubmissionsToCreate = [] diff --git a/lib/evaluation-target-list/table/evaluation-target-table copy.tsx b/lib/evaluation-target-list/table/evaluation-target-table copy.tsx new file mode 100644 index 00000000..b140df0e --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-target-table copy.tsx @@ -0,0 +1,508 @@ +// ============================================================================ +// components/evaluation-targets-table.tsx (CLIENT COMPONENT) +// ─ 완전본 ─ evaluation-targets-columns.tsx 는 별도 +// ============================================================================ +"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 { getEvaluationTargets, getEvaluationTargetsStats } 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 { getEvaluationTargetsColumns } from "./evaluation-targets-columns"; +import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions"; +import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet"; +import { EvaluationTargetWithDepartments } from "@/db/schema"; +import { EditEvaluationTargetSheet } from "./update-evaluation-target"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +/* -------------------------------------------------------------------------- */ +/* Process Guide Popover */ +/* -------------------------------------------------------------------------- */ +function ProcessGuidePopover() { + return ( + <Popover> + <PopoverTrigger asChild> + <Button variant="ghost" size="icon" className="h-6 w-6"> + <HelpCircle className="h-4 w-4 text-muted-foreground" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-96" align="start"> + <div className="space-y-3"> + <div className="space-y-1"> + <h4 className="font-medium">평가 대상 확정 프로세스</h4> + <p className="text-sm text-muted-foreground"> + 발주실적을 기반으로 평가 대상을 확정하는 절차입니다. + </p> + </div> + <div className="space-y-3 text-sm"> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 1 + </div> + <div> + <p className="font-medium">발주실적 기반 자동 추출</p> + <p className="text-muted-foreground">전년도 10월 ~ 해당년도 9월 발주실적에서 업체 목록을 자동으로 생성합니다.</p> + </div> + </div> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 2 + </div> + <div> + <p className="font-medium">담당자 지정</p> + <p className="text-muted-foreground">각 평가 대상별로 5개 부서(발주/조달/품질/설계/CS)의 담당자를 지정합니다.</p> + </div> + </div> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 3 + </div> + <div> + <p className="font-medium">검토 및 의견 수렴</p> + <p className="text-muted-foreground">모든 담당자가 평가 대상 적합성을 검토하고 의견을 제출합니다.</p> + </div> + </div> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 4 + </div> + <div> + <p className="font-medium">최종 확정</p> + <p className="text-muted-foreground">모든 담당자 의견이 일치하면 평가 대상으로 최종 확정됩니다.</p> + </div> + </div> + </div> + </div> + </PopoverContent> + </Popover> + ) +} + +/* -------------------------------------------------------------------------- */ +/* Stats Card */ +/* -------------------------------------------------------------------------- */ +function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) { + const [stats, setStats] = React.useState<any>(null); + const [isLoading, setIsLoading] = React.useState(true); + const [error, setError] = React.useState<string | null>(null); + + React.useEffect(() => { + let mounted = true; + (async () => { + try { + setIsLoading(true); + const data = await getEvaluationTargetsStats(evaluationYear); + mounted && setStats(data); + } catch (e) { + mounted && setError(e instanceof Error ? e.message : "failed"); + } finally { + mounted && setIsLoading(false); + } + })(); + return () => { + mounted = false; + }; + }, [evaluationYear]); + + if (isLoading) + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + {Array.from({ length: 4 }).map((_, i) => ( + <Card key={i}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <Skeleton className="h-4 w-20" /> + </CardHeader> + <CardContent> + <Skeleton className="h-8 w-16" /> + </CardContent> + </Card> + ))} + </div> + ); + if (error) + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6 text-center text-sm text-muted-foreground"> + 통계 데이터를 불러올 수 없습니다: {error} + </CardContent> + </Card> + </div> + ); + if (!stats) + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6 text-center text-sm text-muted-foreground"> + 통계 데이터가 없습니다. + </CardContent> + </Card> + </div> + ); + + const total = stats.total || 0; + const pending = stats.pending || 0; + const confirmed = stats.confirmed || 0; + const consensusRate = total ? Math.round(((stats.consensusTrue || 0) / total) * 100) : 0; + + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 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">{evaluationYear}년</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{total.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + 해양 {stats.oceanDivision || 0}개 | 조선 {stats.shipyardDivision || 0}개 + </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">{pending.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {total ? Math.round((pending / 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="success">완료</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{confirmed.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {total ? Math.round((confirmed / 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={consensusRate >= 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}>{consensusRate}%</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{consensusRate}%</div> + <div className="text-xs text-muted-foreground mt-1"> + 일치 {stats.consensusTrue || 0}개 | 불일치 {stats.consensusFalse || 0}개 + </div> + </CardContent> + </Card> + </div> + ); +} + +/* -------------------------------------------------------------------------- */ +/* EvaluationTargetsTable */ +/* -------------------------------------------------------------------------- */ +interface EvaluationTargetsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]>; + evaluationYear: number; + className?: string; +} + +export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null); + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); + const searchParams = useSearchParams(); + + /* --------------------------- layout refs --------------------------- */ + const containerRef = React.useRef<HTMLDivElement>(null); + const [containerTop, setContainerTop] = React.useState(0); + + // RFQ 패턴으로 변경: State를 통한 위치 관리 + const updateContainerBounds = React.useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setContainerTop(rect.top); + } + }, []); + + 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 [promiseData] = React.use(promises); + const tableData = promiseData; + + /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ + const searchString = React.useMemo( + () => searchParams.toString(), // query가 바뀔 때만 새로 계산 + [searchParams] + ); + + const getSearchParam = React.useCallback( + (key: string, def = "") => + new URLSearchParams(searchString).get(key) ?? def, + [searchString] + ); + + // 제네릭 함수는 useCallback 밖에서 정의 + 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", + basicFilters: parseSearchParam("basicFilters", []), + basicJoinOperator: (getSearchParam("basicJoinOperator") as "and" | "or") || "and", + search: getSearchParam("search", ""), + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: ["actions"] }, + groupBy: [], + expandedRows: [], + }), [getSearchParam]); + + /* --------------------- 프리셋 훅 ------------------------------ */ + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + getCurrentSettings, + } = useTablePresets<EvaluationTargetWithDepartments>( + "evaluation-targets-table", + initialSettings + ); + + /* --------------------- 컬럼 ------------------------------ */ + const columns = React.useMemo(() => getEvaluationTargetsColumns({ setRowAction }), [setRowAction]); +// const columns =[ +// { accessorKey: "vendorCode", header: "벤더 코드" }, +// { accessorKey: "vendorName", header: "벤더명" }, +// { accessorKey: "status", header: "상태" }, +// { accessorKey: "evaluationYear", header: "평가년도" }, +// { accessorKey: "division", header: "구분" } +// ]; + + + /* 기본 필터 */ + const filterFields: DataTableFilterField<EvaluationTargetWithDepartments>[] = [ + { id: "vendorCode", label: "벤더 코드" }, + { id: "vendorName", label: "벤더명" }, + { id: "status", label: "상태" }, + ]; + + /* 고급 필터 */ + const advancedFilterFields: DataTableAdvancedFilterField<EvaluationTargetWithDepartments>[] = [ + { id: "evaluationYear", label: "평가년도", type: "number" }, + { id: "division", label: "구분", type: "select", options: [ { label: "해양", value: "OCEAN" }, { label: "조선", value: "SHIPYARD" } ] }, + { id: "vendorCode", label: "벤더 코드", type: "text" }, + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "domesticForeign", label: "내외자", type: "select", options: [ { label: "내자", value: "DOMESTIC" }, { label: "외자", value: "FOREIGN" } ] }, + { id: "materialType", label: "자재구분", type: "select", options: [ { label: "기자재", value: "EQUIPMENT" }, { label: "벌크", value: "BULK" }, { label: "기/벌", value: "EQUIPMENT_BULK" } ] }, + { id: "status", label: "상태", type: "select", options: [ { label: "검토 중", value: "PENDING" }, { label: "확정", value: "CONFIRMED" }, { label: "제외", value: "EXCLUDED" } ] }, + { id: "consensusStatus", label: "의견 일치", type: "select", options: [ { label: "일치", value: "true" }, { label: "불일치", value: "false" }, { label: "검토 중", value: "null" } ] }, + { id: "adminComment", label: "관리자 의견", type: "text" }, + { id: "consolidatedComment", label: "종합 의견", type: "text" }, + { id: "confirmedAt", 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 getActiveBasicFilterCount = React.useCallback(() => { + try { + const f = getSearchParam("basicFilters"); + return f ? JSON.parse(f).length : 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)` + }} + > + <EvaluationTargetFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={() => setIsFilterPanelOpen(false)} + 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" />} + {getActiveBasicFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveBasicFilterCount()} + </span> + )} + </Button> + <div className="text-sm text-muted-foreground"> + 총 {tableData.total || tableData.data.length}건 + </div> + </div> + + {/* Stats */} + <div className="px-4"> + <EvaluationTargetsStats evaluationYear={evaluationYear} /> + </div> + + {/* Table */} + <div className="flex-1 overflow-hidden" style={{ height: "calc(100vh - 500px)" }}> + <DataTable table={table} className="h-full"> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <TablePresetManager<EvaluationTargetWithDepartments> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <EvaluationTargetsTableToolbarActions table={table} /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 편집 다이얼로그 */} + <EditEvaluationTargetSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + evaluationTarget={rowAction?.row.original ?? null} + /> + </div> + </div> + </div> + </div> + </> + ); +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx index b6631f14..60f1af39 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx @@ -9,34 +9,22 @@ import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table- import { EvaluationTargetWithDepartments } from "@/db/schema"; import type { DataTableRowAction } from "@/types/table"; import { formatDate } from "@/lib/utils"; +import { vendortypeMap } from "@/types/evaluation"; interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EvaluationTargetWithDepartments> | null>>; } -// ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 (매번 재생성 방지) +// ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 const getStatusBadgeVariant = (status: string) => { switch (status) { - case "PENDING": - return "secondary"; - case "CONFIRMED": - return "default"; - case "EXCLUDED": - return "destructive"; - default: - return "outline"; + case "PENDING": return "secondary"; + case "CONFIRMED": return "default"; + case "EXCLUDED": return "destructive"; + default: return "outline"; } }; -const getStatusText = (status: string) => { - const statusMap = { - PENDING: "검토 중", - CONFIRMED: "확정", - EXCLUDED: "제외" - }; - return statusMap[status] || status; -}; - const getConsensusBadge = (consensusStatus: boolean | null) => { if (consensusStatus === null) { return <Badge variant="outline">검토 중</Badge>; @@ -56,12 +44,7 @@ const getDivisionBadge = (division: string) => { }; const getMaterialTypeBadge = (materialType: string) => { - const typeMap = { - EQUIPMENT: "기자재", - BULK: "벌크", - EQUIPMENT_BULK: "기자재/벌크" - }; - return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>; + return <Badge variant="outline">{vendortypeMap[materialType] || materialType}</Badge>; }; const getDomesticForeignBadge = (domesticForeign: string) => { @@ -72,7 +55,6 @@ const getDomesticForeignBadge = (domesticForeign: string) => { ); }; -// ✅ 평가 대상 여부 표시 함수 const getEvaluationTargetBadge = (isTarget: boolean | null) => { if (isTarget === null) { return <Badge variant="outline">미정</Badge>; @@ -90,340 +72,335 @@ const getEvaluationTargetBadge = (isTarget: boolean | null) => { ); }; -export function getEvaluationTargetsColumns({ setRowAction }: GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] { +// ✅ 모든 cell 렌더러 함수들을 미리 정의 (매번 새로 생성 방지) +const renderEvaluationYear = ({ row }: any) => ( + <span className="font-medium">{row.getValue("evaluationYear")}</span> +); + +const renderDivision = ({ row }: any) => getDivisionBadge(row.getValue("division")); + +const renderStatus = ({ row }: any) => { + const status = row.getValue<string>("status"); + return ( + <Badge variant={getStatusBadgeVariant(status)}> + {status} + </Badge> + ); +}; + +const renderConsensusStatus = ({ row }: any) => getConsensusBadge(row.getValue("consensusStatus")); + +const renderVendorCode = ({ row }: any) => ( + <span className="font-mono text-sm">{row.getValue("vendorCode")}</span> +); + +const renderVendorName = ({ row }: any) => ( + <div className="truncate max-w-[200px]" title={row.getValue<string>("vendorName")!}> + {row.getValue("vendorName") as string} + </div> +); + +const renderDomesticForeign = ({ row }: any) => getDomesticForeignBadge(row.getValue("domesticForeign")); + +const renderMaterialType = ({ row }: any) => getMaterialTypeBadge(row.getValue("materialType")); + +const renderReviewerName = (fieldName: string) => ({ row }: any) => { + const reviewerName = row.getValue<string>(fieldName); + return reviewerName ? ( + <div className="truncate max-w-[120px]" title={reviewerName}> + {reviewerName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); +}; + +const renderIsApproved = (fieldName: string) => ({ row }: any) => { + const isApproved = row.getValue<boolean>(fieldName); + return getEvaluationTargetBadge(isApproved); +}; + +const renderComment = (maxWidth: string) => ({ row }: any) => { + const comment = row.getValue<string>("adminComment") || row.getValue<string>("consolidatedComment"); + return comment ? ( + <div className={`truncate ${maxWidth}`} title={comment}> + {comment} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); +}; + +const renderConfirmedAt = ({ row }: any) => { + const confirmedAt = row.getValue<Date>("confirmedAt"); + return <span className="text-sm">{confirmedAt ? formatDate(confirmedAt, "KR") : '-'}</span>; +}; + +const renderCreatedAt = ({ row }: any) => { + const createdAt = row.getValue<Date>("createdAt"); + return <span className="text-sm">{formatDate(createdAt, "KR")}</span>; +}; + +// ✅ 헤더 렌더러들도 미리 정의 +const createHeaderRenderer = (title: string) => ({ column }: any) => ( + <DataTableColumnHeaderSimple column={column} title={title} /> +); + +// ✅ 체크박스 관련 함수들도 미리 정의 +const renderSelectAllCheckbox = ({ table }: any) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v: any) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> +); + +const renderRowCheckbox = ({ row }: any) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v: any) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> +); + +// ✅ 정적 컬럼 정의 (setRowAction만 동적으로 주입) +function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): ColumnDef<EvaluationTargetWithDepartments>[] { + // Actions 컬럼의 클릭 핸들러를 미리 정의 + const renderActionsCell = ({ row }: any) => ( + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setRowAction({ row, type: "update" })} + aria-label="수정" + title="수정" + > + <Pencil className="size-4" /> + </Button> + </div> + ); + return [ - // ✅ Checkbox + // Checkbox { 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" - /> - ), + header: renderSelectAllCheckbox, + cell: renderRowCheckbox, size: 40, enableSorting: false, enableHiding: false, }, - // ✅ 기본 정보 + // 기본 정보 { accessorKey: "evaluationYear", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />, - cell: ({ row }) => <span className="font-medium">{row.getValue("evaluationYear")}</span>, + header: createHeaderRenderer("평가년도"), + cell: renderEvaluationYear, size: 100, }, { accessorKey: "division", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />, - cell: ({ row }) => getDivisionBadge(row.getValue("division")), + header: createHeaderRenderer("구분"), + cell: renderDivision, size: 80, }, { accessorKey: "status", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />, - cell: ({ row }) => { - const status = row.getValue<string>("status"); - return ( - <Badge variant={getStatusBadgeVariant(status)}> - {getStatusText(status)} - </Badge> - ); - }, + header: createHeaderRenderer("상태"), + cell: renderStatus, size: 100, }, { accessorKey: "consensusStatus", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="의견 일치" />, - cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")), + header: createHeaderRenderer("의견 일치"), + cell: renderConsensusStatus, size: 100, }, - // ✅ 벤더 정보 그룹 + // 벤더 정보 { id: "vendorInfo", header: "벤더 정보", columns: [ { accessorKey: "vendorCode", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더 코드" />, - cell: ({ row }) => ( - <span className="font-mono text-sm">{row.getValue("vendorCode")}</span> - ), + header: createHeaderRenderer("벤더 코드"), + cell: renderVendorCode, size: 120, }, { accessorKey: "vendorName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />, - cell: ({ row }) => ( - <div className="truncate max-w-[200px]" title={row.getValue<string>("vendorName")!}> - {row.getValue("vendorName") as string} - </div> - ), + header: createHeaderRenderer("벤더명"), + cell: renderVendorName, size: 200, }, { accessorKey: "domesticForeign", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />, - cell: ({ row }) => getDomesticForeignBadge(row.getValue("domesticForeign")), + header: createHeaderRenderer("내외자"), + cell: renderDomesticForeign, size: 80, }, { accessorKey: "materialType", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />, - cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")), + header: createHeaderRenderer("자재구분"), + cell: renderMaterialType, size: 120, }, ] }, - // ✅ 발주 담당자 + // 발주 담당자 { id: "orderReviewer", header: "발주 담당자", columns: [ { accessorKey: "orderReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, - cell: ({ row }) => { - const reviewerName = row.getValue<string>("orderReviewerName"); - return reviewerName ? ( - <div className="truncate max-w-[120px]" title={reviewerName}> - {reviewerName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("orderReviewerName"), size: 120, }, { accessorKey: "orderIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, - cell: ({ row }) => { - const isApproved = row.getValue<boolean>("orderIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("orderIsApproved"), size: 120, }, ] }, - // ✅ 조달 담당자 + // 조달 담당자 { id: "procurementReviewer", header: "조달 담당자", columns: [ { accessorKey: "procurementReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, - cell: ({ row }) => { - const reviewerName = row.getValue<string>("procurementReviewerName"); - return reviewerName ? ( - <div className="truncate max-w-[120px]" title={reviewerName}> - {reviewerName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("procurementReviewerName"), size: 120, }, { accessorKey: "procurementIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, - cell: ({ row }) => { - const isApproved = row.getValue<boolean>("procurementIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("procurementIsApproved"), size: 120, }, ] }, - // ✅ 품질 담당자 + // 품질 담당자 { id: "qualityReviewer", header: "품질 담당자", columns: [ { accessorKey: "qualityReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, - cell: ({ row }) => { - const reviewerName = row.getValue<string>("qualityReviewerName"); - return reviewerName ? ( - <div className="truncate max-w-[120px]" title={reviewerName}> - {reviewerName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("qualityReviewerName"), size: 120, }, { accessorKey: "qualityIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, - cell: ({ row }) => { - const isApproved = row.getValue<boolean>("qualityIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("qualityIsApproved"), size: 120, }, ] }, - // ✅ 설계 담당자 + // 설계 담당자 { id: "designReviewer", header: "설계 담당자", columns: [ { accessorKey: "designReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, - cell: ({ row }) => { - const reviewerName = row.getValue<string>("designReviewerName"); - return reviewerName ? ( - <div className="truncate max-w-[120px]" title={reviewerName}> - {reviewerName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("designReviewerName"), size: 120, }, { accessorKey: "designIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, - cell: ({ row }) => { - const isApproved = row.getValue<boolean>("designIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("designIsApproved"), size: 120, }, ] }, - // ✅ CS 담당자 + // CS 담당자 { id: "csReviewer", header: "CS 담당자", columns: [ { accessorKey: "csReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, - cell: ({ row }) => { - const reviewerName = row.getValue<string>("csReviewerName"); - return reviewerName ? ( - <div className="truncate max-w-[120px]" title={reviewerName}> - {reviewerName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("csReviewerName"), size: 120, }, { accessorKey: "csIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, - cell: ({ row }) => { - const isApproved = row.getValue<boolean>("csIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("csIsApproved"), size: 120, }, ] }, - // ✅ 의견 및 결과 + // 의견 및 결과 { accessorKey: "adminComment", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="관리자 의견" />, - cell: ({ row }) => { - const comment = row.getValue<string>("adminComment"); - return comment ? ( - <div className="truncate max-w-[150px]" title={comment}> - {comment} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("관리자 의견"), + cell: renderComment("max-w-[150px]"), size: 150, }, { accessorKey: "consolidatedComment", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="종합 의견" />, - cell: ({ row }) => { - const comment = row.getValue<string>("consolidatedComment"); - return comment ? ( - <div className="truncate max-w-[150px]" title={comment}> - {comment} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("종합 의견"), + cell: renderComment("max-w-[150px]"), size: 150, }, { accessorKey: "confirmedAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />, - cell: ({ row }) => { - const confirmedAt = row.getValue<Date>("confirmedAt"); - return <span className="text-sm">{ confirmedAt ? formatDate(confirmedAt, "KR") :'-'}</span>; - }, + header: createHeaderRenderer("확정일"), + cell: renderConfirmedAt, size: 100, }, { accessorKey: "createdAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />, - cell: ({ row }) => { - const createdAt = row.getValue<Date>("createdAt"); - return <span className="text-sm">{formatDate(createdAt, "KR")}</span>; - }, + header: createHeaderRenderer("생성일"), + cell: renderCreatedAt, size: 100, }, - // ✅ Actions - 가장 안전하게 처리 + // Actions { id: "actions", enableHiding: false, size: 40, minSize: 40, - cell: ({ row }) => { - // ✅ 함수를 직접 정의해서 매번 새로 생성되지 않도록 처리 - const handleEdit = () => { - setRowAction({ row, type: "update" }); - }; - - return ( - <div className="flex items-center gap-1"> - <Button - variant="ghost" - size="icon" - className="size-8" - onClick={handleEdit} - aria-label="수정" - title="수정" - > - <Pencil className="size-4" /> - </Button> - </div> - ); - }, + cell: renderActionsCell, }, ]; +} + +// ✅ WeakMap 캐시로 setRowAction별로 컬럼 캐싱 +const columnsCache = new WeakMap<GetColumnsProps['setRowAction'], ColumnDef<EvaluationTargetWithDepartments>[]>(); + +export function getEvaluationTargetsColumns({ setRowAction }: GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] { + // 캐시 확인 + if (columnsCache.has(setRowAction)) { + console.log('✅ 캐시된 컬럼 사용'); + return columnsCache.get(setRowAction)!; + } + + console.log('🏗️ 새로운 컬럼 생성'); + const columns = createStaticColumns(setRowAction); + columnsCache.set(setRowAction, columns); + return columns; }
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx index 82b7c97c..8bc5254c 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -51,16 +51,69 @@ export function EvaluationTargetsTableToolbarActions({ // 선택된 행들 const selectedRows = table.getFilteredSelectedRowModel().rows const hasSelection = selectedRows.length > 0 - const selectedTargets = selectedRows.map(row => row.original) - // 선택된 항목들의 상태 분석 + // ✅ selectedTargets를 useMemo로 안정화 (VendorsTable 방식과 동일) + const selectedTargets = React.useMemo(() => { + return selectedRows.map(row => row.original) + }, [selectedRows]) + + // ✅ 각 상태별 타겟들을 개별적으로 메모이제이션 (VendorsTable 방식과 동일) + const pendingTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.status === "PENDING"); + }, [table.getFilteredSelectedRowModel().rows]); + + const confirmedTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.status === "CONFIRMED"); + }, [table.getFilteredSelectedRowModel().rows]); + + const excludedTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.status === "EXCLUDED"); + }, [table.getFilteredSelectedRowModel().rows]); + + const consensusTrueTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.consensusStatus === true); + }, [table.getFilteredSelectedRowModel().rows]); + + const consensusFalseTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.consensusStatus === false); + }, [table.getFilteredSelectedRowModel().rows]); + + const consensusNullTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.consensusStatus === null); + }, [table.getFilteredSelectedRowModel().rows]); + + // ✅ 선택된 항목들의 상태 분석 - 안정화된 개별 배열들 사용 const selectedStats = React.useMemo(() => { - const pending = selectedTargets.filter(t => t.status === "PENDING").length - const confirmed = selectedTargets.filter(t => t.status === "CONFIRMED").length - const excluded = selectedTargets.filter(t => t.status === "EXCLUDED").length - const consensusTrue = selectedTargets.filter(t => t.consensusStatus === true).length - const consensusFalse = selectedTargets.filter(t => t.consensusStatus === false).length - const consensusNull = selectedTargets.filter(t => t.consensusStatus === null).length + const pending = pendingTargets.length + const confirmed = confirmedTargets.length + const excluded = excludedTargets.length + const consensusTrue = consensusTrueTargets.length + const consensusFalse = consensusFalseTargets.length + const consensusNull = consensusNullTargets.length return { pending, @@ -73,12 +126,19 @@ export function EvaluationTargetsTableToolbarActions({ canExclude: pending > 0, canRequestReview: pending > 0 } - }, [selectedTargets]) + }, [ + pendingTargets.length, + confirmedTargets.length, + excludedTargets.length, + consensusTrueTargets.length, + consensusFalseTargets.length, + consensusNullTargets.length + ]) // ---------------------------------------------------------------- // 신규 평가 대상 생성 (자동) // ---------------------------------------------------------------- - const handleAutoGenerate = async () => { + const handleAutoGenerate = React.useCallback(async () => { setIsLoading(true) try { // TODO: 발주실적에서 자동 추출 API 호출 @@ -90,23 +150,33 @@ export function EvaluationTargetsTableToolbarActions({ } finally { setIsLoading(false) } - } + }, [router]) // ---------------------------------------------------------------- // 신규 평가 대상 생성 (수동) // ---------------------------------------------------------------- - const handleManualCreate = () => { + const handleManualCreate = React.useCallback(() => { setManualCreateDialogOpen(true) - } + }, []) // ---------------------------------------------------------------- // 다이얼로그 성공 핸들러 // ---------------------------------------------------------------- - const handleActionSuccess = () => { + const handleActionSuccess = React.useCallback(() => { table.resetRowSelection() onRefresh?.() router.refresh() - } + }, [table, onRefresh, router]) + + // ---------------------------------------------------------------- + // 내보내기 핸들러 + // ---------------------------------------------------------------- + const handleExport = React.useCallback(() => { + exportTableToExcel(table, { + filename: "vendor-target-list", + excludeColumns: ["select", "actions"], + }) + }, [table]) return ( <> @@ -141,12 +211,7 @@ export function EvaluationTargetsTableToolbarActions({ <Button variant="outline" size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "vendor-target-list", - excludeColumns: ["select", "actions"], - }) - } + onClick={handleExport} className="gap-2" > <Download className="size-4" aria-hidden="true" /> @@ -237,18 +302,6 @@ export function EvaluationTargetsTableToolbarActions({ targets={selectedTargets} onSuccess={handleActionSuccess} /> - - {/* 선택 정보 표시 */} - {/* {hasSelection && ( - <div className="text-xs text-muted-foreground"> - 선택된 {selectedRows.length}개 항목: - 대기중 {selectedStats.pending}개, - 확정 {selectedStats.confirmed}개, - 제외 {selectedStats.excluded}개 - {selectedStats.consensusTrue > 0 && ` | 의견일치 ${selectedStats.consensusTrue}개`} - {selectedStats.consensusFalse > 0 && ` | 의견불일치 ${selectedStats.consensusFalse}개`} - </div> - )} */} </> ) }
\ No newline at end of file diff --git a/lib/evaluation-target-list/validation.ts b/lib/evaluation-target-list/validation.ts index ce5604be..b8df250b 100644 --- a/lib/evaluation-target-list/validation.ts +++ b/lib/evaluation-target-list/validation.ts @@ -1,169 +1,153 @@ import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum, - } from "nuqs/server"; - import * as z from "zod"; - - import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; - - // ============= 메인 검색 파라미터 스키마 ============= - - export const searchParamsEvaluationTargetsCache = createSearchParamsCache({ - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<any>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 기본 필터들 - evaluationYear: parseAsInteger.withDefault(new Date().getFullYear()), - division: parseAsString.withDefault(""), - status: parseAsString.withDefault(""), - domesticForeign: parseAsString.withDefault(""), - materialType: parseAsString.withDefault(""), - consensusStatus: parseAsString.withDefault(""), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 베이직 필터 (커스텀 필터 패널용) - basicFilters: getFiltersStateParser().withDefault([]), - basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 - search: parseAsString.withDefault(""), - }); - - // ============= 타입 정의 ============= - - export type GetEvaluationTargetsSchema = Awaited< - ReturnType<typeof searchParamsEvaluationTargetsCache.parse> - >; - - export type EvaluationTargetStatus = "PENDING" | "CONFIRMED" | "EXCLUDED"; - export type Division = "PLANT" | "SHIP"; - export type MaterialType = "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK"; - export type DomesticForeign = "DOMESTIC" | "FOREIGN"; - - // ============= 필터 옵션 상수들 ============= - - export const EVALUATION_TARGET_FILTER_OPTIONS = { - DIVISIONS: [ - { value: "PLANT", label: "해양" }, - { value: "SHIP", label: "조선" }, - ], - STATUSES: [ - { value: "PENDING", label: "검토 중" }, - { value: "CONFIRMED", label: "확정" }, - { value: "EXCLUDED", label: "제외" }, - ], - DOMESTIC_FOREIGN: [ - { value: "DOMESTIC", label: "내자" }, - { value: "FOREIGN", label: "외자" }, - ], - MATERIAL_TYPES: [ - { value: "EQUIPMENT", label: "기자재" }, - { value: "BULK", label: "벌크" }, - { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, - ], - CONSENSUS_STATUS: [ - { value: "true", label: "의견 일치" }, - { value: "false", label: "의견 불일치" }, - { value: "null", label: "검토 중" }, - ], - } as const; - - // ============= 유효성 검사 함수들 ============= - - export function validateEvaluationYear(year: number): boolean { - const currentYear = new Date().getFullYear(); - return year >= 2020 && year <= currentYear + 1; - } - - export function validateDivision(division: string): division is Division { - return ["PLANT", "SHIP"].includes(division); - } - - export function validateStatus(status: string): status is EvaluationTargetStatus { - return ["PENDING", "CONFIRMED", "EXCLUDED"].includes(status); - } - - export function validateMaterialType(materialType: string): materialType is MaterialType { - return ["EQUIPMENT", "BULK", "EQUIPMENT_BULK"].includes(materialType); - } - - export function validateDomesticForeign(domesticForeign: string): domesticForeign is DomesticForeign { - return ["DOMESTIC", "FOREIGN"].includes(domesticForeign); - } - - // ============= 기본값 제공 함수들 ============= - - export function getDefaultEvaluationYear(): number { - return new Date().getFullYear(); - } - - export function getDefaultSearchParams(): GetEvaluationTargetsSchema { - return { - flags: [], - page: 1, - perPage: 10, - sort: [{ id: "createdAt", desc: true }], - evaluationYear: getDefaultEvaluationYear(), - division: "", - status: "", - domesticForeign: "", - materialType: "", - consensusStatus: "", - filters: [], - joinOperator: "and", - basicFilters: [], - basicJoinOperator: "and", - search: "", - }; - } - - // ============= 편의 함수들 ============= - - // 상태별 라벨 반환 - export function getStatusLabel(status: EvaluationTargetStatus): string { - const statusMap = { - PENDING: "검토 중", - CONFIRMED: "확정", - EXCLUDED: "제외" - }; - return statusMap[status] || status; - } - - // 구분별 라벨 반환 - export function getDivisionLabel(division: Division): string { - const divisionMap = { - PLANT: "해양", - SHIP: "조선" - }; - return divisionMap[division] || division; - } - - // 자재구분별 라벨 반환 - export function getMaterialTypeLabel(materialType: MaterialType): string { - const materialTypeMap = { - EQUIPMENT: "기자재", - BULK: "벌크", - EQUIPMENT_BULK: "기자재/벌크" - }; - return materialTypeMap[materialType] || materialType; - } - - // 내외자별 라벨 반환 - export function getDomesticForeignLabel(domesticForeign: DomesticForeign): string { - const domesticForeignMap = { - DOMESTIC: "내자", - FOREIGN: "외자" - }; - return domesticForeignMap[domesticForeign] || domesticForeign; - } + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { Division, DomesticForeign, EvaluationTargetStatus, MaterialType, divisionMap, domesticForeignMap, vendortypeMap } from "@/types/evaluation"; + +// ============= 메인 검색 파라미터 스키마 ============= + +export const searchParamsEvaluationTargetsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<any>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 기본 필터들 + evaluationYear: parseAsInteger.withDefault(new Date().getFullYear()), + division: parseAsString.withDefault(""), + status: parseAsString.withDefault(""), + domesticForeign: parseAsString.withDefault(""), + materialType: parseAsString.withDefault(""), + consensusStatus: parseAsString.withDefault(""), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 베이직 필터 (커스텀 필터 패널용) + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 + search: parseAsString.withDefault(""), +}); + +// ============= 타입 정의 ============= + +export type GetEvaluationTargetsSchema = Awaited< + ReturnType<typeof searchParamsEvaluationTargetsCache.parse> +>; + + +// ============= 필터 옵션 상수들 ============= + +export const EVALUATION_TARGET_FILTER_OPTIONS = { + DIVISIONS: [ + { value: "PLANT", label: "해양" }, + { value: "SHIP", label: "조선" }, + ], + STATUSES: [ + { value: "PENDING", label: "검토 중" }, + { value: "CONFIRMED", label: "확정" }, + { value: "EXCLUDED", label: "제외" }, + ], + DOMESTIC_FOREIGN: [ + { value: "DOMESTIC", label: "내자" }, + { value: "FOREIGN", label: "외자" }, + ], + MATERIAL_TYPES: [ + { value: "EQUIPMENT", label: "기자재" }, + { value: "BULK", label: "벌크" }, + { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, + ], + CONSENSUS_STATUS: [ + { value: "true", label: "의견 일치" }, + { value: "false", label: "의견 불일치" }, + { value: "null", label: "검토 중" }, + ], +} as const; + +// ============= 유효성 검사 함수들 ============= + +export function validateEvaluationYear(year: number): boolean { + const currentYear = new Date().getFullYear(); + return year >= 2020 && year <= currentYear + 1; +} + +export function validateDivision(division: string): division is Division { + return ["PLANT", "SHIP"].includes(division); +} + +export function validateStatus(status: string): status is EvaluationTargetStatus { + return ["PENDING", "CONFIRMED", "EXCLUDED"].includes(status); +} + +export function validateMaterialType(materialType: string): materialType is MaterialType { + return ["EQUIPMENT", "BULK", "EQUIPMENT_BULK"].includes(materialType); +} + +export function validateDomesticForeign(domesticForeign: string): domesticForeign is DomesticForeign { + return ["DOMESTIC", "FOREIGN"].includes(domesticForeign); +} + +// ============= 기본값 제공 함수들 ============= + +export function getDefaultEvaluationYear(): number { + return new Date().getFullYear(); +} + +export function getDefaultSearchParams(): GetEvaluationTargetsSchema { + return { + flags: [], + page: 1, + perPage: 10, + sort: [{ id: "createdAt", desc: true }], + evaluationYear: getDefaultEvaluationYear(), + division: "", + status: "", + domesticForeign: "", + materialType: "", + consensusStatus: "", + filters: [], + joinOperator: "and", + basicFilters: [], + basicJoinOperator: "and", + search: "", + }; +} + +// ============= 편의 함수들 ============= + +// 상태별 라벨 반환 +export function getStatusLabel(status: EvaluationTargetStatus): string { + const statusMap = { + PENDING: "검토 중", + CONFIRMED: "확정", + EXCLUDED: "제외" + }; + return statusMap[status] || status; +} + +// 구분별 라벨 반환 +export function getDivisionLabel(division: Division): string { + return divisionMap[division] || division; +} + +// 자재구분별 라벨 반환 +export function getMaterialTypeLabel(materialType: MaterialType): string { + return vendortypeMap[materialType] || materialType; +} + +// 내외자별 라벨 반환 +export function getDomesticForeignLabel(domesticForeign: DomesticForeign): string { + return domesticForeignMap[domesticForeign] || domesticForeign; +} |
