summaryrefslogtreecommitdiff
path: root/lib/evaluation-target-list
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-07 01:44:45 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-07 01:44:45 +0000
commit90f79a7a691943a496f67f01c1e493256070e4de (patch)
tree37275fde3ae08c2bca384fbbc8eb378de7e39230 /lib/evaluation-target-list
parentfbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff)
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib/evaluation-target-list')
-rw-r--r--lib/evaluation-target-list/service.ts80
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table copy.tsx508
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-columns.tsx405
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx119
-rw-r--r--lib/evaluation-target-list/validation.ts318
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;
+}