diff options
Diffstat (limited to 'lib/evaluation-target-list/table')
5 files changed, 373 insertions, 531 deletions
diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx index fe0b3188..87be3589 100644 --- a/lib/evaluation-target-list/table/evaluation-target-table.tsx +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -1,76 +1,61 @@ -"use client" - -import * as React from "react" -import { useRouter, 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 { Skeleton } from "@/components/ui/skeleton" +// ============================================================================ +// 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 { 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 { useMemo } from "react" -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" - -interface EvaluationTargetsTableProps { - promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]> - evaluationYear: number - className?: string -} - -// 통계 카드 컴포넌트 (클라이언트 컴포넌트용) +} 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"; + +/* -------------------------------------------------------------------------- */ +/* 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) + const [stats, setStats] = React.useState<any>(null); + const [isLoading, setIsLoading] = React.useState(true); + const [error, setError] = React.useState<string | null>(null); React.useEffect(() => { - let isMounted = true - - async function fetchStats() { + let mounted = true; + (async () => { try { - setIsLoading(true) - setError(null) - const statsData = await getEvaluationTargetsStats(evaluationYear) - - if (isMounted) { - setStats(statsData) - } - } catch (err) { - if (isMounted) { - setError(err instanceof Error ? err.message : 'Failed to fetch stats') - console.error('Error fetching evaluation targets stats:', err) - } + setIsLoading(true); + const data = await getEvaluationTargetsStats(evaluationYear); + mounted && setStats(data); + } catch (e) { + mounted && setError(e instanceof Error ? e.message : "failed"); } finally { - if (isMounted) { - setIsLoading(false) - } + mounted && setIsLoading(false); } - } - - fetchStats() - + })(); return () => { - isMounted = false - } - }, []) + mounted = false; + }; + }, [evaluationYear]); - if (isLoading) { + if (isLoading) return ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> {Array.from({ length: 4 }).map((_, i) => ( @@ -84,42 +69,32 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) </Card> ))} </div> - ) - } - - if (error) { + ); + 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"> - <div className="text-center text-sm text-muted-foreground"> - 통계 데이터를 불러올 수 없습니다: {error} - </div> + <CardContent className="pt-6 text-center text-sm text-muted-foreground"> + 통계 데이터를 불러올 수 없습니다: {error} </CardContent> </Card> </div> - ) - } - - if (!stats) { + ); + 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"> - <div className="text-center text-sm text-muted-foreground"> - 통계 데이터가 없습니다. - </div> + <CardContent className="pt-6 text-center text-sm text-muted-foreground"> + 통계 데이터가 없습니다. </CardContent> </Card> </div> - ) - } + ); - const totalTargets = stats.total || 0 - const pendingTargets = stats.pending || 0 - const confirmedTargets = stats.confirmed || 0 - const excludedTargets = stats.excluded || 0 - const consensusRate = totalTargets > 0 ? Math.round(((stats.consensusTrue || 0) / totalTargets) * 100) : 0 + 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"> @@ -130,7 +105,7 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) <Badge variant="outline">{evaluationYear}년</Badge> </CardHeader> <CardContent> - <div className="text-2xl font-bold">{totalTargets.toLocaleString()}</div> + <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> @@ -144,9 +119,9 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) <Badge variant="secondary">대기</Badge> </CardHeader> <CardContent> - <div className="text-2xl font-bold text-orange-600">{pendingTargets.toLocaleString()}</div> + <div className="text-2xl font-bold text-orange-600">{pending.toLocaleString()}</div> <div className="text-xs text-muted-foreground mt-1"> - {totalTargets > 0 ? Math.round((pendingTargets / totalTargets) * 100) : 0}% of total + {total ? Math.round((pending / total) * 100) : 0}% of total </div> </CardContent> </Card> @@ -155,12 +130,12 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) <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" className="bg-green-600">완료</Badge> + <Badge variant="default">완료</Badge> </CardHeader> <CardContent> - <div className="text-2xl font-bold text-green-600">{confirmedTargets.toLocaleString()}</div> + <div className="text-2xl font-bold text-green-600">{confirmed.toLocaleString()}</div> <div className="text-xs text-muted-foreground mt-1"> - {totalTargets > 0 ? Math.round((confirmedTargets / totalTargets) * 100) : 0}% of total + {total ? Math.round((confirmed / total) * 100) : 0}% of total </div> </CardContent> </Card> @@ -169,9 +144,7 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) <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> + <Badge variant={consensusRate >= 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}>{consensusRate}%</Badge> </CardHeader> <CardContent> <div className="text-2xl font-bold">{consensusRate}%</div> @@ -181,83 +154,92 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) </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 router = useRouter() - const searchParams = useSearchParams() + const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null); + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); + const searchParams = useSearchParams(); - const containerRef = React.useRef<HTMLDivElement>(null) - const [containerTop, setContainerTop] = React.useState(0) + /* --------------------------- layout refs --------------------------- */ + const containerRef = React.useRef<HTMLDivElement>(null); + const [containerTop, setContainerTop] = React.useState(0); - // ✅ 스크롤 이벤트 throttling으로 성능 최적화 + // RFQ 패턴으로 변경: State를 통한 위치 관리 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 - }) + const rect = containerRef.current.getBoundingClientRect(); + setContainerTop(rect.top); } - }, []) - - // ✅ throttle 함수 추가 - const throttledUpdateBounds = React.useCallback(() => { - let timeoutId: NodeJS.Timeout - return () => { - clearTimeout(timeoutId) - timeoutId = setTimeout(updateContainerBounds, 16) // ~60fps - } - }, [updateContainerBounds]) + }, []); React.useEffect(() => { - updateContainerBounds() - - const throttledHandler = throttledUpdateBounds() - + updateContainerBounds(); + const handleResize = () => { - updateContainerBounds() - } - - window.addEventListener('resize', handleResize) - window.addEventListener('scroll', throttledHandler) // ✅ throttled 함수 사용 - + updateContainerBounds(); + }; + + window.addEventListener('resize', handleResize); + window.addEventListener('scroll', updateContainerBounds); + return () => { - window.removeEventListener('resize', handleResize) - window.removeEventListener('scroll', throttledHandler) + window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', updateContainerBounds); + }; + }, [updateContainerBounds]); + + /* ---------------------- 데이터 프리패치 ---------------------- */ + const [promiseData] = React.use(promises); + const tableData = promiseData; + + /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ + const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => { + return searchParams?.get(key) ?? defaultValue ?? ""; + }, [searchParams]); + + // 제네릭 함수는 useCallback 밖에서 정의 + const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => { + try { + const value = getSearchParam(key); + return value ? JSON.parse(value) : defaultValue; + } catch { + return defaultValue; } - }, [updateContainerBounds, throttledUpdateBounds]) + }, [getSearchParam]); - const [promiseData] = React.use(promises) - const tableData = promiseData - - console.log(tableData) +const parseSearchParam = <T,>(key: string, defaultValue: T): T => { + return parseSearchParamHelper(key, defaultValue); +}; + /* ---------------------- 초기 설정 ---------------------------- */ const initialSettings = React.useMemo(() => ({ - page: parseInt(searchParams.get('page') || '1'), - perPage: parseInt(searchParams.get('perPage') || '10'), - sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }], - filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], - joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", - basicFilters: searchParams.get('basicFilters') ? - JSON.parse(searchParams.get('basicFilters')!) : [], - basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", - search: searchParams.get('search') || '', + page: parseInt(getSearchParam("page", "1")), + perPage: parseInt(getSearchParam("perPage", "10")), + sort: parseSearchParam("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: [] - }), [searchParams]) + expandedRows: [], + }), [getSearchParam, parseSearchParamHelper]); + /* --------------------- 프리셋 훅 ------------------------------ */ const { presets, activePresetId, @@ -269,83 +251,59 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: deletePreset, setDefaultPreset, renamePreset, - updateClientState, getCurrentSettings, - } = useTablePresets<EvaluationTargetWithDepartments>('evaluation-targets-table', initialSettings) - - const columns = React.useMemo( - () => getEvaluationTargetsColumns({ setRowAction }), - [setRowAction] - ) - + } = 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: "구분" } +// ]; + +window.addEventListener('beforeunload', () => { + console.trace('[beforeunload] 문서가 통째로 사라지려 합니다!'); +}); + + /* 기본 필터 */ 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: "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: "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" }, - ] - - const currentSettings = useMemo(() => { - return getCurrentSettings() - }, [getCurrentSettings]) - - function getColKey<T>(c: ColumnDef<T>): string | undefined { - if ("accessorKey" in c && c.accessorKey) return c.accessorKey as string - if ("id" in c && c.id) return c.id as string - return undefined - } - - const initialState = useMemo(() => { - return { - sorting: initialSettings.sort.filter(s => - columns.some(c => getColKey(c) === s.id)), - columnVisibility: currentSettings.columnVisibility, - columnPinning: currentSettings.pinnedColumns, - } - }, [columns, currentSettings, initialSettings.sort]) + ]; + + /* current settings */ + const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]); + + const initialState = React.useMemo(() => ({ + sorting: initialSettings.sort.filter((s: any) => columns.some((c: any) => ("accessorKey" in c ? c.accessorKey : c.id) === s.id)), + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + }), [columns, currentSettings, initialSettings.sort]); + /* ----------------------- useDataTable ------------------------ */ const { table } = useDataTable({ data: tableData.data, columns, @@ -355,134 +313,119 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: enablePinning: true, enableAdvancedFilter: true, initialState, - getRowId: (originalRow) => String(originalRow.id), + getRowId: (row) => String(row.id), shallow: false, clearOnDefault: true, - }) + }); - const handleSearch = () => { - setIsFilterPanelOpen(false) - } - - const getActiveBasicFilterCount = () => { + /* ---------------------- helper ------------------------------ */ + const getActiveBasicFilterCount = React.useCallback(() => { try { - const basicFilters = searchParams.get('basicFilters') - return basicFilters ? JSON.parse(basicFilters).length : 0 - } catch (e) { - return 0 + 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', + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", top: `${containerTop}px`, height: `calc(100vh - ${containerTop}px)` }} > - <div className="h-full"> - <EvaluationTargetFilterSheet - isOpen={isFilterPanelOpen} - onClose={() => setIsFilterPanelOpen(false)} - onSearch={handleSearch} - isLoading={false} - /> - </div> + <EvaluationTargetFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={() => setIsFilterPanelOpen(false)} + isLoading={false} + /> </div> - {/* Main Content Container */} - <div - ref={containerRef} - className={cn("relative w-full overflow-hidden", className)} - > + {/* 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' + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%", + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", }} > - {/* Header Bar */} + {/* Header */} <div className="flex items-center justify-between p-4 bg-background shrink-0"> - <div className="flex items-center gap-3"> - <Button - variant="outline" - size="sm" - type='button' - 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> - - <div className="text-sm text-muted-foreground"> - {tableData && ( - <span>총 {tableData.total || tableData.data.length}건</span> + <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 Content Area */} - <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 500px)' }}> - <div className="h-full w-full"> - <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> + {/* 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 93807ef9..e2163cad 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx @@ -4,16 +4,17 @@ 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 { Pencil, Eye, MessageSquare, Check, X } from "lucide-react"; +import { Pencil, Check, X } from "lucide-react"; import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; import { EvaluationTargetWithDepartments } from "@/db/schema"; -import { EditEvaluationTargetSheet } from "./update-evaluation-target"; +import type { DataTableRowAction } from "@/types/table"; +import { formatDate } from "@/lib/utils"; interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EvaluationTargetWithDepartments> | null>>; } -// 상태별 색상 매핑 +// ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 (매번 재생성 방지) const getStatusBadgeVariant = (status: string) => { switch (status) { case "PENDING": @@ -27,7 +28,15 @@ const getStatusBadgeVariant = (status: string) => { } }; -// 의견 일치 여부 배지 +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>; @@ -38,16 +47,14 @@ const getConsensusBadge = (consensusStatus: boolean | null) => { return <Badge variant="destructive">의견 불일치</Badge>; }; -// 구분 배지 const getDivisionBadge = (division: string) => { return ( - <Badge variant={division === "PLANT" ? "default" : "secondary"}> - {division === "PLANT" ? "해양" : "조선"} + <Badge variant={division === "OCEAN" ? "default" : "secondary"}> + {division === "OCEAN" ? "해양" : "조선"} </Badge> ); }; -// 자재구분 배지 const getMaterialTypeBadge = (materialType: string) => { const typeMap = { EQUIPMENT: "기자재", @@ -57,7 +64,6 @@ const getMaterialTypeBadge = (materialType: string) => { return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>; }; -// 내외자 배지 const getDomesticForeignBadge = (domesticForeign: string) => { return ( <Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}> @@ -66,24 +72,27 @@ const getDomesticForeignBadge = (domesticForeign: string) => { ); }; -// 평가 상태 배지 -const getApprovalBadge = (isApproved: boolean | null) => { - if (isApproved === null) { - return <Badge variant="outline" className="text-xs">대기중</Badge>; - } - if (isApproved === true) { - return <Badge variant="default" className="bg-green-600 text-xs">승인</Badge>; +// ✅ 평가 대상 여부 표시 함수 +const getEvaluationTargetBadge = (isTarget: boolean | null) => { + if (isTarget === null) { + return <Badge variant="outline">미정</Badge>; } - return <Badge variant="destructive" className="text-xs">거부</Badge>; + return isTarget ? ( + <Badge variant="default" className="bg-blue-600"> + <Check className="size-3 mr-1" /> + 평가 대상 + </Badge> + ) : ( + <Badge variant="secondary"> + <X className="size-3 mr-1" /> + 평가 제외 + </Badge> + ); }; -export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] { +export function getEvaluationTargetsColumns({ setRowAction }: GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] { return [ - // ═══════════════════════════════════════════════════════════════ - // 기본 정보 - // ═══════════════════════════════════════════════════════════════ - - // Checkbox + // ✅ Checkbox { id: "select", header: ({ table }) => ( @@ -107,15 +116,13 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col enableHiding: false, }, - // ░░░ 평가년도 ░░░ + // ✅ 기본 정보 { accessorKey: "evaluationYear", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />, cell: ({ row }) => <span className="font-medium">{row.getValue("evaluationYear")}</span>, size: 100, }, - - // ░░░ 구분 ░░░ { accessorKey: "division", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />, @@ -127,24 +134,25 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />, cell: ({ row }) => { const status = row.getValue<string>("status"); - const statusMap = { - PENDING: "검토 중", - CONFIRMED: "확정", - EXCLUDED: "제외" - }; return ( <Badge variant={getStatusBadgeVariant(status)}> - {statusMap[status] || status} + {getStatusText(status)} </Badge> ); }, size: 100, }, + { + accessorKey: "consensusStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="의견 일치" />, + cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")), + size: 100, + }, - // ░░░ 벤더 코드 ░░░ - + // ✅ 벤더 정보 그룹 { - header: "협력업체 정보", + id: "vendorInfo", + header: "벤더 정보", columns: [ { accessorKey: "vendorCode", @@ -154,8 +162,6 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col ), size: 120, }, - - // ░░░ 벤더명 ░░░ { accessorKey: "vendorName", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />, @@ -166,267 +172,182 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col ), size: 200, }, - - // ░░░ 내외자 ░░░ { accessorKey: "domesticForeign", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />, cell: ({ row }) => getDomesticForeignBadge(row.getValue("domesticForeign")), size: 80, }, - + { + accessorKey: "materialType", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />, + cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")), + size: 120, + }, ] }, - // ░░░ 자재구분 ░░░ - { - accessorKey: "materialType", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />, - cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")), - size: 120, - }, - - // ░░░ 상태 ░░░ - - - // ░░░ 의견 일치 여부 ░░░ - { - accessorKey: "consensusStatus", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="의견 일치" />, - cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")), - size: 100, - }, - - // ═══════════════════════════════════════════════════════════════ - // 주문 부서 그룹 - // ═══════════════════════════════════════════════════════════════ + // ✅ 발주 담당자 { - header: "발주 평가 담당자", + id: "orderReviewer", + header: "발주 담당자", columns: [ { - accessorKey: "orderDepartmentName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />, - cell: ({ row }) => { - const departmentName = row.getValue<string>("orderDepartmentName"); - return departmentName ? ( - <div className="truncate max-w-[120px]" title={departmentName}> - {departmentName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 120, - }, - { accessorKey: "orderReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, cell: ({ row }) => { const reviewerName = row.getValue<string>("orderReviewerName"); return reviewerName ? ( - <div className="truncate max-w-[100px]" title={reviewerName}> + <div className="truncate max-w-[120px]" title={reviewerName}> {reviewerName} </div> ) : ( <span className="text-muted-foreground">-</span> ); }, - size: 100, + size: 120, }, { accessorKey: "orderIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />, - cell: ({ row }) => getApprovalBadge(row.getValue("orderIsApproved")), - size: 80, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, + cell: ({ row }) => { + const isApproved = row.getValue<boolean>("orderIsApproved"); + return getEvaluationTargetBadge(isApproved); + }, + size: 120, }, - ], + ] }, - // ═══════════════════════════════════════════════════════════════ - // 조달 부서 그룹 - // ═══════════════════════════════════════════════════════════════ + // ✅ 조달 담당자 { - header: "조달 평가 담당자", + id: "procurementReviewer", + header: "조달 담당자", columns: [ { - accessorKey: "procurementDepartmentName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />, - cell: ({ row }) => { - const departmentName = row.getValue<string>("procurementDepartmentName"); - return departmentName ? ( - <div className="truncate max-w-[120px]" title={departmentName}> - {departmentName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 120, - }, - { accessorKey: "procurementReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, cell: ({ row }) => { const reviewerName = row.getValue<string>("procurementReviewerName"); return reviewerName ? ( - <div className="truncate max-w-[100px]" title={reviewerName}> + <div className="truncate max-w-[120px]" title={reviewerName}> {reviewerName} </div> ) : ( <span className="text-muted-foreground">-</span> ); }, - size: 100, + size: 120, }, { accessorKey: "procurementIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />, - cell: ({ row }) => getApprovalBadge(row.getValue("procurementIsApproved")), - size: 80, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, + cell: ({ row }) => { + const isApproved = row.getValue<boolean>("procurementIsApproved"); + return getEvaluationTargetBadge(isApproved); + }, + size: 120, }, - ], + ] }, - // ═══════════════════════════════════════════════════════════════ - // 품질 부서 그룹 - // ═══════════════════════════════════════════════════════════════ + // ✅ 품질 담당자 { - header: "품질 평가 담당자", + id: "qualityReviewer", + header: "품질 담당자", columns: [ { - accessorKey: "qualityDepartmentName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />, - cell: ({ row }) => { - const departmentName = row.getValue<string>("qualityDepartmentName"); - return departmentName ? ( - <div className="truncate max-w-[120px]" title={departmentName}> - {departmentName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 120, - }, - { accessorKey: "qualityReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, cell: ({ row }) => { const reviewerName = row.getValue<string>("qualityReviewerName"); return reviewerName ? ( - <div className="truncate max-w-[100px]" title={reviewerName}> + <div className="truncate max-w-[120px]" title={reviewerName}> {reviewerName} </div> ) : ( <span className="text-muted-foreground">-</span> ); }, - size: 100, + size: 120, }, { accessorKey: "qualityIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />, - cell: ({ row }) => getApprovalBadge(row.getValue("qualityIsApproved")), - size: 80, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, + cell: ({ row }) => { + const isApproved = row.getValue<boolean>("qualityIsApproved"); + return getEvaluationTargetBadge(isApproved); + }, + size: 120, }, - ], + ] }, - // ═══════════════════════════════════════════════════════════════ - // 설계 부서 그룹 - // ═══════════════════════════════════════════════════════════════ + // ✅ 설계 담당자 { - header: "설계 평가 담당자", + id: "designReviewer", + header: "설계 담당자", columns: [ { - accessorKey: "designDepartmentName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />, - cell: ({ row }) => { - const departmentName = row.getValue<string>("designDepartmentName"); - return departmentName ? ( - <div className="truncate max-w-[120px]" title={departmentName}> - {departmentName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 120, - }, - { accessorKey: "designReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, cell: ({ row }) => { const reviewerName = row.getValue<string>("designReviewerName"); return reviewerName ? ( - <div className="truncate max-w-[100px]" title={reviewerName}> + <div className="truncate max-w-[120px]" title={reviewerName}> {reviewerName} </div> ) : ( <span className="text-muted-foreground">-</span> ); }, - size: 100, + size: 120, }, { accessorKey: "designIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />, - cell: ({ row }) => getApprovalBadge(row.getValue("designIsApproved")), - size: 80, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, + cell: ({ row }) => { + const isApproved = row.getValue<boolean>("designIsApproved"); + return getEvaluationTargetBadge(isApproved); + }, + size: 120, }, - ], + ] }, - // ═══════════════════════════════════════════════════════════════ - // CS 부서 그룹 - // ═══════════════════════════════════════════════════════════════ + // ✅ CS 담당자 { - header: "CS 평가 담당자", + id: "csReviewer", + header: "CS 담당자", columns: [ { - accessorKey: "csDepartmentName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />, - cell: ({ row }) => { - const departmentName = row.getValue<string>("csDepartmentName"); - return departmentName ? ( - <div className="truncate max-w-[120px]" title={departmentName}> - {departmentName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 120, - }, - { accessorKey: "csReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, cell: ({ row }) => { const reviewerName = row.getValue<string>("csReviewerName"); return reviewerName ? ( - <div className="truncate max-w-[100px]" title={reviewerName}> + <div className="truncate max-w-[120px]" title={reviewerName}> {reviewerName} </div> ) : ( <span className="text-muted-foreground">-</span> ); }, - size: 100, + size: 120, }, { accessorKey: "csIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />, - cell: ({ row }) => getApprovalBadge(row.getValue("csIsApproved")), - size: 80, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, + cell: ({ row }) => { + const isApproved = row.getValue<boolean>("csIsApproved"); + return getEvaluationTargetBadge(isApproved); + }, + size: 120, }, - ], + ] }, - // ═══════════════════════════════════════════════════════════════ - // 관리 정보 - // ═══════════════════════════════════════════════════════════════ - - // ░░░ 관리자 의견 ░░░ + // ✅ 의견 및 결과 { accessorKey: "adminComment", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="관리자 의견" />, @@ -442,8 +363,6 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col }, size: 150, }, - - // ░░░ 종합 의견 ░░░ { accessorKey: "consolidatedComment", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="종합 의견" />, @@ -459,69 +378,49 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col }, size: 150, }, - - // ░░░ 확정일 ░░░ { accessorKey: "confirmedAt", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />, cell: ({ row }) => { const confirmedAt = row.getValue<Date>("confirmedAt"); - return confirmedAt ? ( - <span className="text-sm"> - {new Intl.DateTimeFormat("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(new Date(confirmedAt))} - </span> - ) : ( - <span className="text-muted-foreground">-</span> - ); + return <span className="text-sm">{formatDate(confirmedAt, "KR")}</span>; }, size: 100, }, - - // ░░░ 생성일 ░░░ { accessorKey: "createdAt", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />, cell: ({ row }) => { const createdAt = row.getValue<Date>("createdAt"); - return createdAt ? ( - <span className="text-sm"> - {new Intl.DateTimeFormat("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(new Date(createdAt))} - </span> - ) : ( - <span className="text-muted-foreground">-</span> - ); + return <span className="text-sm">{formatDate(createdAt, "KR")}</span>; }, size: 100, }, - // ░░░ Actions ░░░ + // ✅ Actions - 가장 안전하게 처리 { id: "actions", enableHiding: false, size: 40, minSize: 40, cell: ({ row }) => { - return ( + // ✅ 함수를 직접 정의해서 매번 새로 생성되지 않도록 처리 + const handleEdit = () => { + setRowAction({ row, type: "update" }); + }; + + return ( <div className="flex items-center gap-1"> <Button variant="ghost" size="icon" className="size-8" - onClick={() => setRowAction({ row, type: "update" })} + onClick={handleEdit} aria-label="수정" title="수정" > <Pencil className="size-4" /> </Button> - </div> ); }, 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 502ee974..c37258ae 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx @@ -365,7 +365,7 @@ export function EvaluationTargetFilterSheet({ setIsInitializing(true); form.reset({ - evaluationYear: new Date().getFullYear().toString(), + evaluationYear: "", division: "", status: "", domesticForeign: "", 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 9043c588..7ea2e0ec 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -239,7 +239,7 @@ export function EvaluationTargetsTableToolbarActions({ /> {/* 선택 정보 표시 */} - {hasSelection && ( + {/* {hasSelection && ( <div className="text-xs text-muted-foreground"> 선택된 {selectedRows.length}개 항목: 대기중 {selectedStats.pending}개, @@ -248,7 +248,7 @@ export function EvaluationTargetsTableToolbarActions({ {selectedStats.consensusTrue > 0 && ` | 의견일치 ${selectedStats.consensusTrue}개`} {selectedStats.consensusFalse > 0 && ` | 의견불일치 ${selectedStats.consensusFalse}개`} </div> - )} + )} */} </> ) }
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx index 0d56addb..9f9b7af4 100644 --- a/lib/evaluation-target-list/table/update-evaluation-target.tsx +++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx @@ -498,7 +498,7 @@ export function EditEvaluationTargetSheet({ {/* 각 부서별 평가 */} <div className="grid grid-cols-1 gap-4"> {[ - { key: "orderIsApproved", label: "주문 부서 평가", email: evaluationTarget.orderReviewerEmail }, + { key: "orderIsApproved", label: "발주 부서 평가", email: evaluationTarget.orderReviewerEmail }, { key: "procurementIsApproved", label: "조달 부서 평가", email: evaluationTarget.procurementReviewerEmail }, { key: "qualityIsApproved", label: "품질 부서 평가", email: evaluationTarget.qualityReviewerEmail }, { key: "designIsApproved", label: "설계 부서 평가", email: evaluationTarget.designReviewerEmail }, @@ -608,7 +608,7 @@ export function EditEvaluationTargetSheet({ </CardHeader> <CardContent className="space-y-4"> {[ - { key: "orderReviewerEmail", label: "주문 부서 담당자", current: evaluationTarget.orderReviewerEmail }, + { key: "orderReviewerEmail", label: "발주 부서 담당자", current: evaluationTarget.orderReviewerEmail }, { key: "procurementReviewerEmail", label: "조달 부서 담당자", current: evaluationTarget.procurementReviewerEmail }, { key: "qualityReviewerEmail", label: "품질 부서 담당자", current: evaluationTarget.qualityReviewerEmail }, { key: "designReviewerEmail", label: "설계 부서 담당자", current: evaluationTarget.designReviewerEmail }, |
