diff options
Diffstat (limited to 'lib/evaluation-target-list')
7 files changed, 3187 insertions, 0 deletions
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts new file mode 100644 index 00000000..62f0f0ef --- /dev/null +++ b/lib/evaluation-target-list/service.ts @@ -0,0 +1,395 @@ +'use server' + +import { and, or, desc, asc, ilike, eq, isNull, sql, count } from "drizzle-orm"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { filterColumns } from "@/lib/filter-columns"; +import db from "@/db/db"; +import { + evaluationTargets, + evaluationTargetReviewers, + evaluationTargetReviews, + users, + vendors, + type EvaluationTargetStatus, + type Division, + type MaterialType, + type DomesticForeign, + EVALUATION_DEPARTMENT_CODES, + EvaluationTargetWithDepartments, + evaluationTargetsWithDepartments +} from "@/db/schema"; +import { GetEvaluationTargetsSchema } from "./validation"; +import { PgTransaction } from "drizzle-orm/pg-core"; + + +export async function selectEvaluationTargetsFromView( + tx: PgTransaction<any, any, any>, + params: { + where?: any; + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(evaluationTargetsWithDepartments) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} + +/** 총 개수 count */ +export async function countEvaluationTargetsFromView( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx + .select({ count: count() }) + .from(evaluationTargetsWithDepartments) + .where(where); + + return res[0]?.count ?? 0; +} + +// ============= 메인 서버 액션도 함께 수정 ============= + +export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + // 고급 필터링 (View 테이블 기준) + const advancedWhere = filterColumns({ + table: evaluationTargetsWithDepartments, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 베이직 필터링 (커스텀 필터) + let basicWhere; + if (input.basicFilters && input.basicFilters.length > 0) { + basicWhere = filterColumns({ + table: evaluationTargetsWithDepartments, + filters: input.basicFilters, + joinOperator: input.basicJoinOperator || "and", + }); + } + + // 전역 검색 (View 테이블 기준) + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(evaluationTargetsWithDepartments.vendorCode, s), + ilike(evaluationTargetsWithDepartments.vendorName, s), + ilike(evaluationTargetsWithDepartments.adminComment, s), + ilike(evaluationTargetsWithDepartments.consolidatedComment, s), + // 담당자 이름으로도 검색 가능 + ilike(evaluationTargetsWithDepartments.orderReviewerName, s), + ilike(evaluationTargetsWithDepartments.procurementReviewerName, s), + ilike(evaluationTargetsWithDepartments.qualityReviewerName, s), + ilike(evaluationTargetsWithDepartments.designReviewerName, s), + ilike(evaluationTargetsWithDepartments.csReviewerName, s) + ); + } + + const finalWhere = and(advancedWhere, basicWhere, globalWhere); + + // 정렬 (View 테이블 기준) + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + const column = evaluationTargetsWithDepartments[item.id as keyof typeof evaluationTargetsWithDepartments]; + return item.desc ? desc(column) : asc(column); + }) + : [desc(evaluationTargetsWithDepartments.createdAt)]; + + // 데이터 조회 - View 테이블 사용 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectEvaluationTargetsFromView(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countEvaluationTargetsFromView(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount, total }; + } catch (err) { + console.error("Error in getEvaluationTargets:", err); + return { data: [], pageCount: 0 }; + } +} + +// ============= 개별 조회 함수도 업데이트 ============= + +export async function getEvaluationTargetById(id: number): Promise<EvaluationTargetWithDepartments | null> { + try { + const results = await db.transaction(async (tx) => { + return await selectEvaluationTargetsFromView(tx, { + where: eq(evaluationTargetsWithDepartments.id, id), + limit: 1, + }); + }); + + return results[0] || null; + } catch (err) { + console.error("Error in getEvaluationTargetById:", err); + return null; + } +} + +// 통계 조회도 View 기반으로 변경 +export async function getEvaluationTargetsStats(evaluationYear: number) { + try { + const stats = await db.transaction(async (tx) => { + const result = await tx + .select({ + total: count(), + pending: sql<number>`sum(case when status = 'PENDING' then 1 else 0 end)`, + confirmed: sql<number>`sum(case when status = 'CONFIRMED' then 1 else 0 end)`, + excluded: sql<number>`sum(case when status = 'EXCLUDED' then 1 else 0 end)`, + consensusTrue: sql<number>`sum(case when consensus_status = true then 1 else 0 end)`, + consensusFalse: sql<number>`sum(case when consensus_status = false then 1 else 0 end)`, + consensusNull: sql<number>`sum(case when consensus_status is null then 1 else 0 end)`, + oceanDivision: sql<number>`sum(case when division = 'OCEAN' then 1 else 0 end)`, + shipyardDivision: sql<number>`sum(case when division = 'SHIPYARD' then 1 else 0 end)`, + }) + .from(evaluationTargetsWithDepartments) + .where(eq(evaluationTargetsWithDepartments.evaluationYear, evaluationYear)); + + return result[0]; + }); + + return stats; + } catch (err) { + console.error("Error in getEvaluationTargetsStats:", err); + return null; + } +} + + +// ============= 수동 생성 관련 서버 액션 ============= + +// 평가 대상 수동 생성 인터페이스 +export interface CreateEvaluationTargetInput { + evaluationYear: number; + division: Division; + vendorId: number; + materialType: MaterialType; + adminComment?: string; + // 각 부서별 담당자 지정 + reviewers: { + departmentCode: keyof typeof EVALUATION_DEPARTMENT_CODES; + reviewerUserId: number; + }[]; +} + +// 평가 대상 수동 생성 +// service.ts 파일의 CreateEvaluationTargetInput 타입 수정 +export interface CreateEvaluationTargetInput { + evaluationYear: number + division: "OCEAN" | "SHIPYARD" + vendorId: number + materialType: "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK" + adminComment?: string + // ✅ 추가된 L/D 클레임 필드들 + ldClaimCount?: number + ldClaimAmount?: number + ldClaimCurrency?: "KRW" | "USD" | "EUR" | "JPY" + reviewers: Array<{ + departmentCode: string + reviewerUserId: number + }> +} + +// createEvaluationTarget 함수 수정 +// service.ts 수정 +export async function createEvaluationTarget( + input: CreateEvaluationTargetInput, + createdBy: number +) { + + console.log(input,"input") + try { + return await db.transaction(async (tx) => { + // 벤더 정보 조회 (기존과 동일) + const vendor = await tx + .select({ + id: vendors.id, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + country: vendors.country, + }) + .from(vendors) + .where(eq(vendors.id, input.vendorId)) + .limit(1); + + if (!vendor.length) { + throw new Error("벤더를 찾을 수 없습니다."); + } + + const vendorInfo = vendor[0]; + + // 중복 체크 (기존과 동일) + const existing = await tx + .select({ id: evaluationTargets.id }) + .from(evaluationTargets) + .where( + and( + eq(evaluationTargets.evaluationYear, input.evaluationYear), + eq(evaluationTargets.vendorId, input.vendorId), + eq(evaluationTargets.materialType, input.materialType) + ) + ) + .limit(1); + + if (existing.length > 0) { + throw new Error("이미 동일한 평가 대상이 존재합니다."); + } + + // 평가 대상 생성 (기존과 동일) + const newEvaluationTarget = await tx + .insert(evaluationTargets) + .values({ + evaluationYear: input.evaluationYear, + division: input.division, + vendorId: input.vendorId, + vendorCode: vendorInfo.vendorCode || "", + vendorName: vendorInfo.vendorName, + domesticForeign: vendorInfo.country === "KR" ? "DOMESTIC" : "FOREIGN", + materialType: input.materialType, + status: "PENDING", + adminComment: input.adminComment, + adminUserId: createdBy, + ldClaimCount: input.ldClaimCount || 0, + ldClaimAmount: input.ldClaimAmount?.toString() || "0", + ldClaimCurrency: input.ldClaimCurrency || "KRW", + }) + .returning({ id: evaluationTargets.id }); + + const evaluationTargetId = newEvaluationTarget[0].id; + + // ✅ 담당자들 지정 (departmentNameFrom 추가) + if (input.reviewers && input.reviewers.length > 0) { + // 담당자들의 부서 정보 조회 + const reviewerIds = input.reviewers.map(r => r.reviewerUserId); + const reviewerInfos = await tx + .select({ + id: users.id, + departmentName: users.departmentName, // users 테이블에 부서명 필드가 있다고 가정 + }) + .from(users) + .where(sql`${users.id} = ANY(${reviewerIds})`); + + const reviewerAssignments = input.reviewers.map((reviewer) => { + const reviewerInfo = reviewerInfos.find(info => info.id === reviewer.reviewerUserId); + + return { + evaluationTargetId, + departmentCode: reviewer.departmentCode, + departmentNameFrom: reviewerInfo?.departmentName || null, // ✅ 실제 부서명 저장 + reviewerUserId: reviewer.reviewerUserId, + assignedBy: createdBy, + }; + }); + + await tx.insert(evaluationTargetReviewers).values(reviewerAssignments); + } + + return { + success: true, + evaluationTargetId, + message: "평가 대상이 성공적으로 생성되었습니다.", + }; + }); + } catch (error) { + console.error("Error creating evaluation target:", error); + return { + success: false, + error: error instanceof Error ? error.message : "평가 대상 생성 중 오류가 발생했습니다.", + }; + } +} + +// 담당자 목록 조회 시 부서 정보도 함께 반환 +export async function getAvailableReviewers(departmentCode?: string) { + try { + const reviewers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + // departmentName: "API로 추후", // ✅ 부서명도 반환 + }) + .from(users) + .orderBy(users.name) + .limit(100); + + return reviewers; + } catch (error) { + console.error("Error fetching available reviewers:", error); + return []; + } +} + +// 사용 가능한 벤더 목록 조회 +export async function getAvailableVendors(search?: string) { + try { + let query = db + .select({ + id: vendors.id, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + status: vendors.status, + }) + .from(vendors) + .where( + and( + // 활성 상태인 벤더만 + // eq(vendors.status, "ACTIVE"), + // 검색어가 있으면 적용 + search + ? or( + ilike(vendors.vendorCode, `%${search}%`), + ilike(vendors.vendorName, `%${search}%`) + ) + : undefined + ) + ) + .orderBy(vendors.vendorName) + .limit(100); + + return await query; + } catch (error) { + console.error("Error fetching available vendors:", error); + return []; + } +} + + +// 부서 정보 조회 (상수에서) +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], + key, + }; + }); +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx new file mode 100644 index 00000000..15837733 --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -0,0 +1,452 @@ +"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" +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" + +interface EvaluationTargetsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]> + evaluationYear: number + className?: string +} + +// 통계 카드 컴포넌트 (클라이언트 컴포넌트용) +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 isMounted = true + + async function fetchStats() { + 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) + } + } finally { + if (isMounted) { + setIsLoading(false) + } + } + } + + fetchStats() + + return () => { + isMounted = false + } + }, []) + + 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"> + <div className="text-center text-sm text-muted-foreground"> + 통계 데이터를 불러올 수 없습니다: {error} + </div> + </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"> + <div className="text-center text-sm text-muted-foreground"> + 통계 데이터가 없습니다. + </div> + </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 + + 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">{totalTargets.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">{pendingTargets.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {totalTargets > 0 ? Math.round((pendingTargets / totalTargets) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + {/* 확정 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">확정</CardTitle> + <Badge variant="default" className="bg-green-600">완료</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{confirmedTargets.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {totalTargets > 0 ? Math.round((confirmedTargets / totalTargets) * 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> + ) +} + +export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null) + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + console.count("E Targets render"); + const router = useRouter() + const searchParams = useSearchParams() + + const containerRef = React.useRef<HTMLDivElement>(null) + const [containerTop, setContainerTop] = React.useState(0) + + 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 + + console.log("Evaluation Targets Table Data:", { + dataLength: tableData.data?.length, + pageCount: tableData.pageCount, + total: tableData.total, + sampleData: tableData.data?.[0] + }) + + 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') || '', + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: ["actions"] }, + groupBy: [], + expandedRows: [] + }), [searchParams]) + + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + updateClientState, + getCurrentSettings, + } = useTablePresets<EvaluationTargetWithDepartments>('evaluation-targets-table', initialSettings) + + const columns = React.useMemo( + () => getEvaluationTargetsColumns(), + [] + ) + + 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" }, + ] + + const currentSettings = useMemo(() => { + return getCurrentSettings() + }, [getCurrentSettings]) + + const initialState = useMemo(() => { + return { + sorting: initialSettings.sort.filter(sortItem => { + const columnExists = columns.some(col => col.accessorKey === sortItem.id || col.id === sortItem.id) + return columnExists + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + const handleSearch = () => { + setIsFilterPanelOpen(false) + } + + const getActiveBasicFilterCount = () => { + try { + const basicFilters = searchParams.get('basicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch (e) { + return 0 + } + } + + const FILTER_PANEL_WIDTH = 400; + + 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)` + }} + > + <div className="h-full"> + <EvaluationTargetFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} + /> + </div> + </div> + + {/* Main Content 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 Bar */} + <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> + )} + </div> + </div> + + {/* 통계 카드들 */} + <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> + </div> + </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 new file mode 100644 index 00000000..b1e19434 --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx @@ -0,0 +1,345 @@ +"use client"; +import * as React from "react"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Pencil, Eye, MessageSquare, Check, X } from "lucide-react"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import { EvaluationTargetWithDepartments } from "@/db/schema"; + +// 상태별 색상 매핑 +const getStatusBadgeVariant = (status: string) => { + switch (status) { + case "PENDING": + return "secondary"; + case "CONFIRMED": + return "default"; + case "EXCLUDED": + return "destructive"; + default: + return "outline"; + } +}; + +// 의견 일치 여부 배지 +const getConsensusBadge = (consensusStatus: boolean | null) => { + if (consensusStatus === null) { + return <Badge variant="outline">검토 중</Badge>; + } + if (consensusStatus === true) { + return <Badge variant="default" className="bg-green-600">의견 일치</Badge>; + } + return <Badge variant="destructive">의견 불일치</Badge>; +}; + +// 구분 배지 +const getDivisionBadge = (division: string) => { + return ( + <Badge variant={division === "OCEAN" ? "default" : "secondary"}> + {division === "OCEAN" ? "해양" : "조선"} + </Badge> + ); +}; + +// 자재구분 배지 +const getMaterialTypeBadge = (materialType: string) => { + const typeMap = { + EQUIPMENT: "기자재", + BULK: "벌크", + EQUIPMENT_BULK: "기자재/벌크" + }; + return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>; +}; + +// 내외자 배지 +const getDomesticForeignBadge = (domesticForeign: string) => { + return ( + <Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}> + {domesticForeign === "DOMESTIC" ? "내자" : "외자"} + </Badge> + ); +}; + +export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDepartments>[] { + return [ + // 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" + /> + ), + size: 40, + enableSorting: false, + 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="구분" />, + cell: ({ row }) => getDivisionBadge(row.getValue("division")), + size: 80, + }, + + // ░░░ 벤더 코드 ░░░ + { + accessorKey: "vendorCode", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더 코드" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.getValue("vendorCode")}</span> + ), + 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> + ), + 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: "status", + 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} + </Badge> + ); + }, + size: 100, + }, + + // ░░░ 의견 일치 여부 ░░░ + { + accessorKey: "consensusStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="의견 일치" />, + cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")), + size: 100, + }, + + // ░░░ 담당자 현황 ░░░ + { + id: "reviewers", + header: "담당자 현황", + cell: ({ row }) => { + const reviewers = row.original.reviewers || []; + const totalReviewers = reviewers.length; + const completedReviews = reviewers.filter(r => r.review?.isApproved !== null).length; + const approvedReviews = reviewers.filter(r => r.review?.isApproved === true).length; + + return ( + <div className="flex items-center gap-2"> + <div className="text-xs"> + <span className="text-green-600 font-medium">{approvedReviews}</span> + <span className="text-muted-foreground">/{completedReviews}</span> + <span className="text-muted-foreground">/{totalReviewers}</span> + </div> + {totalReviewers > 0 && ( + <div className="flex gap-1"> + {reviewers.slice(0, 3).map((reviewer, idx) => ( + <div + key={idx} + className={`w-2 h-2 rounded-full ${ + reviewer.review?.isApproved === true + ? "bg-green-500" + : reviewer.review?.isApproved === false + ? "bg-red-500" + : "bg-gray-300" + }`} + title={`${reviewer.departmentCode}: ${ + reviewer.review?.isApproved === true + ? "승인" + : reviewer.review?.isApproved === false + ? "거부" + : "대기중" + }`} + /> + ))} + {totalReviewers > 3 && ( + <span className="text-xs text-muted-foreground">+{totalReviewers - 3}</span> + )} + </div> + )} + </div> + ); + }, + size: 120, + enableSorting: false, + }, + + // ░░░ 관리자 의견 ░░░ + { + 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> + ); + }, + 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> + ); + }, + 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> + ); + }, + size: 100, + }, + + // ░░░ Actions ░░░ + { + id: "actions", + enableHiding: false, + size: 120, + minSize: 120, + cell: ({ row }) => { + const record = row.original; + const [openDetail, setOpenDetail] = React.useState(false); + const [openEdit, setOpenEdit] = React.useState(false); + const [openRequest, setOpenRequest] = React.useState(false); + + return ( + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setOpenDetail(true)} + aria-label="상세보기" + title="상세보기" + > + <Eye className="size-4" /> + </Button> + + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setOpenEdit(true)} + aria-label="수정" + title="수정" + > + <Pencil className="size-4" /> + </Button> + + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setOpenRequest(true)} + aria-label="의견요청" + title="의견요청" + > + <MessageSquare className="size-4" /> + </Button> + + {/* TODO: 실제 다이얼로그 컴포넌트들로 교체 */} + {openDetail && ( + <div onClick={() => setOpenDetail(false)}> + {/* <EvaluationTargetDetailDialog /> */} + </div> + )} + {openEdit && ( + <div onClick={() => setOpenEdit(false)}> + {/* <EditEvaluationTargetDialog /> */} + </div> + )} + {openRequest && ( + <div onClick={() => setOpenRequest(false)}> + {/* <RequestReviewDialog /> */} + </div> + )} + </div> + ); + }, + }, + ]; +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx new file mode 100644 index 00000000..c14ae83f --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx @@ -0,0 +1,756 @@ +"use client" + +import { useEffect, useTransition, useState, useRef } from "react" +import { useRouter, useParams } from "next/navigation" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Search, X } from "lucide-react" +import { customAlphabet } from "nanoid" +import { parseAsStringEnum, useQueryState } from "nuqs" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { getFiltersStateParser } from "@/lib/parsers" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// 평가 대상 필터 스키마 정의 +const evaluationTargetFilterSchema = z.object({ + evaluationYear: z.string().optional(), + division: z.string().optional(), + status: z.string().optional(), + domesticForeign: z.string().optional(), + materialType: z.string().optional(), + consensusStatus: z.string().optional(), + vendorCode: z.string().optional(), + vendorName: z.string().optional(), + reviewerUserId: z.string().optional(), // 담당자 ID로 필터링 +}) + +// 옵션 정의 +const divisionOptions = [ + { value: "OCEAN", label: "해양" }, + { value: "SHIPYARD", label: "조선" }, +] + +const statusOptions = [ + { value: "PENDING", label: "검토 중" }, + { value: "CONFIRMED", label: "확정" }, + { value: "EXCLUDED", label: "제외" }, +] + +const domesticForeignOptions = [ + { value: "DOMESTIC", label: "내자" }, + { value: "FOREIGN", label: "외자" }, +] + +const materialTypeOptions = [ + { value: "EQUIPMENT", label: "기자재" }, + { value: "BULK", label: "벌크" }, + { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, +] + +const consensusStatusOptions = [ + { value: "true", label: "의견 일치" }, + { value: "false", label: "의견 불일치" }, + { value: "null", label: "검토 중" }, +] + +type EvaluationTargetFilterFormValues = z.infer<typeof evaluationTargetFilterSchema> + +interface EvaluationTargetFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +export function EvaluationTargetFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: EvaluationTargetFilterSheetProps) { + const router = useRouter() + const params = useParams(); + const lng = params ? (params.lng as string) : 'ko'; + + const [isPending, startTransition] = useTransition() + + // 초기화 상태 추가 + const [isInitializing, setIsInitializing] = useState(false) + const lastAppliedFilters = useRef<string>("") + + // nuqs로 URL 상태 관리 + const [filters, setFilters] = useQueryState( + "basicFilters", + getFiltersStateParser().withDefault([]) + ) + + // joinOperator 설정 + const [joinOperator, setJoinOperator] = useQueryState( + "basicJoinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and") + ) + + // 현재 URL의 페이지 파라미터도 가져옴 + const [page, setPage] = useQueryState("page", { defaultValue: "1" }) + + // 폼 상태 초기화 + const form = useForm<EvaluationTargetFilterFormValues>({ + resolver: zodResolver(evaluationTargetFilterSchema), + defaultValues: { + evaluationYear: new Date().getFullYear().toString(), + division: "", + status: "", + domesticForeign: "", + materialType: "", + consensusStatus: "", + vendorCode: "", + vendorName: "", + reviewerUserId: "", + }, + }) + + // URL 필터에서 초기 폼 상태 설정 + useEffect(() => { + const currentFiltersString = JSON.stringify(filters); + + if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { + setIsInitializing(true); + + const formValues = { ...form.getValues() }; + let formUpdated = false; + + filters.forEach(filter => { + if (filter.id in formValues) { + // @ts-ignore - 동적 필드 접근 + formValues[filter.id] = filter.value; + formUpdated = true; + } + }); + + if (formUpdated) { + form.reset(formValues); + lastAppliedFilters.current = currentFiltersString; + } + + setIsInitializing(false); + } + }, [filters, isOpen]) + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + + // 폼 제출 핸들러 + async function onSubmit(data: EvaluationTargetFilterFormValues) { + if (isInitializing) return; + + startTransition(async () => { + try { + const newFilters = [] + + if (data.evaluationYear?.trim()) { + newFilters.push({ + id: "evaluationYear", + value: parseInt(data.evaluationYear.trim()), + type: "number", + operator: "eq", + rowId: generateId() + }) + } + + if (data.division?.trim()) { + newFilters.push({ + id: "division", + value: data.division.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.domesticForeign?.trim()) { + newFilters.push({ + id: "domesticForeign", + value: data.domesticForeign.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.materialType?.trim()) { + newFilters.push({ + id: "materialType", + value: data.materialType.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.consensusStatus?.trim()) { + newFilters.push({ + id: "consensusStatus", + value: data.consensusStatus.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.vendorCode?.trim()) { + newFilters.push({ + id: "vendorCode", + value: data.vendorCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.vendorName?.trim()) { + newFilters.push({ + id: "vendorName", + value: data.vendorName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.reviewerUserId?.trim()) { + newFilters.push({ + id: "reviewerUserId", + value: parseInt(data.reviewerUserId.trim()), + type: "number", + operator: "eq", + rowId: generateId() + }) + } + + // URL 업데이트 + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 기존 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('basicJoinOperator'); + params.delete('page'); + + // 새로운 필터 추가 + if (newFilters.length > 0) { + params.set('basicFilters', JSON.stringify(newFilters)); + params.set('basicJoinOperator', joinOperator); + } + + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("New Evaluation Target Filter URL:", newUrl); + + // 페이지 완전 새로고침 + window.location.href = newUrl; + + lastAppliedFilters.current = JSON.stringify(newFilters); + + if (onSearch) { + console.log("Calling evaluation target onSearch..."); + onSearch(); + } + + console.log("=== Evaluation Target Filter Submit Complete ==="); + } catch (error) { + console.error("평가 대상 필터 적용 오류:", error); + } + }) + } + + // 필터 초기화 핸들러 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + evaluationYear: new Date().getFullYear().toString(), + division: "", + status: "", + domesticForeign: "", + materialType: "", + consensusStatus: "", + vendorCode: "", + vendorName: "", + reviewerUserId: "", + }); + + // URL 초기화 + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + params.delete('basicFilters'); + params.delete('basicJoinOperator'); + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + window.location.href = newUrl; + + lastAppliedFilters.current = ""; + setIsInitializing(false); + } catch (error) { + console.error("평가 대상 필터 초기화 오류:", error); + setIsInitializing(false); + } + } + + if (!isOpen) { + return null; + } + + return ( + <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> + {/* Filter Panel Header */} + <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> + <h3 className="text-lg font-semibold whitespace-nowrap">평가 대상 검색 필터</h3> + <div className="flex items-center gap-2"> + {getActiveFilterCount() > 0 && ( + <Badge variant="secondary" className="px-2 py-1"> + {getActiveFilterCount()}개 필터 적용됨 + </Badge> + )} + </div> + </div> + + {/* Join Operator Selection */} + <div className="px-6 shrink-0"> + <label className="text-sm font-medium">조건 결합 방식</label> + <Select + value={joinOperator} + onValueChange={(value: "and" | "or") => setJoinOperator(value)} + disabled={isInitializing} + > + <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> + <SelectValue placeholder="조건 결합 방식" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> + <SelectItem value="or">하나라도 충족 (OR)</SelectItem> + </SelectContent> + </Select> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> + {/* Scrollable content area */} + <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> + <div className="space-y-4 pt-2"> + + {/* 평가년도 */} + <FormField + control={form.control} + name="evaluationYear" + render={({ field }) => ( + <FormItem> + <FormLabel>평가년도</FormLabel> + <FormControl> + <div className="relative"> + <Input + type="number" + placeholder="평가년도 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("evaluationYear", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 구분 */} + <FormField + control={form.control} + name="division" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="구분 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("division", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {divisionOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 상태 */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>상태</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="상태 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("status", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {statusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 내외자 구분 */} + <FormField + control={form.control} + name="domesticForeign" + render={({ field }) => ( + <FormItem> + <FormLabel>내외자 구분</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="내외자 구분 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("domesticForeign", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {domesticForeignOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재구분 */} + <FormField + control={form.control} + name="materialType" + render={({ field }) => ( + <FormItem> + <FormLabel>자재구분</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="자재구분 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("materialType", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {materialTypeOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 의견 일치 여부 */} + <FormField + control={form.control} + name="consensusStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>의견 일치 여부</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="의견 일치 여부 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("consensusStatus", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {consensusStatusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더 코드 */} + <FormField + control={form.control} + name="vendorCode" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더 코드</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="벤더 코드 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("vendorCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더명 */} + <FormField + control={form.control} + name="vendorName" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더명</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="벤더명 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("vendorName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + </div> + </div> + + {/* Fixed buttons at bottom */} + <div className="p-4 shrink-0"> + <div className="flex gap-2 justify-end"> + <Button + type="button" + variant="outline" + onClick={handleReset} + disabled={isPending || getActiveFilterCount() === 0 || isInitializing} + className="px-4" + > + 초기화 + </Button> + <Button + type="submit" + variant="samsung" + disabled={isPending || isLoading || isInitializing} + className="px-4" + > + <Search className="size-4 mr-2" /> + {isPending || isLoading ? "조회 중..." : "조회"} + </Button> + </div> + </div> + </form> + </Form> + </div> + ) +}
\ 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 new file mode 100644 index 00000000..3fb47771 --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -0,0 +1,298 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + Plus, + Check, + MessageSquare, + X, + Download, + Upload, + RefreshCw, + Settings +} from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { ManualCreateEvaluationTargetDialog } from "./manual-create-evaluation-target-dialog" +import { EvaluationTargetWithDepartments } from "@/db/schema" + +interface EvaluationTargetsTableToolbarActionsProps { + table: Table<EvaluationTargetWithDepartments> + onRefresh?: () => void +} + +export function EvaluationTargetsTableToolbarActions({ + table, + onRefresh +}: EvaluationTargetsTableToolbarActionsProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [manualCreateDialogOpen, setManualCreateDialogOpen] = React.useState(false) + const router = useRouter() + + // 선택된 행들 + const selectedRows = table.getFilteredSelectedRowModel().rows + const hasSelection = selectedRows.length > 0 + const selectedTargets = selectedRows.map(row => row.original) + + // 선택된 항목들의 상태 분석 + 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 + + return { + pending, + confirmed, + excluded, + consensusTrue, + consensusFalse, + consensusNull, + canConfirm: pending > 0 && consensusTrue > 0, + canExclude: pending > 0, + canRequestReview: pending > 0 + } + }, [selectedTargets]) + + // ---------------------------------------------------------------- + // 신규 평가 대상 생성 (자동) + // ---------------------------------------------------------------- + const handleAutoGenerate = async () => { + setIsLoading(true) + try { + // TODO: 발주실적에서 자동 추출 API 호출 + toast.success("평가 대상이 자동으로 생성되었습니다.") + router.refresh() + } catch (error) { + console.error('Error auto generating targets:', error) + toast.error("자동 생성 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + // ---------------------------------------------------------------- + // 신규 평가 대상 생성 (수동) + // ---------------------------------------------------------------- + const handleManualCreate = () => { + setManualCreateDialogOpen(true) + } + + // ---------------------------------------------------------------- + // 선택된 항목들 확정 + // ---------------------------------------------------------------- + const handleConfirmSelected = async () => { + if (!hasSelection || !selectedStats.canConfirm) return + + setIsLoading(true) + try { + // TODO: 확정 API 호출 + const confirmableTargets = selectedTargets.filter( + t => t.status === "PENDING" && t.consensusStatus === true + ) + + toast.success(`${confirmableTargets.length}개 항목이 확정되었습니다.`) + table.resetRowSelection() + router.refresh() + } catch (error) { + console.error('Error confirming targets:', error) + toast.error("확정 처리 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + // ---------------------------------------------------------------- + // 선택된 항목들 제외 + // ---------------------------------------------------------------- + const handleExcludeSelected = async () => { + if (!hasSelection || !selectedStats.canExclude) return + + setIsLoading(true) + try { + // TODO: 제외 API 호출 + const excludableTargets = selectedTargets.filter(t => t.status === "PENDING") + + toast.success(`${excludableTargets.length}개 항목이 제외되었습니다.`) + table.resetRowSelection() + router.refresh() + } catch (error) { + console.error('Error excluding targets:', error) + toast.error("제외 처리 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + // ---------------------------------------------------------------- + // 선택된 항목들 의견 요청 + // ---------------------------------------------------------------- + const handleRequestReview = async () => { + if (!hasSelection || !selectedStats.canRequestReview) return + + // TODO: 의견 요청 다이얼로그 열기 + toast.info("의견 요청 다이얼로그를 구현해주세요.") + } + + // ---------------------------------------------------------------- + // Excel 내보내기 + // ---------------------------------------------------------------- + const handleExport = () => { + try { + // TODO: Excel 내보내기 구현 + toast.success("Excel 파일이 다운로드되었습니다.") + } catch (error) { + console.error('Error exporting to Excel:', error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + } + } + + // ---------------------------------------------------------------- + // 새로고침 + // ---------------------------------------------------------------- + const handleRefresh = () => { + if (onRefresh) { + onRefresh() + } else { + router.refresh() + } + toast.success("데이터가 새로고침되었습니다.") + } + + return ( + <> + <div className="flex items-center gap-2"> + {/* 신규 생성 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="default" + size="sm" + className="gap-2" + disabled={isLoading} + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">신규 생성</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem onClick={handleAutoGenerate} disabled={isLoading}> + <RefreshCw className="size-4 mr-2" /> + 자동 생성 (발주실적 기반) + </DropdownMenuItem> + <DropdownMenuItem onClick={handleManualCreate}> + <Plus className="size-4 mr-2" /> + 수동 생성 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* 유틸리티 버튼들 */} + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + <Button + variant="outline" + size="sm" + onClick={handleExport} + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">내보내기</span> + </Button> + + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + className="gap-2" + > + <RefreshCw className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">새로고침</span> + </Button> + </div> + + {/* 선택된 항목 액션 버튼들 */} + {hasSelection && ( + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + {/* 확정 버튼 */} + {selectedStats.canConfirm && ( + <Button + variant="default" + size="sm" + className="gap-2 bg-green-600 hover:bg-green-700" + onClick={handleConfirmSelected} + disabled={isLoading} + > + <Check className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 확정 ({selectedStats.consensusTrue}) + </span> + </Button> + )} + + {/* 제외 버튼 */} + {selectedStats.canExclude && ( + <Button + variant="destructive" + size="sm" + className="gap-2" + onClick={handleExcludeSelected} + disabled={isLoading} + > + <X className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 제외 ({selectedStats.pending}) + </span> + </Button> + )} + + {/* 의견 요청 버튼 */} + {selectedStats.canRequestReview && ( + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={handleRequestReview} + disabled={isLoading} + > + <MessageSquare className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 의견 요청 ({selectedStats.pending}) + </span> + </Button> + )} + </div> + )} + </div> + + {/* 수동 생성 다이얼로그 */} + <ManualCreateEvaluationTargetDialog + open={manualCreateDialogOpen} + onOpenChange={setManualCreateDialogOpen} + /> + + {/* 선택 정보 표시 */} + {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/table/manual-create-evaluation-target-dialog.tsx b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx new file mode 100644 index 00000000..5704cba1 --- /dev/null +++ b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx @@ -0,0 +1,772 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Check, ChevronsUpDown, Loader2, Search } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +import { + createEvaluationTarget, + getAvailableVendors, + getAvailableReviewers, + getDepartmentInfo, + type CreateEvaluationTargetInput, +} from "../service" +import { EVALUATION_TARGET_FILTER_OPTIONS, getDefaultEvaluationYear } from "../validation" +import { useSession } from "next-auth/react" + +// 폼 스키마 정의 +const createEvaluationTargetSchema = z.object({ + evaluationYear: z.number().min(2020).max(2030), + division: z.enum(["OCEAN", "SHIPYARD"]), + vendorId: z.number().min(1, "벤더를 선택해주세요"), + materialType: z.enum(["EQUIPMENT", "BULK", "EQUIPMENT_BULK"]), + adminComment: z.string().optional(), + // L/D 클레임 정보 + ldClaimCount: z.number().min(0).optional(), + ldClaimAmount: z.number().min(0).optional(), + ldClaimCurrency: z.enum(["KRW", "USD", "EUR", "JPY"]).optional(), + reviewers: z.array( + z.object({ + departmentCode: z.string(), + reviewerUserId: z.number().min(1, "담당자를 선택해주세요"), + }) + ).min(1, "최소 1명의 담당자를 지정해주세요"), +}) + +type CreateEvaluationTargetFormValues = z.infer<typeof createEvaluationTargetSchema> + +interface ManualCreateEvaluationTargetDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ManualCreateEvaluationTargetDialog({ + open, + onOpenChange, +}: ManualCreateEvaluationTargetDialogProps) { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = React.useState(false) + const { data: session } = useSession() + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : 1; + }, [session]); + + // 벤더 관련 상태 + const [vendors, setVendors] = React.useState<Array<{ id: number, vendorCode: string, vendorName: string }>>([]) + const [vendorSearch, setVendorSearch] = React.useState("") + const [vendorOpen, setVendorOpen] = React.useState(false) + const [isLoadingVendors, setIsLoadingVendors] = React.useState(false) + + // 담당자 관련 상태 + const [reviewers, setReviewers] = React.useState<Array<{ id: number, name: string, email: string }>>([]) + const [isLoadingReviewers, setIsLoadingReviewers] = React.useState(false) + + // 각 부서별 담당자 선택 상태 + const [reviewerSearches, setReviewerSearches] = React.useState<Record<string, string>>({}) + const [reviewerOpens, setReviewerOpens] = React.useState<Record<string, boolean>>({}) + + // 부서 정보 상태 + const [departments, setDepartments] = React.useState<Array<{ code: string, name: string, key: string }>>([]) + + const form = useForm<CreateEvaluationTargetFormValues>({ + resolver: zodResolver(createEvaluationTargetSchema), + defaultValues: { + evaluationYear: getDefaultEvaluationYear(), + division: "OCEAN", + vendorId: 0, + materialType: "EQUIPMENT", + adminComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + reviewers: [], // 초기에는 빈 배열, useEffect에서 설정 + }, + }) + + // 부서 정보 로드 + const loadDepartments = React.useCallback(async () => { + try { + const departmentList = await getDepartmentInfo() + setDepartments(departmentList) + } catch (error) { + console.error("Error loading departments:", error) + toast.error("부서 정보를 불러오는데 실패했습니다.") + } + }, []) // form 의존성 제거 + + // 벤더 목록 로드 + const loadVendors = React.useCallback(async (search?: string) => { + setIsLoadingVendors(true) + try { + const vendorList = await getAvailableVendors(search) + setVendors(vendorList) + } catch (error) { + console.error("Error loading vendors:", error) + toast.error("벤더 목록을 불러오는데 실패했습니다.") + } finally { + setIsLoadingVendors(false) + } + }, []) + + // 담당자 목록 로드 + const loadReviewers = React.useCallback(async () => { + setIsLoadingReviewers(true) + try { + const reviewerList = await getAvailableReviewers() + setReviewers(reviewerList) + } catch (error) { + console.error("Error loading reviewers:", error) + toast.error("담당자 목록을 불러오는데 실패했습니다.") + } finally { + setIsLoadingReviewers(false) + } + }, []) + + // 다이얼로그 열릴 때 데이터 로드 + React.useEffect(() => { + if (open) { + loadDepartments() + loadVendors() + loadReviewers() + } + }, [open]) // 함수 의존성 제거 + + // 부서 정보가 로드되면 reviewers 기본값 설정 + React.useEffect(() => { + if (departments.length > 0 && open) { + const currentReviewers = form.getValues("reviewers") + + // 이미 설정되어 있으면 다시 설정하지 않음 + if (currentReviewers.length === 0) { + const defaultReviewers = departments.map(dept => ({ + departmentCode: dept.code, + reviewerUserId: 0, + })) + form.setValue('reviewers', defaultReviewers) + } + } + }, [departments, open]) // form 의존성 제거하고 조건 추가 + + console.log(departments) + + // 벤더 검색 + React.useEffect(() => { + const timeoutId = setTimeout(() => { + if (vendorSearch || vendorOpen) { + loadVendors(vendorSearch) + } + }, 300) + + return () => clearTimeout(timeoutId) + }, [vendorSearch, vendorOpen]) // loadVendors 의존성 제거 + + // 폼 제출 + async function onSubmit(data: CreateEvaluationTargetFormValues) { + console.log("Form submitted with data:", data) // 디버깅용 + setIsSubmitting(true) + try { + // 담당자가 지정되지 않은 부서 제외 + const validReviewers = data.reviewers.filter(r => r.reviewerUserId > 0) + + if (validReviewers.length === 0) { + toast.error("최소 1명의 담당자를 지정해주세요.") + return + } + + const input: CreateEvaluationTargetInput = { + ...data, + reviewers: validReviewers, + } + + console.log(input,"client") + + const result = await createEvaluationTarget(input, userId) + + if (result.success) { + toast.success(result.message) + onOpenChange(false) + // 폼과 상태 초기화 + form.reset({ + evaluationYear: getDefaultEvaluationYear(), + division: "OCEAN", + vendorId: 0, + materialType: "EQUIPMENT", + adminComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + reviewers: [], + }) + setDepartments([]) + setVendors([]) + setReviewers([]) + setVendorSearch("") + setReviewerSearches({}) + setReviewerOpens({}) + router.refresh() + } else { + toast.error(result.error || "평가 대상 생성에 실패했습니다.") + } + } catch (error) { + console.error("Error creating evaluation target:", error) + toast.error("평가 대상 생성 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 다이얼로그 닫기 핸들러 + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + + // 다이얼로그가 닫힐 때 상태 초기화 + if (!open) { + form.reset({ + evaluationYear: getDefaultEvaluationYear(), + division: "OCEAN", + vendorId: 0, + materialType: "EQUIPMENT", + adminComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + reviewers: [], + }) + setDepartments([]) + setVendors([]) + setReviewers([]) + setVendorSearch("") + setReviewerSearches({}) + setReviewerOpens({}) + } + } + + // 선택된 벤더 정보 + const selectedVendor = vendors.find(v => v.id === form.watch("vendorId")) + + // 현재 선택된 자재구분 + const currentMaterialType = form.watch("materialType") + + // BULK 자재일 때 비활성화할 부서 코드들 + const BULK_DISABLED_DEPARTMENTS = ["DESIGN_EVAL", "CS_EVAL"] // 설계(DES), CS 부서 코드 (실제 코드에 맞게 수정 필요) + + // 담당자 검색 필터링 + const getFilteredReviewers = (search: string) => { + if (!search) return reviewers + return reviewers.filter(reviewer => + reviewer.name.toLowerCase().includes(search.toLowerCase()) || + reviewer.email.toLowerCase().includes(search.toLowerCase()) + ) + } + + // 부서가 비활성화되어야 하는지 확인 + const isDepartmentDisabled = (departmentCode: string) => { + return currentMaterialType === "BULK" && BULK_DISABLED_DEPARTMENTS.includes(departmentCode) + } + + // 자재구분 변경 시 BULK 비활성화 부서들의 담당자 초기화 + React.useEffect(() => { + if (currentMaterialType === "BULK") { + const currentReviewers = form.getValues("reviewers") + const updatedReviewers = currentReviewers.map(reviewer => { + if (BULK_DISABLED_DEPARTMENTS.includes(reviewer.departmentCode)) { + return { ...reviewer, reviewerUserId: 0 } + } + return reviewer + }) + form.setValue("reviewers", updatedReviewers) + } + }, [currentMaterialType, form]) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="max-w-lg flex flex-col h-[90vh]"> + {/* 고정 헤더 */} + <DialogHeader className="flex-shrink-0"> + <DialogTitle>평가 대상 수동 생성</DialogTitle> + <DialogDescription> + 새로운 평가 대상을 수동으로 생성하고 담당자를 지정합니다. + </DialogDescription> + </DialogHeader> + + {/* Form을 전체 콘텐츠를 감싸도록 수정 */} + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col flex-1" + id="evaluation-target-form" + > + {/* 스크롤 가능한 콘텐츠 영역 */} + <div className="flex-1 overflow-y-auto"> + <div className="space-y-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + {/* 평가년도 */} + <FormField + control={form.control} + name="evaluationYear" + render={({ field }) => ( + <FormItem> + <FormLabel>평가년도</FormLabel> + <FormControl> + <Input + type="number" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value))} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 구분 */} + <FormField + control={form.control} + name="division" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {EVALUATION_TARGET_FILTER_OPTIONS.DIVISIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 벤더 선택 */} + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더</FormLabel> + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className="w-full justify-between" + > + {selectedVendor ? ( + <span className="flex items-center gap-2"> + <Badge variant="outline">{selectedVendor.vendorCode}</Badge> + {selectedVendor.vendorName} + </span> + ) : ( + "벤더 선택..." + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput + placeholder="벤더 검색..." + value={vendorSearch} + onValueChange={setVendorSearch} + /> + <CommandList> + <CommandEmpty> + {isLoadingVendors ? ( + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + 로딩 중... + </div> + ) : ( + "검색 결과가 없습니다." + )} + </CommandEmpty> + <CommandGroup> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorCode} ${vendor.vendorName}`} + onSelect={() => { + field.onChange(vendor.id) + setVendorOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + vendor.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex items-center gap-2"> + <Badge variant="outline">{vendor.vendorCode}</Badge> + <span>{vendor.vendorName}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재구분 */} + <FormField + control={form.control} + name="materialType" + render={({ field }) => ( + <FormItem> + <FormLabel>자재구분</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="자재구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {EVALUATION_TARGET_FILTER_OPTIONS.MATERIAL_TYPES.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 관리자 의견 */} + <FormField + control={form.control} + name="adminComment" + render={({ field }) => ( + <FormItem> + <FormLabel>관리자 의견 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="관리자 의견을 입력하세요..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* L/D 클레임 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">L/D 클레임 정보</CardTitle> + <p className="text-sm text-muted-foreground"> + 지연 배송(Late Delivery) 클레임 관련 정보를 입력하세요. + </p> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-3 gap-4"> + {/* 클레임 건수 */} + <FormField + control={form.control} + name="ldClaimCount" + render={({ field }) => ( + <FormItem> + <FormLabel>클레임 건수</FormLabel> + <FormControl> + <Input + type="number" + min="0" + placeholder="0" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 클레임 금액 */} + <FormField + control={form.control} + name="ldClaimAmount" + render={({ field }) => ( + <FormItem> + <FormLabel>클레임 금액</FormLabel> + <FormControl> + <Input + type="number" + min="0" + step="0.01" + placeholder="0" + {...field} + onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 통화단위 */} + <FormField + control={form.control} + name="ldClaimCurrency" + render={({ field }) => ( + <FormItem> + <FormLabel>통화단위</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="통화 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="KRW">KRW (원)</SelectItem> + <SelectItem value="USD">USD (달러)</SelectItem> + <SelectItem value="EUR">EUR (유로)</SelectItem> + <SelectItem value="JPY">JPY (엔)</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + {/* 담당자 지정 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">담당자 지정</CardTitle> + <p className="text-sm text-muted-foreground"> + 각 부서별로 담당자를 지정해주세요. 최소 1명 이상 지정해야 합니다. + </p> + </CardHeader> + <CardContent> + {departments.length === 0 ? ( + <div className="flex items-center justify-center py-8"> + <div className="flex items-center gap-2 text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + 부서 정보를 불러오는 중... + </div> + </div> + ) : ( + <div className="space-y-4"> + {departments.map((department, index) => { + const selectedReviewer = reviewers.find(r => r.id === form.watch(`reviewers.${index}.reviewerUserId`)) + const filteredReviewers = getFilteredReviewers(reviewerSearches[department.code] || "") + const isDisabled = isDepartmentDisabled(department.code) + + return ( + <FormField + key={department.code} + control={form.control} + name={`reviewers.${index}.reviewerUserId`} + render={({ field }) => ( + <FormItem> + <FormLabel className={isDisabled ? "text-muted-foreground" : ""}> + {department.name} + {isDisabled && ( + <span className="text-xs ml-2 text-muted-foreground"> + (벌크 자재 시 비활성화) + </span> + )} + </FormLabel> + <Popover + open={!isDisabled && (reviewerOpens[department.code] || false)} + onOpenChange={(open) => !isDisabled && setReviewerOpens(prev => ({...prev, [department.code]: open}))} + > + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={reviewerOpens[department.code]} + className={cn( + "w-full justify-between", + isDisabled && "opacity-50 cursor-not-allowed" + )} + disabled={isDisabled} + > + {selectedReviewer && !isDisabled ? ( + <span className="flex items-center gap-2"> + <span>{selectedReviewer.name}</span> + <span className="text-xs text-muted-foreground"> + ({selectedReviewer.email}) + </span> + </span> + ) : ( + isDisabled ? "벌크 자재 시 비활성화" : "담당자 선택 (선택사항)" + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput + placeholder="담당자 검색..." + value={reviewerSearches[department.code] || ""} + onValueChange={(value) => setReviewerSearches(prev => ({...prev, [department.code]: value}))} + /> + <CommandList> + <CommandEmpty> + {isLoadingReviewers ? ( + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + 로딩 중... + </div> + ) : ( + "검색 결과가 없습니다." + )} + </CommandEmpty> + <CommandGroup> + <CommandItem + value="선택 안함" + onSelect={() => { + field.onChange(0) + setReviewerOpens(prev => ({...prev, [department.code]: false})) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + field.value === 0 ? "opacity-100" : "opacity-0" + )} + /> + 선택 안함 + </CommandItem> + {filteredReviewers.map((reviewer) => ( + <CommandItem + key={reviewer.id} + value={`${reviewer.name} ${reviewer.email}`} + onSelect={() => { + field.onChange(reviewer.id) + setReviewerOpens(prev => ({...prev, [department.code]: false})) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + reviewer.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex items-center gap-2"> + <span>{reviewer.name}</span> + <span className="text-xs text-muted-foreground"> + ({reviewer.email}) + </span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + ) + })} + </div> + )} + </CardContent> + </Card> + </div> + </div> + + {/* 고정 버튼 영역 */} + <div className="flex-shrink-0 flex justify-end gap-3 pt-4 border-t"> + <Button + type="button" + variant="outline" + onClick={() => handleOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSubmitting} + > + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 생성 + </Button> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/validation.ts b/lib/evaluation-target-list/validation.ts new file mode 100644 index 00000000..e42f536b --- /dev/null +++ b/lib/evaluation-target-list/validation.ts @@ -0,0 +1,169 @@ +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 = "OCEAN" | "SHIPYARD"; + export type MaterialType = "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK"; + export type DomesticForeign = "DOMESTIC" | "FOREIGN"; + + // ============= 필터 옵션 상수들 ============= + + export const EVALUATION_TARGET_FILTER_OPTIONS = { + DIVISIONS: [ + { value: "OCEAN", label: "해양" }, + { value: "SHIPYARD", 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 ["OCEAN", "SHIPYARD"].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 = { + OCEAN: "해양", + SHIPYARD: "조선" + }; + 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; + } + |
