diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-10 09:55:45 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-10 09:55:45 +0000 |
| commit | c657ef972feeafff16ab0e07cb4771f7dd141ba0 (patch) | |
| tree | befabd884b00d3cc632c628b3e3810f61cc9f38d /lib/evaluation-target-list | |
| parent | b8a03c9d130435a71c5d6217d06ccb0beb9697e5 (diff) | |
(대표님) 20250710 작업사항 - 평가 첨부, 로그인, SEDP 변경 요구사항 반영
Diffstat (limited to 'lib/evaluation-target-list')
5 files changed, 380 insertions, 74 deletions
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts index 9e21dc51..6de00329 100644 --- a/lib/evaluation-target-list/service.ts +++ b/lib/evaluation-target-list/service.ts @@ -1,6 +1,6 @@ 'use server' -import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray } from "drizzle-orm"; +import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray, gte, lte } from "drizzle-orm"; import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; import { filterColumns } from "@/lib/filter-columns"; @@ -22,7 +22,9 @@ import { reviewerEvaluations, evaluationSubmissions, generalEvaluations, - esgEvaluationItems + esgEvaluationItems, + contracts, + projects } from "@/db/schema"; @@ -33,6 +35,7 @@ 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"; +import { revalidatePath } from "next/cache"; export async function selectEvaluationTargetsFromView( tx: PgTransaction<any, any, any>, @@ -214,8 +217,8 @@ export async function getEvaluationTargetsStats(evaluationYear: number) { 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)`, + oceanDivision: sql<number>`sum(case when division = 'PLANT' then 1 else 0 end)`, + shipyardDivision: sql<number>`sum(case when division = 'SHIP' then 1 else 0 end)`, }) .from(evaluationTargetsWithDepartments) .where(eq(evaluationTargetsWithDepartments.evaluationYear, evaluationYear)); @@ -1165,4 +1168,188 @@ export async function requestEvaluationReview(targetIds: number[], message?: str error: error instanceof Error ? error.message : "의견 요청 중 오류가 발생했습니다." } } +} + + + +interface AutoGenerateResult { + success: boolean + message: string + error?: string + generatedCount?: number + skippedCount?: number + details?: { + shipTargets: number + plantTargets: number + duplicateSkipped: number + } +} + +/** + * 자동으로 평가 대상을 생성하는 서버 액션 + * 전년도 10월부터 현재년도 9월까지의 계약을 기준으로 평가 대상을 생성 + */ +export async function autoGenerateEvaluationTargets( + evaluationYear: number, + adminUserId: number +): Promise<AutoGenerateResult> { + try { + // 평가 기간 계산 (전년도 10월 ~ 현재년도 9월) + const startDate = `${evaluationYear - 1}-10-01` + const endDate = `${evaluationYear}-09-30` + + console.log(`Generating evaluation targets for period: ${startDate} to ${endDate}`) + + // 1. 해당 기간의 계약들과 관련 정보를 조회 + const contractsWithDetails = await db + .select({ + contractId: contracts.id, + vendorId: contracts.vendorId, + projectId: contracts.projectId, + startDate: contracts.startDate, + // vendor 정보 + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + vendorType: vendors.country ==="KR"? "DOMESTIC":"FOREIGN", // DOMESTIC | FOREIGN + // project 정보 + projectType: projects.type, // ship | plant + }) + .from(contracts) + .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .where( + and( + gte(contracts.startDate, startDate), + lte(contracts.startDate, endDate) + ) + ) + + if (contractsWithDetails.length === 0) { + return { + success: true, + message: "해당 기간에 생성할 평가 대상이 없습니다.", + generatedCount: 0, + skippedCount: 0 + } + } + + console.log(`Found ${contractsWithDetails.length} contracts in the period`) + + // 2. 벤더별, 구분별로 그룹화하여 중복 제거 + const targetGroups = new Map<string, { + vendorId: number + vendorCode: string + vendorName: string + domesticForeign: "DOMESTIC" | "FOREIGN" + division: "SHIP" | "PLANT" + materialType: "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK" + }>() + + contractsWithDetails.forEach(contract => { + const division = contract.projectType === "ship" ? "SHIP" : "PLANT" + const key = `${contract.vendorId}-${division}` + + if (!targetGroups.has(key)) { + targetGroups.set(key, { + vendorId: contract.vendorId, + vendorCode: contract.vendorCode, + vendorName: contract.vendorName, + domesticForeign: contract.vendorType === "DOMESTIC" ? "DOMESTIC" : "FOREIGN", + division: division as "SHIP" | "PLANT", + // 기본값으로 EQUIPMENT 설정 (추후 더 정교한 로직 필요시 수정) + materialType: "EQUIPMENT" as const + }) + } + }) + + console.log(`Created ${targetGroups.size} unique vendor-division combinations`) + + // 3. 이미 존재하는 평가 대상 확인 + const existingTargetsKeys = new Set<string>() + if (targetGroups.size > 0) { + const vendorIds = Array.from(targetGroups.values()).map(t => t.vendorId) + const existingTargets = await db + .select({ + vendorId: evaluationTargets.vendorId, + division: evaluationTargets.division + }) + .from(evaluationTargets) + .where( + and( + eq(evaluationTargets.evaluationYear, evaluationYear), + inArray(evaluationTargets.vendorId, vendorIds) + ) + ) + + existingTargets.forEach(target => { + existingTargetsKeys.add(`${target.vendorId}-${target.division}`) + }) + } + + console.log(`Found ${existingTargetsKeys.size} existing targets`) + + // 4. 새로운 평가 대상만 필터링 + const newTargets = Array.from(targetGroups.entries()) + .filter(([key]) => !existingTargetsKeys.has(key)) + .map(([_, target]) => target) + + if (newTargets.length === 0) { + return { + success: true, + message: "이미 모든 평가 대상이 생성되어 있습니다.", + generatedCount: 0, + skippedCount: targetGroups.size + } + } + + // 5. 평가 대상 생성 + const evaluationTargetsToInsert = newTargets.map(target => ({ + evaluationYear, + division: target.division, + vendorId: target.vendorId, + vendorCode: target.vendorCode, + vendorName: target.vendorName, + domesticForeign: target.domesticForeign, + materialType: target.materialType, + status: "PENDING" as const, + adminUserId, + ldClaimCount: 0, + ldClaimAmount: "0", + ldClaimCurrency: "KRW" as const + })) + + // 배치로 삽입 + await db.insert(evaluationTargets).values(evaluationTargetsToInsert) + + // 통계 계산 + const shipTargets = newTargets.filter(t => t.division === "SHIP").length + const plantTargets = newTargets.filter(t => t.division === "PLANT").length + const duplicateSkipped = existingTargetsKeys.size + + console.log(`Successfully created ${newTargets.length} evaluation targets`) + + // 캐시 무효화 + revalidatePath("/evcp/evaluation-target-list") + revalidatePath("/procurement/evaluation-target-list") + + return { + success: true, + message: `${newTargets.length}개의 평가 대상이 성공적으로 생성되었습니다.`, + generatedCount: newTargets.length, + skippedCount: duplicateSkipped, + details: { + shipTargets, + plantTargets, + duplicateSkipped + } + } + + } catch (error) { + console.error("Error auto generating evaluation targets:", error) + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + message: "평가 대상 자동 생성에 실패했습니다." + } + } }
\ 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 index b140df0e..5560d3ff 100644 --- a/lib/evaluation-target-list/table/evaluation-target-table.tsx +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -271,6 +271,8 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: const [promiseData] = React.use(promises); const tableData = promiseData; + console.log(tableData) + /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ const searchString = React.useMemo( () => searchParams.toString(), // query가 바뀔 때만 새로 계산 diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx index 60f1af39..c3aa9d71 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx @@ -16,7 +16,7 @@ interface GetColumnsProps { } // ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 -const getStatusBadgeVariant = (status: string) => { +export const getStatusBadgeVariant = (status: string) => { switch (status) { case "PENDING": return "secondary"; case "CONFIRMED": return "default"; @@ -43,14 +43,14 @@ const getDivisionBadge = (division: string) => { ); }; -const getMaterialTypeBadge = (materialType: string) => { +export const getMaterialTypeBadge = (materialType: string) => { return <Badge variant="outline">{vendortypeMap[materialType] || materialType}</Badge>; }; const getDomesticForeignBadge = (domesticForeign: string) => { return ( <Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}> - {domesticForeign === "DOMESTIC" ? "내자" : "외자"} + {domesticForeign === "DOMESTIC" ? "D" : "F"} </Badge> ); }; @@ -72,6 +72,16 @@ const getEvaluationTargetBadge = (isTarget: boolean | null) => { ); }; +const getStatusLabel = (status: string) => { + const statusMap = { + PENDING: "미확정", + EXCLUDED: "제외", + CONFIRMED: "확정" + }; + return statusMap[status] || status; +}; + + // ✅ 모든 cell 렌더러 함수들을 미리 정의 (매번 새로 생성 방지) const renderEvaluationYear = ({ row }: any) => ( <span className="font-medium">{row.getValue("evaluationYear")}</span> @@ -83,7 +93,7 @@ const renderStatus = ({ row }: any) => { const status = row.getValue<string>("status"); return ( <Badge variant={getStatusBadgeVariant(status)}> - {status} + {getStatusLabel(status)} </Badge> ); }; @@ -213,12 +223,7 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col cell: renderStatus, size: 100, }, - { - accessorKey: "consensusStatus", - header: createHeaderRenderer("의견 일치"), - cell: renderConsensusStatus, - size: 100, - }, + // 벤더 정보 { @@ -252,6 +257,47 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col ] }, + { + accessorKey: "consensusStatus", + header: createHeaderRenderer("의견 일치"), + cell: renderConsensusStatus, + size: 100, + }, + + { + id: "claim", + header: "L/D, Claim", + columns:[ + { + accessorKey: "ldClaimCount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="건수" />, + cell: ({ row }) => ( + <span className="text-sm">{row.original.ldClaimCount}</span> + ), + size: 80, + }, + + { + accessorKey: "ldClaimAmount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="금액" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{(Number(row.original.ldClaimAmount).toLocaleString())}</span> + ), + size: 80, + }, + { + accessorKey: "ldClaimCurrency", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="단위" />, + + cell: ({ row }) => ( + <span className="text-sm">{row.original.ldClaimCurrency}</span> + ), + size: 80, + }, + ] + + }, + // 발주 담당자 { id: "orderReviewer", 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 8bc5254c..d1c7e500 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" import { Button } from "@/components/ui/button" import { @@ -31,6 +32,8 @@ import { } from "./evaluation-target-action-dialogs" import { EvaluationTargetWithDepartments } from "@/db/schema" import { exportTableToExcel } from "@/lib/export" +import { autoGenerateEvaluationTargets } from "../service" // 서버 액션 import +import { useAuthRole } from "@/hooks/use-auth-role" interface EvaluationTargetsTableToolbarActionsProps { table: Table<EvaluationTargetWithDepartments> @@ -47,6 +50,16 @@ export function EvaluationTargetsTableToolbarActions({ const [excludeDialogOpen, setExcludeDialogOpen] = React.useState(false) const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false) const router = useRouter() + const { data: session } = useSession() + + // 권한 체크 + const { hasRole, isLoading: roleLoading } = useAuthRole() + const canManageEvaluations = hasRole('정기평가') || hasRole('admin') + + // 사용자 ID 가져오기 + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : 1; + }, [session]); // 선택된 행들 const selectedRows = table.getFilteredSelectedRowModel().rows @@ -141,16 +154,36 @@ export function EvaluationTargetsTableToolbarActions({ const handleAutoGenerate = React.useCallback(async () => { setIsLoading(true) try { - // TODO: 발주실적에서 자동 추출 API 호출 - toast.success("평가 대상이 자동으로 생성되었습니다.") - router.refresh() + // 현재 년도를 기준으로 평가 대상 자동 생성 + const currentYear = new Date().getFullYear() + const result = await autoGenerateEvaluationTargets(currentYear, userId) + + if (result.success) { + if (result.generatedCount === 0) { + toast.info(result.message, { + description: result.skippedCount + ? `이미 존재하는 평가 대상: ${result.skippedCount}개` + : undefined + }) + } else { + toast.success(result.message, { + description: result.details + ? `해양: ${result.details.shipTargets}개, 조선: ${result.details.plantTargets}개 생성${result.details.duplicateSkipped > 0 ? `, 중복 건너뜀: ${result.details.duplicateSkipped}개` : ''}` + : undefined + }) + } + onRefresh?.() + router.refresh() + } else { + toast.error(result.error || "자동 생성 중 오류가 발생했습니다.") + } } catch (error) { console.error('Error auto generating targets:', error) toast.error("자동 생성 중 오류가 발생했습니다.") } finally { setIsLoading(false) } - }, [router]) + }, [router, onRefresh, userId]) // ---------------------------------------------------------------- // 신규 평가 대상 생성 (수동) @@ -178,33 +211,54 @@ export function EvaluationTargetsTableToolbarActions({ }) }, [table]) + // 권한이 없거나 로딩 중인 경우 내보내기 버튼만 표시 + if (roleLoading) { + return ( + <div className="flex items-center gap-2"> + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + <Button + variant="outline" + size="sm" + disabled + className="gap-2" + > + <Download className="size-4 animate-spin" aria-hidden="true" /> + <span className="hidden sm:inline">로딩중...</span> + </Button> + </div> + </div> + ) + } + 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> + {/* 신규 생성 드롭다운 - 정기평가 권한이 있는 경우만 표시 */} + {canManageEvaluations && ( + <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 ${isLoading ? 'animate-spin' : ''}`} /> + 자동 생성 (발주실적 기반) + </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"> @@ -219,8 +273,8 @@ export function EvaluationTargetsTableToolbarActions({ </Button> </div> - {/* 선택된 항목 액션 버튼들 */} - {hasSelection && ( + {/* 선택된 항목 액션 버튼들 - 정기평가 권한이 있는 경우만 표시 */} + {canManageEvaluations && hasSelection && ( <div className="flex items-center gap-1 border-l pl-2 ml-2"> {/* 확정 버튼 */} {selectedStats.canConfirm && ( @@ -271,37 +325,52 @@ export function EvaluationTargetsTableToolbarActions({ )} </div> )} + + {/* 권한이 없는 경우 안내 메시지 (선택사항) */} + {!canManageEvaluations && hasSelection && ( + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + <div className="text-xs text-muted-foreground px-2 py-1"> + 평가 관리 권한이 필요합니다 + </div> + </div> + )} </div> - {/* 수동 생성 다이얼로그 */} - <ManualCreateEvaluationTargetDialog - open={manualCreateDialogOpen} - onOpenChange={setManualCreateDialogOpen} - /> + {/* 다이얼로그들 - 권한이 있는 경우만 렌더링 */} + {canManageEvaluations && ( + <> + {/* 수동 생성 다이얼로그 */} + <ManualCreateEvaluationTargetDialog + open={manualCreateDialogOpen} + onOpenChange={setManualCreateDialogOpen} + onSuccess={handleActionSuccess} + /> - {/* 확정 컨펌 다이얼로그 */} - <ConfirmTargetsDialog - open={confirmDialogOpen} - onOpenChange={setConfirmDialogOpen} - targets={selectedTargets} - onSuccess={handleActionSuccess} - /> + {/* 확정 컨펌 다이얼로그 */} + <ConfirmTargetsDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + targets={selectedTargets} + onSuccess={handleActionSuccess} + /> - {/* 제외 컨펌 다이얼로그 */} - <ExcludeTargetsDialog - open={excludeDialogOpen} - onOpenChange={setExcludeDialogOpen} - targets={selectedTargets} - onSuccess={handleActionSuccess} - /> + {/* 제외 컨펌 다이얼로그 */} + <ExcludeTargetsDialog + open={excludeDialogOpen} + onOpenChange={setExcludeDialogOpen} + targets={selectedTargets} + onSuccess={handleActionSuccess} + /> - {/* 의견 요청 다이얼로그 */} - <RequestReviewDialog - open={reviewDialogOpen} - onOpenChange={setReviewDialogOpen} - targets={selectedTargets} - onSuccess={handleActionSuccess} - /> + {/* 의견 요청 다이얼로그 */} + <RequestReviewDialog + open={reviewDialogOpen} + onOpenChange={setReviewDialogOpen} + targets={selectedTargets} + onSuccess={handleActionSuccess} + /> + </> + )} </> ) }
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx index 9f9b7af4..8ea63a1a 100644 --- a/lib/evaluation-target-list/table/update-evaluation-target.tsx +++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx @@ -58,6 +58,8 @@ import { type UpdateEvaluationTargetInput, } from "../service" import { EvaluationTargetWithDepartments } from "@/db/schema" +import { getMaterialTypeBadge } from "./evaluation-targets-columns" +import { getStatusLabel } from "../validation" // 편집 가능한 필드들에 대한 스키마 const editEvaluationTargetSchema = z.object({ @@ -123,10 +125,10 @@ export function EditEvaluationTargetSheet({ } const userEmail = session.user.email - const userRole = session.user.role + const userRole = session.user?.roles // 평가관리자는 모든 권한 - if (userRole === "평가관리자") { + if (userRole?.some(role => role.includes('정기평가'))|| userRole?.some(role => role.toLocaleLowerCase().includes('admin'))) { return { level: "admin", editableApprovals: [ @@ -372,10 +374,10 @@ export function EditEvaluationTargetSheet({ <span className="font-medium">벤더명:</span> {evaluationTarget.vendorName} </div> <div> - <span className="font-medium">자재구분:</span> {evaluationTarget.materialType} + <span className="font-medium">자재구분:</span> {getMaterialTypeBadge(evaluationTarget.materialType)} </div> <div> - <span className="font-medium">상태:</span> {evaluationTarget.status} + <span className="font-medium">상태:</span> {getStatusLabel(evaluationTarget.status)} </div> </div> </CardContent> |
