diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:44:45 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:44:45 +0000 |
| commit | 90f79a7a691943a496f67f01c1e493256070e4de (patch) | |
| tree | 37275fde3ae08c2bca384fbbc8eb378de7e39230 /lib | |
| parent | fbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff) | |
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib')
56 files changed, 8758 insertions, 2070 deletions
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 09f8f119..9890cdfc 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -14,10 +14,6 @@ import { vendors,
type BasicContractTemplate as DBBasicContractTemplate,
} from "@/db/schema";
-import { toast } from "sonner";
-import { promises as fs } from "fs";
-import path from "path";
-import crypto from "crypto";
import {
GetBasicContractTemplatesSchema,
@@ -40,6 +36,7 @@ import { sendEmail } from "../mail/sendEmail"; import { headers } from 'next/headers';
import { filterColumns } from "@/lib/filter-columns";
import { differenceInDays, addYears, isBefore } from "date-fns";
+import { deleteFile, saveFile } from "@/lib/file-stroage";
@@ -72,61 +69,27 @@ export async function addTemplate( error: "유효기간은 1~120개월 사이의 유효한 값이어야 합니다."
};
}
+ const saveResult = await saveFile({file, directory:"basicContract/template" });
- // 원본 파일 이름과 확장자 분리
- const originalFileName = file.name;
- const fileExtension = path.extname(originalFileName);
- const fileNameWithoutExt = path.basename(originalFileName, fileExtension);
-
- // 해시된 파일 이름 생성 (타임스탬프 + 랜덤 해시 + 확장자)
- const timestamp = Date.now();
- const randomHash = crypto.createHash('md5')
- .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`)
- .digest('hex')
- .substring(0, 8);
-
- const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`;
-
- // 저장 디렉토리 설정 (uploads/contracts 폴더 사용)
- const uploadDir = path.join(process.cwd(), "public", "basicContract", "template");
-
- // 디렉토리가 없으면 생성
- try {
- await fs.mkdir(uploadDir, { recursive: true });
- } catch (err) {
- console.log("Directory already exists or creation failed:", err);
+ if (!saveResult.success) {
+ return { success: false, error: saveResult.error };
}
- // 파일 경로 설정
- const filePath = path.join(uploadDir, hashedFileName);
- const publicFilePath = `/basicContract/template/${hashedFileName}`;
-
- // 파일을 ArrayBuffer로 변환
- const arrayBuffer = await file.arrayBuffer();
- const buffer = Buffer.from(arrayBuffer);
-
- // 파일 저장
- await fs.writeFile(filePath, buffer);
-
// DB에 저장할 데이터 구성
const formattedData = {
templateName,
status,
validityPeriod, // 숫자로 변환된 유효기간
- fileName: originalFileName, // 원본 파일 이름
- filePath: publicFilePath, // 공개 접근 가능한 경로
+ fileName: file.name,
+ filePath: saveResult.publicPath!
};
// DB에 저장
const { data, error } = await createBasicContractTemplate(formattedData);
if (error) {
- // 파일 저장 후 DB 저장 실패 시 저장된 파일 삭제
- try {
- await fs.unlink(filePath);
- } catch (unlinkError) {
- console.error("파일 삭제 실패:", unlinkError);
- }
+ // DB 저장 실패 시 파일 삭제
+ await deleteFile(saveResult.publicPath!);
return { success: false, error };
}
@@ -267,16 +230,20 @@ export const saveSignedContract = async ( ): Promise<{ result: true } | { result: false; error: string }> => {
try {
const originalName = `${tableRowId}_${templateName}`;
- const ext = path.extname(originalName);
- const uniqueName = uuidv4() + ext;
-
- const publicDir = path.join(process.cwd(), "public", "basicContract");
- const relativePath = `/basicContract/${uniqueName}`;
- const absolutePath = path.join(publicDir, uniqueName);
- const buffer = Buffer.from(fileBuffer);
+
+ // ArrayBuffer를 File 객체로 변환
+ const file = new File([fileBuffer], originalName);
+
+ // ✅ 서명된 계약서 저장
+ // 개발: /project/public/basicContract/signed/
+ // 프로덕션: /nas_evcp/basicContract/signed/
+ const saveResult = await saveFile({file,directory: "basicContract/signed" ,originalName:originalName});
+
+ if (!saveResult.success) {
+ return { result: false, error: saveResult.error! };
+ }
- await fs.mkdir(publicDir, { recursive: true });
- await fs.writeFile(absolutePath, buffer);
+ console.log(`✅ 서명된 계약서 저장됨: ${saveResult.filePath}`);
await db.transaction(async (tx) => {
await tx
@@ -284,7 +251,7 @@ export const saveSignedContract = async ( .set({
status: "COMPLETED",
fileName: originalName,
- filePath: relativePath,
+ filePath: saveResult.publicPath, // 웹 접근 경로 저장
})
.where(eq(basicContract.id, tableRowId));
});
@@ -348,18 +315,16 @@ export async function removeTemplates({ // 파일 시스템 삭제는 트랜잭션 성공 후 수행
for (const template of templateFiles) {
- if (template.filePath) {
- const absoluteFilePath = path.join(process.cwd(), 'public', template.filePath);
-
- try {
- await fs.access(absoluteFilePath);
- await fs.unlink(absoluteFilePath);
- } catch (fileError) {
- console.log(`파일 없음 또는 삭제 실패: ${template.filePath}`, fileError);
- // 파일 삭제 실패는 전체 작업 성공에 영향 없음
- }
+ const deleted = await deleteFile(template.filePath);
+
+ if (deleted) {
+ console.log(`✅ 파일 삭제됨: ${template.filePath}`);
+ } else {
+ console.log(`⚠️ 파일 삭제 실패: ${template.filePath}`);
}
}
+
+
revalidateTag("basic-contract-templates");
revalidateTag("template-status-counts");
@@ -413,41 +378,11 @@ export async function updateTemplate({ // 파일이 있는 경우 처리
if (file) {
- // 원본 파일 이름과 확장자 분리
- const originalFileName = file.name;
- const fileExtension = path.extname(originalFileName);
- const fileNameWithoutExt = path.basename(originalFileName, fileExtension);
-
- // 해시된 파일 이름 생성
- const timestamp = Date.now();
- const randomHash = crypto.createHash('md5')
- .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`)
- .digest('hex')
- .substring(0, 8);
-
- const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`;
-
- // 저장 디렉토리 설정
- const uploadDir = path.join(process.cwd(), "public", "basicContract", "template");
-
- // 디렉토리가 없으면 생성
- try {
- await fs.mkdir(uploadDir, { recursive: true });
- } catch (err) {
- console.log("Directory already exists or creation failed:", err);
+ const saveResult = await saveFile({file,directory:"basicContract/template"});
+ if (!saveResult.success) {
+ return { success: false, error: saveResult.error };
}
- // 파일 경로 설정
- const filePath = path.join(uploadDir, hashedFileName);
- const publicFilePath = `/basicContract/template/${hashedFileName}`;
-
- // 파일을 ArrayBuffer로 변환
- const arrayBuffer = await file.arrayBuffer();
- const buffer = Buffer.from(arrayBuffer);
-
- // 파일 저장
- await fs.writeFile(filePath, buffer);
-
// 기존 파일 정보 가져오기
const existingTemplate = await db.query.basicContractTemplates.findFirst({
where: eq(basicContractTemplates.id, id)
@@ -455,18 +390,18 @@ export async function updateTemplate({ // 기존 파일이 있다면 삭제
if (existingTemplate?.filePath) {
- try {
- const existingFilePath = path.join(process.cwd(), "public", existingTemplate.filePath);
- await fs.access(existingFilePath); // 파일 존재 확인
- await fs.unlink(existingFilePath); // 파일 삭제
- } catch (error) {
- console.log("기존 파일 삭제 실패 또는 파일이 없음:", error);
+
+ const deleted = await deleteFile(existingTemplate.filePath);
+ if (deleted) {
+ console.log(`✅ 파일 삭제됨: ${existingTemplate.filePath}`);
+ } else {
+ console.log(`⚠️ 파일 삭제 실패: ${existingTemplate.filePath}`);
}
}
// 업데이트 데이터에 파일 정보 추가
- updateData.fileName = originalFileName;
- updateData.filePath = publicFilePath;
+ updateData.fileName = file.name;
+ updateData.filePath = saveResult.publicPath;
}
// DB 업데이트
diff --git a/lib/basic-contract/status/basic-contract-columns.tsx b/lib/basic-contract/status/basic-contract-columns.tsx index 6ca4a096..54504be4 100644 --- a/lib/basic-contract/status/basic-contract-columns.tsx +++ b/lib/basic-contract/status/basic-contract-columns.tsx @@ -3,29 +3,16 @@ import * as React from "react" import { type DataTableRowAction } from "@/types/table" import { type ColumnDef } from "@tanstack/react-table" -import { Paperclip } from "lucide-react" -import { toast } from "sonner" -import { getErrorMessage } from "@/lib/handle-error" -import { formatDate, formatDateTime } from "@/lib/utils" +import { formatDateTime } from "@/lib/utils" import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" - import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { + FileActionsDropdown, + FileNameLink +} from "@/components/ui/file-actions" + import { basicContractColumnsConfig } from "@/config/basicContractColumnsConfig" import { BasicContractView } from "@/db/schema" @@ -34,38 +21,7 @@ interface GetColumnsProps { } /** - * 파일 다운로드 함수 - */ -/** - * 파일 다운로드 함수 - */ -const handleFileDownload = (filePath: string | null, fileName: string | null) => { - if (!filePath || !fileName) { - toast.error("파일 정보가 없습니다."); - return; - } - - try { - // 전체 URL 생성 - const fullUrl = `${window.location.origin}${filePath}`; - - // a 태그를 생성하여 다운로드 실행 - const link = document.createElement('a'); - link.href = fullUrl; - link.download = fileName; // 다운로드될 파일명 설정 - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - toast.success("파일 다운로드를 시작합니다."); - } catch (error) { - console.error("파일 다운로드 오류:", error); - toast.error("파일 다운로드 중 오류가 발생했습니다."); - } -}; - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) + * 공용 파일 다운로드 유틸리티를 사용하는 간소화된 컬럼 정의 */ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractView>[] { // ---------------------------------------------------------------- @@ -98,7 +54,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo } // ---------------------------------------------------------------- - // 2) 파일 다운로드 컬럼 (아이콘) + // 2) 파일 다운로드 컬럼 (공용 컴포넌트 사용) // ---------------------------------------------------------------- const downloadColumn: ColumnDef<BasicContractView> = { id: "download", @@ -106,39 +62,35 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo cell: ({ row }) => { const template = row.original; + if (!template.filePath || !template.fileName) { + return null; + } + return ( - <Button + <FileActionsDropdown + filePath={template.filePath} + fileName={template.fileName} variant="ghost" size="icon" - onClick={() => handleFileDownload(template.filePath, template.fileName)} - title={`${template.fileName} 다운로드`} - className="hover:bg-muted" - > - <Paperclip className="h-4 w-4" /> - <span className="sr-only">다운로드</span> - </Button> + /> ); }, maxSize: 30, enableSorting: false, } - // ---------------------------------------------------------------- - // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 // ---------------------------------------------------------------- - // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractView>[] } const groupMap: Record<string, ColumnDef<BasicContractView>[]> = {} basicContractColumnsConfig.forEach((cfg) => { - // 만약 group가 없으면 "_noGroup" 처리 const groupName = cfg.group || "_noGroup" if (!groupMap[groupName]) { groupMap[groupName] = [] } - // child column 정의 const childCol: ColumnDef<BasicContractView> = { accessorKey: cfg.id, enableResizing: true, @@ -157,57 +109,88 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo return formatDateTime(dateVal) } - // Status 컬럼에 Badge 적용 + // Status 컬럼에 Badge 적용 (확장) if (cfg.id === "status") { const status = row.getValue(cfg.id) as string - const isActive = status === "ACTIVE" - return ( - <Badge - variant={isActive ? "default" : "secondary"} - > - {isActive ? "활성" : "비활성"} - </Badge> - ) + let variant: "default" | "secondary" | "destructive" | "outline" = "secondary"; + let label = status; + + switch (status) { + case "ACTIVE": + variant = "default"; + label = "활성"; + break; + case "INACTIVE": + variant = "secondary"; + label = "비활성"; + break; + case "PENDING": + variant = "outline"; + label = "대기중"; + break; + case "COMPLETED": + variant = "default"; + label = "완료"; + break; + default: + variant = "secondary"; + label = status; + } + + return <Badge variant={variant}>{label}</Badge> + } + + // ✅ 파일 이름 컬럼 (공용 컴포넌트 사용) + if (cfg.id === "fileName") { + const fileName = cell.getValue() as string; + const filePath = row.original.filePath; + + if (fileName && filePath) { + return ( + <FileNameLink + filePath={filePath} + fileName={fileName} + maxLength={200} + showIcon={true} + /> + ); + } + return fileName || ""; } // 나머지 컬럼은 그대로 값 표시 return row.getValue(cfg.id) ?? "" }, - minSize: 80, - + minSize: 80, } groupMap[groupName].push(childCol) }) // ---------------------------------------------------------------- - // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // 4) groupMap에서 실제 상위 컬럼(그룹)을 만들기 // ---------------------------------------------------------------- const nestedColumns: ColumnDef<BasicContractView>[] = [] - // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 - // 여기서는 그냥 Object.entries 순서 Object.entries(groupMap).forEach(([groupName, colDefs]) => { if (groupName === "_noGroup") { - // 그룹 없음 → 그냥 최상위 레벨 컬럼 nestedColumns.push(...colDefs) } else { - // 상위 컬럼 nestedColumns.push({ id: groupName, - header: groupName, // "Basic Info", "Metadata" 등 + header: groupName, columns: colDefs, }) } }) // ---------------------------------------------------------------- - // 5) 최종 컬럼 배열: select, download, nestedColumns, actions + // 5) 최종 컬럼 배열 // ---------------------------------------------------------------- return [ selectColumn, - downloadColumn, // 다운로드 컬럼 추가 + downloadColumn, // ✅ 공용 파일 액션 컴포넌트 사용 ...nestedColumns, ] -}
\ No newline at end of file +} diff --git a/lib/dashboard/dashboard-client.tsx b/lib/dashboard/dashboard-client.tsx index 37dc1901..cda1ed8e 100644 --- a/lib/dashboard/dashboard-client.tsx +++ b/lib/dashboard/dashboard-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useTransition } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { RefreshCw } from "lucide-react"; @@ -8,31 +8,22 @@ import { DashboardStatsCard } from "./dashboard-stats-card"; import { DashboardOverviewChart } from "./dashboard-overview-chart"; import { DashboardSummaryCards } from "./dashboard-summary-cards"; import { toast } from "sonner"; -import { DashboardData } from "./service"; +import { DashboardData, getDashboardData } from "./service"; interface DashboardClientProps { initialData: DashboardData; - onRefresh: () => Promise<DashboardData>; } -export function DashboardClient({ initialData, onRefresh }: DashboardClientProps) { +export function DashboardClient({ initialData }: DashboardClientProps) { + + const [data, setData] = useState<DashboardData>(initialData); - const [isRefreshing, setIsRefreshing] = useState(false); - - - const handleRefresh = async () => { - try { - setIsRefreshing(true); - const newData = await onRefresh(); - setData(newData); - toast.success("대시보드 데이터가 새로고침되었습니다."); - } catch (error) { - toast.error("데이터 새로고침에 실패했습니다."); - console.error("Dashboard refresh error:", error); - } finally { - setIsRefreshing(false); - } - }; + const [isPending, startTransition] = useTransition(); + + + console.log(data) + + const { domain, teamStats, userStats, summary } = data; const getDomainDisplayName = (domain: string) => { const domainNames: Record<string, string> = { @@ -44,51 +35,81 @@ export function DashboardClient({ initialData, onRefresh }: DashboardClientProps return domainNames[domain] || domain; }; + const handleRefresh = () => { + startTransition(async () => { + try { + const refreshedData = await getDashboardData(domain); + setData(refreshedData); + toast.success("데이터가 새로고침되었습니다."); + } catch (error) { + console.error("Refresh failed:", error); + toast.error("데이터 새로고침에 실패했습니다."); + } + }); + }; + + // 데이터가 없으면 에러 상태 표시 + if (!summary) { + return ( + <div className="flex items-center justify-center py-12"> + <div className="text-center space-y-2"> + <p className="text-destructive">데이터를 불러올 수 없습니다.</p> + <Button onClick={handleRefresh} variant="outline" size="sm"> + 다시 시도 + </Button> + </div> + </div> + ); + } + return ( <div className="space-y-6"> {/* 헤더 */} <div className="flex items-center justify-between"> <div> <h2 className="text-2xl font-bold tracking-tight"> - {getDomainDisplayName(data.domain)} Dashboard + {getDomainDisplayName(domain)} Dashboard </h2> <p className="text-muted-foreground"> - {data.domain ==="partners"? "회사와 개인에게 할당된 일들을 보여줍니다.":"팀과 개인에게 할당된 일들을 보여줍니다."} + {domain === "partners" + ? "회사와 개인에게 할당된 일들을 보여줍니다." + : "팀과 개인에게 할당된 일들을 보여줍니다." + } </p> </div> - <Button - onClick={handleRefresh} - disabled={isRefreshing} - variant="outline" + <Button + onClick={handleRefresh} + variant="outline" size="sm" + disabled={isPending} > - <RefreshCw - className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} - /> + <RefreshCw className={`h-4 w-4 mr-2 ${isPending ? 'animate-spin' : ''}`} /> 새로고침 </Button> </div> {/* 요약 카드 */} - <DashboardSummaryCards summary={data.summary} /> + <DashboardSummaryCards summary={summary} /> {/* 차트 */} <DashboardOverviewChart - data={data.teamStats} - title={getDomainDisplayName(data.domain)} + data={teamStats} + title={getDomainDisplayName(domain)} description="업무 타입별 현황을 확인하세요" /> {/* 탭 */} <Tabs defaultValue="team" className="space-y-4"> <TabsList className="grid w-full grid-cols-2 max-w-md"> - <TabsTrigger value="team"> {data.domain ==="partners"? "회사 업무 현황":"팀 업무 현황"}</TabsTrigger> + <TabsTrigger value="team"> + {domain === "partners" ? "회사 업무 현황" : "팀 업무 현황"} + </TabsTrigger> <TabsTrigger value="personal">내 업무 현황</TabsTrigger> </TabsList> <TabsContent value="team" className="space-y-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> - {data.teamStats.map((stats) => ( + {teamStats.map((stats) => ( <DashboardStatsCard key={stats.tableName} stats={stats} @@ -100,7 +121,7 @@ export function DashboardClient({ initialData, onRefresh }: DashboardClientProps <TabsContent value="personal" className="space-y-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> - {data.userStats.map((stats) => ( + {userStats.map((stats) => ( <DashboardStatsCard key={stats.tableName} stats={stats} diff --git a/lib/dashboard/service.ts b/lib/dashboard/service.ts index 16b05d45..569ff9cd 100644 --- a/lib/dashboard/service.ts +++ b/lib/dashboard/service.ts @@ -127,6 +127,16 @@ export async function getUserDashboardData(domain: string): Promise<UserDashboar } } + +export async function refreshDashboardData(department: string = "engineering") { + try { + return await getDashboardData(department); + } catch (error) { + console.error("Dashboard refresh error:", error); + throw error; + } +} + // 전체 대시보드 데이터 조회 (팀 + 개인) export async function getDashboardData(domain: string): Promise<DashboardData> { try { diff --git a/lib/evaluation-submit/evaluation-form.tsx b/lib/evaluation-submit/evaluation-form.tsx new file mode 100644 index 00000000..65da72b6 --- /dev/null +++ b/lib/evaluation-submit/evaluation-form.tsx @@ -0,0 +1,592 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Textarea } from "@/components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + Building2, + CheckCircle, + Clock, + Save, + Send, + ArrowLeft, + AlertCircle, + FileText +} from "lucide-react" +import { useRouter } from "next/navigation" +import { useToast } from "@/hooks/use-toast" +import { + updateEvaluationResponse, + completeEvaluation +} from "./service" +import { + type EvaluationFormData, + type EvaluationQuestionItem, + EVALUATION_CATEGORIES +} from "./validation" +import { DEPARTMENT_CODE_LABELS, divisionMap, vendortypeMap } from "@/types/evaluation" + +interface EvaluationFormProps { + formData: EvaluationFormData + onSubmit?: () => void +} + +interface QuestionResponse { + detailId: number | null + score: number | null + comment: string +} + +/** + * 평가 폼 메인 컴포넌트 (테이블 레이아웃) + */ +export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { + const router = useRouter() + const { toast } = useToast() + const [isLoading, setIsLoading] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false) + const [showCompleteDialog, setShowCompleteDialog] = React.useState(false) + + const { evaluationInfo, questions } = formData + + // 로컬 상태로 모든 응답 관리 + const [responses, setResponses] = React.useState<Record<number, QuestionResponse>>(() => { + const initial: Record<number, QuestionResponse> = {} + questions.forEach(question => { + const isVariable = question.scoreType === 'variable' + + initial[question.criteriaId] = { + detailId: isVariable ? -1 : question.selectedDetailId, + score: isVariable ? + question.currentScore || null : + (question.selectedDetailId ? + question.availableOptions.find(opt => opt.detailId === question.selectedDetailId)?.score || question.currentScore || null + : question.currentScore || null), + comment: question.currentComment || "", + } + }) + return initial + }) + + // 카테고리별 질문 그룹화 + const questionsByCategory = React.useMemo(() => { + const grouped = questions.reduce((acc, question) => { + const key = question.category + if (!acc[key]) { + acc[key] = [] + } + acc[key].push(question) + return acc + }, {} as Record<string, EvaluationQuestionItem[]>) + + return grouped + }, [questions]) + + const categoryNames = EVALUATION_CATEGORIES + + // 응답 변경 핸들러 + const handleResponseChange = (questionId: number, detailId: number, customScore?: number) => { + const question = questions.find(q => q.criteriaId === questionId) + if (!question) return + + const selectedOption = question.availableOptions.find(opt => opt.detailId === detailId) + + setResponses(prev => ({ + ...prev, + [questionId]: { + ...prev[questionId], + detailId, + score: customScore !== undefined ? customScore : selectedOption?.score || null, + } + })) + setHasUnsavedChanges(true) + } + + // 점수 직접 입력 핸들러 (variable 타입용) + const handleScoreChange = (questionId: number, score: number | null) => { + console.log('Score changed:', questionId, score) + + setResponses(prev => ({ + ...prev, + [questionId]: { + ...prev[questionId], + score, + detailId: prev[questionId].detailId || -1 + } + })) + setHasUnsavedChanges(true) + } + + // 코멘트 변경 핸들러 + const handleCommentChange = (questionId: number, comment: string) => { + setResponses(prev => ({ + ...prev, + [questionId]: { + ...prev[questionId], + comment + } + })) + setHasUnsavedChanges(true) + } + + // 임시저장 + const handleSave = async () => { + try { + setIsSaving(true) + + const promises = Object.entries(responses) + .filter(([questionId, response]) => { + const question = questions.find(q => q.criteriaId === parseInt(questionId)) + const isVariable = question?.scoreType === 'variable' + + if (isVariable) { + return response.score !== null + } else { + return response.detailId !== null && response.detailId > 0 + } + }) + .map(([questionId, response]) => { + const question = questions.find(q => q.criteriaId === parseInt(questionId)) + const isVariable = question?.scoreType === 'variable' + + return updateEvaluationResponse( + evaluationInfo.id, + isVariable ? -1 : response.detailId!, + response.comment, + response.score || undefined + ) + }) + + await Promise.all(promises) + setHasUnsavedChanges(false) + + toast({ + title: "임시저장 완료", + description: "응답이 성공적으로 저장되었습니다.", + }) + } catch (error) { + console.error('Failed to save responses:', error) + toast({ + title: "저장 실패", + description: "응답 저장 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsSaving(false) + } + } + + // 평가 완료 처리 (실제 완료 로직) + const handleCompleteConfirmed = async () => { + try { + setIsLoading(true) + setShowCompleteDialog(false) + + // 먼저 모든 응답 저장 + await handleSave() + + // 평가 완료 처리 + await completeEvaluation(evaluationInfo.id) + + toast({ + title: "평가 완료", + description: "평가가 성공적으로 완료되었습니다.", + }) + + onSubmit?.() + router.push('/evcp/evaluation-input') + } catch (error) { + console.error('Failed to complete evaluation:', error) + toast({ + title: "완료 실패", + description: "평가 완료 처리 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + // 평가 완료 버튼 클릭 (다이얼로그 표시) + const handleCompleteClick = () => { + setShowCompleteDialog(true) + } + + const completedCount = Object.values(responses).filter(r => { + const question = questions.find(q => q.criteriaId === parseInt(Object.keys(responses).find(key => responses[parseInt(key)] === r) || '0')) + const isVariable = question?.scoreType === 'variable' + + if (isVariable) { + return r.score !== null + } else { + return r.detailId !== null && r.detailId > 0 + } + }).length + + const totalCount = questions.length + const allCompleted = completedCount === totalCount + + return ( + <div className="container mx-auto py-6 space-y-6"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Button + variant="ghost" + size="icon" + onClick={() => router.back()} + > + <ArrowLeft className="h-4 w-4" /> + </Button> + <div> + <h1 className="text-2xl font-bold">평가 작성</h1> + <p className="text-muted-foreground">협력업체 평가를 진행해주세요</p> + </div> + </div> + + <div className="flex items-center gap-2"> + {evaluationInfo.isCompleted ? ( + <Badge variant="default" className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3" /> + 완료 + </Badge> + ) : ( + <Badge variant="secondary" className="flex items-center gap-1"> + <Clock className="h-3 w-3" /> + 진행중 + </Badge> + )} + </div> + </div> + + {/* 평가 정보 카드 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Building2 className="h-5 w-5" /> + 평가 정보 + </CardTitle> + </CardHeader> + <CardContent className="pt-4 pb-4"> + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">협력업체</Label> + <div className="font-medium text-sm">{evaluationInfo.vendorName} ({evaluationInfo.vendorCode})</div> + </div> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">사업부</Label> + <div> + <Badge variant="outline"> + {divisionMap[evaluationInfo.division] || evaluationInfo.division} + </Badge> + </div> + </div> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">자재유형</Label> + <div> + <Badge variant="outline"> + {vendortypeMap[evaluationInfo.materialType] || evaluationInfo.materialType} + </Badge> + </div> + </div> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">담당부서</Label> + <div className="font-medium text-sm"> + {DEPARTMENT_CODE_LABELS[evaluationInfo.departmentCode] || evaluationInfo.departmentCode} + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 평가 테이블 - 카테고리별 */} + {Object.entries(questionsByCategory).map(([category, categoryQuestions]) => { + const categoryCompletedCount = categoryQuestions.filter(q => { + const response = responses[q.criteriaId] + const isVariable = q.scoreType === 'variable' + + if (isVariable) { + return response.score !== null + } else { + return response.detailId !== null + } + }).length + + const categoryTotalCount = categoryQuestions.length + const categoryProgress = (categoryCompletedCount / categoryTotalCount) * 100 + + return ( + <Card key={category}> + <CardHeader> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <CardTitle className="text-lg">{categoryNames[category] || category}</CardTitle> + <Badge variant="secondary"> + {categoryQuestions.length}개 질문 + </Badge> + </div> + <div className="flex items-center gap-4"> + <div className="text-right"> + <div className="text-sm font-medium"> + {categoryCompletedCount} / {categoryTotalCount} 완료 + </div> + <div className="text-xs text-muted-foreground"> + {Math.round(categoryProgress)}% + </div> + </div> + <div className="w-24 bg-muted rounded-full h-2"> + <div + className="bg-primary h-2 rounded-full transition-all duration-300" + style={{ width: `${categoryProgress}%` }} + /> + </div> + </div> + </div> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[150px]">평가</TableHead> + <TableHead className="w-[200px]">범위</TableHead> + <TableHead className="w-[250px]">비고</TableHead> + <TableHead className="w-[200px]">답변 선택</TableHead> + <TableHead className="w-[80px]">점수</TableHead> + <TableHead className="w-[250px]">추가 의견</TableHead> + <TableHead className="w-[80px]">상태</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {categoryQuestions.map((question) => { + const response = responses[question.criteriaId] + + const isVariable = question.scoreType === 'variable' + const isAnswered = isVariable ? + (response.score !== null) : + (response.detailId !== null && response.detailId > 0) + + return ( + <TableRow key={question.criteriaId} className={isAnswered ? "bg-green-50" : "bg-yellow-50"}> + <TableCell className="font-medium"> + {question.classification} + </TableCell> + + <TableCell className="text-sm"> + {question.range} + </TableCell> + + <TableCell className="text-sm"> + {question.remarks} + </TableCell> + + <TableCell> + {!isVariable && ( + <Select + value={response.detailId?.toString() || ""} + onValueChange={(value) => handleResponseChange(question.criteriaId, parseInt(value))} + disabled={isLoading || isSaving} + > + <SelectTrigger> + <SelectValue placeholder="답변을 선택하세요" /> + </SelectTrigger> + <SelectContent> + {question.availableOptions + .sort((a, b) => b.score - a.score) + .map((option) => ( + <SelectItem key={option.detailId} value={option.detailId.toString()}> + <div className="flex items-center justify-between w-full"> + <span>{option.detail}</span> + {!option.detail.includes('variable') && ( + <Badge variant="outline" className="ml-2"> + {option.score}점 + </Badge> + )} + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + )} + + {isVariable && ( + <Input + type="number" + min="0" + step="1" + value={response.score !== null ? response.score : ""} + onChange={(e) => { + const value = e.target.value + + if (value === '') { + handleScoreChange(question.criteriaId, null) + return + } + + const numericValue = parseInt(value) + + // 0 이상의 정수만 허용 + if (!isNaN(numericValue) && numericValue >= 0) { + handleScoreChange(question.criteriaId, numericValue) + } + }} + onBlur={(e) => { + // 포커스를 잃을 때 추가 검증 + const value = e.target.value + if (value !== '' && (isNaN(parseInt(value)) || parseInt(value) < 0)) { + handleScoreChange(question.criteriaId, null) + } + }} + placeholder="점수 입력 (0 이상)" + className="w-48" + disabled={isLoading || isSaving} + /> + )} + </TableCell> + + <TableCell> + {isAnswered && ( + <Badge variant={response.score! >= 4 ? "default" : response.score! >= 3 ? "secondary" : "destructive"}> + {response.score}점 + </Badge> + )} + </TableCell> + + <TableCell> + <Textarea + placeholder={isAnswered ? "추가 의견을 입력하세요..." : "먼저 답변을 선택하세요"} + value={response.comment} + onChange={(e) => handleCommentChange(question.criteriaId, e.target.value)} + disabled={isLoading || isSaving || !isAnswered} + rows={2} + className="resize-none min-w-[200px]" + /> + </TableCell> + + <TableCell> + {isAnswered ? ( + <Badge variant="default" className="text-xs"> + 완료 + </Badge> + ) : ( + <Badge variant="destructive" className="text-xs"> + 미답변 + </Badge> + )} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </CardContent> + </Card> + ) + })} + + {/* 하단 액션 버튼 */} + <div className="sticky bottom-0 bg-background border-t p-4"> + <div className="flex items-center justify-between max-w-7xl mx-auto"> + {!evaluationInfo.isCompleted && ( + <> + <div className="flex items-center gap-4 text-sm text-muted-foreground"> + + {hasUnsavedChanges && ( + <div className="flex items-center gap-1"> + <AlertCircle className="h-4 w-4 text-amber-500" /> + <span>저장되지 않은 변경사항이 있습니다</span> + </div> + )} + <div className="flex items-center gap-1"> + <FileText className="h-4 w-4" /> + <span>진행률: {completedCount}/{totalCount}</span> + </div> + </div> + + <div className="flex items-center gap-2"> + <Button + variant="outline" + onClick={() => router.back()} + disabled={isLoading || isSaving} + > + 취소 + </Button> + + <Button + variant="secondary" + onClick={handleSave} + disabled={isLoading || isSaving || !hasUnsavedChanges} + className="flex items-center gap-2" + > + <Save className="h-4 w-4" /> + {isSaving ? "저장 중..." : "임시저장"} + </Button> + + + <Button + onClick={handleCompleteClick} + disabled={isLoading || isSaving || !allCompleted} + className="flex items-center gap-2" + > + <Send className="h-4 w-4" /> + 평가 완료 + </Button> + + </div> + </> + )} + </div> + </div> + + {/* 평가 완료 확인 다이얼로그 */} + <AlertDialog open={showCompleteDialog} onOpenChange={setShowCompleteDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <CheckCircle className="h-5 w-5 text-green-600" /> + 평가 완료 확인 + </AlertDialogTitle> + <AlertDialogDescription className="space-y-2"> + <p>평가를 완료하시겠습니까?</p> + <div className="bg-muted p-3 rounded-md text-sm"> + <div className="font-medium text-foreground mb-1">평가 정보</div> + <div>• 협력업체: {evaluationInfo.vendorName}</div> + <div>• 완료된 문항: {completedCount}/{totalCount}개</div> + <div>• 진행률: {Math.round((completedCount / totalCount) * 100)}%</div> + </div> + <p className="text-sm text-muted-foreground"> + 완료 후에는 수정이 제한될 수 있습니다. + </p> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleCompleteConfirmed} + disabled={isLoading} + className="bg-green-600 hover:bg-green-700" + > + {isLoading ? "처리 중..." : "평가 완료"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + ) +}
\ No newline at end of file diff --git a/lib/evaluation-submit/evaluation-page.tsx b/lib/evaluation-submit/evaluation-page.tsx new file mode 100644 index 00000000..810ed03e --- /dev/null +++ b/lib/evaluation-submit/evaluation-page.tsx @@ -0,0 +1,258 @@ +"use client" + +import * as React from "react" +import { useParams, useRouter } from "next/navigation" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react" +import { Alert, AlertDescription } from "@/components/ui/alert" + +import { getEvaluationFormData, EvaluationFormData } from "./service" +import { EvaluationForm } from "./evaluation-form" + +/** + * 로딩 스켈레톤 컴포넌트 + */ +function EvaluationFormSkeleton() { + return ( + <div className="container mx-auto py-6 space-y-6"> + {/* 헤더 스켈레톤 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Skeleton className="h-10 w-10" /> + <div className="space-y-2"> + <Skeleton className="h-8 w-32" /> + <Skeleton className="h-4 w-48" /> + </div> + </div> + <Skeleton className="h-6 w-16" /> + </div> + + {/* 평가 정보 카드 스켈레톤 */} + <Card> + <CardHeader> + <Skeleton className="h-6 w-24" /> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> + {[...Array(4)].map((_, i) => ( + <div key={i} className="space-y-2"> + <Skeleton className="h-4 w-16" /> + <Skeleton className="h-5 w-24" /> + <Skeleton className="h-3 w-20" /> + </div> + ))} + </div> + </CardContent> + </Card> + + {/* 진행률 카드 스켈레톤 */} + <Card> + <CardContent className="pt-6"> + <div className="flex items-center justify-between"> + <div className="space-y-1"> + <Skeleton className="h-4 w-16" /> + <Skeleton className="h-6 w-24" /> + </div> + <Skeleton className="h-2 w-32" /> + </div> + </CardContent> + </Card> + + {/* 질문 카드들 스켈레톤 */} + {[...Array(3)].map((_, i) => ( + <Card key={i} className="mb-6"> + <CardHeader className="pb-4"> + <div className="flex items-start justify-between"> + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Skeleton className="h-5 w-16" /> + <Skeleton className="h-5 w-12" /> + </div> + <Skeleton className="h-6 w-64" /> + <Skeleton className="h-4 w-48" /> + </div> + </div> + </CardHeader> + <CardContent className="space-y-4"> + <div className="space-y-3"> + <Skeleton className="h-4 w-32" /> + {[...Array(3)].map((_, j) => ( + <div key={j} className="flex items-center space-x-3 p-3 border rounded-lg"> + <Skeleton className="h-4 w-4 rounded-full" /> + <div className="flex-1 flex items-center justify-between"> + <Skeleton className="h-4 w-32" /> + <Skeleton className="h-5 w-12" /> + </div> + </div> + ))} + </div> + <div className="space-y-2"> + <Skeleton className="h-4 w-24" /> + <Skeleton className="h-20 w-full" /> + </div> + </CardContent> + </Card> + ))} + </div> + ) +} + +/** + * 에러 상태 컴포넌트 + */ +function EvaluationFormError({ + error, + onRetry +}: { + error: string + onRetry: () => void +}) { + const router = useRouter() + + return ( + <div className="container mx-auto py-6 space-y-6"> + <div className="flex items-center gap-4"> + <Button + variant="ghost" + size="icon" + onClick={() => router.back()} + > + <ArrowLeft className="h-4 w-4" /> + </Button> + <div> + <h1 className="text-2xl font-bold">평가 작성</h1> + <p className="text-muted-foreground">평가를 불러오는 중 오류가 발생했습니다</p> + </div> + </div> + + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {error} + </AlertDescription> + </Alert> + + <Card> + <CardHeader> + <CardTitle>문제 해결</CardTitle> + <CardDescription> + 다음 방법들을 시도해보세요: + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <ul className="list-disc pl-6 space-y-2 text-sm"> + <li>페이지를 새로고침해보세요</li> + <li>인터넷 연결 상태를 확인해보세요</li> + <li>잠시 후 다시 시도해보세요</li> + <li>문제가 지속되면 관리자에게 문의하세요</li> + </ul> + + <div className="flex items-center gap-2 pt-4"> + <Button onClick={onRetry} className="flex items-center gap-2"> + <RefreshCw className="h-4 w-4" /> + 다시 시도 + </Button> + <Button variant="outline" onClick={() => router.back()}> + 목록으로 돌아가기 + </Button> + </div> + </CardContent> + </Card> + </div> + ) +} + +/** + * 평가 작성 페이지 메인 컴포넌트 + */ +export function EvaluationPage() { + const params = useParams() + const router = useRouter() + const [formData, setFormData] = React.useState<EvaluationFormData | null>(null) + const [isLoading, setIsLoading] = React.useState(true) + const [error, setError] = React.useState<string | null>(null) + + const reviewerEvaluationId = params.id ? parseInt(params.id as string) : null + + // 평가 데이터 로드 + const loadEvaluationData = React.useCallback(async () => { + if (!reviewerEvaluationId) { + setError("잘못된 평가 ID입니다.") + setIsLoading(false) + return + } + + try { + setIsLoading(true) + setError(null) + + const data = await getEvaluationFormData(reviewerEvaluationId) + + if (!data) { + setError("평가 데이터를 찾을 수 없습니다.") + return + } + + setFormData(data) + } catch (err) { + console.error('Failed to load evaluation data:', err) + setError( + err instanceof Error + ? err.message + : "평가 데이터를 불러오는 중 오류가 발생했습니다." + ) + } finally { + setIsLoading(false) + } + }, [reviewerEvaluationId]) + + // 초기 데이터 로드 + React.useEffect(() => { + loadEvaluationData() + }, [loadEvaluationData]) + + // 평가 완료 후 처리 + const handleSubmitSuccess = React.useCallback(() => { + router.push('/evaluations') + }, [router]) + + // 로딩 상태 + if (isLoading) { + return <EvaluationFormSkeleton /> + } + + // 에러 상태 + if (error) { + return ( + <EvaluationFormError + error={error} + onRetry={loadEvaluationData} + /> + ) + } + + // 데이터가 없는 경우 + if (!formData) { + return ( + <EvaluationFormError + error="평가 데이터를 불러올 수 없습니다." + onRetry={loadEvaluationData} + /> + ) + } + + // 정상 상태 - 평가 폼 렌더링 + return ( + <EvaluationForm + formData={formData} + onSubmit={handleSubmitSuccess} + /> + ) +} + +// 페이지 컴포넌트용 기본 export +export default function EvaluationPageWrapper() { + return <EvaluationPage /> +}
\ No newline at end of file diff --git a/lib/evaluation-submit/service.ts b/lib/evaluation-submit/service.ts new file mode 100644 index 00000000..84d356e7 --- /dev/null +++ b/lib/evaluation-submit/service.ts @@ -0,0 +1,562 @@ +'use server' + +import db from "@/db/db"; +import { + reviewerEvaluations, + reviewerEvaluationsView, + reviewerEvaluationDetails, + regEvalCriteriaDetails, + regEvalCriteriaView, + NewReviewerEvaluationDetail, + ReviewerEvaluationDetail, + evaluationTargetReviewers, + evaluationTargets, + regEvalCriteria, + periodicEvaluations +} from "@/db/schema"; +import { and, asc, desc, eq, ilike, or, SQL, count , sql, avg, isNotNull} from "drizzle-orm"; +import { filterColumns } from "@/lib/filter-columns"; +import { DEPARTMENT_CATEGORY_MAPPING, EvaluationFormData, EvaluationQuestionItem, GetSHIEvaluationsSubmitSchema, REVIEWER_TYPES, ReviewerType } from "./validation"; + + +// =============================================================================== +// UTILITY FUNCTIONS +// =============================================================================== + +/** + * division과 materialType을 기반으로 reviewerType을 계산합니다 + */ +function calculateReviewerType(division: string, materialType: string): ReviewerType { + if (division === 'SHIP') { + if (materialType === 'EQUIPMENT' || materialType === 'EQUIPMENT_BULK') { + return REVIEWER_TYPES.EQUIPMENT_SHIP; + } else if (materialType === 'BULK') { + return REVIEWER_TYPES.BULK_SHIP; + } + return REVIEWER_TYPES.EQUIPMENT_SHIP; // 기본값 + } else if (division === 'PLANT') { + if (materialType === 'EQUIPMENT' || materialType === 'EQUIPMENT_BULK') { + return REVIEWER_TYPES.EQUIPMENT_MARINE; + } else if (materialType === 'BULK') { + return REVIEWER_TYPES.BULK_MARINE; + } + return REVIEWER_TYPES.EQUIPMENT_MARINE; // 기본값 + } + return REVIEWER_TYPES.EQUIPMENT_SHIP; // 기본값 +} + +/** + * reviewerType에 따라 해당하는 점수 필드를 가져옵니다 + */ +function getScoreByReviewerType( + detailRecord: any, + reviewerType: ReviewerType +): number | null { + let score: string | null = null; + + switch (reviewerType) { + case REVIEWER_TYPES.EQUIPMENT_SHIP: + score = detailRecord.scoreEquipShip; + break; + case REVIEWER_TYPES.EQUIPMENT_MARINE: + score = detailRecord.scoreEquipMarine; + break; + case REVIEWER_TYPES.BULK_SHIP: + score = detailRecord.scoreBulkShip; + break; + case REVIEWER_TYPES.BULK_MARINE: + score = detailRecord.scoreBulkMarine; + break; + } + + return score ? parseFloat(score) : null; +} + + +function getCategoryFilterByDepartment(departmentCode: string): SQL<unknown> { + const categoryMapping = DEPARTMENT_CATEGORY_MAPPING as Record<string, string>; + const category = categoryMapping[departmentCode] || 'administrator'; + return eq(regEvalCriteria.category, category); +} + + +// =============================================================================== +// MAIN FUNCTIONS +// =============================================================================== + + + +/** + * 평가 폼 데이터를 조회하고, 응답 레코드가 없으면 생성합니다 + */ +export async function getEvaluationFormData(reviewerEvaluationId: number): Promise<EvaluationFormData | null> { + try { + // 1. 리뷰어 평가 정보 조회 (부서 정보 + 평가 대상 정보 포함) + const reviewerEvaluationInfo = await db + .select({ + id: reviewerEvaluations.id, + periodicEvaluationId: reviewerEvaluations.periodicEvaluationId, + evaluationTargetReviewerId: reviewerEvaluations.evaluationTargetReviewerId, + isCompleted: reviewerEvaluations.isCompleted, + // evaluationTargetReviewers 테이블에서 부서 정보 + departmentCode: evaluationTargetReviewers.departmentCode, + // evaluationTargets 테이블에서 division과 materialType 정보 + division: evaluationTargets.division, + materialType: evaluationTargets.materialType, + vendorName: evaluationTargets.vendorName, + vendorCode: evaluationTargets.vendorCode, + }) + .from(reviewerEvaluations) + .leftJoin( + evaluationTargetReviewers, + eq(reviewerEvaluations.evaluationTargetReviewerId, evaluationTargetReviewers.id) + ) + .leftJoin( + evaluationTargets, + eq(evaluationTargetReviewers.evaluationTargetId, evaluationTargets.id) + ) + .where(eq(reviewerEvaluations.id, reviewerEvaluationId)) + .limit(1); + + if (reviewerEvaluationInfo.length === 0) { + throw new Error('Reviewer evaluation not found'); + } + + const evaluation = reviewerEvaluationInfo[0]; + + // 1-1. division과 materialType을 기반으로 reviewerType 계산 + const reviewerType = calculateReviewerType(evaluation.division, evaluation.materialType); + + // 2. 부서에 따른 카테고리 필터링 로직 + // const categoryFilter = getCategoryFilterByDepartment("admin"); + const categoryFilter = getCategoryFilterByDepartment(evaluation.departmentCode); + + // 3. 해당 부서에 맞는 평가 기준들과 답변 옵션들 조회 + const criteriaWithDetails = await db + .select({ + // 질문 정보 (실제 스키마 기준) + criteriaId: regEvalCriteria.id, + category: regEvalCriteria.category, // 평가부문 + category2: regEvalCriteria.category2, // 점수유형 + item: regEvalCriteria.item, // 항목 + classification: regEvalCriteria.classification, // 구분 (실제 질문) + range: regEvalCriteria.range, // 범위 (실제로 평가명) + remarks: regEvalCriteria.remarks, + scoreType: regEvalCriteria.scoreType, // ✅ fixed | variable + variableScoreMin: regEvalCriteria.variableScoreMin, + variableScoreMax: regEvalCriteria.variableScoreMax, + variableScoreUnit: regEvalCriteria.variableScoreUnit, // ✅ 오타 있지만 실제 스키마 따름 + + // 답변 옵션 정보 + detailId: regEvalCriteriaDetails.id, + detail: regEvalCriteriaDetails.detail, + orderIndex: regEvalCriteriaDetails.orderIndex, + scoreEquipShip: regEvalCriteriaDetails.scoreEquipShip, + scoreEquipMarine: regEvalCriteriaDetails.scoreEquipMarine, + scoreBulkShip: regEvalCriteriaDetails.scoreBulkShip, + scoreBulkMarine: regEvalCriteriaDetails.scoreBulkMarine, + }) + .from(regEvalCriteria) + .leftJoin( + regEvalCriteriaDetails, + eq(regEvalCriteria.id, regEvalCriteriaDetails.criteriaId) + ) + .where(categoryFilter) + .orderBy( + regEvalCriteria.id, + regEvalCriteriaDetails.orderIndex + ); + + // 4. 기존 응답 데이터 조회 (실제 답변만) + const existingResponses = await db + .select({ + id: reviewerEvaluationDetails.id, + reviewerEvaluationId: reviewerEvaluationDetails.reviewerEvaluationId, + regEvalCriteriaDetailsId: reviewerEvaluationDetails.regEvalCriteriaDetailsId, + score: reviewerEvaluationDetails.score, + comment: reviewerEvaluationDetails.comment, + createdAt: reviewerEvaluationDetails.createdAt, + updatedAt: reviewerEvaluationDetails.updatedAt, + }) + .from(reviewerEvaluationDetails) + .where( + and( + eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId), + // ✅ null이 아닌 실제 응답만 조회 + isNotNull(reviewerEvaluationDetails.regEvalCriteriaDetailsId) + ) + ); + + // 5. 질문별로 그룹화하고 답변 옵션들 정리 + const questionsMap = new Map<number, EvaluationQuestionItem>(); + + criteriaWithDetails.forEach(record => { + if (!record.detailId) return; // 답변 옵션이 없는 경우 스킵 + + const criteriaId = record.criteriaId; + + // 해당 reviewerType에 맞는 점수 가져오기 + const score = getScoreByReviewerType(record, reviewerType); + if (score === null) return; // 해당 리뷰어 타입에 점수가 없으면 스킵 + + // 질문이 이미 존재하는지 확인 + if (!questionsMap.has(criteriaId)) { + questionsMap.set(criteriaId, { + criteriaId: record.criteriaId, + category: record.category, + category2: record.category2, + item: record.item, + classification: record.classification, + range: record.range, + scoreType: record.scoreType, + remarks: record.remarks, + availableOptions: [], + responseId: null, + selectedDetailId: null, // ✅ 초기값은 null (아직 선택하지 않음) + currentScore: null, + currentComment: null, + }); + } + + // 답변 옵션 추가 + const question = questionsMap.get(criteriaId)!; + question.availableOptions.push({ + detailId: record.detailId, + detail: record.detail, + score: score, + orderIndex: record.orderIndex, + }); + }); + + // 6. ✅ 초기 응답 생성하지 않음 - 사용자가 실제로 답변할 때만 생성 + + // 7. 기존 응답 데이터를 질문에 매핑 + const existingResponsesMap = new Map( + existingResponses.map(r => [r.regEvalCriteriaDetailsId, r]) + ); + + // 8. 각 질문에 현재 응답 정보 매핑 + const questions: EvaluationQuestionItem[] = []; + questionsMap.forEach(question => { + // 현재 선택된 답변 찾기 (실제 응답이 있는 경우에만) + let selectedResponse = null; + for (const option of question.availableOptions) { + const response = existingResponsesMap.get(option.detailId); + if (response) { + selectedResponse = response; + question.selectedDetailId = option.detailId; + break; + } + } + + if (selectedResponse) { + question.responseId = selectedResponse.id; + question.currentScore = selectedResponse.score; + question.currentComment = selectedResponse.comment; + } + // ✅ else 케이스: 아직 답변하지 않은 상태 (모든 값이 null) + + questions.push(question); + }); + + return { + evaluationInfo: { + ...evaluation, + reviewerType + }, + questions, + }; + + } catch (err) { + console.error('Error in getEvaluationFormData:', err); + return null; + } +} + + + +/** + * 평가 제출 목록을 조회합니다 + */ +export async function getSHIEvaluationSubmissions(input: GetSHIEvaluationsSubmitSchema, userId: number) { + try { + const offset = (input.page - 1) * input.perPage; + + // 고급 필터링 + const advancedWhere = filterColumns({ + table: reviewerEvaluationsView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 + let globalWhere: SQL<unknown> | undefined; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(reviewerEvaluationsView.isCompleted, s), + ); + } + + const existingReviewer = await db.query.evaluationTargetReviewers.findFirst({ + where: eq(evaluationTargetReviewers.reviewerUserId, userId), + }); + + + + const finalWhere = and( + advancedWhere, + globalWhere, + eq(reviewerEvaluationsView.evaluationTargetReviewerId, existingReviewer?.id), + ); + + // 정렬 + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + return item.desc + ? desc(reviewerEvaluationsView[item.id]) + : asc(reviewerEvaluationsView[item.id]); + }) + : [desc(reviewerEvaluationsView.reviewerEvaluationCreatedAt)]; + + // 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + // 메인 데이터 조회 + const data = await tx + .select() + .from(reviewerEvaluationsView) + .where(finalWhere) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + // 총 개수 조회 + const totalResult = await tx + .select({ count: count() }) + .from(reviewerEvaluationsView) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + console.log('Error in getEvaluationSubmissions:', err); + return { data: [], pageCount: 0 }; + } +} + +/** + * 특정 평가 제출의 상세 정보를 조회합니다 + */ +export async function getSHIEvaluationSubmissionById(id: number) { + try { + const result = await db + .select() + .from(reviewerEvaluationsView) + .where( + and( + eq(reviewerEvaluationsView.evaluationTargetReviewerId, id), + ) + ) + .limit(1); + + if (result.length === 0) { + return null; + } + + const submission = result[0]; + + // 응답 데이터도 함께 조회 + const [generalResponses] = await Promise.all([ + db + .select() + .from(reviewerEvaluationDetails) + .where( + and( + eq(reviewerEvaluationDetails.reviewerEvaluationId, id), + ) + ), + ]); + + return { + ...submission, + generalResponses, + }; + } catch (err) { + console.error('Error in getEvaluationSubmissionById:', err); + return null; + } +} + +/** + * 평가 응답을 업데이트합니다 + */ +export async function updateEvaluationResponse( + reviewerEvaluationId: number, + selectedDetailId: number, + comment?: string +) { + try { + await db.transaction(async (tx) => { + // 1. 선택된 답변 옵션의 정보 조회 + const selectedDetail = await tx + .select() + .from(regEvalCriteriaDetails) + .where(eq(regEvalCriteriaDetails.id, selectedDetailId)) + .limit(1); + + if (selectedDetail.length === 0) { + throw new Error('Selected detail not found'); + } + + // 2. reviewerEvaluation 정보 조회 (periodicEvaluationId 포함) + const reviewerEvaluationInfo = await tx + .select({ + periodicEvaluationId: reviewerEvaluations.periodicEvaluationId, + }) + .from(reviewerEvaluations) + .where(eq(reviewerEvaluations.id, reviewerEvaluationId)) + .limit(1); + + if (reviewerEvaluationInfo.length === 0) { + throw new Error('Reviewer evaluation not found'); + } + + const { periodicEvaluationId } = reviewerEvaluationInfo[0]; + + // 3. periodicEvaluation의 현재 상태 확인 및 업데이트 + const currentStatus = await tx + .select({ + status: periodicEvaluations.status, + }) + .from(periodicEvaluations) + .where(eq(periodicEvaluations.id, periodicEvaluationId)) + .limit(1); + + if (currentStatus.length > 0 && currentStatus[0].status !== "IN_REVIEW") { + await tx + .update(periodicEvaluations) + .set({ + status: "IN_REVIEW", + updatedAt: new Date(), + }) + .where(eq(periodicEvaluations.id, periodicEvaluationId)); + } + + // 4. 리뷰어 타입 정보 조회 + const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId); + if (!evaluationInfo) { + throw new Error('Evaluation not found'); + } + + // 5. 해당 리뷰어 타입에 맞는 점수 가져오기 + const score = getScoreByReviewerType(selectedDetail[0], evaluationInfo.evaluationInfo.reviewerType); + if (score === null) { + throw new Error('Score not found for this reviewer type'); + } + + // 6. 같은 질문에 대한 기존 응답들 삭제 + const criteriaId = selectedDetail[0].criteriaId; + await tx + .delete(reviewerEvaluationDetails) + .where( + and( + eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId), + sql`${reviewerEvaluationDetails.regEvalCriteriaDetailsId} IN ( + SELECT id FROM reg_eval_criteria_details WHERE criteria_id = ${criteriaId} + )` + ) + ); + + // 7. 새로운 응답 생성 + await tx + .insert(reviewerEvaluationDetails) + .values({ + reviewerEvaluationId, + regEvalCriteriaDetailsId: selectedDetailId, + score: score.toString(), + comment, + }); + + // 8. 카테고리별 점수 계산 및 총점 업데이트 + await recalculateEvaluationScores(tx, reviewerEvaluationId); + }); + + return { success: true }; + } catch (err) { + console.error('Error in updateEvaluationResponse:', err); + throw err; + } +} + + +/** + * 평가 점수 재계산 + */ +async function recalculateEvaluationScores(tx: any, reviewerEvaluationId: number) { + await tx + .update(reviewerEvaluations) + .set({ + updatedAt: new Date(), + }) + .where(eq(reviewerEvaluations.id, reviewerEvaluationId)); +} + + +export async function completeEvaluation( + reviewerEvaluationId: number, + reviewerComment?: string +) { + try { + await db.transaction(async (tx) => { + // 1. 먼저 해당 리뷰어 평가를 완료로 표시 + const updatedEvaluation = await tx + .update(reviewerEvaluations) + .set({ + isCompleted: true, + completedAt: new Date(), + reviewerComment, + updatedAt: new Date(), + }) + .where(eq(reviewerEvaluations.id, reviewerEvaluationId)) + .returning({ periodicEvaluationId: reviewerEvaluations.periodicEvaluationId }); + + if (updatedEvaluation.length === 0) { + throw new Error('Reviewer evaluation not found'); + } + + const { periodicEvaluationId } = updatedEvaluation[0]; + + // 2. 같은 periodicEvaluationId를 가진 모든 리뷰어 평가가 완료되었는지 확인 + const allEvaluations = await tx + .select({ + isCompleted: reviewerEvaluations.isCompleted, + }) + .from(reviewerEvaluations) + .where(eq(reviewerEvaluations.periodicEvaluationId, periodicEvaluationId)); + + // 3. 모든 평가가 완료되었는지 확인 + const allCompleted = allEvaluations.every(evaluation => evaluation.isCompleted); + + // 4. 모든 평가가 완료되었다면 periodicEvaluations의 status 업데이트 + if (allCompleted) { + await tx + .update(periodicEvaluations) + .set({ + status: "REVIEW_COMPLETED", + updatedAt: new Date(), + }) + .where(eq(periodicEvaluations.id, periodicEvaluationId)); + } + }); + + return { success: true }; + } catch (err) { + console.error('Error in completeEvaluation:', err); + throw err; + } +}
\ No newline at end of file diff --git a/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx b/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx new file mode 100644 index 00000000..1ec0284f --- /dev/null +++ b/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx @@ -0,0 +1,556 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { + Ellipsis, + InfoIcon, + PenToolIcon, + FileTextIcon, + ClipboardListIcon, + CheckIcon, + XIcon, + ClockIcon, + Send, + User, + Calendar +} from "lucide-react" + +import { formatDate, formatCurrency } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Badge } from "@/components/ui/badge" +import { useRouter } from "next/navigation" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { ReviewerEvaluationView } from "@/db/schema" + + +type NextRouter = ReturnType<typeof useRouter>; + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ReviewerEvaluationView> | null>> + router: NextRouter; + +} + +/** + * 평가 진행 상태에 따른 배지 스타일 + */ +const getProgressBadge = (isCompleted: boolean, completedAt: Date | null) => { + if (isCompleted && completedAt) { + return { + variant: "default" as const, + icon: <CheckIcon className="h-3 w-3" />, + label: "완료", + className: "bg-green-100 text-green-800 border-green-200" + } + } else { + return { + variant: "secondary" as const, + icon: <ClockIcon className="h-3 w-3" />, + label: "미완료" + } + } +} + +/** + * 정기평가 상태에 따른 배지 스타일 + */ +const getPeriodicStatusBadge = (status: string) => { + switch (status) { + case 'PENDING': + return { + variant: "secondary" as const, + icon: <ClockIcon className="h-3 w-3" />, + label: "대기중" + } + + case 'PENDING_SUBMISSION': + return { + variant: "secondary" as const, + icon: <ClockIcon className="h-3 w-3" />, + label: "업체 제출 대기중" + } + case 'IN_PROGRESS': + return { + variant: "default" as const, + icon: <PenToolIcon className="h-3 w-3" />, + label: "진행중" + } + case 'REVIEW': + return { + variant: "outline" as const, + icon: <ClipboardListIcon className="h-3 w-3" />, + label: "검토중" + } + case 'COMPLETED': + return { + variant: "default" as const, + icon: <CheckIcon className="h-3 w-3" />, + label: "완료", + className: "bg-green-100 text-green-800 border-green-200" + } + default: + return { + variant: "secondary" as const, + icon: null, + label: status + } + } +} + +/** + * 평가 제출 테이블 컬럼 정의 + */ +export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<ReviewerEvaluationView>[] { + + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<ReviewerEvaluationView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + } + + // ---------------------------------------------------------------- + // 2) 기본 정보 컬럼들 + // ---------------------------------------------------------------- + const basicColumns: ColumnDef<ReviewerEvaluationView>[] = [ + { + accessorKey: "evaluationYear", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="평가연도" /> + ), + cell: ({ row }) => ( + <Badge variant="outline"> + {row.getValue("evaluationYear")}년 + </Badge> + ), + size: 80, + }, + + { + id: "vendorInfo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체" /> + ), + cell: ({ row }) => { + const vendorName = row.original.vendorName; + const vendorCode = row.original.vendorCode; + const domesticForeign = row.original.domesticForeign; + + return ( + <div className="space-y-1"> + <div className="font-medium">{vendorName}</div> + <div className="text-sm text-muted-foreground"> + {vendorCode} • {domesticForeign === 'DOMESTIC' ? 'D' : 'F'} + </div> + </div> + ); + }, + enableSorting: false, + size: 200, + }, + + + { + id: "materialType", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재구분" /> + ), + cell: ({ row }) => { + const materialType = row.original.materialType; + const material = materialType ==="BULK" ? "벌크": materialType ==="EQUIPMENT" ? "기자재" :"기자재/벌크" + + return ( + <div className="space-y-1"> + <div className="font-medium">{material}</div> + + </div> + ); + }, + enableSorting: false, + }, + + { + id: "division", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="division" /> + ), + cell: ({ row }) => { + const division = row.original.division; + const divisionKR = division === "PLANT"?"해양":"조선"; + + return ( + <div className="space-y-1"> + <div className="font-medium">{divisionKR}</div> + + </div> + ); + }, + enableSorting: false, + }, + ] + + // ---------------------------------------------------------------- + // 3) 상태 정보 컬럼들 + // ---------------------------------------------------------------- + const statusColumns: ColumnDef<ReviewerEvaluationView>[] = [ + { + id: "evaluationProgress", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="평가 진행상태" /> + ), + cell: ({ row }) => { + const isCompleted = row.original.isCompleted; + const completedAt = row.original.completedAt; + const badgeInfo = getProgressBadge(isCompleted, completedAt); + + return ( + <div className="space-y-1"> + <Badge + variant={badgeInfo.variant} + className={`flex items-center gap-1 ${badgeInfo.className || ''}`} + > + {badgeInfo.icon} + {badgeInfo.label} + </Badge> + {completedAt && ( + <div className="text-xs text-muted-foreground"> + {formatDate(completedAt,"KR")} + </div> + )} + </div> + ); + }, + size: 130, + }, + + { + id: "periodicStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="정기평가 상태" /> + ), + cell: ({ row }) => { + const status = row.original.periodicStatus; + const badgeInfo = getPeriodicStatusBadge(status); + + return ( + <Badge + variant={badgeInfo.variant} + className={`flex items-center gap-1 ${badgeInfo.className || ''}`} + > + {badgeInfo.icon} + {badgeInfo.label} + </Badge> + ); + }, + size: 120, + }, + + // { + // id: "submissionInfo", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="제출정보" /> + // ), + // cell: ({ row }) => { + // // const submissionDate = row.original.submittedAt; + // const completedAt = row.original.completedAt; + + // return ( + // <div className="space-y-1"> + // <div className="flex items-center gap-1"> + // <Badge variant={submissionDate ? "default" : "secondary"}> + // {submissionDate ? "제출완료" : "미제출"} + // </Badge> + // </div> + + // {completedAt && ( + // <div className="text-xs text-muted-foreground"> + // 평가완료: {formatDate(completedAt, "KR")} + // </div> + // )} + // {/* {submissionDate && ( + // <div className="text-xs text-muted-foreground"> + // 제출: {formatDate(submissionDate, "KR")} + // </div> + // )} */} + + // </div> + // ); + // }, + // enableSorting: false, + // size: 140, + // }, + ] + + // ---------------------------------------------------------------- + // 4) 점수 및 평가 정보 컬럼들 + // ---------------------------------------------------------------- + const scoreColumns: ColumnDef<ReviewerEvaluationView>[] = [ + { + id: "periodicScores", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="정기평가 점수" /> + ), + cell: ({ row }) => { + const finalScore = row.original.periodicFinalScore; + const finalGrade = row.original.periodicFinalGrade; + const evaluationScore = row.original.periodicEvaluationScore; + const evaluationGrade = row.original.periodicEvaluationGrade; + + return ( + <div className="text-center space-y-1"> + {finalScore && finalGrade ? ( + <div className="space-y-1"> + <div className="font-medium text-blue-600"> + 최종: {parseFloat(finalScore.toString()).toFixed(1)}점 + </div> + <Badge variant="outline">{finalGrade}</Badge> + </div> + ) : evaluationScore && evaluationGrade ? ( + <div className="space-y-1"> + <div className="font-medium"> + {parseFloat(evaluationScore.toString()).toFixed(1)}점 + </div> + <Badge variant="outline">{evaluationGrade}</Badge> + </div> + ) : ( + <span className="text-muted-foreground">미산정</span> + )} + </div> + ); + }, + enableSorting: false, + size: 120, + }, + + ] + + + + // ---------------------------------------------------------------- + // 6) 메타데이터 컬럼들 + // ---------------------------------------------------------------- + const metaColumns: ColumnDef<ReviewerEvaluationView>[] = [ + { + accessorKey: "reviewerEvaluationCreatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + cell: ({ row }) => { + const date = row.getValue("reviewerEvaluationCreatedAt") as Date; + return formatDate(date); + }, + size: 140, + }, + { + accessorKey: "reviewerEvaluationUpdatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정일" /> + ), + cell: ({ row }) => { + const date = row.getValue("reviewerEvaluationUpdatedAt") as Date; + return formatDate(date); + }, + size: 140, + }, + ] + + // ---------------------------------------------------------------- + // 7) actions 컬럼 (드롭다운 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<ReviewerEvaluationView> = { + id: "actions", + header: "작업", + enableHiding: false, + cell: function Cell({ row }) { + const isCompleted = row.original.isCompleted; + const reviewerEvaluationId = row.original.reviewerEvaluationId; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <Ellipsis className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => router.push(`/evcp/evaluation-input/${reviewerEvaluationId}`)} + > + {isCompleted ? "완료된 평가보기":"평가 작성하기"} + </DropdownMenuItem> + + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 80, + } + + // ---------------------------------------------------------------- + // 8) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...basicColumns, + { + id: "statusInfo", + header: "상태 정보", + columns: statusColumns, + }, + { + id: "scoreInfo", + header: "점수 및 평가", + columns: scoreColumns, + }, + + { + id: "metadata", + header: "메타데이터", + columns: metaColumns, + }, + actionsColumn, + ] +} + +// ---------------------------------------------------------------- +// 9) 컬럼 설정 (필터링용) +// ---------------------------------------------------------------- +export const evaluationSubmissionsColumnsConfig = [ + { + id: "reviewerEvaluationId", + label: "평가 ID", + group: "기본 정보", + type: "text", + excelHeader: "Evaluation ID", + }, + { + id: "vendorName", + label: "협력업체명", + group: "기본 정보", + type: "text", + excelHeader: "Vendor Name", + }, + { + id: "vendorCode", + label: "협력업체 코드", + group: "기본 정보", + type: "text", + excelHeader: "Vendor Code", + }, + { + id: "evaluationYear", + label: "평가연도", + group: "기본 정보", + type: "number", + excelHeader: "Evaluation Year", + }, + { + id: "departmentCode", + label: "부서코드", + group: "기본 정보", + type: "text", + excelHeader: "Department Code", + }, + { + id: "isCompleted", + label: "완료 여부", + group: "상태 정보", + type: "select", + options: [ + { label: "완료", value: "true" }, + { label: "미완료", value: "false" }, + ], + excelHeader: "Is Completed", + }, + { + id: "periodicStatus", + label: "정기평가 상태", + group: "상태 정보", + type: "select", + options: [ + { label: "대기중", value: "PENDING" }, + { label: "진행중", value: "IN_PROGRESS" }, + { label: "검토중", value: "REVIEW" }, + { label: "완료", value: "COMPLETED" }, + ], + excelHeader: "Periodic Status", + }, + { + id: "documentsSubmitted", + label: "문서 제출여부", + group: "상태 정보", + type: "select", + options: [ + { label: "제출완료", value: "true" }, + { label: "미제출", value: "false" }, + ], + excelHeader: "Documents Submitted", + }, + { + id: "periodicFinalScore", + label: "최종점수", + group: "점수 정보", + type: "number", + excelHeader: "Final Score", + }, + { + id: "periodicFinalGrade", + label: "최종등급", + group: "점수 정보", + type: "text", + excelHeader: "Final Grade", + }, + { + id: "reviewerEvaluationCreatedAt", + label: "생성일", + group: "메타데이터", + type: "date", + excelHeader: "Created At", + }, + { + id: "reviewerEvaluationUpdatedAt", + label: "수정일", + group: "메타데이터", + type: "date", + excelHeader: "Updated At", + }, +] as const;
\ No newline at end of file diff --git a/lib/evaluation-submit/table/evaluation-submit-dialog.tsx b/lib/evaluation-submit/table/evaluation-submit-dialog.tsx new file mode 100644 index 00000000..20ed5f30 --- /dev/null +++ b/lib/evaluation-submit/table/evaluation-submit-dialog.tsx @@ -0,0 +1,353 @@ +"use client" + +import * as React from "react" +import { + AlertTriangleIcon, + CheckCircleIcon, + SendIcon, + XCircleIcon, + FileTextIcon, + ClipboardListIcon, + LoaderIcon +} from "lucide-react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { toast } from "sonner" + +// Progress 컴포넌트 (간단한 구현) +function Progress({ value, className }: { value: number; className?: string }) { + return ( + <div className={`w-full bg-gray-200 rounded-full overflow-hidden ${className}`}> + <div + className={`h-full bg-blue-600 transition-all duration-300 ${ + value === 100 ? 'bg-green-500' : value >= 50 ? 'bg-blue-500' : 'bg-yellow-500' + }`} + style={{ width: `${Math.min(100, Math.max(0, value))}%` }} + /> + </div> + ) +} + +import { + getEvaluationSubmissionCompleteness, + updateEvaluationSubmissionStatus +} from "../service" +import type { EvaluationSubmissionWithVendor } from "../service" + +interface EvaluationSubmissionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: EvaluationSubmissionWithVendor | null + onSuccess: () => void +} + +type CompletenessData = { + general: { + total: number + completed: number + percentage: number + isComplete: boolean + } + esg: { + total: number + completed: number + percentage: number + averageScore: number + isComplete: boolean + } + overall: { + isComplete: boolean + totalItems: number + completedItems: number + } +} + +export function EvaluationSubmissionDialog({ + open, + onOpenChange, + submission, + onSuccess, +}: EvaluationSubmissionDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [completeness, setCompleteness] = React.useState<CompletenessData | null>(null) + + // 완성도 데이터 로딩 + React.useEffect(() => { + if (open && submission?.id) { + loadCompleteness() + } + }, [open, submission?.id]) + + const loadCompleteness = async () => { + if (!submission?.id) return + + setIsLoading(true) + try { + const data = await getEvaluationSubmissionCompleteness(submission.id) + setCompleteness(data) + } catch (error) { + console.error('Error loading completeness:', error) + toast.error('완성도 정보를 불러오는데 실패했습니다.') + } finally { + setIsLoading(false) + } + } + + // 제출하기 + const handleSubmit = async () => { + if (!submission?.id || !completeness) return + + if (!completeness.overall.isComplete) { + toast.error('모든 평가 항목을 완료해야 제출할 수 있습니다.') + return + } + + setIsSubmitting(true) + try { + await updateEvaluationSubmissionStatus(submission.id, 'submitted') + toast.success('평가가 성공적으로 제출되었습니다.') + onSuccess() + } catch (error: any) { + console.error('Error submitting evaluation:', error) + toast.error(error.message || '제출에 실패했습니다.') + } finally { + setIsSubmitting(false) + } + } + + const isKorean = submission?.vendor.countryCode === 'KR' + + if (isLoading) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <div className="flex items-center justify-center py-8"> + <div className="text-center space-y-4"> + <LoaderIcon className="h-8 w-8 animate-spin mx-auto" /> + <p>완성도를 확인하는 중...</p> + </div> + </div> + </DialogContent> + </Dialog> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <SendIcon className="h-5 w-5" /> + 평가 제출하기 + </DialogTitle> + <DialogDescription> + {submission?.vendor.vendorName}의 {submission?.evaluationYear}년 평가를 제출합니다. + </DialogDescription> + </DialogHeader> + + {completeness && ( + <div className="space-y-6"> + {/* 전체 완성도 카드 */} + <Card> + <CardHeader> + <CardTitle className="text-base flex items-center justify-between"> + <span>전체 완성도</span> + <Badge + variant={completeness.overall.isComplete ? "default" : "secondary"} + className={ + completeness.overall.isComplete + ? "bg-green-100 text-green-800 border-green-200" + : "" + } + > + {completeness.overall.isComplete ? "완료" : "미완료"} + </Badge> + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>전체 진행률</span> + <span className="font-medium"> + {completeness.overall.completedItems}/{completeness.overall.totalItems}개 완료 + </span> + </div> + <Progress + value={ + completeness.overall.totalItems > 0 + ? (completeness.overall.completedItems / completeness.overall.totalItems) * 100 + : 0 + } + className="h-2" + /> + <p className="text-xs text-muted-foreground"> + {completeness.overall.totalItems > 0 + ? Math.round((completeness.overall.completedItems / completeness.overall.totalItems) * 100) + : 0}% 완료 + </p> + </div> + </CardContent> + </Card> + + {/* 세부 완성도 */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* 일반평가 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-sm flex items-center gap-2"> + <FileTextIcon className="h-4 w-4" /> + 일반평가 + {completeness.general.isComplete ? ( + <CheckCircleIcon className="h-4 w-4 text-green-600" /> + ) : ( + <XCircleIcon className="h-4 w-4 text-red-600" /> + )} + </CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="space-y-1"> + <div className="flex items-center justify-between text-xs"> + <span>응답 완료</span> + <span className="font-medium"> + {completeness.general.completed}/{completeness.general.total}개 + </span> + </div> + <Progress value={completeness.general.percentage} className="h-1" /> + <p className="text-xs text-muted-foreground"> + {completeness.general.percentage.toFixed(0)}% 완료 + </p> + </div> + + {!completeness.general.isComplete && ( + <p className="text-xs text-red-600"> + {completeness.general.total - completeness.general.completed}개 항목이 미완료입니다. + </p> + )} + </CardContent> + </Card> + + {/* ESG평가 */} + {isKorean ? ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-sm flex items-center gap-2"> + <ClipboardListIcon className="h-4 w-4" /> + ESG평가 + {completeness.esg.isComplete ? ( + <CheckCircleIcon className="h-4 w-4 text-green-600" /> + ) : ( + <XCircleIcon className="h-4 w-4 text-red-600" /> + )} + </CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="space-y-1"> + <div className="flex items-center justify-between text-xs"> + <span>응답 완료</span> + <span className="font-medium"> + {completeness.esg.completed}/{completeness.esg.total}개 + </span> + </div> + <Progress value={completeness.esg.percentage} className="h-1" /> + <p className="text-xs text-muted-foreground"> + {completeness.esg.percentage.toFixed(0)}% 완료 + </p> + </div> + + {completeness.esg.completed > 0 && ( + <div className="text-xs"> + <span className="text-muted-foreground">평균 점수: </span> + <span className="font-medium text-blue-600"> + {completeness.esg.averageScore.toFixed(1)}점 + </span> + </div> + )} + + {!completeness.esg.isComplete && ( + <p className="text-xs text-red-600"> + {completeness.esg.total - completeness.esg.completed}개 항목이 미완료입니다. + </p> + )} + </CardContent> + </Card> + ) : ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-sm flex items-center gap-2"> + <ClipboardListIcon className="h-4 w-4" /> + ESG평가 + </CardTitle> + </CardHeader> + <CardContent> + <div className="text-center text-muted-foreground"> + <Badge variant="outline">해당없음</Badge> + <p className="text-xs mt-2">한국 업체가 아니므로 ESG 평가가 제외됩니다.</p> + </div> + </CardContent> + </Card> + )} + </div> + + {/* 제출 상태 알림 */} + {completeness.overall.isComplete ? ( + <Alert> + <CheckCircleIcon className="h-4 w-4" /> + <AlertTitle>제출 준비 완료</AlertTitle> + <AlertDescription> + 모든 평가 항목이 완료되었습니다. 제출하시겠습니까? + </AlertDescription> + </Alert> + ) : ( + <Alert variant="destructive"> + <AlertTriangleIcon className="h-4 w-4" /> + <AlertTitle>제출 불가</AlertTitle> + <AlertDescription> + 아직 완료되지 않은 평가 항목이 있습니다. 모든 항목을 완료한 후 제출해 주세요. + </AlertDescription> + </Alert> + )} + </div> + )} + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={!completeness?.overall.isComplete || isSubmitting} + className="min-w-[100px]" + > + {isSubmitting ? ( + <> + <LoaderIcon className="mr-2 h-4 w-4 animate-spin" /> + 제출 중... + </> + ) : ( + <> + <SendIcon className="mr-2 h-4 w-4" /> + 제출하기 + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/evaluation-submit/table/submit-table.tsx b/lib/evaluation-submit/table/submit-table.tsx new file mode 100644 index 00000000..9000c48b --- /dev/null +++ b/lib/evaluation-submit/table/submit-table.tsx @@ -0,0 +1,281 @@ +"use client" + +import * as React from "react" +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 { getSHIEvaluationSubmissions } from "../service" +import { getColumns } from "./evaluation-submissions-table-columns" +import { useRouter } from "next/navigation" +import { ReviewerEvaluationView } from "@/db/schema" + +interface EvaluationSubmissionsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getSHIEvaluationSubmissions>>, + ] + > +} + +export function SHIEvaluationSubmissionsTable({ promises }: EvaluationSubmissionsTableProps) { + // 1. 데이터 로딩 상태 관리 + const [isLoading, setIsLoading] = React.useState(true) + const [tableData, setTableData] = React.useState<{ + data: ReviewerEvaluationView[] + pageCount: number + }>({ data: [], pageCount: 0 }) + const router = useRouter() + + console.log(tableData) + + + // 2. 행 액션 상태 관리 + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ReviewerEvaluationView> | null>(null) + + // 3. Promise 해결을 useEffect로 처리 + React.useEffect(() => { + promises + .then(([result]) => { + setTableData(result) + setIsLoading(false) + }) + // .catch((error) => { + // console.error('Failed to load evaluation submissions:', error) + // setIsLoading(false) + // }) + }, [promises]) + + // 4. 컬럼 정의 + const columns = React.useMemo( + () => getColumns({ setRowAction , router}), + [setRowAction, router] + ) + + // 5. 필터 필드 정의 + const filterFields: DataTableFilterField<ReviewerEvaluationView>[] = [ + { + id: "isCompleted", + label: "완료상태", + placeholder: "완료상태 선택...", + }, + { + id: "periodicStatus", + label: "정기평가 상태", + placeholder: "상태 선택...", + }, + { + id: "evaluationYear", + label: "평가연도", + placeholder: "연도 선택...", + }, + { + id: "departmentCode", + label: "담당부서", + placeholder: "부서 선택...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<ReviewerEvaluationView>[] = [ + { + id: "reviewerEvaluationId", + label: "평가 ID", + type: "text", + }, + { + id: "vendorName", + label: "협력업체명", + type: "text", + }, + { + id: "vendorCode", + label: "협력업체 코드", + type: "text", + }, + { + id: "evaluationYear", + label: "평가연도", + type: "number", + }, + { + id: "departmentCode", + label: "부서코드", + type: "select", + options: [ + { label: "구매평가", value: "ORDER_EVAL" }, + { label: "조달평가", value: "PROCUREMENT_EVAL" }, + { label: "품질평가", value: "QUALITY_EVAL" }, + { label: "CS평가", value: "CS_EVAL" }, + { label: "관리자", value: "ADMIN_EVAL" }, + ], + }, + { + id: "division", + label: "사업부", + type: "select", + options: [ + { label: "조선", value: "SHIP" }, + { label: "플랜트", value: "PLANT" }, + ], + }, + { + id: "materialType", + label: "자재유형", + type: "select", + options: [ + { label: "장비", value: "EQUIPMENT" }, + { label: "벌크", value: "BULK" }, + { label: "장비+벌크", value: "EQUIPMENT_BULK" }, + ], + }, + { + id: "domesticForeign", + label: "국내/해외", + type: "select", + options: [ + { label: "국내", value: "DOMESTIC" }, + { label: "해외", value: "FOREIGN" }, + ], + }, + { + id: "isCompleted", + label: "평가완료 여부", + type: "select", + options: [ + { label: "완료", value: "true" }, + { label: "미완료", value: "false" }, + ], + }, + { + id: "periodicStatus", + label: "정기평가 상태", + type: "select", + options: [ + { label: "대기중", value: "PENDING" }, + { label: "진행중", value: "IN_PROGRESS" }, + { label: "검토중", value: "REVIEW" }, + { label: "완료", value: "COMPLETED" }, + ], + }, + { + id: "documentsSubmitted", + label: "문서 제출여부", + type: "select", + options: [ + { label: "제출완료", value: "true" }, + { label: "미제출", value: "false" }, + ], + }, + { + id: "periodicFinalScore", + label: "최종점수", + type: "number", + }, + { + id: "periodicFinalGrade", + label: "최종등급", + type: "text", + }, + { + id: "ldClaimCount", + label: "LD 클레임 건수", + type: "number", + }, + { + id: "submissionDate", + label: "제출일", + type: "date", + }, + { + id: "submissionDeadline", + label: "제출마감일", + type: "date", + }, + { + id: "completedAt", + label: "완료일시", + type: "date", + }, + { + id: "reviewerEvaluationCreatedAt", + label: "생성일", + type: "date", + }, + { + id: "reviewerEvaluationUpdatedAt", + label: "수정일", + type: "date", + }, + ] + + // 6. 데이터 테이블 설정 + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "reviewerEvaluationUpdatedAt", desc: true }], + columnPinning: { left: ["select"], right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.reviewerEvaluationId), + shallow: false, + clearOnDefault: true, + }) + + // 7. 데이터 새로고침 함수 + const handleRefresh = React.useCallback(() => { + setIsLoading(true) + router.refresh() + }, [router]) + + // 8. 각종 성공 핸들러 + const handleActionSuccess = React.useCallback(() => { + setRowAction(null) + table.resetRowSelection() + handleRefresh() + }, [handleRefresh, table]) + + // 9. 로딩 상태 표시 + if (isLoading) { + return ( + <div className="flex items-center justify-center h-32"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div> + <span className="ml-2">평가 제출 목록을 불러오는 중...</span> + </div> + ) + } + + return ( + <> + {/* 메인 테이블 */} + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + {/* 추가 툴바 버튼들이 필요하면 여기에 */} + </DataTableAdvancedToolbar> + </DataTable> + + {/* 행 액션 모달들 - 필요에 따라 구현 */} + {/* {rowAction?.type === "view_detail" && ( + <EvaluationDetailDialog + row={rowAction.row} + onClose={() => setRowAction(null)} + onSuccess={handleActionSuccess} + /> + )} */} + </> + ) +}
\ No newline at end of file diff --git a/lib/evaluation-submit/validation.ts b/lib/evaluation-submit/validation.ts new file mode 100644 index 00000000..dc6f3f0f --- /dev/null +++ b/lib/evaluation-submit/validation.ts @@ -0,0 +1,161 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { ReviewerEvaluationView } from "@/db/schema"; + + +export const getSHIEvaluationsSubmitSchema = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<ReviewerEvaluationView>().withDefault([ + { id: "reviewerEvaluationCreatedAt", desc: true }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); + +export type GetSHIEvaluationsSubmitSchema = Awaited<ReturnType<typeof getSHIEvaluationsSubmitSchema.parse>> + +// 리뷰어 타입 상수 정의 +export const REVIEWER_TYPES = { + EQUIPMENT_SHIP: 'equipment_ship', + EQUIPMENT_MARINE: 'equipment_marine', + BULK_SHIP: 'bulk_ship', + BULK_MARINE: 'bulk_marine', +} as const; + +// 리뷰어 타입 union type +export type ReviewerType = typeof REVIEWER_TYPES[keyof typeof REVIEWER_TYPES]; + +// 답변 옵션 타입 (각 질문에 대한 선택 가능한 답변들) +export type EvaluationAnswerOption = { + detailId: number; + detail: string; + score: number; + orderIndex: number; + isNotApplicable?: boolean; // "해당없음" 옵션 여부 + isCustomScore?: boolean; // 사용자 직접 입력 옵션 여부 +}; + +// 평가 질문 항목 타입 +// 확장된 평가 질문 항목 타입 +export type EvaluationQuestionItem = { + // 질문 정보 (regEvalCriteria) + criteriaId: number; + category: string; + category2: string; + item: string; + classification: string; + range: string | null; + remarks: string | null; + + // 평가 및 점수 유형 + scoreType: ScoreType; // fixed | variable + + // 가변 점수용 설정 + variableScoreMin?: number; // 최소 점수 (예: -3) + variableScoreMax?: number; // 최대 점수 (예: +5) + variableScoreUnit?: string; // 단위 설명 (예: "1건당 1점", "1일당 0.5점") + + // 답변 옵션들 + availableOptions: EvaluationAnswerOption[]; + + // 현재 응답 정보 + responseId: number | null; + selectedDetailId: number | null; + currentScore: string | null; + currentComment: string | null; + + // 가변 점수용 추가 필드 + customScore?: number; // 사용자가 입력한 점수 + customScoreReason?: string; // 점수 입력 근거 +}; + + +// 평가 정보 타입 +export type EvaluationInfo = { + id: number; + periodicEvaluationId: number; + evaluationTargetReviewerId: number; + isCompleted: boolean; + + // 부서 및 벤더 정보 + departmentCode: string; + division: 'SHIP' | 'PLANT'; + materialType: 'EQUIPMENT' | 'BULK' | 'EQUIPMENT_BULK'; + vendorName: string; + vendorCode: string; + + // 계산된 리뷰어 타입 + reviewerType: ReviewerType; +}; + +// 전체 평가 폼 데이터 타입 +export type EvaluationFormData = { + evaluationInfo: EvaluationInfo; + questions: EvaluationQuestionItem[]; +}; + +// 평가 응답 업데이트용 타입 +export type EvaluationResponseUpdate = { + regEvalCriteriaDetailsId: number; // 선택된 답변 옵션의 ID + score: string; // 해당 답변 옵션의 점수 + comment?: string; +}; + +// 평가 제출 목록 조회용 뷰 타입 (기존 타입에서 확장) +export type EvaluationSubmissionWithVendor = { + vendor: { + id: number; + vendorCode: string; + vendorName: string; + countryCode: string; + contactEmail: string; + }; + _count: { + generalResponses: number; + esgResponses: number; + attachments: number; + }; +}; + +// 평가 카테고리 매핑 +export const EVALUATION_CATEGORIES = { + 'customer-service': 'CS 평가', + 'administrator': '관리자 평가', + 'procurement': '구매 평가', + 'design': '설계 평가', + 'sourcing': '조달 평가', + 'quality': '품질 평가', +} as const; + +// 부서 코드별 카테고리 매핑 +export const DEPARTMENT_CATEGORY_MAPPING = { + 'ORDER_EVAL': 'procurement', + 'PROCUREMENT_EVAL': 'sourcing', + 'QUALITY_EVAL': 'quality', + 'CS_EVAL': 'customer-service', + 'DESIGN_EVAL': 'design' +} as const; + +// 점수 유형 정의 +export const SCORE_TYPES = { + FIXED: 'fixed', // 미리 정해진 점수 (기존 방식) + VARIABLE: 'variable', // 사용자 직접 입력 점수 +} as const; + +export type ScoreType = typeof SCORE_TYPES[keyof typeof SCORE_TYPES]; + diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts index bb47fca4..0e209aa2 100644 --- a/lib/evaluation-target-list/service.ts +++ b/lib/evaluation-target-list/service.ts @@ -32,6 +32,7 @@ import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { sendEmail } from "../mail/sendEmail"; import type { SQL } from "drizzle-orm" +import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation"; export async function selectEvaluationTargetsFromView( tx: PgTransaction<any, any, any>, @@ -685,17 +686,10 @@ export async function getAvailableVendors(search?: string) { // 부서 정보 조회 (상수에서) export async function getDepartmentInfo() { return Object.entries(EVALUATION_DEPARTMENT_CODES).map(([key, value]) => { - const departmentNames = { - ORDER_EVAL: "발주 평가 담당", - PROCUREMENT_EVAL: "조달 평가 담당", - QUALITY_EVAL: "품질 평가 담당", - DESIGN_EVAL: "설계 평가 담당", - CS_EVAL: "CS 평가 담당", - }; return { code: value, - name: departmentNames[key as keyof typeof departmentNames], + name: DEPARTMENT_CODE_LABELS[key as keyof typeof DEPARTMENT_CODE_LABELS], key, }; }); @@ -810,44 +804,44 @@ export async function confirmEvaluationTargets( const totalEsgItems = esgItemsCount[0]?.count || 0 // 5. 각 periodicEvaluation에 대해 담당자별 reviewerEvaluations도 생성 - if (periodicEvaluationsToCreate.length > 0) { - // 새로 생성된 periodicEvaluations 조회 - const newPeriodicEvaluations = await tx - .select({ - id: periodicEvaluations.id, - evaluationTargetId: periodicEvaluations.evaluationTargetId - }) - .from(periodicEvaluations) - .where( - and( - inArray(periodicEvaluations.evaluationTargetId, confirmedTargetIds), - eq(periodicEvaluations.evaluationPeriod, currentPeriod) - ) - ) + // if (periodicEvaluationsToCreate.length > 0) { + // // 새로 생성된 periodicEvaluations 조회 + // const newPeriodicEvaluations = await tx + // .select({ + // id: periodicEvaluations.id, + // evaluationTargetId: periodicEvaluations.evaluationTargetId + // }) + // .from(periodicEvaluations) + // .where( + // and( + // inArray(periodicEvaluations.evaluationTargetId, confirmedTargetIds), + // eq(periodicEvaluations.evaluationPeriod, currentPeriod) + // ) + // ) - // 각 평가에 대해 담당자별 reviewerEvaluations 생성 - for (const periodicEval of newPeriodicEvaluations) { - // 해당 evaluationTarget의 담당자들 조회 - const reviewers = await tx - .select() - .from(evaluationTargetReviewers) - .where(eq(evaluationTargetReviewers.evaluationTargetId, periodicEval.evaluationTargetId)) + // // 각 평가에 대해 담당자별 reviewerEvaluations 생성 + // for (const periodicEval of newPeriodicEvaluations) { + // // 해당 evaluationTarget의 담당자들 조회 + // const reviewers = await tx + // .select() + // .from(evaluationTargetReviewers) + // .where(eq(evaluationTargetReviewers.evaluationTargetId, periodicEval.evaluationTargetId)) - if (reviewers.length > 0) { - const reviewerEvaluationsToCreate = reviewers.map(reviewer => ({ - periodicEvaluationId: periodicEval.id, - evaluationTargetReviewerId: reviewer.id, - isCompleted: false, - createdAt: new Date(), - updatedAt: new Date() - })) + // if (reviewers.length > 0) { + // const reviewerEvaluationsToCreate = reviewers.map(reviewer => ({ + // periodicEvaluationId: periodicEval.id, + // evaluationTargetReviewerId: reviewer.id, + // isCompleted: false, + // createdAt: new Date(), + // updatedAt: new Date() + // })) - await tx - .insert(reviewerEvaluations) - .values(reviewerEvaluationsToCreate) - } - } - } + // await tx + // .insert(reviewerEvaluations) + // .values(reviewerEvaluationsToCreate) + // } + // } + // } // 6. 벤더별 evaluationSubmissions 레코드 생성 const evaluationSubmissionsToCreate = [] diff --git a/lib/evaluation-target-list/table/evaluation-target-table copy.tsx b/lib/evaluation-target-list/table/evaluation-target-table copy.tsx new file mode 100644 index 00000000..b140df0e --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-target-table copy.tsx @@ -0,0 +1,508 @@ +// ============================================================================ +// components/evaluation-targets-table.tsx (CLIENT COMPONENT) +// ─ 완전본 ─ evaluation-targets-columns.tsx 는 별도 +// ============================================================================ +"use client"; + +import * as React from "react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { HelpCircle, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import { getEvaluationTargets, getEvaluationTargetsStats } from "../service"; +import { cn } from "@/lib/utils"; +import { useTablePresets } from "@/components/data-table/use-table-presets"; +import { TablePresetManager } from "@/components/data-table/data-table-preset"; +import { getEvaluationTargetsColumns } from "./evaluation-targets-columns"; +import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions"; +import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet"; +import { EvaluationTargetWithDepartments } from "@/db/schema"; +import { EditEvaluationTargetSheet } from "./update-evaluation-target"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +/* -------------------------------------------------------------------------- */ +/* Process Guide Popover */ +/* -------------------------------------------------------------------------- */ +function ProcessGuidePopover() { + return ( + <Popover> + <PopoverTrigger asChild> + <Button variant="ghost" size="icon" className="h-6 w-6"> + <HelpCircle className="h-4 w-4 text-muted-foreground" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-96" align="start"> + <div className="space-y-3"> + <div className="space-y-1"> + <h4 className="font-medium">평가 대상 확정 프로세스</h4> + <p className="text-sm text-muted-foreground"> + 발주실적을 기반으로 평가 대상을 확정하는 절차입니다. + </p> + </div> + <div className="space-y-3 text-sm"> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 1 + </div> + <div> + <p className="font-medium">발주실적 기반 자동 추출</p> + <p className="text-muted-foreground">전년도 10월 ~ 해당년도 9월 발주실적에서 업체 목록을 자동으로 생성합니다.</p> + </div> + </div> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 2 + </div> + <div> + <p className="font-medium">담당자 지정</p> + <p className="text-muted-foreground">각 평가 대상별로 5개 부서(발주/조달/품질/설계/CS)의 담당자를 지정합니다.</p> + </div> + </div> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 3 + </div> + <div> + <p className="font-medium">검토 및 의견 수렴</p> + <p className="text-muted-foreground">모든 담당자가 평가 대상 적합성을 검토하고 의견을 제출합니다.</p> + </div> + </div> + <div className="flex gap-3"> + <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> + 4 + </div> + <div> + <p className="font-medium">최종 확정</p> + <p className="text-muted-foreground">모든 담당자 의견이 일치하면 평가 대상으로 최종 확정됩니다.</p> + </div> + </div> + </div> + </div> + </PopoverContent> + </Popover> + ) +} + +/* -------------------------------------------------------------------------- */ +/* Stats Card */ +/* -------------------------------------------------------------------------- */ +function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) { + const [stats, setStats] = React.useState<any>(null); + const [isLoading, setIsLoading] = React.useState(true); + const [error, setError] = React.useState<string | null>(null); + + React.useEffect(() => { + let mounted = true; + (async () => { + try { + setIsLoading(true); + const data = await getEvaluationTargetsStats(evaluationYear); + mounted && setStats(data); + } catch (e) { + mounted && setError(e instanceof Error ? e.message : "failed"); + } finally { + mounted && setIsLoading(false); + } + })(); + return () => { + mounted = false; + }; + }, [evaluationYear]); + + if (isLoading) + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + {Array.from({ length: 4 }).map((_, i) => ( + <Card key={i}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <Skeleton className="h-4 w-20" /> + </CardHeader> + <CardContent> + <Skeleton className="h-8 w-16" /> + </CardContent> + </Card> + ))} + </div> + ); + if (error) + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6 text-center text-sm text-muted-foreground"> + 통계 데이터를 불러올 수 없습니다: {error} + </CardContent> + </Card> + </div> + ); + if (!stats) + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6 text-center text-sm text-muted-foreground"> + 통계 데이터가 없습니다. + </CardContent> + </Card> + </div> + ); + + const total = stats.total || 0; + const pending = stats.pending || 0; + const confirmed = stats.confirmed || 0; + const consensusRate = total ? Math.round(((stats.consensusTrue || 0) / total) * 100) : 0; + + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + {/* 총 평가 대상 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">총 평가 대상</CardTitle> + <Badge variant="outline">{evaluationYear}년</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{total.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + 해양 {stats.oceanDivision || 0}개 | 조선 {stats.shipyardDivision || 0}개 + </div> + </CardContent> + </Card> + + {/* 검토 중 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">검토 중</CardTitle> + <Badge variant="secondary">대기</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{pending.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {total ? Math.round((pending / total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + {/* 확정 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">확정</CardTitle> + <Badge variant="success">완료</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{confirmed.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {total ? Math.round((confirmed / total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + {/* 의견 일치율 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">의견 일치율</CardTitle> + <Badge variant={consensusRate >= 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}>{consensusRate}%</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{consensusRate}%</div> + <div className="text-xs text-muted-foreground mt-1"> + 일치 {stats.consensusTrue || 0}개 | 불일치 {stats.consensusFalse || 0}개 + </div> + </CardContent> + </Card> + </div> + ); +} + +/* -------------------------------------------------------------------------- */ +/* EvaluationTargetsTable */ +/* -------------------------------------------------------------------------- */ +interface EvaluationTargetsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]>; + evaluationYear: number; + className?: string; +} + +export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null); + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); + const searchParams = useSearchParams(); + + /* --------------------------- layout refs --------------------------- */ + const containerRef = React.useRef<HTMLDivElement>(null); + const [containerTop, setContainerTop] = React.useState(0); + + // RFQ 패턴으로 변경: State를 통한 위치 관리 + const updateContainerBounds = React.useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setContainerTop(rect.top); + } + }, []); + + React.useEffect(() => { + updateContainerBounds(); + + const handleResize = () => { + updateContainerBounds(); + }; + + window.addEventListener('resize', handleResize); + window.addEventListener('scroll', updateContainerBounds); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', updateContainerBounds); + }; + }, [updateContainerBounds]); + + /* ---------------------- 데이터 프리패치 ---------------------- */ + const [promiseData] = React.use(promises); + const tableData = promiseData; + + /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ + const searchString = React.useMemo( + () => searchParams.toString(), // query가 바뀔 때만 새로 계산 + [searchParams] + ); + + const getSearchParam = React.useCallback( + (key: string, def = "") => + new URLSearchParams(searchString).get(key) ?? def, + [searchString] + ); + + // 제네릭 함수는 useCallback 밖에서 정의 + const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => { + try { + const value = getSearchParam(key); + return value ? JSON.parse(value) : defaultValue; + } catch { + return defaultValue; + } + }, [getSearchParam]); + +const parseSearchParam = <T,>(key: string, defaultValue: T): T => { + return parseSearchParamHelper(key, defaultValue); +}; + + /* ---------------------- 초기 설정 ---------------------------- */ + const initialSettings = React.useMemo(() => ({ + page: parseInt(getSearchParam("page", "1")), + perPage: parseInt(getSearchParam("perPage", "10")), + sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }], + filters: parseSearchParam("filters", []), + joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and", + basicFilters: parseSearchParam("basicFilters", []), + basicJoinOperator: (getSearchParam("basicJoinOperator") as "and" | "or") || "and", + search: getSearchParam("search", ""), + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: ["actions"] }, + groupBy: [], + expandedRows: [], + }), [getSearchParam]); + + /* --------------------- 프리셋 훅 ------------------------------ */ + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + getCurrentSettings, + } = useTablePresets<EvaluationTargetWithDepartments>( + "evaluation-targets-table", + initialSettings + ); + + /* --------------------- 컬럼 ------------------------------ */ + const columns = React.useMemo(() => getEvaluationTargetsColumns({ setRowAction }), [setRowAction]); +// const columns =[ +// { accessorKey: "vendorCode", header: "벤더 코드" }, +// { accessorKey: "vendorName", header: "벤더명" }, +// { accessorKey: "status", header: "상태" }, +// { accessorKey: "evaluationYear", header: "평가년도" }, +// { accessorKey: "division", header: "구분" } +// ]; + + + /* 기본 필터 */ + const filterFields: DataTableFilterField<EvaluationTargetWithDepartments>[] = [ + { id: "vendorCode", label: "벤더 코드" }, + { id: "vendorName", label: "벤더명" }, + { id: "status", label: "상태" }, + ]; + + /* 고급 필터 */ + const advancedFilterFields: DataTableAdvancedFilterField<EvaluationTargetWithDepartments>[] = [ + { id: "evaluationYear", label: "평가년도", type: "number" }, + { id: "division", label: "구분", type: "select", options: [ { label: "해양", value: "OCEAN" }, { label: "조선", value: "SHIPYARD" } ] }, + { id: "vendorCode", label: "벤더 코드", type: "text" }, + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "domesticForeign", label: "내외자", type: "select", options: [ { label: "내자", value: "DOMESTIC" }, { label: "외자", value: "FOREIGN" } ] }, + { id: "materialType", label: "자재구분", type: "select", options: [ { label: "기자재", value: "EQUIPMENT" }, { label: "벌크", value: "BULK" }, { label: "기/벌", value: "EQUIPMENT_BULK" } ] }, + { id: "status", label: "상태", type: "select", options: [ { label: "검토 중", value: "PENDING" }, { label: "확정", value: "CONFIRMED" }, { label: "제외", value: "EXCLUDED" } ] }, + { id: "consensusStatus", label: "의견 일치", type: "select", options: [ { label: "일치", value: "true" }, { label: "불일치", value: "false" }, { label: "검토 중", value: "null" } ] }, + { id: "adminComment", label: "관리자 의견", type: "text" }, + { id: "consolidatedComment", label: "종합 의견", type: "text" }, + { id: "confirmedAt", label: "확정일", type: "date" }, + { id: "createdAt", label: "생성일", type: "date" }, + ]; + + /* current settings */ + const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]); + + const initialState = React.useMemo(() => { + return { + sorting: initialSettings.sort.filter(sortItem => { + const columnExists = columns.some(col => col.accessorKey === sortItem.id) + return columnExists + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + /* ----------------------- useDataTable ------------------------ */ + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (row) => String(row.id), + shallow: false, + clearOnDefault: true, + }); + + /* ---------------------- helper ------------------------------ */ + const getActiveBasicFilterCount = React.useCallback(() => { + try { + const f = getSearchParam("basicFilters"); + return f ? JSON.parse(f).length : 0; + } catch { + return 0; + } + }, [getSearchParam]); + + const FILTER_PANEL_WIDTH = 400; + + /* ---------------------------- JSX ---------------------------- */ + return ( + <> + {/* Filter Panel */} + <div + className={cn( + "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", + top: `${containerTop}px`, + height: `calc(100vh - ${containerTop}px)` + }} + > + <EvaluationTargetFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={() => setIsFilterPanelOpen(false)} + isLoading={false} + /> + </div> + + {/* Main Container */} + <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)}> + <div className="flex w-full h-full"> + <div + className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%", + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", + }} + > + {/* Header */} + <div className="flex items-center justify-between p-4 bg-background shrink-0"> + <Button + variant="outline" + size="sm" + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />} + {getActiveBasicFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveBasicFilterCount()} + </span> + )} + </Button> + <div className="text-sm text-muted-foreground"> + 총 {tableData.total || tableData.data.length}건 + </div> + </div> + + {/* Stats */} + <div className="px-4"> + <EvaluationTargetsStats evaluationYear={evaluationYear} /> + </div> + + {/* Table */} + <div className="flex-1 overflow-hidden" style={{ height: "calc(100vh - 500px)" }}> + <DataTable table={table} className="h-full"> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <TablePresetManager<EvaluationTargetWithDepartments> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <EvaluationTargetsTableToolbarActions table={table} /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 편집 다이얼로그 */} + <EditEvaluationTargetSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + evaluationTarget={rowAction?.row.original ?? null} + /> + </div> + </div> + </div> + </div> + </> + ); +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx index b6631f14..60f1af39 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx @@ -9,34 +9,22 @@ import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table- import { EvaluationTargetWithDepartments } from "@/db/schema"; import type { DataTableRowAction } from "@/types/table"; import { formatDate } from "@/lib/utils"; +import { vendortypeMap } from "@/types/evaluation"; interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EvaluationTargetWithDepartments> | null>>; } -// ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 (매번 재생성 방지) +// ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 const getStatusBadgeVariant = (status: string) => { switch (status) { - case "PENDING": - return "secondary"; - case "CONFIRMED": - return "default"; - case "EXCLUDED": - return "destructive"; - default: - return "outline"; + case "PENDING": return "secondary"; + case "CONFIRMED": return "default"; + case "EXCLUDED": return "destructive"; + default: return "outline"; } }; -const getStatusText = (status: string) => { - const statusMap = { - PENDING: "검토 중", - CONFIRMED: "확정", - EXCLUDED: "제외" - }; - return statusMap[status] || status; -}; - const getConsensusBadge = (consensusStatus: boolean | null) => { if (consensusStatus === null) { return <Badge variant="outline">검토 중</Badge>; @@ -56,12 +44,7 @@ const getDivisionBadge = (division: string) => { }; const getMaterialTypeBadge = (materialType: string) => { - const typeMap = { - EQUIPMENT: "기자재", - BULK: "벌크", - EQUIPMENT_BULK: "기자재/벌크" - }; - return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>; + return <Badge variant="outline">{vendortypeMap[materialType] || materialType}</Badge>; }; const getDomesticForeignBadge = (domesticForeign: string) => { @@ -72,7 +55,6 @@ const getDomesticForeignBadge = (domesticForeign: string) => { ); }; -// ✅ 평가 대상 여부 표시 함수 const getEvaluationTargetBadge = (isTarget: boolean | null) => { if (isTarget === null) { return <Badge variant="outline">미정</Badge>; @@ -90,340 +72,335 @@ const getEvaluationTargetBadge = (isTarget: boolean | null) => { ); }; -export function getEvaluationTargetsColumns({ setRowAction }: GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] { +// ✅ 모든 cell 렌더러 함수들을 미리 정의 (매번 새로 생성 방지) +const renderEvaluationYear = ({ row }: any) => ( + <span className="font-medium">{row.getValue("evaluationYear")}</span> +); + +const renderDivision = ({ row }: any) => getDivisionBadge(row.getValue("division")); + +const renderStatus = ({ row }: any) => { + const status = row.getValue<string>("status"); + return ( + <Badge variant={getStatusBadgeVariant(status)}> + {status} + </Badge> + ); +}; + +const renderConsensusStatus = ({ row }: any) => getConsensusBadge(row.getValue("consensusStatus")); + +const renderVendorCode = ({ row }: any) => ( + <span className="font-mono text-sm">{row.getValue("vendorCode")}</span> +); + +const renderVendorName = ({ row }: any) => ( + <div className="truncate max-w-[200px]" title={row.getValue<string>("vendorName")!}> + {row.getValue("vendorName") as string} + </div> +); + +const renderDomesticForeign = ({ row }: any) => getDomesticForeignBadge(row.getValue("domesticForeign")); + +const renderMaterialType = ({ row }: any) => getMaterialTypeBadge(row.getValue("materialType")); + +const renderReviewerName = (fieldName: string) => ({ row }: any) => { + const reviewerName = row.getValue<string>(fieldName); + return reviewerName ? ( + <div className="truncate max-w-[120px]" title={reviewerName}> + {reviewerName} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); +}; + +const renderIsApproved = (fieldName: string) => ({ row }: any) => { + const isApproved = row.getValue<boolean>(fieldName); + return getEvaluationTargetBadge(isApproved); +}; + +const renderComment = (maxWidth: string) => ({ row }: any) => { + const comment = row.getValue<string>("adminComment") || row.getValue<string>("consolidatedComment"); + return comment ? ( + <div className={`truncate ${maxWidth}`} title={comment}> + {comment} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); +}; + +const renderConfirmedAt = ({ row }: any) => { + const confirmedAt = row.getValue<Date>("confirmedAt"); + return <span className="text-sm">{confirmedAt ? formatDate(confirmedAt, "KR") : '-'}</span>; +}; + +const renderCreatedAt = ({ row }: any) => { + const createdAt = row.getValue<Date>("createdAt"); + return <span className="text-sm">{formatDate(createdAt, "KR")}</span>; +}; + +// ✅ 헤더 렌더러들도 미리 정의 +const createHeaderRenderer = (title: string) => ({ column }: any) => ( + <DataTableColumnHeaderSimple column={column} title={title} /> +); + +// ✅ 체크박스 관련 함수들도 미리 정의 +const renderSelectAllCheckbox = ({ table }: any) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v: any) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> +); + +const renderRowCheckbox = ({ row }: any) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v: any) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> +); + +// ✅ 정적 컬럼 정의 (setRowAction만 동적으로 주입) +function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): ColumnDef<EvaluationTargetWithDepartments>[] { + // Actions 컬럼의 클릭 핸들러를 미리 정의 + const renderActionsCell = ({ row }: any) => ( + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setRowAction({ row, type: "update" })} + aria-label="수정" + title="수정" + > + <Pencil className="size-4" /> + </Button> + </div> + ); + return [ - // ✅ Checkbox + // Checkbox { id: "select", - header: ({ table }) => ( - <Checkbox - checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} - onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} - aria-label="select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(v) => row.toggleSelected(!!v)} - aria-label="select row" - className="translate-y-0.5" - /> - ), + header: renderSelectAllCheckbox, + cell: renderRowCheckbox, size: 40, enableSorting: false, enableHiding: false, }, - // ✅ 기본 정보 + // 기본 정보 { accessorKey: "evaluationYear", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />, - cell: ({ row }) => <span className="font-medium">{row.getValue("evaluationYear")}</span>, + header: createHeaderRenderer("평가년도"), + cell: renderEvaluationYear, size: 100, }, { accessorKey: "division", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />, - cell: ({ row }) => getDivisionBadge(row.getValue("division")), + header: createHeaderRenderer("구분"), + cell: renderDivision, size: 80, }, { accessorKey: "status", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />, - cell: ({ row }) => { - const status = row.getValue<string>("status"); - return ( - <Badge variant={getStatusBadgeVariant(status)}> - {getStatusText(status)} - </Badge> - ); - }, + header: createHeaderRenderer("상태"), + cell: renderStatus, size: 100, }, { accessorKey: "consensusStatus", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="의견 일치" />, - cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")), + header: createHeaderRenderer("의견 일치"), + cell: renderConsensusStatus, size: 100, }, - // ✅ 벤더 정보 그룹 + // 벤더 정보 { id: "vendorInfo", header: "벤더 정보", columns: [ { accessorKey: "vendorCode", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더 코드" />, - cell: ({ row }) => ( - <span className="font-mono text-sm">{row.getValue("vendorCode")}</span> - ), + header: createHeaderRenderer("벤더 코드"), + cell: renderVendorCode, size: 120, }, { accessorKey: "vendorName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />, - cell: ({ row }) => ( - <div className="truncate max-w-[200px]" title={row.getValue<string>("vendorName")!}> - {row.getValue("vendorName") as string} - </div> - ), + header: createHeaderRenderer("벤더명"), + cell: renderVendorName, size: 200, }, { accessorKey: "domesticForeign", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />, - cell: ({ row }) => getDomesticForeignBadge(row.getValue("domesticForeign")), + header: createHeaderRenderer("내외자"), + cell: renderDomesticForeign, size: 80, }, { accessorKey: "materialType", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />, - cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")), + header: createHeaderRenderer("자재구분"), + cell: renderMaterialType, size: 120, }, ] }, - // ✅ 발주 담당자 + // 발주 담당자 { id: "orderReviewer", header: "발주 담당자", columns: [ { accessorKey: "orderReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, - cell: ({ row }) => { - const reviewerName = row.getValue<string>("orderReviewerName"); - return reviewerName ? ( - <div className="truncate max-w-[120px]" title={reviewerName}> - {reviewerName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("orderReviewerName"), size: 120, }, { accessorKey: "orderIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, - cell: ({ row }) => { - const isApproved = row.getValue<boolean>("orderIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("orderIsApproved"), size: 120, }, ] }, - // ✅ 조달 담당자 + // 조달 담당자 { id: "procurementReviewer", header: "조달 담당자", columns: [ { accessorKey: "procurementReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, - cell: ({ row }) => { - const reviewerName = row.getValue<string>("procurementReviewerName"); - return reviewerName ? ( - <div className="truncate max-w-[120px]" title={reviewerName}> - {reviewerName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("procurementReviewerName"), size: 120, }, { accessorKey: "procurementIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, - cell: ({ row }) => { - const isApproved = row.getValue<boolean>("procurementIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("procurementIsApproved"), size: 120, }, ] }, - // ✅ 품질 담당자 + // 품질 담당자 { id: "qualityReviewer", header: "품질 담당자", columns: [ { accessorKey: "qualityReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, - cell: ({ row }) => { - const reviewerName = row.getValue<string>("qualityReviewerName"); - return reviewerName ? ( - <div className="truncate max-w-[120px]" title={reviewerName}> - {reviewerName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("qualityReviewerName"), size: 120, }, { accessorKey: "qualityIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, - cell: ({ row }) => { - const isApproved = row.getValue<boolean>("qualityIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("qualityIsApproved"), size: 120, }, ] }, - // ✅ 설계 담당자 + // 설계 담당자 { id: "designReviewer", header: "설계 담당자", columns: [ { accessorKey: "designReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, - cell: ({ row }) => { - const reviewerName = row.getValue<string>("designReviewerName"); - return reviewerName ? ( - <div className="truncate max-w-[120px]" title={reviewerName}> - {reviewerName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("designReviewerName"), size: 120, }, { accessorKey: "designIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, - cell: ({ row }) => { - const isApproved = row.getValue<boolean>("designIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("designIsApproved"), size: 120, }, ] }, - // ✅ CS 담당자 + // CS 담당자 { id: "csReviewer", header: "CS 담당자", columns: [ { accessorKey: "csReviewerName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />, - cell: ({ row }) => { - const reviewerName = row.getValue<string>("csReviewerName"); - return reviewerName ? ( - <div className="truncate max-w-[120px]" title={reviewerName}> - {reviewerName} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("csReviewerName"), size: 120, }, { accessorKey: "csIsApproved", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />, - cell: ({ row }) => { - const isApproved = row.getValue<boolean>("csIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("csIsApproved"), size: 120, }, ] }, - // ✅ 의견 및 결과 + // 의견 및 결과 { accessorKey: "adminComment", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="관리자 의견" />, - cell: ({ row }) => { - const comment = row.getValue<string>("adminComment"); - return comment ? ( - <div className="truncate max-w-[150px]" title={comment}> - {comment} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("관리자 의견"), + cell: renderComment("max-w-[150px]"), size: 150, }, { accessorKey: "consolidatedComment", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="종합 의견" />, - cell: ({ row }) => { - const comment = row.getValue<string>("consolidatedComment"); - return comment ? ( - <div className="truncate max-w-[150px]" title={comment}> - {comment} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, + header: createHeaderRenderer("종합 의견"), + cell: renderComment("max-w-[150px]"), size: 150, }, { accessorKey: "confirmedAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />, - cell: ({ row }) => { - const confirmedAt = row.getValue<Date>("confirmedAt"); - return <span className="text-sm">{ confirmedAt ? formatDate(confirmedAt, "KR") :'-'}</span>; - }, + header: createHeaderRenderer("확정일"), + cell: renderConfirmedAt, size: 100, }, { accessorKey: "createdAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />, - cell: ({ row }) => { - const createdAt = row.getValue<Date>("createdAt"); - return <span className="text-sm">{formatDate(createdAt, "KR")}</span>; - }, + header: createHeaderRenderer("생성일"), + cell: renderCreatedAt, size: 100, }, - // ✅ Actions - 가장 안전하게 처리 + // Actions { id: "actions", enableHiding: false, size: 40, minSize: 40, - cell: ({ row }) => { - // ✅ 함수를 직접 정의해서 매번 새로 생성되지 않도록 처리 - const handleEdit = () => { - setRowAction({ row, type: "update" }); - }; - - return ( - <div className="flex items-center gap-1"> - <Button - variant="ghost" - size="icon" - className="size-8" - onClick={handleEdit} - aria-label="수정" - title="수정" - > - <Pencil className="size-4" /> - </Button> - </div> - ); - }, + cell: renderActionsCell, }, ]; +} + +// ✅ WeakMap 캐시로 setRowAction별로 컬럼 캐싱 +const columnsCache = new WeakMap<GetColumnsProps['setRowAction'], ColumnDef<EvaluationTargetWithDepartments>[]>(); + +export function getEvaluationTargetsColumns({ setRowAction }: GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] { + // 캐시 확인 + if (columnsCache.has(setRowAction)) { + console.log('✅ 캐시된 컬럼 사용'); + return columnsCache.get(setRowAction)!; + } + + console.log('🏗️ 새로운 컬럼 생성'); + const columns = createStaticColumns(setRowAction); + columnsCache.set(setRowAction, columns); + return columns; }
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx index 82b7c97c..8bc5254c 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -51,16 +51,69 @@ export function EvaluationTargetsTableToolbarActions({ // 선택된 행들 const selectedRows = table.getFilteredSelectedRowModel().rows const hasSelection = selectedRows.length > 0 - const selectedTargets = selectedRows.map(row => row.original) - // 선택된 항목들의 상태 분석 + // ✅ selectedTargets를 useMemo로 안정화 (VendorsTable 방식과 동일) + const selectedTargets = React.useMemo(() => { + return selectedRows.map(row => row.original) + }, [selectedRows]) + + // ✅ 각 상태별 타겟들을 개별적으로 메모이제이션 (VendorsTable 방식과 동일) + const pendingTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.status === "PENDING"); + }, [table.getFilteredSelectedRowModel().rows]); + + const confirmedTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.status === "CONFIRMED"); + }, [table.getFilteredSelectedRowModel().rows]); + + const excludedTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.status === "EXCLUDED"); + }, [table.getFilteredSelectedRowModel().rows]); + + const consensusTrueTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.consensusStatus === true); + }, [table.getFilteredSelectedRowModel().rows]); + + const consensusFalseTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.consensusStatus === false); + }, [table.getFilteredSelectedRowModel().rows]); + + const consensusNullTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.consensusStatus === null); + }, [table.getFilteredSelectedRowModel().rows]); + + // ✅ 선택된 항목들의 상태 분석 - 안정화된 개별 배열들 사용 const selectedStats = React.useMemo(() => { - const pending = selectedTargets.filter(t => t.status === "PENDING").length - const confirmed = selectedTargets.filter(t => t.status === "CONFIRMED").length - const excluded = selectedTargets.filter(t => t.status === "EXCLUDED").length - const consensusTrue = selectedTargets.filter(t => t.consensusStatus === true).length - const consensusFalse = selectedTargets.filter(t => t.consensusStatus === false).length - const consensusNull = selectedTargets.filter(t => t.consensusStatus === null).length + const pending = pendingTargets.length + const confirmed = confirmedTargets.length + const excluded = excludedTargets.length + const consensusTrue = consensusTrueTargets.length + const consensusFalse = consensusFalseTargets.length + const consensusNull = consensusNullTargets.length return { pending, @@ -73,12 +126,19 @@ export function EvaluationTargetsTableToolbarActions({ canExclude: pending > 0, canRequestReview: pending > 0 } - }, [selectedTargets]) + }, [ + pendingTargets.length, + confirmedTargets.length, + excludedTargets.length, + consensusTrueTargets.length, + consensusFalseTargets.length, + consensusNullTargets.length + ]) // ---------------------------------------------------------------- // 신규 평가 대상 생성 (자동) // ---------------------------------------------------------------- - const handleAutoGenerate = async () => { + const handleAutoGenerate = React.useCallback(async () => { setIsLoading(true) try { // TODO: 발주실적에서 자동 추출 API 호출 @@ -90,23 +150,33 @@ export function EvaluationTargetsTableToolbarActions({ } finally { setIsLoading(false) } - } + }, [router]) // ---------------------------------------------------------------- // 신규 평가 대상 생성 (수동) // ---------------------------------------------------------------- - const handleManualCreate = () => { + const handleManualCreate = React.useCallback(() => { setManualCreateDialogOpen(true) - } + }, []) // ---------------------------------------------------------------- // 다이얼로그 성공 핸들러 // ---------------------------------------------------------------- - const handleActionSuccess = () => { + const handleActionSuccess = React.useCallback(() => { table.resetRowSelection() onRefresh?.() router.refresh() - } + }, [table, onRefresh, router]) + + // ---------------------------------------------------------------- + // 내보내기 핸들러 + // ---------------------------------------------------------------- + const handleExport = React.useCallback(() => { + exportTableToExcel(table, { + filename: "vendor-target-list", + excludeColumns: ["select", "actions"], + }) + }, [table]) return ( <> @@ -141,12 +211,7 @@ export function EvaluationTargetsTableToolbarActions({ <Button variant="outline" size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "vendor-target-list", - excludeColumns: ["select", "actions"], - }) - } + onClick={handleExport} className="gap-2" > <Download className="size-4" aria-hidden="true" /> @@ -237,18 +302,6 @@ export function EvaluationTargetsTableToolbarActions({ targets={selectedTargets} onSuccess={handleActionSuccess} /> - - {/* 선택 정보 표시 */} - {/* {hasSelection && ( - <div className="text-xs text-muted-foreground"> - 선택된 {selectedRows.length}개 항목: - 대기중 {selectedStats.pending}개, - 확정 {selectedStats.confirmed}개, - 제외 {selectedStats.excluded}개 - {selectedStats.consensusTrue > 0 && ` | 의견일치 ${selectedStats.consensusTrue}개`} - {selectedStats.consensusFalse > 0 && ` | 의견불일치 ${selectedStats.consensusFalse}개`} - </div> - )} */} </> ) }
\ No newline at end of file diff --git a/lib/evaluation-target-list/validation.ts b/lib/evaluation-target-list/validation.ts index ce5604be..b8df250b 100644 --- a/lib/evaluation-target-list/validation.ts +++ b/lib/evaluation-target-list/validation.ts @@ -1,169 +1,153 @@ import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum, - } from "nuqs/server"; - import * as z from "zod"; - - import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; - - // ============= 메인 검색 파라미터 스키마 ============= - - export const searchParamsEvaluationTargetsCache = createSearchParamsCache({ - flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<any>().withDefault([ - { id: "createdAt", desc: true }, - ]), - - // 기본 필터들 - evaluationYear: parseAsInteger.withDefault(new Date().getFullYear()), - division: parseAsString.withDefault(""), - status: parseAsString.withDefault(""), - domesticForeign: parseAsString.withDefault(""), - materialType: parseAsString.withDefault(""), - consensusStatus: parseAsString.withDefault(""), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 베이직 필터 (커스텀 필터 패널용) - basicFilters: getFiltersStateParser().withDefault([]), - basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 - search: parseAsString.withDefault(""), - }); - - // ============= 타입 정의 ============= - - export type GetEvaluationTargetsSchema = Awaited< - ReturnType<typeof searchParamsEvaluationTargetsCache.parse> - >; - - export type EvaluationTargetStatus = "PENDING" | "CONFIRMED" | "EXCLUDED"; - export type Division = "PLANT" | "SHIP"; - export type MaterialType = "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK"; - export type DomesticForeign = "DOMESTIC" | "FOREIGN"; - - // ============= 필터 옵션 상수들 ============= - - export const EVALUATION_TARGET_FILTER_OPTIONS = { - DIVISIONS: [ - { value: "PLANT", label: "해양" }, - { value: "SHIP", label: "조선" }, - ], - STATUSES: [ - { value: "PENDING", label: "검토 중" }, - { value: "CONFIRMED", label: "확정" }, - { value: "EXCLUDED", label: "제외" }, - ], - DOMESTIC_FOREIGN: [ - { value: "DOMESTIC", label: "내자" }, - { value: "FOREIGN", label: "외자" }, - ], - MATERIAL_TYPES: [ - { value: "EQUIPMENT", label: "기자재" }, - { value: "BULK", label: "벌크" }, - { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, - ], - CONSENSUS_STATUS: [ - { value: "true", label: "의견 일치" }, - { value: "false", label: "의견 불일치" }, - { value: "null", label: "검토 중" }, - ], - } as const; - - // ============= 유효성 검사 함수들 ============= - - export function validateEvaluationYear(year: number): boolean { - const currentYear = new Date().getFullYear(); - return year >= 2020 && year <= currentYear + 1; - } - - export function validateDivision(division: string): division is Division { - return ["PLANT", "SHIP"].includes(division); - } - - export function validateStatus(status: string): status is EvaluationTargetStatus { - return ["PENDING", "CONFIRMED", "EXCLUDED"].includes(status); - } - - export function validateMaterialType(materialType: string): materialType is MaterialType { - return ["EQUIPMENT", "BULK", "EQUIPMENT_BULK"].includes(materialType); - } - - export function validateDomesticForeign(domesticForeign: string): domesticForeign is DomesticForeign { - return ["DOMESTIC", "FOREIGN"].includes(domesticForeign); - } - - // ============= 기본값 제공 함수들 ============= - - export function getDefaultEvaluationYear(): number { - return new Date().getFullYear(); - } - - export function getDefaultSearchParams(): GetEvaluationTargetsSchema { - return { - flags: [], - page: 1, - perPage: 10, - sort: [{ id: "createdAt", desc: true }], - evaluationYear: getDefaultEvaluationYear(), - division: "", - status: "", - domesticForeign: "", - materialType: "", - consensusStatus: "", - filters: [], - joinOperator: "and", - basicFilters: [], - basicJoinOperator: "and", - search: "", - }; - } - - // ============= 편의 함수들 ============= - - // 상태별 라벨 반환 - export function getStatusLabel(status: EvaluationTargetStatus): string { - const statusMap = { - PENDING: "검토 중", - CONFIRMED: "확정", - EXCLUDED: "제외" - }; - return statusMap[status] || status; - } - - // 구분별 라벨 반환 - export function getDivisionLabel(division: Division): string { - const divisionMap = { - PLANT: "해양", - SHIP: "조선" - }; - return divisionMap[division] || division; - } - - // 자재구분별 라벨 반환 - export function getMaterialTypeLabel(materialType: MaterialType): string { - const materialTypeMap = { - EQUIPMENT: "기자재", - BULK: "벌크", - EQUIPMENT_BULK: "기자재/벌크" - }; - return materialTypeMap[materialType] || materialType; - } - - // 내외자별 라벨 반환 - export function getDomesticForeignLabel(domesticForeign: DomesticForeign): string { - const domesticForeignMap = { - DOMESTIC: "내자", - FOREIGN: "외자" - }; - return domesticForeignMap[domesticForeign] || domesticForeign; - } + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { Division, DomesticForeign, EvaluationTargetStatus, MaterialType, divisionMap, domesticForeignMap, vendortypeMap } from "@/types/evaluation"; + +// ============= 메인 검색 파라미터 스키마 ============= + +export const searchParamsEvaluationTargetsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<any>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 기본 필터들 + evaluationYear: parseAsInteger.withDefault(new Date().getFullYear()), + division: parseAsString.withDefault(""), + status: parseAsString.withDefault(""), + domesticForeign: parseAsString.withDefault(""), + materialType: parseAsString.withDefault(""), + consensusStatus: parseAsString.withDefault(""), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 베이직 필터 (커스텀 필터 패널용) + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 + search: parseAsString.withDefault(""), +}); + +// ============= 타입 정의 ============= + +export type GetEvaluationTargetsSchema = Awaited< + ReturnType<typeof searchParamsEvaluationTargetsCache.parse> +>; + + +// ============= 필터 옵션 상수들 ============= + +export const EVALUATION_TARGET_FILTER_OPTIONS = { + DIVISIONS: [ + { value: "PLANT", label: "해양" }, + { value: "SHIP", label: "조선" }, + ], + STATUSES: [ + { value: "PENDING", label: "검토 중" }, + { value: "CONFIRMED", label: "확정" }, + { value: "EXCLUDED", label: "제외" }, + ], + DOMESTIC_FOREIGN: [ + { value: "DOMESTIC", label: "내자" }, + { value: "FOREIGN", label: "외자" }, + ], + MATERIAL_TYPES: [ + { value: "EQUIPMENT", label: "기자재" }, + { value: "BULK", label: "벌크" }, + { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, + ], + CONSENSUS_STATUS: [ + { value: "true", label: "의견 일치" }, + { value: "false", label: "의견 불일치" }, + { value: "null", label: "검토 중" }, + ], +} as const; + +// ============= 유효성 검사 함수들 ============= + +export function validateEvaluationYear(year: number): boolean { + const currentYear = new Date().getFullYear(); + return year >= 2020 && year <= currentYear + 1; +} + +export function validateDivision(division: string): division is Division { + return ["PLANT", "SHIP"].includes(division); +} + +export function validateStatus(status: string): status is EvaluationTargetStatus { + return ["PENDING", "CONFIRMED", "EXCLUDED"].includes(status); +} + +export function validateMaterialType(materialType: string): materialType is MaterialType { + return ["EQUIPMENT", "BULK", "EQUIPMENT_BULK"].includes(materialType); +} + +export function validateDomesticForeign(domesticForeign: string): domesticForeign is DomesticForeign { + return ["DOMESTIC", "FOREIGN"].includes(domesticForeign); +} + +// ============= 기본값 제공 함수들 ============= + +export function getDefaultEvaluationYear(): number { + return new Date().getFullYear(); +} + +export function getDefaultSearchParams(): GetEvaluationTargetsSchema { + return { + flags: [], + page: 1, + perPage: 10, + sort: [{ id: "createdAt", desc: true }], + evaluationYear: getDefaultEvaluationYear(), + division: "", + status: "", + domesticForeign: "", + materialType: "", + consensusStatus: "", + filters: [], + joinOperator: "and", + basicFilters: [], + basicJoinOperator: "and", + search: "", + }; +} + +// ============= 편의 함수들 ============= + +// 상태별 라벨 반환 +export function getStatusLabel(status: EvaluationTargetStatus): string { + const statusMap = { + PENDING: "검토 중", + CONFIRMED: "확정", + EXCLUDED: "제외" + }; + return statusMap[status] || status; +} + +// 구분별 라벨 반환 +export function getDivisionLabel(division: Division): string { + return divisionMap[division] || division; +} + +// 자재구분별 라벨 반환 +export function getMaterialTypeLabel(materialType: MaterialType): string { + return vendortypeMap[materialType] || materialType; +} + +// 내외자별 라벨 반환 +export function getDomesticForeignLabel(domesticForeign: DomesticForeign): string { + return domesticForeignMap[domesticForeign] || domesticForeign; +} diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts index 19e41dff..67a692ab 100644 --- a/lib/evaluation/service.ts +++ b/lib/evaluation/service.ts @@ -1,389 +1,1047 @@ 'use server' import db from "@/db/db" -import { +import { evaluationSubmissions, - periodicEvaluationsView, - type PeriodicEvaluationView + evaluationTargetReviewers, + evaluationTargets, + periodicEvaluations, + periodicEvaluationsView, + regEvalCriteria, + regEvalCriteriaDetails, + reviewerEvaluationDetails, + reviewerEvaluations, + users, + type PeriodicEvaluationView } from "@/db/schema" -import { - and, - asc, - count, - desc, - ilike, - or, sql , eq, avg, - type SQL +import { + and, + asc, + count, + desc, + ilike, + or, sql, eq, avg, inArray, + type SQL } from "drizzle-orm" import { filterColumns } from "@/lib/filter-columns" import { GetEvaluationTargetsSchema } from "../evaluation-target-list/validation"; +import { sendEmail } from "../mail/sendEmail" +import { revalidatePath } from "next/cache" +import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation" export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) { - try { + try { - const offset = (input.page - 1) * input.perPage; - - // ✅ getEvaluationTargets 방식과 동일한 필터링 처리 - // 1) 고급 필터 조건 - let advancedWhere: SQL<unknown> | undefined = undefined; - if (input.filters && input.filters.length > 0) { - advancedWhere = filterColumns({ - table: periodicEvaluationsView, - filters: input.filters, - joinOperator: input.joinOperator || 'and', - }); - } - - // 2) 기본 필터 조건 - let basicWhere: SQL<unknown> | undefined = undefined; - if (input.basicFilters && input.basicFilters.length > 0) { - basicWhere = filterColumns({ - table: periodicEvaluationsView, - filters: input.basicFilters, - joinOperator: input.basicJoinOperator || 'and', - }); - } - - // 3) 글로벌 검색 조건 - let globalWhere: SQL<unknown> | undefined = undefined; - if (input.search) { - const s = `%${input.search}%`; - - const validSearchConditions: SQL<unknown>[] = []; - - // 벤더 정보로 검색 - const vendorCodeCondition = ilike(periodicEvaluationsView.vendorCode, s); - if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition); - - const vendorNameCondition = ilike(periodicEvaluationsView.vendorName, s); - if (vendorNameCondition) validSearchConditions.push(vendorNameCondition); - - // 평가 관련 코멘트로 검색 - const evaluationNoteCondition = ilike(periodicEvaluationsView.evaluationNote, s); - if (evaluationNoteCondition) validSearchConditions.push(evaluationNoteCondition); - - const adminCommentCondition = ilike(periodicEvaluationsView.evaluationTargetAdminComment, s); - if (adminCommentCondition) validSearchConditions.push(adminCommentCondition); - - const consolidatedCommentCondition = ilike(periodicEvaluationsView.evaluationTargetConsolidatedComment, s); - if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition); - - // 최종 확정자 이름으로 검색 - const finalizedByUserNameCondition = ilike(periodicEvaluationsView.finalizedByUserName, s); - if (finalizedByUserNameCondition) validSearchConditions.push(finalizedByUserNameCondition); - - if (validSearchConditions.length > 0) { - globalWhere = or(...validSearchConditions); - } - } - - // ✅ getEvaluationTargets 방식과 동일한 WHERE 조건 생성 - const whereConditions: SQL<unknown>[] = []; - - if (advancedWhere) whereConditions.push(advancedWhere); - if (basicWhere) whereConditions.push(basicWhere); - if (globalWhere) whereConditions.push(globalWhere); - - const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; - - // ✅ getEvaluationTargets 방식과 동일한 전체 데이터 수 조회 - const totalResult = await db - .select({ count: count() }) - .from(periodicEvaluationsView) - .where(finalWhere); - - const total = totalResult[0]?.count || 0; - - if (total === 0) { - return { data: [], pageCount: 0, total: 0 }; - } - - console.log("Total periodic evaluations:", total); - - // ✅ getEvaluationTargets 방식과 동일한 정렬 및 페이징 처리된 데이터 조회 - const orderByColumns = input.sort.map((sort) => { - const column = sort.id as keyof typeof periodicEvaluationsView.$inferSelect; - return sort.desc ? desc(periodicEvaluationsView[column]) : asc(periodicEvaluationsView[column]); + const offset = (input.page - 1) * input.perPage; + + // ✅ getEvaluationTargets 방식과 동일한 필터링 처리 + // 1) 고급 필터 조건 + let advancedWhere: SQL<unknown> | undefined = undefined; + if (input.filters && input.filters.length > 0) { + advancedWhere = filterColumns({ + table: periodicEvaluationsView, + filters: input.filters, + joinOperator: input.joinOperator || 'and', }); - - if (orderByColumns.length === 0) { - orderByColumns.push(desc(periodicEvaluationsView.createdAt)); + } + + // 2) 기본 필터 조건 + let basicWhere: SQL<unknown> | undefined = undefined; + if (input.basicFilters && input.basicFilters.length > 0) { + basicWhere = filterColumns({ + table: periodicEvaluationsView, + filters: input.basicFilters, + joinOperator: input.basicJoinOperator || 'and', + }); + } + + // 3) 글로벌 검색 조건 + let globalWhere: SQL<unknown> | undefined = undefined; + if (input.search) { + const s = `%${input.search}%`; + + const validSearchConditions: SQL<unknown>[] = []; + + // 벤더 정보로 검색 + const vendorCodeCondition = ilike(periodicEvaluationsView.vendorCode, s); + if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition); + + const vendorNameCondition = ilike(periodicEvaluationsView.vendorName, s); + if (vendorNameCondition) validSearchConditions.push(vendorNameCondition); + + // 평가 관련 코멘트로 검색 + const evaluationNoteCondition = ilike(periodicEvaluationsView.evaluationNote, s); + if (evaluationNoteCondition) validSearchConditions.push(evaluationNoteCondition); + + const adminCommentCondition = ilike(periodicEvaluationsView.evaluationTargetAdminComment, s); + if (adminCommentCondition) validSearchConditions.push(adminCommentCondition); + + const consolidatedCommentCondition = ilike(periodicEvaluationsView.evaluationTargetConsolidatedComment, s); + if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition); + + // 최종 확정자 이름으로 검색 + const finalizedByUserNameCondition = ilike(periodicEvaluationsView.finalizedByUserName, s); + if (finalizedByUserNameCondition) validSearchConditions.push(finalizedByUserNameCondition); + + if (validSearchConditions.length > 0) { + globalWhere = or(...validSearchConditions); } - - const periodicEvaluationsData = await db - .select() - .from(periodicEvaluationsView) - .where(finalWhere) - .orderBy(...orderByColumns) - .limit(input.perPage) - .offset(offset); - - const pageCount = Math.ceil(total / input.perPage); + } - console.log(periodicEvaluationsData,"periodicEvaluationsData") - - return { data: periodicEvaluationsData, pageCount, total }; - } catch (err) { - console.error("Error in getPeriodicEvaluations:", err); - // ✅ getEvaluationTargets 방식과 동일한 에러 반환 (total 포함) + // ✅ getEvaluationTargets 방식과 동일한 WHERE 조건 생성 + const whereConditions: SQL<unknown>[] = []; + + if (advancedWhere) whereConditions.push(advancedWhere); + if (basicWhere) whereConditions.push(basicWhere); + if (globalWhere) whereConditions.push(globalWhere); + + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; + + // ✅ getEvaluationTargets 방식과 동일한 전체 데이터 수 조회 + const totalResult = await db + .select({ count: count() }) + .from(periodicEvaluationsView) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + + if (total === 0) { return { data: [], pageCount: 0, total: 0 }; } - } - export interface PeriodicEvaluationsStats { - total: number - pendingSubmission: number - submitted: number - inReview: number - reviewCompleted: number - finalized: number - averageScore: number | null - completionRate: number - averageFinalScore: number | null - documentsSubmittedCount: number - documentsNotSubmittedCount: number - reviewProgress: { - totalReviewers: number - completedReviewers: number - pendingReviewers: number - reviewCompletionRate: number + console.log("Total periodic evaluations:", total); + + // ✅ getEvaluationTargets 방식과 동일한 정렬 및 페이징 처리된 데이터 조회 + const orderByColumns = input.sort.map((sort) => { + const column = sort.id as keyof typeof periodicEvaluationsView.$inferSelect; + return sort.desc ? desc(periodicEvaluationsView[column]) : asc(periodicEvaluationsView[column]); + }); + + if (orderByColumns.length === 0) { + orderByColumns.push(desc(periodicEvaluationsView.createdAt)); } + + const periodicEvaluationsData = await db + .select() + .from(periodicEvaluationsView) + .where(finalWhere) + .orderBy(...orderByColumns) + .limit(input.perPage) + .offset(offset); + + const pageCount = Math.ceil(total / input.perPage); + + console.log(periodicEvaluationsData, "periodicEvaluationsData") + + return { data: periodicEvaluationsData, pageCount, total }; + } catch (err) { + console.error("Error in getPeriodicEvaluations:", err); + // ✅ getEvaluationTargets 방식과 동일한 에러 반환 (total 포함) + return { data: [], pageCount: 0, total: 0 }; } - - export async function getPeriodicEvaluationsStats(evaluationYear: number): Promise<PeriodicEvaluationsStats> { - try { - // 기본 WHERE 조건: 해당 연도의 평가만 - const baseWhere = eq(periodicEvaluationsView.evaluationYear, evaluationYear) - - // 1. 전체 통계 조회 - const totalStatsResult = await db - .select({ - total: count(), - averageScore: avg(periodicEvaluationsView.totalScore), - averageFinalScore: avg(periodicEvaluationsView.finalScore), - }) - .from(periodicEvaluationsView) - .where(baseWhere) - - const totalStats = totalStatsResult[0] || { - total: 0, - averageScore: null, - averageFinalScore: null +} + +export interface PeriodicEvaluationsStats { + total: number + pendingSubmission: number + submitted: number + inReview: number + reviewCompleted: number + finalized: number + averageScore: number | null + completionRate: number + averageFinalScore: number | null + documentsSubmittedCount: number + documentsNotSubmittedCount: number + reviewProgress: { + totalReviewers: number + completedReviewers: number + pendingReviewers: number + reviewCompletionRate: number + } +} + +export async function getPeriodicEvaluationsStats(evaluationYear: number): Promise<PeriodicEvaluationsStats> { + try { + // 기본 WHERE 조건: 해당 연도의 평가만 + const baseWhere = eq(periodicEvaluationsView.evaluationYear, evaluationYear) + + // 1. 전체 통계 조회 + const totalStatsResult = await db + .select({ + total: count(), + averageScore: avg(periodicEvaluationsView.totalScore), + averageFinalScore: avg(periodicEvaluationsView.finalScore), + }) + .from(periodicEvaluationsView) + .where(baseWhere) + + const totalStats = totalStatsResult[0] || { + total: 0, + averageScore: null, + averageFinalScore: null + } + + // 2. 상태별 카운트 조회 + const statusStatsResult = await db + .select({ + status: periodicEvaluationsView.status, + count: count(), + }) + .from(periodicEvaluationsView) + .where(baseWhere) + .groupBy(periodicEvaluationsView.status) + + // 상태별 카운트를 객체로 변환 + const statusCounts = statusStatsResult.reduce((acc, item) => { + acc[item.status] = item.count + return acc + }, {} as Record<string, number>) + + // 3. 문서 제출 상태 통계 + const documentStatsResult = await db + .select({ + documentsSubmitted: periodicEvaluationsView.documentsSubmitted, + count: count(), + }) + .from(periodicEvaluationsView) + .where(baseWhere) + .groupBy(periodicEvaluationsView.documentsSubmitted) + + const documentCounts = documentStatsResult.reduce((acc, item) => { + if (item.documentsSubmitted) { + acc.submitted = item.count + } else { + acc.notSubmitted = item.count } - - // 2. 상태별 카운트 조회 - const statusStatsResult = await db - .select({ - status: periodicEvaluationsView.status, - count: count(), - }) - .from(periodicEvaluationsView) - .where(baseWhere) - .groupBy(periodicEvaluationsView.status) - - // 상태별 카운트를 객체로 변환 - const statusCounts = statusStatsResult.reduce((acc, item) => { - acc[item.status] = item.count - return acc - }, {} as Record<string, number>) - - // 3. 문서 제출 상태 통계 - const documentStatsResult = await db - .select({ - documentsSubmitted: periodicEvaluationsView.documentsSubmitted, - count: count(), + return acc + }, { submitted: 0, notSubmitted: 0 }) + + // 4. 리뷰어 진행 상황 통계 + const reviewProgressResult = await db + .select({ + totalReviewers: sql<number>`SUM(${periodicEvaluationsView.totalReviewers})`.as('total_reviewers'), + completedReviewers: sql<number>`SUM(${periodicEvaluationsView.completedReviewers})`.as('completed_reviewers'), + pendingReviewers: sql<number>`SUM(${periodicEvaluationsView.pendingReviewers})`.as('pending_reviewers'), + }) + .from(periodicEvaluationsView) + .where(baseWhere) + + const reviewProgress = reviewProgressResult[0] || { + totalReviewers: 0, + completedReviewers: 0, + pendingReviewers: 0, + } + + // 5. 완료율 계산 + const finalizedCount = statusCounts['FINALIZED'] || 0 + const totalCount = totalStats.total + const completionRate = totalCount > 0 ? Math.round((finalizedCount / totalCount) * 100) : 0 + + // 6. 리뷰 완료율 계산 + const reviewCompletionRate = reviewProgress.totalReviewers > 0 + ? Math.round((reviewProgress.completedReviewers / reviewProgress.totalReviewers) * 100) + : 0 + + // 7. 평균 점수 포맷팅 (소수점 1자리) + const formatScore = (score: string | number | null): number | null => { + if (score === null || score === undefined) return null + return Math.round(Number(score) * 10) / 10 + } + + return { + total: totalCount, + pendingSubmission: statusCounts['PENDING_SUBMISSION'] || 0, + submitted: statusCounts['SUBMITTED'] || 0, + inReview: statusCounts['IN_REVIEW'] || 0, + reviewCompleted: statusCounts['REVIEW_COMPLETED'] || 0, + finalized: finalizedCount, + averageScore: formatScore(totalStats.averageScore), + averageFinalScore: formatScore(totalStats.averageFinalScore), + completionRate, + documentsSubmittedCount: documentCounts.submitted, + documentsNotSubmittedCount: documentCounts.notSubmitted, + reviewProgress: { + totalReviewers: reviewProgress.totalReviewers, + completedReviewers: reviewProgress.completedReviewers, + pendingReviewers: reviewProgress.pendingReviewers, + reviewCompletionRate, + }, + } + + } catch (error) { + console.error('Error in getPeriodicEvaluationsStats:', error) + // 에러 발생 시 기본값 반환 + return { + total: 0, + pendingSubmission: 0, + submitted: 0, + inReview: 0, + reviewCompleted: 0, + finalized: 0, + averageScore: null, + averageFinalScore: null, + completionRate: 0, + documentsSubmittedCount: 0, + documentsNotSubmittedCount: 0, + reviewProgress: { + totalReviewers: 0, + completedReviewers: 0, + pendingReviewers: 0, + reviewCompletionRate: 0, + }, + } + } +} + + + +interface RequestDocumentsData { + periodicEvaluationId: number + companyId: number + evaluationYear: number + evaluationRound: string + message: string +} + +export async function requestDocumentsFromVendors(data: RequestDocumentsData[]) { + try { + // 각 평가에 대해 evaluationSubmissions 레코드 생성 + const submissions = await Promise.all( + data.map(async (item) => { + // 이미 해당 periodicEvaluationId와 companyId로 생성된 submission이 있는지 확인 + const existingSubmission = await db.query.evaluationSubmissions.findFirst({ + where: and( + eq(evaluationSubmissions.periodicEvaluationId, item.periodicEvaluationId), + eq(evaluationSubmissions.companyId, item.companyId) + ) }) - .from(periodicEvaluationsView) - .where(baseWhere) - .groupBy(periodicEvaluationsView.documentsSubmitted) - - const documentCounts = documentStatsResult.reduce((acc, item) => { - if (item.documentsSubmitted) { - acc.submitted = item.count + + if (existingSubmission) { + // 이미 존재하면 reviewComments만 업데이트 + const [updated] = await db + .update(evaluationSubmissions) + .set({ + reviewComments: item.message, + updatedAt: new Date() + }) + .where(eq(evaluationSubmissions.id, existingSubmission.id)) + .returning() + + return updated } else { - acc.notSubmitted = item.count + // 새로 생성 + const [created] = await db + .insert(evaluationSubmissions) + .values({ + periodicEvaluationId: item.periodicEvaluationId, + companyId: item.companyId, + evaluationYear: item.evaluationYear, + evaluationRound: item.evaluationRound, + submissionStatus: 'draft', // 기본값 + reviewComments: item.message, + // 진행률 관련 필드들은 기본값 0으로 설정됨 + totalGeneralItems: 0, + completedGeneralItems: 0, + totalEsgItems: 0, + completedEsgItems: 0, + isActive: true + }) + .returning() + + return created } - return acc - }, { submitted: 0, notSubmitted: 0 }) - - // 4. 리뷰어 진행 상황 통계 - const reviewProgressResult = await db + }) + ) + + + return { + success: true, + message: `${submissions.length}개 업체에 자료 요청이 완료되었습니다.`, + submissions + } + + } catch (error) { + console.error("Error requesting documents from vendors:", error) + return { + success: false, + message: "자료 요청 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error" + } + } +} + +// 기존 요청 상태 확인 함수 추가 +export async function checkExistingSubmissions(periodicEvaluationIds: number[]) { + try { + const existingSubmissions = await db.query.evaluationSubmissions.findMany({ + where: (submissions) => { + // periodicEvaluationIds 배열에 포함된 ID들을 확인 + return periodicEvaluationIds.length === 1 + ? eq(submissions.periodicEvaluationId, periodicEvaluationIds[0]) + : periodicEvaluationIds.length > 1 + ? or(...periodicEvaluationIds.map(id => eq(submissions.periodicEvaluationId, id))) + : eq(submissions.id, -1) // 빈 배열인 경우 결과 없음 + }, + columns: { + id: true, + periodicEvaluationId: true, + companyId: true, + createdAt: true, + reviewComments: true + } + }) + + return existingSubmissions + } catch (error) { + console.error("Error checking existing submissions:", error) + return [] + } +} + + +// ================================================================ +// 타입 정의 +// ================================================================ +interface ReviewerInfo { + id: number + name: string + email: string + deptName: string | null + departmentCode: string + evaluationTargetId: number + evaluationTargetReviewerId: number +} + +interface ReviewerEvaluationRequestData { + periodicEvaluationId: number + evaluationTargetReviewerId: number + message: string +} + +// ================================================================ +// 1. 평가 대상별 리뷰어 정보 가져오기 +// ================================================================ +export async function getReviewersForEvaluations( + evaluationTargetIds: number[] +): Promise<ReviewerInfo[]> { + try { + if (evaluationTargetIds.length === 0) { + return [] + } + + // evaluation_target_reviewers와 users 테이블 조인 + const reviewers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + deptName: users.deptName, + departmentCode: evaluationTargetReviewers.departmentCode, + evaluationTargetId: evaluationTargetReviewers.evaluationTargetId, + evaluationTargetReviewerId: evaluationTargetReviewers.id, + }) + .from(evaluationTargetReviewers) + .innerJoin(users, eq(evaluationTargetReviewers.reviewerUserId, users.id)) + .where( + and( + inArray(evaluationTargetReviewers.evaluationTargetId, evaluationTargetIds), + eq(users.isActive, true) // 활성 사용자만 + ) + ) + .orderBy(evaluationTargetReviewers.evaluationTargetId, users.name) + + return reviewers + } catch (error) { + console.error('Error fetching reviewers for evaluations:', error) + throw new Error('평가자 정보를 가져오는데 실패했습니다.') + } +} +// ================================================================ +// 2. 리뷰어 평가 요청 생성 및 알림 발송 +// ================================================================ +export async function createReviewerEvaluationsRequest( + requestData: ReviewerEvaluationRequestData[] +): Promise<{ success: boolean; message: string }> { + try { + if (requestData.length === 0) { + return { + success: false, + message: "요청할 평가 데이터가 없습니다." + } + } + + console.log('평가 요청 데이터:', requestData) + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. 기존 reviewerEvaluations 확인 (중복 방지) + const existingEvaluations = await tx .select({ - totalReviewers: sql<number>`SUM(${periodicEvaluationsView.totalReviewers})`.as('total_reviewers'), - completedReviewers: sql<number>`SUM(${periodicEvaluationsView.completedReviewers})`.as('completed_reviewers'), - pendingReviewers: sql<number>`SUM(${periodicEvaluationsView.pendingReviewers})`.as('pending_reviewers'), + periodicEvaluationId: reviewerEvaluations.periodicEvaluationId, + evaluationTargetReviewerId: reviewerEvaluations.evaluationTargetReviewerId, }) - .from(periodicEvaluationsView) - .where(baseWhere) - - const reviewProgress = reviewProgressResult[0] || { - totalReviewers: 0, - completedReviewers: 0, - pendingReviewers: 0, + .from(reviewerEvaluations) + .where( + and( + inArray( + reviewerEvaluations.periodicEvaluationId, + requestData.map(r => r.periodicEvaluationId) + ), + inArray( + reviewerEvaluations.evaluationTargetReviewerId, + requestData.map(r => r.evaluationTargetReviewerId) + ) + ) + ) + + // 2. 중복되지 않는 새로운 평가 요청만 필터링 + const newRequestData = requestData.filter(request => + !existingEvaluations.some(existing => + existing.periodicEvaluationId === request.periodicEvaluationId && + existing.evaluationTargetReviewerId === request.evaluationTargetReviewerId + ) + ) + + if (newRequestData.length === 0) { + throw new Error("모든 평가 요청이 이미 생성되어 있습니다.") } - - // 5. 완료율 계산 - const finalizedCount = statusCounts['FINALIZED'] || 0 - const totalCount = totalStats.total - const completionRate = totalCount > 0 ? Math.round((finalizedCount / totalCount) * 100) : 0 - - // 6. 리뷰 완료율 계산 - const reviewCompletionRate = reviewProgress.totalReviewers > 0 - ? Math.round((reviewProgress.completedReviewers / reviewProgress.totalReviewers) * 100) - : 0 - - // 7. 평균 점수 포맷팅 (소수점 1자리) - const formatScore = (score: string | number | null): number | null => { - if (score === null || score === undefined) return null - return Math.round(Number(score) * 10) / 10 + + console.log(`새로 생성할 평가 요청: ${newRequestData.length}개`) + + // 3. reviewerEvaluations 테이블에 레코드 생성 + const reviewerEvaluationInsertData = newRequestData.map(request => ({ + periodicEvaluationId: request.periodicEvaluationId, + evaluationTargetReviewerId: request.evaluationTargetReviewerId, + isCompleted: false, + // 기본값들 + processScore: null, + priceScore: null, + deliveryScore: null, + selfEvaluationScore: null, + participationBonus: "0", + qualityDeduction: "0", + totalScore: null, + grade: null, + completedAt: null, + reviewerComment: null, + })) + + const insertedEvaluations = await tx.insert(reviewerEvaluations).values(reviewerEvaluationInsertData).returning({ id: reviewerEvaluations.id }) + console.log(`reviewerEvaluations 레코드 생성 완료: ${insertedEvaluations.length}개`) + + // 4. 이메일 발송을 위한 상세 정보 수집 + try { + await sendEvaluationRequestEmails(tx, newRequestData, requestData[0]?.message || "") + } catch (emailError) { + console.error('이메일 발송 중 오류:', emailError) + // 이메일 발송 실패해도 전체 트랜잭션은 성공으로 처리 } - + }) + + const totalReviewers = [...new Set(requestData.map(r => r.evaluationTargetReviewerId))].length + const totalEvaluations = [...new Set(requestData.map(r => r.periodicEvaluationId))].length + + return { + success: true, + message: `${totalEvaluations}개 평가에 대해 ${totalReviewers}명의 평가자에게 요청이 발송되었습니다.` + } + + } catch (error) { + console.error('Error creating reviewer evaluation requests:', error) + return { + success: false, + message: error instanceof Error ? error.message : "평가 요청 생성 중 오류가 발생했습니다." + } + } +} + + +const getDepartmentLabel = (code: string): string => { + return DEPARTMENT_CODE_LABELS[code as keyof typeof DEPARTMENT_CODE_LABELS] || code +} + +// ================================================================ +// 이메일 발송 헬퍼 함수 (완전 새로 작성) +// ================================================================ +async function sendEvaluationRequestEmails( + tx: any, + requestData: ReviewerEvaluationRequestData[], + message: string +) { + try { + + // 1. 평가 정보 수집 (periodicEvaluations + evaluationTargets 조인) + const evaluationIds = [...new Set(requestData.map(r => r.periodicEvaluationId))] + + const evaluationDetails = await tx + .select({ + periodicEvaluationId: periodicEvaluations.id, + evaluationTargetId: periodicEvaluations.evaluationTargetId, + evaluationYear: evaluationTargets.evaluationYear, + evaluationPeriod: periodicEvaluations.evaluationPeriod, + vendorCode: evaluationTargets.vendorCode, + vendorName: evaluationTargets.vendorName, + division: evaluationTargets.division, + materialType: evaluationTargets.materialType, + domesticForeign: evaluationTargets.domesticForeign, + submissionDeadline: periodicEvaluations.submissionDeadline, + }) + .from(periodicEvaluations) + .innerJoin(evaluationTargets, eq(periodicEvaluations.evaluationTargetId, evaluationTargets.id)) + .where(inArray(periodicEvaluations.id, evaluationIds)) + + console.log('평가 상세 정보:', evaluationDetails) + + // 2. 리뷰어 정보 수집 + const reviewerIds = [...new Set(requestData.map(r => r.evaluationTargetReviewerId))] + console.log('리뷰어 ID들:', reviewerIds) + + const reviewerDetails = await tx + .select({ + evaluationTargetReviewerId: evaluationTargetReviewers.id, + evaluationTargetId: evaluationTargetReviewers.evaluationTargetId, + departmentCode: evaluationTargetReviewers.departmentCode, + reviewerUserId: evaluationTargetReviewers.reviewerUserId, + userName: users.name, + userEmail: users.email, + deptName: users.deptName, + }) + .from(evaluationTargetReviewers) + .innerJoin(users, eq(evaluationTargetReviewers.reviewerUserId, users.id)) + .where(inArray(evaluationTargetReviewers.id, reviewerIds)) + + console.log('리뷰어 상세 정보:', reviewerDetails) + + // 3. 평가별로 그룹핑 (각 평가에 대한 리뷰어들) + const evaluationGroups = evaluationDetails.map(evaluation => { + const relatedRequests = requestData.filter(req => req.periodicEvaluationId === evaluation.periodicEvaluationId) + const evaluationReviewers = relatedRequests.map(req => { + const reviewer = reviewerDetails.find(r => r.evaluationTargetReviewerId === req.evaluationTargetReviewerId) + return { + ...reviewer, + departmentLabel: getDepartmentLabel(reviewer?.departmentCode || ''), + } + }).filter(Boolean) + return { - total: totalCount, - pendingSubmission: statusCounts['PENDING_SUBMISSION'] || 0, - submitted: statusCounts['SUBMITTED'] || 0, - inReview: statusCounts['IN_REVIEW'] || 0, - reviewCompleted: statusCounts['REVIEW_COMPLETED'] || 0, - finalized: finalizedCount, - averageScore: formatScore(totalStats.averageScore), - averageFinalScore: formatScore(totalStats.averageFinalScore), - completionRate, - documentsSubmittedCount: documentCounts.submitted, - documentsNotSubmittedCount: documentCounts.notSubmitted, - reviewProgress: { - totalReviewers: reviewProgress.totalReviewers, - completedReviewers: reviewProgress.completedReviewers, - pendingReviewers: reviewProgress.pendingReviewers, - reviewCompletionRate, - }, + ...evaluation, + reviewers: evaluationReviewers, + relatedRequests } - - } catch (error) { - console.error('Error in getPeriodicEvaluationsStats:', error) - // 에러 발생 시 기본값 반환 - return { - total: 0, - pendingSubmission: 0, - submitted: 0, - inReview: 0, - reviewCompleted: 0, - finalized: 0, - averageScore: null, - averageFinalScore: null, - completionRate: 0, - documentsSubmittedCount: 0, - documentsNotSubmittedCount: 0, - reviewProgress: { - totalReviewers: 0, - completedReviewers: 0, - pendingReviewers: 0, - reviewCompletionRate: 0, - }, + }) + + console.log('평가 그룹:', evaluationGroups) + + // 4. 각 리뷰어에게 개별 이메일 발송 + const emailPromises = [] + + for (const group of evaluationGroups) { + for (const reviewer of group.reviewers) { + if (!reviewer?.userEmail) { + console.log(`이메일 주소가 없는 리뷰어 스킵: ${reviewer?.userName}`) + continue + } + + // 해당 리뷰어를 제외한 다른 리뷰어들 + const otherReviewers = group.reviewers.filter(r => r?.evaluationTargetReviewerId !== reviewer.evaluationTargetReviewerId) + + console.log(`${reviewer.userName}(${reviewer.userEmail})에게 이메일 발송 준비`) + + const emailPromise = sendEmail({ + to: reviewer.userEmail, + subject: `[평가 요청] ${group.vendorName} - ${group.evaluationYear}년 ${group.evaluationPeriod} 정기평가`, + template: "evaluation-request", + context: { + language: "ko", + reviewerName: reviewer.userName, + departmentLabel: reviewer.departmentLabel, + evaluation: { + vendorName: group.vendorName, + vendorCode: group.vendorCode, + evaluationYear: group.evaluationYear, + evaluationPeriod: group.evaluationPeriod, + division: group.division, + materialType: group.materialType, + domesticForeign: group.domesticForeign, + submissionDeadline: group.submissionDeadline ? new Date(group.submissionDeadline).toLocaleDateString('ko-KR') : null, + }, + otherReviewers: otherReviewers.map(r => ({ + name: r?.userName, + department: r?.departmentLabel, + email: r?.userEmail + })).filter(r => r.name), + message: message || "협력업체 정기평가를 진행해 주시기 바랍니다.", + evaluationUrl: `${process.env.NEXT_PUBLIC_APP_URL}/evaluations/${group.periodicEvaluationId}/review` + }, + }).catch(error => { + console.error(`${reviewer.userEmail}에게 이메일 발송 실패:`, error) + return null + }) + + emailPromises.push(emailPromise) } } + + // 5. 모든 이메일 발송 대기 + const emailResults = await Promise.allSettled(emailPromises) + const successCount = emailResults.filter(result => result.status === 'fulfilled').length + const failureCount = emailResults.filter(result => result.status === 'rejected').length + + console.log(`이메일 발송 완료: 성공 ${successCount}개, 실패 ${failureCount}개`) + + if (failureCount > 0) { + console.error('실패한 이메일들:', emailResults.filter(r => r.status === 'rejected').map(r => r.reason)) + } + + } catch (error) { + console.error('Error sending evaluation request emails:', error) + throw error // 이메일 발송 실패도 에러로 처리하려면 throw, 아니면 console.error만 } +} +// ================================================================ +// 3. 리뷰어별 평가 완료 상태 확인 (선택적 기능) +// ================================================================ +export async function getReviewerEvaluationStatus( + periodicEvaluationIds: number[] +): Promise<Array<{ + periodicEvaluationId: number + totalReviewers: number + completedReviewers: number + completionRate: number +}>> { + try { + if (periodicEvaluationIds.length === 0) { + return [] + } + const evaluationStatus = await db + .select({ + periodicEvaluationId: reviewerEvaluations.periodicEvaluationId, + totalReviewers: db.$count(reviewerEvaluations.id), + completedReviewers: db.$count( + reviewerEvaluations.id, + eq(reviewerEvaluations.isCompleted, true) + ), + }) + .from(reviewerEvaluations) + .where(inArray(reviewerEvaluations.periodicEvaluationId, periodicEvaluationIds)) + .groupBy(reviewerEvaluations.periodicEvaluationId) + return evaluationStatus.map(status => ({ + ...status, + completionRate: status.totalReviewers > 0 + ? Math.round((status.completedReviewers / status.totalReviewers) * 100) + : 0 + })) - interface RequestDocumentsData { - periodicEvaluationId: number - companyId: number - evaluationYear: number - evaluationRound: string - message: string + } catch (error) { + console.error('Error fetching reviewer evaluation status:', error) + throw new Error('평가 완료 상태를 가져오는데 실패했습니다.') } - - export async function requestDocumentsFromVendors(data: RequestDocumentsData[]) { - try { - // 각 평가에 대해 evaluationSubmissions 레코드 생성 - const submissions = await Promise.all( - data.map(async (item) => { - // 이미 해당 periodicEvaluationId와 companyId로 생성된 submission이 있는지 확인 - const existingSubmission = await db.query.evaluationSubmissions.findFirst({ - where: and( - eq(evaluationSubmissions.periodicEvaluationId, item.periodicEvaluationId), - eq(evaluationSubmissions.companyId, item.companyId) - ) +} + +// 평가 확정 데이터 타입 +interface FinalizeEvaluationData { + id: number + finalScore: number + finalGrade: "S" | "A" | "B" | "C" | "D" +} + +/** + * 평가를 최종 확정합니다 + */ +export async function finalizeEvaluations( + evaluationData: FinalizeEvaluationData[] +) { + try { + // 현재 사용자 정보 가져오기 + const currentUser = await getCurrentUser() + if (!currentUser) { + throw new Error("인증이 필요합니다") + } + + // 트랜잭션으로 여러 평가를 한번에 처리 + await db.transaction(async (tx) => { + const now = new Date() + + // 각 평가를 순차적으로 처리 + for (const evaluation of evaluationData) { + // 1. 평가 상태가 REVIEW_COMPLETED인지 확인 + const existingEvaluation = await tx + .select({ + id: periodicEvaluations.id, + status: periodicEvaluations.status, }) - - if (existingSubmission) { - // 이미 존재하면 reviewComments만 업데이트 - const [updated] = await db - .update(evaluationSubmissions) - .set({ - reviewComments: item.message, - updatedAt: new Date() - }) - .where(eq(evaluationSubmissions.id, existingSubmission.id)) - .returning() - - return updated - } else { - // 새로 생성 - const [created] = await db - .insert(evaluationSubmissions) - .values({ - periodicEvaluationId: item.periodicEvaluationId, - companyId: item.companyId, - evaluationYear: item.evaluationYear, - evaluationRound: item.evaluationRound, - submissionStatus: 'draft', // 기본값 - reviewComments: item.message, - // 진행률 관련 필드들은 기본값 0으로 설정됨 - totalGeneralItems: 0, - completedGeneralItems: 0, - totalEsgItems: 0, - completedEsgItems: 0, - isActive: true - }) - .returning() - - return created - } - }) - ) - - - return { - success: true, - message: `${submissions.length}개 업체에 자료 요청이 완료되었습니다.`, - submissions + .from(periodicEvaluations) + .where(eq(periodicEvaluations.id, evaluation.id)) + .limit(1) + + if (existingEvaluation.length === 0) { + throw new Error(`평가를 찾을 수 없습니다: ID ${evaluation.id}`) + } + + if (existingEvaluation[0].status !== "REVIEW_COMPLETED") { + throw new Error( + `평가 ${evaluation.id}는 검토 완료 상태가 아닙니다. 현재 상태: ${existingEvaluation[0].status}` + ) + } + + // 2. 평가를 최종 확정으로 업데이트 + await tx + .update(periodicEvaluations) + .set({ + finalScore: evaluation.finalScore.toString(), + finalGrade: evaluation.finalGrade, + status: "FINALIZED", + finalizedAt: now, + finalizedBy: currentUser.id, + updatedAt: now, + }) + .where(eq(periodicEvaluations.id, evaluation.id)) } - - } catch (error) { - console.error("Error requesting documents from vendors:", error) - return { - success: false, - message: "자료 요청 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error" + }) + + revalidatePath("/evcp/evaluation") + revalidatePath("/procurement/evaluation") + + return { + success: true, + message: `${evaluationData.length}건의 평가가 성공적으로 확정되었습니다`, + } + } catch (error) { + console.error("Error finalizing evaluations:", error) + throw new Error( + error instanceof Error + ? error.message + : "평가 확정 중 오류가 발생했습니다" + ) + } +} + +/** + * 평가 확정을 취소합니다 (필요시 추가) + */ +export async function unfinalizeEvaluations(evaluationIds: number[]) { + try { + const currentUser = await getCurrentUser() + if (!currentUser) { + throw new Error("인증이 필요합니다") + } + + await db.transaction(async (tx) => { + for (const evaluationId of evaluationIds) { + // 1. 평가 상태가 FINALIZED인지 확인 + const existingEvaluation = await tx + .select({ + id: periodicEvaluations.id, + status: periodicEvaluations.status, + }) + .from(periodicEvaluations) + .where(eq(periodicEvaluations.id, evaluationId)) + .limit(1) + + if (existingEvaluation.length === 0) { + throw new Error(`평가를 찾을 수 없습니다: ID ${evaluationId}`) + } + + if (existingEvaluation[0].status !== "FINALIZED") { + throw new Error( + `평가 ${evaluationId}는 확정 상태가 아닙니다. 현재 상태: ${existingEvaluation[0].status}` + ) + } + + // 2. 확정 해제 - 검토 완료 상태로 되돌림 + await tx + .update(periodicEvaluations) + .set({ + finalScore: null, + finalGrade: null, + status: "REVIEW_COMPLETED", + finalizedAt: null, + finalizedBy: null, + updatedAt: new Date(), + }) + .where(eq(periodicEvaluations.id, evaluationId)) } + }) + + revalidatePath("/evcp/evaluation") + revalidatePath("/procurement/evaluation") + + return { + success: true, + message: `${evaluationIds.length}건의 평가 확정이 취소되었습니다`, } + } catch (error) { + console.error("Error unfinalizing evaluations:", error) + throw new Error( + error instanceof Error + ? error.message + : "평가 확정 취소 중 오류가 발생했습니다" + ) } +} + + +// 평가 상세 정보 타입 +export interface EvaluationDetailData { + // 리뷰어 정보 + reviewerEvaluationId: number + reviewerName: string + reviewerEmail: string + departmentCode: string + departmentName: string + isCompleted: boolean + completedAt: Date | null + reviewerComment: string | null - // 기존 요청 상태 확인 함수 추가 - export async function checkExistingSubmissions(periodicEvaluationIds: number[]) { - try { - const existingSubmissions = await db.query.evaluationSubmissions.findMany({ - where: (submissions) => { - // periodicEvaluationIds 배열에 포함된 ID들을 확인 - return periodicEvaluationIds.length === 1 - ? eq(submissions.periodicEvaluationId, periodicEvaluationIds[0]) - : periodicEvaluationIds.length > 1 - ? or(...periodicEvaluationIds.map(id => eq(submissions.periodicEvaluationId, id))) - : eq(submissions.id, -1) // 빈 배열인 경우 결과 없음 - }, - columns: { - id: true, - periodicEvaluationId: true, - companyId: true, - createdAt: true, - reviewComments: true - } + // 평가 항목별 상세 + evaluationItems: { + // 평가 기준 정보 + criteriaId: number + category: string + category2: string + item: string + classification: string + range: string | null + remarks: string | null + scoreType: string + + // 선택된 옵션 정보 (fixed 타입인 경우) + selectedDetailId: number | null + selectedDetail: string | null + + // 점수 및 의견 + score: number | null + comment: string | null + }[] +} + + +/** + * 특정 정기평가의 상세 정보를 조회합니다 + */ +export async function getEvaluationDetails(periodicEvaluationId: number): Promise<{ + evaluationInfo: { + id: number + vendorName: string + vendorCode: string + evaluationYear: number + division: string + status: string + } + reviewerDetails: EvaluationDetailData[] +}> { + try { + // 1. 평가 기본 정보 조회 + const evaluationInfo = await db + .select({ + id: periodicEvaluations.id, + vendorName: evaluationTargets.vendorName, + vendorCode: evaluationTargets.vendorCode, + evaluationYear: evaluationTargets.evaluationYear, + division: evaluationTargets.division, + status: periodicEvaluations.status, }) - - return existingSubmissions - } catch (error) { - console.error("Error checking existing submissions:", error) - return [] + .from(periodicEvaluations) + .leftJoin(evaluationTargets, eq(periodicEvaluations.evaluationTargetId, evaluationTargets.id)) + .where(eq(periodicEvaluations.id, periodicEvaluationId)) + .limit(1) + + if (evaluationInfo.length === 0) { + throw new Error("평가를 찾을 수 없습니다") } - }
\ No newline at end of file + + // 2. 리뷰어별 평가 상세 정보 조회 + const reviewerDetailsRaw = await db + .select({ + // 리뷰어 평가 기본 정보 + reviewerEvaluationId: reviewerEvaluations.id, + reviewerName: users.name, + reviewerEmail: users.email, + departmentCode: evaluationTargetReviewers.departmentCode, + isCompleted: reviewerEvaluations.isCompleted, + completedAt: reviewerEvaluations.completedAt, + reviewerComment: reviewerEvaluations.reviewerComment, + + // 평가 항목 상세 + detailId: reviewerEvaluationDetails.id, + criteriaId: regEvalCriteria.id, + category: regEvalCriteria.category, + category2: regEvalCriteria.category2, + item: regEvalCriteria.item, + classification: regEvalCriteria.classification, + range: regEvalCriteria.range, + remarks: regEvalCriteria.remarks, + scoreType: regEvalCriteria.scoreType, + + // 선택된 옵션 정보 + selectedDetailId: reviewerEvaluationDetails.regEvalCriteriaDetailsId, + selectedDetail: regEvalCriteriaDetails.detail, + + // 점수 및 의견 + score: reviewerEvaluationDetails.score, + comment: reviewerEvaluationDetails.comment, + }) + .from(reviewerEvaluations) + .leftJoin(evaluationTargetReviewers, eq(reviewerEvaluations.evaluationTargetReviewerId, evaluationTargetReviewers.id)) + .leftJoin(users, eq(evaluationTargetReviewers.reviewerUserId, users.id)) + .leftJoin(reviewerEvaluationDetails, eq(reviewerEvaluations.id, reviewerEvaluationDetails.reviewerEvaluationId)) + .leftJoin(regEvalCriteriaDetails, eq(reviewerEvaluationDetails.regEvalCriteriaDetailsId, regEvalCriteriaDetails.id)) + .leftJoin(regEvalCriteria, eq(regEvalCriteriaDetails.criteriaId, regEvalCriteria.id)) + .where(eq(reviewerEvaluations.periodicEvaluationId, periodicEvaluationId)) + .orderBy(evaluationTargetReviewers.departmentCode, regEvalCriteria.category, regEvalCriteria.classification) + + // 3. 리뷰어별로 그룹화 + const reviewerDetailsMap = new Map<number, EvaluationDetailData>() + + reviewerDetailsRaw.forEach(row => { + if (!reviewerDetailsMap.has(row.reviewerEvaluationId)) { + reviewerDetailsMap.set(row.reviewerEvaluationId, { + reviewerEvaluationId: row.reviewerEvaluationId, + reviewerName: row.reviewerName || "", + reviewerEmail: row.reviewerEmail || "", + departmentCode: row.departmentCode || "", + departmentName: DEPARTMENT_CODE_LABELS[row.departmentCode as keyof typeof DEPARTMENT_CODE_LABELS] || row.departmentCode || "", + isCompleted: row.isCompleted || false, + completedAt: row.completedAt, + reviewerComment: row.reviewerComment, + evaluationItems: [] + }) + } + + // 평가 항목이 있는 경우에만 추가 + if (row.criteriaId && row.detailId) { + const reviewer = reviewerDetailsMap.get(row.reviewerEvaluationId)! + reviewer.evaluationItems.push({ + criteriaId: row.criteriaId, + category: row.category || "", + category2: row.category2 || "", + item: row.item || "", + classification: row.classification || "", + range: row.range, + remarks: row.remarks, + scoreType: row.scoreType || "fixed", + selectedDetailId: row.selectedDetailId, + selectedDetail: row.selectedDetail, + score: row.score ? Number(row.score) : null, + comment: row.comment + }) + } + }) + + return { + evaluationInfo: evaluationInfo[0], + reviewerDetails: Array.from(reviewerDetailsMap.values()) + } + + } catch (error) { + console.error("Error fetching evaluation details:", error) + throw new Error( + error instanceof Error + ? error.message + : "평가 상세 정보 조회 중 오류가 발생했습니다" + ) + } +}
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx index 10aa7704..e88c5764 100644 --- a/lib/evaluation/table/evaluation-columns.tsx +++ b/lib/evaluation/table/evaluation-columns.tsx @@ -8,10 +8,11 @@ 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, Clock, FileText } from "lucide-react"; +import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle } from "lucide-react"; import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; import { PeriodicEvaluationView } from "@/db/schema"; import { DataTableRowAction } from "@/types/table"; +import { vendortypeMap } from "@/types/evaluation"; @@ -48,6 +49,63 @@ const getStatusLabel = (status: string) => { return statusMap[status] || status; }; +// 부서별 상태 배지 함수 +const getDepartmentStatusBadge = (status: string | null) => { + if (!status) return ( + <div className="flex items-center gap-1"> + {/* <Circle className="w-4 h-4 fill-gray-300 text-gray-300" /> */} + <span className="text-xs text-gray-500">-</span> + </div> + ); + + switch (status) { + case "NOT_ASSIGNED": + return ( + <div className="flex items-center gap-1"> + {/* <Circle className="w-4 h-4 fill-gray-400 text-gray-400" /> */} + <span className="text-xs text-gray-600">미지정</span> + </div> + ); + case "NOT_STARTED": + return ( + <div className="flex items-center gap-1"> + <div className="w-4 h-4 rounded-full bg-red-500 shadow-sm" /> + + {/* <span className="text-xs text-red-600">시작전</span> */} + </div> + ); + case "IN_PROGRESS": + return ( + <div className="flex items-center gap-1"> + <div className="w-4 h-4 rounded-full bg-yellow-500 shadow-sm" /> + {/* <span className="text-xs text-yellow-600">진행중</span> */} + </div> + ); + case "COMPLETED": + return ( + <div className="flex items-center gap-1"> + <div className="w-4 h-4 rounded-full bg-green-500 shadow-sm" /> + {/* <span className="text-xs text-green-600">완료</span> */} + </div> + ); + default: + return ( + <div className="flex items-center gap-1"> + {/* <Circle className="w-4 h-4 fill-gray-300 text-gray-300" /> */} + <span className="text-xs text-gray-500">-</span> + </div> + ); + } +}; +// 부서명 라벨 +const DEPARTMENT_LABELS = { + ORDER_EVAL: "발주", + PROCUREMENT_EVAL: "조달", + QUALITY_EVAL: "품질", + DESIGN_EVAL: "설계", + CS_EVAL: "CS" +} as const; + // 등급별 색상 const getGradeBadgeVariant = (grade: string | null) => { if (!grade) return "outline"; @@ -78,19 +136,15 @@ const getDivisionBadge = (division: string) => { // 자재구분 배지 const getMaterialTypeBadge = (materialType: string) => { - const typeMap = { - EQUIPMENT: "기자재", - BULK: "벌크", - EQUIPMENT_BULK: "기자재/벌크" - }; - return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>; + + return <Badge variant="outline">{vendortypeMap[materialType] || materialType}</Badge>; }; // 내외자 배지 const getDomesticForeignBadge = (domesticForeign: string) => { return ( <Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}> - {domesticForeign === "DOMESTIC" ? "내자" : "외자"} + {domesticForeign === "DOMESTIC" ? "D" : "F"} </Badge> ); }; @@ -237,70 +291,41 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): // 진행 현황 // ═══════════════════════════════════════════════════════════════ { - header: "평가자 진행 현황", + header: "부서별 평가 현황", columns: [ { - accessorKey: "status", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />, - cell: ({ row }) => { - const status = row.getValue<string>("status"); - return ( - <Badge variant={getStatusBadgeVariant(status)}> - {getStatusLabel(status)} - </Badge> - ); - }, - size: 100, + accessorKey: "orderEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="발주" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("orderEvalStatus")), + size: 60, }, - + { - id: "reviewProgress", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리뷰진행" />, - cell: ({ row }) => { - const totalReviewers = row.original.totalReviewers || 0; - const completedReviewers = row.original.completedReviewers || 0; - - return getProgressBadge(completedReviewers, totalReviewers); - }, - size: 120, + accessorKey: "procurementEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="조달" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("procurementEvalStatus")), + size: 70, }, - + { - accessorKey: "reviewCompletedAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="검토완료일" />, - cell: ({ row }) => { - const completedAt = row.getValue<Date>("reviewCompletedAt"); - return completedAt ? ( - <span className="text-sm"> - {new Intl.DateTimeFormat("ko-KR", { - month: "2-digit", - day: "2-digit", - }).format(new Date(completedAt))} - </span> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 100, + accessorKey: "qualityEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품질" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("qualityEvalStatus")), + size: 70, }, - + { - accessorKey: "finalizedAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />, - cell: ({ row }) => { - const finalizedAt = row.getValue<Date>("finalizedAt"); - return finalizedAt ? ( - <span className="text-sm font-medium"> - {new Intl.DateTimeFormat("ko-KR", { - month: "2-digit", - day: "2-digit", - }).format(new Date(finalizedAt))} - </span> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 80, + accessorKey: "designEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("designEvalStatus")), + size: 70, + }, + + { + accessorKey: "csEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="CS" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("csEvalStatus")), + size: 70, }, ] }, @@ -321,7 +346,7 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): </Badge> ); }, - size: 100, + size: 120, }, { @@ -519,7 +544,7 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): <span className="text-muted-foreground">-</span> ); }, - size: 80, + minSize: 100, }, ] @@ -528,38 +553,28 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): // ░░░ Actions ░░░ - // { - // id: "actions", - // enableHiding: false, - // size: 40, - // minSize: 40, - // cell: ({ row }) => { - // return ( - // <div className="flex items-center gap-1"> - // <Button - // variant="ghost" - // size="icon" - // className="size-8" - // onClick={() => setRowAction({ row, type: "view" })} - // aria-label="상세보기" - // title="상세보기" - // > - // <Eye className="size-4" /> - // </Button> - - // <Button - // variant="ghost" - // size="icon" - // className="size-8" - // onClick={() => setRowAction({ row, type: "update" })} - // aria-label="수정" - // title="수정" - // > - // <Pencil className="size-4" /> - // </Button> - // </div> - // ); - // }, - // }, + { + id: "actions", + enableHiding: false, + size: 40, + minSize: 40, + cell: ({ row }) => { + return ( + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setRowAction({ row, type: "view" })} + aria-label="상세보기" + title="상세보기" + > + <Eye className="size-4" /> + </Button> + + </div> + ); + }, + }, ]; }
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-details-dialog.tsx b/lib/evaluation/table/evaluation-details-dialog.tsx new file mode 100644 index 00000000..df4ef016 --- /dev/null +++ b/lib/evaluation/table/evaluation-details-dialog.tsx @@ -0,0 +1,366 @@ +"use client" + +import * as React from "react" +import { + Eye, + Building2, + User, + Calendar, + CheckCircle2, + Clock, + MessageSquare, + Award, + FileText +} from "lucide-react" + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Separator } from "@/components/ui/separator" +import { Skeleton } from "@/components/ui/skeleton" +import { PeriodicEvaluationView } from "@/db/schema" +import { getEvaluationDetails, type EvaluationDetailData } from "../service" + +interface EvaluationDetailsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluation: PeriodicEvaluationView | null +} + +// 카테고리별 색상 매핑 +const getCategoryBadgeVariant = (category: string) => { + switch (category) { + case "quality": + return "default" + case "delivery": + return "secondary" + case "price": + return "outline" + case "cooperation": + return "destructive" + default: + return "outline" + } +} + +// 카테고리명 매핑 +const CATEGORY_LABELS = { + "customer-service": "CS", + administrator: "관리자", + procurement: "구매", + design: "설계", + sourcing: "조달", + quality: "품질" +} as const + +const CATEGORY_LABELS2 = { + bonus: "가점항목", + delivery: "납기", + management: "경영현황", + penalty: "감점항목", + procurement: "구매", + quality: "품질" + } as const + +export function EvaluationDetailsDialog({ + open, + onOpenChange, + evaluation, +}: EvaluationDetailsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [evaluationDetails, setEvaluationDetails] = React.useState<{ + evaluationInfo: any + reviewerDetails: EvaluationDetailData[] + } | null>(null) + + // 평가 상세 정보 로드 + React.useEffect(() => { + if (open && evaluation?.id) { + const loadEvaluationDetails = async () => { + try { + setIsLoading(true) + const details = await getEvaluationDetails(evaluation.id) + setEvaluationDetails(details) + } catch (error) { + console.error("Failed to load evaluation details:", error) + } finally { + setIsLoading(false) + } + } + + loadEvaluationDetails() + } + }, [open, evaluation?.id]) + + // 다이얼로그 닫을 때 데이터 리셋 + React.useEffect(() => { + if (!open) { + setEvaluationDetails(null) + } + }, [open]) + + if (!evaluation) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto"> + <DialogHeader className="space-y-4"> + <DialogTitle className="flex items-center gap-2"> + <Eye className="h-5 w-5 text-blue-600" /> + 평가 상세 + </DialogTitle> + + {/* 평가 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-lg"> + <Building2 className="h-5 w-5" /> + 평가 정보 + </CardTitle> + </CardHeader> + <CardContent> + <div className="flex flex-wrap items-center gap-6 text-sm"> + {/* 협력업체 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">협력업체:</span> + <span className="font-medium">{evaluation.vendorName}</span> + <span className="text-muted-foreground">({evaluation.vendorCode})</span> + </div> + + {/* 평가년도 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">년도:</span> + <span className="font-medium">{evaluation.evaluationYear}년</span> + </div> + + {/* 구분 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">구분:</span> + <Badge variant="outline" className="text-xs"> + {evaluation.division === "PLANT" ? "해양" : "조선"} + </Badge> + </div> + + {/* 진행상태 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">상태:</span> + <Badge variant="secondary" className="text-xs">{evaluation.status}</Badge> + </div> + + {/* 평가점수/등급 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">평가점수/등급:</span> + {evaluation.evaluationScore ? ( + <div className="flex items-center gap-1"> + <span className="font-bold text-blue-600"> + {Number(evaluation.evaluationScore).toFixed(1)}점 + </span> + {evaluation.evaluationGrade && ( + <Badge variant="default" className="text-xs h-5"> + {evaluation.evaluationGrade} + </Badge> + )} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </div> + + {/* 확정점수/등급 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">확정점수/등급:</span> + {evaluation.finalScore ? ( + <div className="flex items-center gap-1"> + <span className="font-bold text-green-600"> + {Number(evaluation.finalScore).toFixed(1)}점 + </span> + {evaluation.finalGrade && ( + <Badge variant="default" className="bg-green-600 text-xs h-5"> + {evaluation.finalGrade} + </Badge> + )} + </div> + ) : ( + <span className="text-muted-foreground">미확정</span> + )} + </div> + </div> + </CardContent> + </Card> + </DialogHeader> + + {isLoading ? ( + <div className="space-y-4"> + <Card> + <CardHeader> + <Skeleton className="h-6 w-48" /> + </CardHeader> + <CardContent> + <Skeleton className="h-64 w-full" /> + </CardContent> + </Card> + </div> + ) : evaluationDetails ? ( + <div className="space-y-6"> + {/* 통합 평가 테이블 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 평가 상세 내역 + </CardTitle> + </CardHeader> + <CardContent> + {evaluationDetails.reviewerDetails.some(r => r.evaluationItems.length > 0) ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">담당자</TableHead> + {/* <TableHead className="w-[80px]">상태</TableHead> */} + <TableHead className="w-[100px]">평가부문</TableHead> + <TableHead className="w-[100px]">항목</TableHead> + <TableHead className="w-[150px]">구분</TableHead> + <TableHead className="w-[200px]">범위</TableHead> + <TableHead className="w-[200px]">선택옵션</TableHead> + <TableHead className="w-[80px]">점수</TableHead> + <TableHead className="min-w-[200px]">의견</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {evaluationDetails.reviewerDetails.map((reviewer) => + reviewer.evaluationItems.map((item, index) => ( + <TableRow key={`${reviewer.reviewerEvaluationId}-${item.criteriaId}-${index}`}> + <TableCell> + <div className="space-y-1"> + <div className="font-medium text-sm">{reviewer.departmentName}</div> + <div className="text-xs text-muted-foreground"> + {reviewer.reviewerName} + </div> + </div> + </TableCell> + {/* <TableCell> + {reviewer.isCompleted ? ( + <Badge variant="default" className="flex items-center gap-1"> + <CheckCircle2 className="h-3 w-3" /> + 완료 + </Badge> + ) : ( + <Badge variant="secondary" className="flex items-center gap-1"> + <Clock className="h-3 w-3" /> + 진행중 + </Badge> + )} + </TableCell> */} + <TableCell> + <Badge variant={getCategoryBadgeVariant(item.category)}> + {CATEGORY_LABELS[item.category as keyof typeof CATEGORY_LABELS] || item.category} + </Badge> + </TableCell> + <TableCell> + {CATEGORY_LABELS2[item.item as keyof typeof CATEGORY_LABELS2] || item.item} + </TableCell> + <TableCell className="font-medium"> + {item.classification} + </TableCell> + <TableCell className="text-sm"> + {item.range || "-"} + </TableCell> + <TableCell className="text-sm"> + {item.scoreType === "variable" ? ( + <Badge variant="outline">직접 입력</Badge> + ) : ( + item.selectedDetail || "-" + )} + </TableCell> + <TableCell> + {item.score !== null ? ( + <Badge variant="default" className="font-mono"> + {item.score.toFixed(1)} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </TableCell> + <TableCell className="text-sm"> + {item.comment || ( + <span className="text-muted-foreground">의견 없음</span> + )} + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + ) : ( + <div className="text-center text-muted-foreground py-8"> + <FileText className="h-8 w-8 mx-auto mb-2" /> + <div>평가 항목이 없습니다</div> + </div> + )} + </CardContent> + </Card> + + {/* 리뷰어별 종합 의견 (있는 경우만) */} + {evaluationDetails.reviewerDetails.some(r => r.reviewerComment) && ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <MessageSquare className="h-5 w-5" /> + 종합 의견 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {evaluationDetails.reviewerDetails + .filter(reviewer => reviewer.reviewerComment) + .map((reviewer) => ( + <div key={reviewer.reviewerEvaluationId} className="space-y-2"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{reviewer.departmentName}</Badge> + <span className="text-sm font-medium">{reviewer.reviewerName}</span> + </div> + <div className="bg-muted p-3 rounded-md text-sm"> + {reviewer.reviewerComment} + </div> + </div> + ))} + </CardContent> + </Card> + )} + + {evaluationDetails.reviewerDetails.length === 0 && ( + <Card> + <CardContent className="py-8"> + <div className="text-center text-muted-foreground"> + <User className="h-8 w-8 mx-auto mb-2" /> + <div>배정된 리뷰어가 없습니다</div> + </div> + </CardContent> + </Card> + )} + </div> + ) : ( + <Card> + <CardContent className="py-8"> + <div className="text-center text-muted-foreground"> + 평가 상세 정보를 불러올 수 없습니다 + </div> + </CardContent> + </Card> + )} + + <div className="flex justify-end pt-4"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx index 9e32debb..cecaeeaa 100644 --- a/lib/evaluation/table/evaluation-table.tsx +++ b/lib/evaluation/table/evaluation-table.tsx @@ -25,6 +25,7 @@ import { getPeriodicEvaluationsColumns } from "./evaluation-columns" import { PeriodicEvaluationView } from "@/db/schema" import { getPeriodicEvaluations, getPeriodicEvaluationsStats } from "../service" import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions" +import { EvaluationDetailsDialog } from "./evaluation-details-dialog" interface PeriodicEvaluationsTableProps { promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]> @@ -456,7 +457,15 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } </DataTableAdvancedToolbar> </DataTable> - {/* TODO: 수정/상세보기 모달 구현 */} + <EvaluationDetailsDialog + open={rowAction?.type === "view"} + onOpenChange={(open) => { + if (!open) { + setRowAction(null) + } + }} + evaluation={rowAction?.row.original || null} + /> </div> </div> diff --git a/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx index 30ff9535..fc07aea1 100644 --- a/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx +++ b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx @@ -14,11 +14,42 @@ import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { FileText, Users, Calendar, Send } from "lucide-react" +import { FileText, Users, Calendar, Send, Mail, Building } from "lucide-react" import { toast } from "sonner" import { PeriodicEvaluationView } from "@/db/schema" -import { checkExistingSubmissions, requestDocumentsFromVendors } from "../service" +import { + checkExistingSubmissions, + requestDocumentsFromVendors, + getReviewersForEvaluations, + createReviewerEvaluationsRequest +} from "../service" +import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation" +// ================================================================ +// 부서 코드 매핑 +// ================================================================ + + +const getDepartmentLabel = (code: string): string => { + return DEPARTMENT_CODE_LABELS[code as keyof typeof DEPARTMENT_CODE_LABELS] || code +} + +// ================================================================ +// 타입 정의 +// ================================================================ +interface ReviewerInfo { + id: number + name: string + email: string + deptName: string | null + departmentCode: string + evaluationTargetId: number + evaluationTargetReviewerId: number +} + +interface EvaluationWithReviewers extends PeriodicEvaluationView { + reviewers: ReviewerInfo[] +} // ================================================================ // 2. 협력업체 자료 요청 다이얼로그 @@ -259,10 +290,8 @@ export function RequestDocumentsDialog({ ) } - - // ================================================================ -// 3. 평가자 평가 요청 다이얼로그 +// 3. 평가자 평가 요청 다이얼로그 (업데이트됨) // ================================================================ interface RequestEvaluationDialogProps { open: boolean @@ -278,10 +307,61 @@ export function RequestEvaluationDialog({ onSuccess, }: RequestEvaluationDialogProps) { const [isLoading, setIsLoading] = React.useState(false) + const [isLoadingReviewers, setIsLoadingReviewers] = React.useState(false) const [message, setMessage] = React.useState("") + const [evaluationsWithReviewers, setEvaluationsWithReviewers] = React.useState<EvaluationWithReviewers[]>([]) // 제출완료 상태인 평가들만 필터링 - const submittedEvaluations = evaluations.filter(e => e.status === "SUBMITTED") + const submittedEvaluations = evaluations.filter(e => + e.status === "SUBMITTED" || e.status === "PENDING_SUBMISSION" + ) + + // 리뷰어 정보 로딩 + React.useEffect(() => { + if (!open || submittedEvaluations.length === 0) { + setEvaluationsWithReviewers([]) + return + } + + const loadReviewers = async () => { + setIsLoadingReviewers(true) + try { + const evaluationTargetIds = submittedEvaluations + .map(e => e.evaluationTargetId) + .filter(id => id !== null) + + if (evaluationTargetIds.length === 0) { + setEvaluationsWithReviewers([]) + return + } + + const reviewersData = await getReviewersForEvaluations(evaluationTargetIds) + + // 평가별로 리뷰어 그룹핑 + const evaluationsWithReviewersData = submittedEvaluations.map(evaluation => ({ + ...evaluation, + reviewers: reviewersData.filter(reviewer => + reviewer.evaluationTargetId === evaluation.evaluationTargetId + ) + })) + + setEvaluationsWithReviewers(evaluationsWithReviewersData) + } catch (error) { + console.error('Error loading reviewers:', error) + toast.error("평가자 정보를 불러오는데 실패했습니다.") + setEvaluationsWithReviewers([]) + } finally { + setIsLoadingReviewers(false) + } + } + + loadReviewers() + }, [open, submittedEvaluations.length]) + + // 총 리뷰어 수 계산 + const totalReviewers = evaluationsWithReviewers.reduce((sum, evaluation) => + sum + evaluation.reviewers.length, 0 + ) const handleSubmit = async () => { if (!message.trim()) { @@ -289,13 +369,34 @@ export function RequestEvaluationDialog({ return } + if (evaluationsWithReviewers.length === 0) { + toast.error("평가 요청할 대상이 없습니다.") + return + } + setIsLoading(true) try { - // TODO: 평가자들에게 평가 요청 API 호출 - toast.success(`${submittedEvaluations.length}개 평가에 대한 평가 요청이 발송되었습니다.`) - onSuccess() - onOpenChange(false) - setMessage("") + // 리뷰어 평가 레코드 생성 데이터 준비 + const reviewerEvaluationsData = evaluationsWithReviewers.flatMap(evaluation => + evaluation.reviewers.map(reviewer => ({ + periodicEvaluationId: evaluation.id, + evaluationTargetId: evaluation.evaluationTargetId, // 추가됨 + evaluationTargetReviewerId: reviewer.evaluationTargetReviewerId, + message: message.trim() + })) + ) + + // 서버 액션 호출 + const result = await createReviewerEvaluationsRequest(reviewerEvaluationsData) + + if (result.success) { + toast.success(result.message) + onSuccess() + onOpenChange(false) + setMessage("") + } else { + toast.error(result.message) + } } catch (error) { console.error('Error requesting evaluation:', error) toast.error("평가 요청 발송 중 오류가 발생했습니다.") @@ -306,7 +407,7 @@ export function RequestEvaluationDialog({ return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-2xl"> + <DialogContent className="sm:max-w-4xl max-h-[80vh] overflow-y-auto"> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Users className="size-4" /> @@ -318,28 +419,84 @@ export function RequestEvaluationDialog({ </DialogHeader> <div className="space-y-4"> - {/* 대상 평가 목록 */} - <Card> - <CardHeader className="pb-3"> - <CardTitle className="text-sm"> - 평가 대상 ({submittedEvaluations.length}개 평가) - </CardTitle> - </CardHeader> - <CardContent className="space-y-2 max-h-32 overflow-y-auto"> - {submittedEvaluations.map((evaluation) => ( - <div - key={evaluation.id} - className="flex items-center justify-between text-sm" - > - <span className="font-medium">{evaluation.vendorName}</span> - <div className="flex gap-2"> - <Badge variant="outline">{evaluation.evaluationPeriod}</Badge> - <Badge variant="secondary">제출완료</Badge> + {isLoadingReviewers ? ( + <div className="flex items-center justify-center py-8"> + <div className="text-sm text-muted-foreground">평가자 정보를 불러오고 있습니다...</div> + </div> + ) : ( + <> + {/* 평가별 리뷰어 목록 */} + {evaluationsWithReviewers.length > 0 ? ( + <div className="space-y-4"> + <div className="text-sm font-medium text-green-600"> + 총 {evaluationsWithReviewers.length}개 평가, {totalReviewers}명의 평가자 </div> + + {evaluationsWithReviewers.map((evaluation) => ( + <Card key={evaluation.id}> + <CardHeader className="pb-3"> + <CardTitle className="text-sm flex items-center justify-between"> + <span>{evaluation.vendorName}</span> + <div className="flex gap-2"> + <Badge variant="outline">{evaluation.vendorCode}</Badge> + <Badge variant={evaluation.submissionDate ? "default" : "secondary"}> + {evaluation.submissionDate ? "자료 제출완료" : "자료 미제출"} + </Badge> + </div> + </CardTitle> + </CardHeader> + <CardContent> + {evaluation.reviewers.length > 0 ? ( + <div className="space-y-2"> + <div className="text-xs text-muted-foreground mb-2"> + 평가자 {evaluation.reviewers.length}명 + </div> + <div className="grid grid-cols-1 md:grid-cols-2 gap-2"> + {evaluation.reviewers.map((reviewer) => ( + <div + key={reviewer.evaluationTargetReviewerId} + className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg" + > + <div className="flex-1"> + <div className="font-medium text-sm">{reviewer.name}</div> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <Mail className="size-3" /> + {reviewer.email} + </div> + {reviewer.deptName && ( + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <Building className="size-3" /> + {reviewer.deptName} + </div> + )} + </div> + <Badge variant="outline" className="text-xs"> + {getDepartmentLabel(reviewer.departmentCode)} + </Badge> + </div> + ))} + </div> + </div> + ) : ( + <div className="text-sm text-muted-foreground text-center py-4"> + 지정된 평가자가 없습니다. + </div> + )} + </CardContent> + </Card> + ))} </div> - ))} - </CardContent> - </Card> + ) : ( + <Card> + <CardContent className="pt-6"> + <div className="text-center text-sm text-muted-foreground"> + 평가 요청할 대상이 없습니다. + </div> + </CardContent> + </Card> + )} + </> + )} {/* 요청 메시지 */} <div className="space-y-2"> @@ -350,6 +507,7 @@ export function RequestEvaluationDialog({ value={message} onChange={(e) => setMessage(e.target.value)} rows={4} + disabled={isLoadingReviewers} /> </div> </div> @@ -358,13 +516,16 @@ export function RequestEvaluationDialog({ <Button variant="outline" onClick={() => onOpenChange(false)} - disabled={isLoading} + disabled={isLoading || isLoadingReviewers} > 취소 </Button> - <Button onClick={handleSubmit} disabled={isLoading}> + <Button + onClick={handleSubmit} + disabled={isLoading || isLoadingReviewers || totalReviewers === 0} + > <Send className="size-4 mr-2" /> - {isLoading ? "발송 중..." : `${submittedEvaluations.length}개 평가 요청`} + {isLoading ? "발송 중..." : `${totalReviewers}명에게 평가 요청`} </Button> </DialogFooter> </DialogContent> diff --git a/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx new file mode 100644 index 00000000..7d6ca45d --- /dev/null +++ b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx @@ -0,0 +1,305 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm, useFieldArray } from "react-hook-form" +import * as z from "zod" +import { toast } from "sonner" +import { CheckCircle2, AlertCircle, Building2 } from "lucide-react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { PeriodicEvaluationView } from "@/db/schema" +import { finalizeEvaluations } from "../service" + +// 등급 옵션 +const GRADE_OPTIONS = [ + { value: "S", label: "S등급 (90점 이상)" }, + { value: "A", label: "A등급 (80-89점)" }, + { value: "B", label: "B등급 (70-79점)" }, + { value: "C", label: "C등급 (60-69점)" }, + { value: "D", label: "D등급 (60점 미만)" }, +] as const + +// 점수에 따른 등급 계산 +const calculateGrade = (score: number): string => { + if (score >= 90) return "S" + if (score >= 80) return "A" + if (score >= 70) return "B" + if (score >= 60) return "C" + return "D" +} + +// 개별 평가 스키마 +const evaluationItemSchema = z.object({ + id: z.number(), + vendorName: z.string(), + vendorCode: z.string(), + evaluationScore: z.number().nullable(), + finalScore: z.number() + .min(0, "점수는 0 이상이어야 합니다"), + // .max(100, "점수는 100 이하여야 합니다"), + finalGrade: z.enum(["S", "A", "B", "C", "D"]), +}) + +// 전체 폼 스키마 +const finalizeEvaluationSchema = z.object({ + evaluations: z.array(evaluationItemSchema).min(1, "확정할 평가가 없습니다"), +}) + +type FinalizeEvaluationFormData = z.infer<typeof finalizeEvaluationSchema> + +interface FinalizeEvaluationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluations: PeriodicEvaluationView[] + onSuccess?: () => void +} + +export function FinalizeEvaluationDialog({ + open, + onOpenChange, + evaluations, + onSuccess, +}: FinalizeEvaluationDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + const form = useForm<FinalizeEvaluationFormData>({ + resolver: zodResolver(finalizeEvaluationSchema), + defaultValues: { + evaluations: [], + }, + }) + + const { fields, update } = useFieldArray({ + control: form.control, + name: "evaluations", + }) + + // evaluations가 변경될 때 폼 초기화 + React.useEffect(() => { + if (evaluations.length > 0) { + const formData = evaluations.map(evaluation => ({ + id: evaluation.id, + vendorName: evaluation.vendorName || "", + vendorCode: evaluation.vendorCode || "", + evaluationScore: evaluation.evaluationScore || null, + finalScore: Number(evaluation.evaluationScore || 0), + finalGrade: calculateGrade(Number(evaluation.evaluationScore || 0)), + })) + + form.reset({ evaluations: formData }) + } + }, [evaluations, form]) + + // 점수 변경 시 등급 자동 계산 + const handleScoreChange = (index: number, score: number) => { + const currentEvaluation = form.getValues(`evaluations.${index}`) + const newGrade = calculateGrade(score) + + update(index, { + ...currentEvaluation, + finalScore: score, + finalGrade: newGrade, + }) + } + + // 폼 제출 + const onSubmit = async (data: FinalizeEvaluationFormData) => { + try { + setIsLoading(true) + + const finalizeData = data.evaluations.map(evaluation => ({ + id: evaluation.id, + finalScore: evaluation.finalScore, + finalGrade: evaluation.finalGrade, + })) + + await finalizeEvaluations(finalizeData) + + toast.success("평가가 확정되었습니다", { + description: `${data.evaluations.length}건의 평가가 최종 확정되었습니다.`, + }) + + onSuccess?.() + onOpenChange(false) + } catch (error) { + console.error("Failed to finalize evaluations:", error) + toast.error("평가 확정 실패", { + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + }) + } finally { + setIsLoading(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <CheckCircle2 className="h-5 w-5 text-purple-600" /> + 평가 확정 + </DialogTitle> + <DialogDescription> + 검토가 완료된 평가의 최종 점수와 등급을 확정합니다. + 확정 후에는 수정이 제한됩니다. + </DialogDescription> + </DialogHeader> + + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 확정할 평가: <strong>{evaluations.length}건</strong> + <br /> + 평가 점수는 리뷰어들의 평가를 바탕으로 계산된 값을 기본으로 하며, 필요시 조정 가능합니다. + </AlertDescription> + </Alert> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <div className="rounded-md border"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[200px]">협력업체</TableHead> + <TableHead className="w-[100px]">평가점수</TableHead> + <TableHead className="w-[120px]">최종점수</TableHead> + <TableHead className="w-[120px]">최종등급</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {fields.map((field, index) => ( + <TableRow key={field.id}> + <TableCell> + <div className="space-y-1"> + <div className="font-medium"> + {form.watch(`evaluations.${index}.vendorName`)} + </div> + <div className="text-sm text-muted-foreground"> + {form.watch(`evaluations.${index}.vendorCode`)} + </div> + </div> + </TableCell> + + <TableCell> + <div className="text-center"> + {form.watch(`evaluations.${index}.evaluationScore`) !== null ? ( + <Badge variant="outline" className="font-mono"> + {Number(form.watch(`evaluations.${index}.evaluationScore`)).toFixed(1)}점 + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </div> + </TableCell> + + <TableCell> + <FormField + control={form.control} + name={`evaluations.${index}.finalScore`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="0" + max="100" + step="0.1" + {...field} + onChange={(e) => { + const value = parseFloat(e.target.value) + field.onChange(value) + if (!isNaN(value)) { + handleScoreChange(index, value) + } + }} + className="text-center font-mono" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + + <TableCell> + <FormField + control={form.control} + name={`evaluations.${index}.finalGrade`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Select value={field.value} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {GRADE_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading} + className="bg-purple-600 hover:bg-purple-700" + > + {isLoading ? "확정 중..." : `평가 확정 (${fields.length}건)`} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx index 2d2bebc1..bb63a1fd 100644 --- a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx +++ b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { type Table } from "@tanstack/react-table" import { @@ -9,7 +7,8 @@ import { Download, RefreshCw, FileText, - MessageSquare + MessageSquare, + CheckCircle2 } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" @@ -28,6 +27,7 @@ import { } from "./periodic-evaluation-action-dialogs" import { PeriodicEvaluationView } from "@/db/schema" import { exportTableToExcel } from "@/lib/export" +import { FinalizeEvaluationDialog } from "./periodic-evaluation-finalize-dialogs" interface PeriodicEvaluationsTableToolbarActionsProps { table: Table<PeriodicEvaluationView> @@ -42,20 +42,66 @@ export function PeriodicEvaluationsTableToolbarActions({ const [createEvaluationDialogOpen, setCreateEvaluationDialogOpen] = React.useState(false) const [requestDocumentsDialogOpen, setRequestDocumentsDialogOpen] = React.useState(false) const [requestEvaluationDialogOpen, setRequestEvaluationDialogOpen] = React.useState(false) + const [finalizeEvaluationDialogOpen, setFinalizeEvaluationDialogOpen] = React.useState(false) const router = useRouter() // 선택된 행들 const selectedRows = table.getFilteredSelectedRowModel().rows const hasSelection = selectedRows.length > 0 - const selectedEvaluations = selectedRows.map(row => row.original) - // 선택된 항목들의 상태 분석 + // ✅ selectedEvaluations를 useMemo로 안정화 (VendorsTable 방식과 동일) + const selectedEvaluations = React.useMemo(() => { + return selectedRows.map(row => row.original) + }, [selectedRows]) + + // ✅ 각 상태별 평가들을 개별적으로 메모이제이션 (VendorsTable 방식과 동일) + const pendingSubmissionEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "PENDING_SUBMISSION"); + }, [table.getFilteredSelectedRowModel().rows]); + + const submittedEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "SUBMITTED" || e.status === "PENDING_SUBMISSION"); + }, [table.getFilteredSelectedRowModel().rows]); + + const inReviewEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "IN_REVIEW"); + }, [table.getFilteredSelectedRowModel().rows]); + + const reviewCompletedEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "REVIEW_COMPLETED"); + }, [table.getFilteredSelectedRowModel().rows]); + + const finalizedEvaluations = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(e => e.status === "FINALIZED"); + }, [table.getFilteredSelectedRowModel().rows]); + + // ✅ 선택된 항목들의 상태 분석 - 안정화된 개별 배열들 사용 const selectedStats = React.useMemo(() => { - const pendingSubmission = selectedEvaluations.filter(e => e.status === "PENDING_SUBMISSION").length - const submitted = selectedEvaluations.filter(e => e.status === "SUBMITTED").length - const inReview = selectedEvaluations.filter(e => e.status === "IN_REVIEW").length - const reviewCompleted = selectedEvaluations.filter(e => e.status === "REVIEW_COMPLETED").length - const finalized = selectedEvaluations.filter(e => e.status === "FINALIZED").length + const pendingSubmission = pendingSubmissionEvaluations.length + const submitted = submittedEvaluations.length + const inReview = inReviewEvaluations.length + const reviewCompleted = reviewCompletedEvaluations.length + const finalized = finalizedEvaluations.length // 협력업체에게 자료 요청 가능: PENDING_SUBMISSION 상태 const canRequestDocuments = pendingSubmission > 0 @@ -63,6 +109,9 @@ export function PeriodicEvaluationsTableToolbarActions({ // 평가자에게 평가 요청 가능: SUBMITTED 상태 (제출됐지만 아직 평가 시작 안됨) const canRequestEvaluation = submitted > 0 + // 평가 확정 가능: REVIEW_COMPLETED 상태 + const canFinalizeEvaluation = reviewCompleted > 0 + return { pendingSubmission, submitted, @@ -71,42 +120,37 @@ export function PeriodicEvaluationsTableToolbarActions({ finalized, canRequestDocuments, canRequestEvaluation, + canFinalizeEvaluation, total: selectedEvaluations.length } - }, [selectedEvaluations]) - - // ---------------------------------------------------------------- - // 신규 정기평가 생성 (자동) - // ---------------------------------------------------------------- - const handleAutoGenerate = async () => { - setIsLoading(true) - try { - // TODO: 평가대상에서 자동 생성 API 호출 - toast.success("정기평가가 자동으로 생성되었습니다.") - router.refresh() - } catch (error) { - console.error('Error auto generating periodic evaluations:', error) - toast.error("자동 생성 중 오류가 발생했습니다.") - } finally { - setIsLoading(false) - } - } - - // ---------------------------------------------------------------- - // 신규 정기평가 생성 (수동) - // ---------------------------------------------------------------- - const handleManualCreate = () => { - setCreateEvaluationDialogOpen(true) - } - + }, [ + pendingSubmissionEvaluations.length, + submittedEvaluations.length, + inReviewEvaluations.length, + reviewCompletedEvaluations.length, + finalizedEvaluations.length, + selectedEvaluations.length + ]) + + // ---------------------------------------------------------------- // 다이얼로그 성공 핸들러 // ---------------------------------------------------------------- - const handleActionSuccess = () => { + const handleActionSuccess = React.useCallback(() => { table.resetRowSelection() onRefresh?.() router.refresh() - } + }, [table, onRefresh, router]) + + // ---------------------------------------------------------------- + // 내보내기 핸들러 + // ---------------------------------------------------------------- + const handleExport = React.useCallback(() => { + exportTableToExcel(table, { + filename: "periodic-evaluations", + excludeColumns: ["select", "actions"], + }) + }, [table]) return ( <> @@ -117,12 +161,7 @@ export function PeriodicEvaluationsTableToolbarActions({ <Button variant="outline" size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "periodic-evaluations", - excludeColumns: ["select", "actions"], - }) - } + onClick={handleExport} className="gap-2" > <Download className="size-4" aria-hidden="true" /> @@ -165,27 +204,25 @@ export function PeriodicEvaluationsTableToolbarActions({ </Button> )} - {/* 알림 발송 버튼 (선택사항) */} - <Button - variant="outline" - size="sm" - className="gap-2" - onClick={() => { - // TODO: 선택된 평가에 대한 알림 발송 - toast.info("알림이 발송되었습니다.") - }} - disabled={isLoading} - > - <MessageSquare className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - 알림 발송 ({selectedStats.total}) - </span> - </Button> + {/* 평가 확정 버튼 */} + {selectedStats.canFinalizeEvaluation && ( + <Button + variant="outline" + size="sm" + className="gap-2 text-purple-600 border-purple-200 hover:bg-purple-50" + onClick={() => setFinalizeEvaluationDialogOpen(true)} + disabled={isLoading} + > + <CheckCircle2 className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 평가 확정 ({selectedStats.reviewCompleted}) + </span> + </Button> + )} </div> )} </div> - {/* 협력업체 자료 요청 다이얼로그 */} <RequestDocumentsDialog open={requestDocumentsDialogOpen} @@ -202,17 +239,13 @@ export function PeriodicEvaluationsTableToolbarActions({ onSuccess={handleActionSuccess} /> - {/* 선택 정보 표시 (디버깅용 - 필요시 주석 해제) */} - {/* {hasSelection && ( - <div className="text-xs text-muted-foreground mt-2"> - 선택된 {selectedRows.length}개 항목: - 제출대기 {selectedStats.pendingSubmission}개, - 제출완료 {selectedStats.submitted}개, - 검토중 {selectedStats.inReview}개, - 검토완료 {selectedStats.reviewCompleted}개, - 최종확정 {selectedStats.finalized}개 - </div> - )} */} + {/* 평가 확정 다이얼로그 */} + <FinalizeEvaluationDialog + open={finalizeEvaluationDialogOpen} + onOpenChange={setFinalizeEvaluationDialogOpen} + evaluations={reviewCompletedEvaluations} + onSuccess={handleActionSuccess} + /> </> ) -}
\ No newline at end of file +} diff --git a/lib/file-download.ts b/lib/file-download.ts new file mode 100644 index 00000000..1e8536b5 --- /dev/null +++ b/lib/file-download.ts @@ -0,0 +1,260 @@ +// lib/file-download.ts +// 공용 파일 다운로드 유틸리티 + +import { toast } from "sonner"; + +/** + * 파일 타입 정보 + */ +export interface FileInfo { + type: 'pdf' | 'document' | 'spreadsheet' | 'image' | 'archive' | 'other'; + canPreview: boolean; + icon: string; + mimeType?: string; +} + +/** + * 파일 정보 가져오기 + */ +export const getFileInfo = (fileName: string): FileInfo => { + const ext = fileName.toLowerCase().split('.').pop(); + + const fileTypes: Record<string, FileInfo> = { + pdf: { type: 'pdf', canPreview: true, icon: '📄', mimeType: 'application/pdf' }, + doc: { type: 'document', canPreview: false, icon: '📝', mimeType: 'application/msword' }, + docx: { type: 'document', canPreview: false, icon: '📝', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + xls: { type: 'spreadsheet', canPreview: false, icon: '📊', mimeType: 'application/vnd.ms-excel' }, + xlsx: { type: 'spreadsheet', canPreview: false, icon: '📊', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + ppt: { type: 'document', canPreview: false, icon: '📑', mimeType: 'application/vnd.ms-powerpoint' }, + pptx: { type: 'document', canPreview: false, icon: '📑', mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }, + jpg: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/jpeg' }, + jpeg: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/jpeg' }, + png: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/png' }, + gif: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/gif' }, + webp: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/webp' }, + svg: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/svg+xml' }, + zip: { type: 'archive', canPreview: false, icon: '📦', mimeType: 'application/zip' }, + rar: { type: 'archive', canPreview: false, icon: '📦', mimeType: 'application/x-rar-compressed' }, + '7z': { type: 'archive', canPreview: false, icon: '📦', mimeType: 'application/x-7z-compressed' }, + txt: { type: 'document', canPreview: true, icon: '📝', mimeType: 'text/plain' }, + csv: { type: 'spreadsheet', canPreview: true, icon: '📊', mimeType: 'text/csv' }, + }; + + return fileTypes[ext || ''] || { type: 'other', canPreview: false, icon: '📎', mimeType: 'application/octet-stream' }; +}; + +/** + * 파일 크기를 읽기 쉬운 형태로 변환 + */ +export const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +/** + * 파일 다운로드 옵션 + */ +export interface FileDownloadOptions { + /** 다운로드 액션 타입 */ + action?: 'download' | 'preview'; + /** 에러 시 토스트 표시 여부 */ + showToast?: boolean; + /** 성공 시 토스트 표시 여부 */ + showSuccessToast?: boolean; + /** 커스텀 에러 핸들러 */ + onError?: (error: string) => void; + /** 커스텀 성공 핸들러 */ + onSuccess?: (fileName: string, fileSize?: number) => void; + /** 진행률 콜백 (큰 파일용) */ + onProgress?: (progress: number) => void; +} + +/** + * 파일 다운로드 결과 + */ +export interface FileDownloadResult { + success: boolean; + error?: string; + fileSize?: number; + fileInfo?: FileInfo; +} + +/** + * 파일 메타데이터 확인 + */ +export const checkFileMetadata = async (url: string): Promise<{ + exists: boolean; + size?: number; + contentType?: string; + lastModified?: Date; + error?: string; +}> => { + try { + const response = await fetch(url, { + method: 'HEAD', + headers: { 'Cache-Control': 'no-cache' } + }); + + if (!response.ok) { + let error = "파일 접근 실패"; + + switch (response.status) { + case 404: + error = "파일을 찾을 수 없습니다"; + break; + case 403: + error = "파일 접근 권한이 없습니다"; + break; + case 500: + error = "서버 오류가 발생했습니다"; + break; + default: + error = `파일 접근 실패 (${response.status})`; + } + + return { exists: false, error }; + } + + const contentLength = response.headers.get('Content-Length'); + const contentType = response.headers.get('Content-Type'); + const lastModified = response.headers.get('Last-Modified'); + + return { + exists: true, + size: contentLength ? parseInt(contentLength, 10) : undefined, + contentType: contentType || undefined, + lastModified: lastModified ? new Date(lastModified) : undefined, + }; + } catch (error) { + return { + exists: false, + error: error instanceof Error ? error.message : "네트워크 오류가 발생했습니다" + }; + } +}; + +/** + * 메인 파일 다운로드 함수 + */ +export const downloadFile = async ( + filePath: string, + fileName: string, + options: FileDownloadOptions = {} +): Promise<FileDownloadResult> => { + const { action = 'download', showToast = true, onError, onSuccess } = options; + + try { + // ✅ URL에 다운로드 강제 파라미터 추가 + const baseUrl = filePath.startsWith('http') + ? filePath + : `${window.location.origin}${filePath}`; + + const url = new URL(baseUrl); + if (action === 'download') { + url.searchParams.set('download', 'true'); // 🔑 핵심! + } + + const fullUrl = url.toString(); + + // 파일 정보 확인 + const metadata = await checkFileMetadata(fullUrl); + if (!metadata.exists) { + const error = metadata.error || "파일을 찾을 수 없습니다"; + if (showToast) toast.error(error); + if (onError) onError(error); + return { success: false, error }; + } + + const fileInfo = getFileInfo(fileName); + + // 미리보기 처리 (download=true 없이) + if (action === 'preview' && fileInfo.canPreview) { + const previewUrl = filePath.startsWith('http') + ? filePath + : `${window.location.origin}${filePath}`; + + window.open(previewUrl, '_blank', 'noopener,noreferrer'); + if (showToast) toast.success(`${fileInfo.icon} 파일을 새 탭에서 열었습니다`); + if (onSuccess) onSuccess(fileName, metadata.size); + return { success: true, fileSize: metadata.size, fileInfo }; + } + + // ✅ 안전한 다운로드 방식 (fetch + Blob) + console.log(`📥 안전한 다운로드: ${fullUrl}`); + + const response = await fetch(fullUrl); + if (!response.ok) { + throw new Error(`다운로드 실패: ${response.status}`); + } + + // Blob으로 변환 + const blob = await response.blob(); + + // ✅ 브라우저 호환성을 고려한 다운로드 + const downloadUrl = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = fileName; + link.style.display = 'none'; // 화면에 표시되지 않도록 + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // 메모리 정리 (중요!) + setTimeout(() => URL.revokeObjectURL(downloadUrl), 100); + + // 성공 처리 + if (showToast) { + const sizeText = metadata.size ? ` (${formatFileSize(metadata.size)})` : ''; + toast.success(`${fileInfo.icon} 파일 다운로드 완료: ${fileName}${sizeText}`); + } + if (onSuccess) onSuccess(fileName, metadata.size); + + return { success: true, fileSize: metadata.size, fileInfo }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다"; + console.error("❌ 다운로드 오류:", error); + if (showToast) toast.error(errorMessage); + if (onError) onError(errorMessage); + return { success: false, error: errorMessage }; + } +}; + +/** + * 간편 다운로드 함수 + */ +export const quickDownload = (filePath: string, fileName: string) => { + return downloadFile(filePath, fileName, { action: 'download' }); +}; + +/** + * 간편 미리보기 함수 + */ +export const quickPreview = (filePath: string, fileName: string) => { + const fileInfo = getFileInfo(fileName); + + if (!fileInfo.canPreview) { + toast.warning("이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다."); + return downloadFile(filePath, fileName, { action: 'download' }); + } + + return downloadFile(filePath, fileName, { action: 'preview' }); +}; + +/** + * 파일 다운로드 또는 미리보기 (자동 판단) + */ +export const smartFileAction = (filePath: string, fileName: string) => { + const fileInfo = getFileInfo(fileName); + const action = fileInfo.canPreview ? 'preview' : 'download'; + + return downloadFile(filePath, fileName, { action }); +};
\ No newline at end of file diff --git a/lib/file-stroage.ts b/lib/file-stroage.ts new file mode 100644 index 00000000..ae84f506 --- /dev/null +++ b/lib/file-stroage.ts @@ -0,0 +1,283 @@ +// lib/file-storage.ts - File과 ArrayBuffer를 위한 분리된 함수들 + +import { promises as fs } from "fs"; +import path from "path"; +import crypto from "crypto"; + +interface FileStorageConfig { + baseDir: string; + publicUrl: string; + isProduction: boolean; +} + +// 파일명 해시 생성 유틸리티 +export function generateHashedFileName(originalName: string): string { + const fileExtension = path.extname(originalName); + const fileNameWithoutExt = path.basename(originalName, fileExtension); + + const timestamp = Date.now(); + const randomHash = crypto.createHash('md5') + .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`) + .digest('hex') + .substring(0, 8); + + return `${timestamp}-${randomHash}${fileExtension}`; +} + +// ✅ File 저장용 인터페이스 +interface SaveFileOptions { + file: File; + directory: string; + originalName?: string; +} + +// ✅ Buffer/ArrayBuffer 저장용 인터페이스 +interface SaveBufferOptions { + buffer: Buffer | ArrayBuffer; + fileName: string; + directory: string; + originalName?: string; +} + +interface SaveFileResult { + success: boolean; + filePath?: string; + publicPath?: string; + fileName?: string; + error?: string; +} + +const nasPath = process.env.NAS_PATH || "/evcp_nas" + +// 환경별 설정 +function getStorageConfig(): FileStorageConfig { + const isProduction = process.env.NODE_ENV === "production"; + + if (isProduction) { + return { + baseDir: nasPath, + publicUrl: "/api/files", + isProduction: true, + }; + } else { + return { + baseDir: path.join(process.cwd(), "public"), + publicUrl: "", + isProduction: false, + }; + } +} + +// ✅ 1. File 객체 저장 함수 (기존 방식) +export async function saveFile({ + file, + directory, + originalName +}: SaveFileOptions): Promise<SaveFileResult> { + try { + const config = getStorageConfig(); + const finalFileName = originalName || file.name; + const hashedFileName = generateHashedFileName(finalFileName); + + // 저장 경로 설정 + const saveDir = path.join(config.baseDir, directory); + const filePath = path.join(saveDir, hashedFileName); + + // 웹 접근 경로 + let publicPath: string; + if (config.isProduction) { + publicPath = `${config.publicUrl}/${directory}/${hashedFileName}`; + } else { + publicPath = `/${directory}/${hashedFileName}`; + } + + console.log(`📄 File 객체 저장: ${finalFileName}`); + console.log(`📁 저장 위치: ${filePath}`); + console.log(`🌐 웹 접근 경로: ${publicPath}`); + + // 디렉토리 생성 + await fs.mkdir(saveDir, { recursive: true }); + + // File 객체에서 데이터 추출 + const arrayBuffer = await file.arrayBuffer(); + const dataBuffer = Buffer.from(arrayBuffer); + + // 파일 저장 + await fs.writeFile(filePath, dataBuffer); + + console.log(`✅ File 저장 완료: ${hashedFileName} (${dataBuffer.length} bytes)`); + + return { + success: true, + filePath, + publicPath, + fileName: hashedFileName, + }; + } catch (error) { + console.error("File 저장 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "File 저장 중 오류가 발생했습니다.", + }; + } +} + +// ✅ 2. Buffer/ArrayBuffer 저장 함수 (DRM 복호화용) +export async function saveBuffer({ + buffer, + fileName, + directory, + originalName +}: SaveBufferOptions): Promise<SaveFileResult> { + try { + const config = getStorageConfig(); + const finalFileName = originalName || fileName; + const hashedFileName = generateHashedFileName(finalFileName); + + // 저장 경로 설정 + const saveDir = path.join(config.baseDir, directory); + const filePath = path.join(saveDir, hashedFileName); + + // 웹 접근 경로 + let publicPath: string; + if (config.isProduction) { + publicPath = `${config.publicUrl}/${directory}/${hashedFileName}`; + } else { + publicPath = `/${directory}/${hashedFileName}`; + } + + console.log(`🔓 Buffer/ArrayBuffer 저장: ${finalFileName}`); + console.log(`📁 저장 위치: ${filePath}`); + console.log(`🌐 웹 접근 경로: ${publicPath}`); + + // 디렉토리 생성 + await fs.mkdir(saveDir, { recursive: true }); + + // Buffer 준비 + const dataBuffer = buffer instanceof ArrayBuffer ? Buffer.from(buffer) : buffer; + + // 파일 저장 + await fs.writeFile(filePath, dataBuffer); + + console.log(`✅ Buffer 저장 완료: ${hashedFileName} (${dataBuffer.length} bytes)`); + + return { + success: true, + filePath, + publicPath, + fileName: hashedFileName, + }; + } catch (error) { + console.error("Buffer 저장 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Buffer 저장 중 오류가 발생했습니다.", + }; + } +} + +// ✅ 업데이트 함수들 +export async function updateFile( + options: SaveFileOptions, + oldFilePath?: string +): Promise<SaveFileResult> { + try { + const result = await saveFile(options); + + if (result.success && oldFilePath) { + await deleteFile(oldFilePath); + } + + return result; + } catch (error) { + console.error("File 업데이트 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "File 업데이트 중 오류가 발생했습니다.", + }; + } +} + +export async function updateBuffer( + options: SaveBufferOptions, + oldFilePath?: string +): Promise<SaveFileResult> { + try { + const result = await saveBuffer(options); + + if (result.success && oldFilePath) { + await deleteFile(oldFilePath); + } + + return result; + } catch (error) { + console.error("Buffer 업데이트 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Buffer 업데이트 중 오류가 발생했습니다.", + }; + } +} + +// 파일 삭제 함수 +export async function deleteFile(publicPath: string): Promise<boolean> { + try { + const config = getStorageConfig(); + + let absolutePath: string; + if (config.isProduction) { + const relativePath = publicPath.replace('/api/files/', ''); + absolutePath = path.join(nasPath, relativePath); + } else { + absolutePath = path.join(process.cwd(), 'public', publicPath); + } + + console.log(`🗑️ 파일 삭제: ${absolutePath}`); + + await fs.access(absolutePath); + await fs.unlink(absolutePath); + return true; + } catch (error) { + console.log("파일 삭제 실패 또는 파일이 없음:", error); + return false; + } +} + +// ✅ 편의 함수들 (하위 호환성) +export const save = { + file: saveFile, + buffer: saveBuffer, +}; + +// ✅ DRM 워크플로우 통합 함수 +export async function saveDRMFile( + originalFile: File, + decryptFunction: (file: File) => Promise<ArrayBuffer>, + directory: string +): Promise<SaveFileResult> { + try { + console.log(`🔐 DRM 파일 처리 시작: ${originalFile.name}`); + + // 1. DRM 복호화 + const decryptedData = await decryptFunction(originalFile); + + // 2. 복호화된 데이터 저장 + const result = await saveBuffer({ + buffer: decryptedData, + fileName: originalFile.name, + directory + }); + + if (result.success) { + console.log(`✅ DRM 파일 처리 완료: ${originalFile.name}`); + } + + return result; + } catch (error) { + console.error(`❌ DRM 파일 처리 실패: ${originalFile.name}`, error); + return { + success: false, + error: error instanceof Error ? error.message : "DRM 파일 처리 중 오류가 발생했습니다.", + }; + } +}
\ No newline at end of file diff --git a/lib/forms/services.ts b/lib/forms/services.ts index 0558e83f..7c1219d2 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -28,6 +28,7 @@ import { DataTableColumnJSON } from "@/components/form-data/form-data-table-colu import { contractItems, contracts, items, projects } from "@/db/schema"; import { getSEDPToken } from "../sedp/sedp-token"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; +import { deleteFile, saveFile } from "@/lib/file-stroage"; export type FormInfo = InferSelectModel<typeof forms>; @@ -882,26 +883,11 @@ export async function uploadReportTemp( ); } if (file && file.size > 0) { - const originalName = customFileName; - const ext = path.extname(originalName); - const uniqueName = uuidv4() + ext; - const baseDir = path.join( - process.cwd(), - "public", - "vendorFormData", - packageId.toString(), - formId.toString() - ); - - const savePath = path.join(baseDir, uniqueName); - - // const arrayBuffer = await file.arrayBuffer(); - const arrayBuffer = await decryptWithServerAction(file); - const buffer = Buffer.from(arrayBuffer); - - await fs.mkdir(baseDir, { recursive: true }); - - await fs.writeFile(savePath, buffer); + + const saveResult = await saveFile({file, directory:"vendorFormData",originalName:customFileName}); + if (!saveResult.success) { + return { success: false, error: saveResult.error }; + } return db.transaction(async (tx) => { // 파일 정보를 테이블에 저장 @@ -910,8 +896,8 @@ export async function uploadReportTemp( .values({ contractItemId: packageId, formId: formId, - fileName: originalName, - filePath: `/vendorFormData/${packageId.toString()}/${formId.toString()}/${uniqueName}`, + fileName: customFileName, + filePath:saveResult.publicPath!, }) .returning(); }); @@ -927,24 +913,6 @@ export const getOrigin = async (): Promise<string> => { return origin; }; -export const getReportTempFileData = async (): Promise<{ - fileName: string; - fileType: string; - base64: string; -}> => { - const fileName = "sample_template_file.docx"; - - const tempFile = await fs.readFile( - `public/vendorFormReportSample/${fileName}` - ); - - return { - fileName, - fileType: - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - base64: tempFile.toString("base64"), - }; -}; type deleteReportTempFile = (id: number) => Promise<{ result: boolean; @@ -969,7 +937,7 @@ export const deleteReportTempFile: deleteReportTempFile = async (id) => { const { filePath } = targetTempFile; - await fs.unlink("public" + filePath); + await deleteFile(filePath); return { result: true }; }); diff --git a/lib/information/table/update-information-dialog.tsx b/lib/information/table/update-information-dialog.tsx index ed749fe7..b4c11e17 100644 --- a/lib/information/table/update-information-dialog.tsx +++ b/lib/information/table/update-information-dialog.tsx @@ -157,11 +157,11 @@ export function UpdateInformationDialog({ <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- <div className="bg-blue-50 p-4 rounded-lg">
+ <div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
- <span className="font-medium text-blue-900">페이지 정보</span>
+ <span className="font-medium">페이지 정보</span>
</div>
- <div className="text-sm text-blue-700">
+ <div className="text-sm ">
<div><strong>페이지명:</strong> {information?.pageName}</div>
<div><strong>경로:</strong> {information?.pagePath}</div>
</div>
diff --git a/lib/login-session/service.ts b/lib/login-session/service.ts new file mode 100644 index 00000000..4fa35376 --- /dev/null +++ b/lib/login-session/service.ts @@ -0,0 +1,118 @@ +import db from "@/db/db" +import { loginSessions, users } from "@/db/schema" +import { and, or, ilike, eq, desc, asc, count, sql } from "drizzle-orm" +import { filterColumns } from "@/lib/filter-columns"; +import type { GetLoginSessionsSchema, ExtendedLoginSession } from "./validation" + +export async function getLoginSessions(input: GetLoginSessionsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + const advancedTable = true; + + // 고급 필터 처리 + const advancedWhere = filterColumns({ + table: loginSessions, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(users.email, s), + ilike(users.name, s), + ilike(loginSessions.authMethod, s), + ilike(loginSessions.ipAddress, s) + ); + } + + // 조건 결합 + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + let finalWhere; + if (conditions.length > 0) { + finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; + } + + // 정렬 처리 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => { + // 사용자 관련 필드 정렬 + if (item.id === 'userEmail') { + return item.desc ? desc(users.email) : asc(users.email); + } else if (item.id === 'userName') { + return item.desc ? desc(users.name) : asc(users.name); + } else { + // 세션 필드 정렬 + return item.desc + ? desc(loginSessions[item.id as keyof typeof loginSessions.$inferSelect]) + : asc(loginSessions[item.id as keyof typeof loginSessions.$inferSelect]); + } + }) + : [desc(loginSessions.loginAt)]; + + // 데이터 조회 + const data = await db + .select({ + id: loginSessions.id, + userId: loginSessions.userId, + loginAt: loginSessions.loginAt, + logoutAt: loginSessions.logoutAt, + lastActivityAt: loginSessions.lastActivityAt, + ipAddress: loginSessions.ipAddress, + userAgent: loginSessions.userAgent, + authMethod: loginSessions.authMethod, + isActive: loginSessions.isActive, + sessionExpiredAt: loginSessions.sessionExpiredAt, + createdAt: loginSessions.createdAt, + userEmail: users.email, + userName: users.name, + // 세션 지속 시간 계산 (분 단위) + sessionDuration: sql<number>` + CASE + WHEN ${loginSessions.logoutAt} IS NOT NULL THEN + EXTRACT(EPOCH FROM (${loginSessions.logoutAt} - ${loginSessions.loginAt})) / 60 + WHEN ${loginSessions.isActive} = true THEN + EXTRACT(EPOCH FROM (${loginSessions.lastActivityAt} - ${loginSessions.loginAt})) / 60 + ELSE NULL + END + `, + // 현재 활성 여부 + isCurrentlyActive: sql<boolean>` + CASE + WHEN ${loginSessions.isActive} = true + AND (${loginSessions.sessionExpiredAt} IS NULL + OR ${loginSessions.sessionExpiredAt} > NOW()) + THEN true + ELSE false + END + ` + }) + .from(loginSessions) + .innerJoin(users, eq(loginSessions.userId, users.id)) + .where(finalWhere) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + // 총 개수 조회 + const totalResult = await db + .select({ count: count() }) + .from(loginSessions) + .innerJoin(users, eq(loginSessions.userId, users.id)) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + const pageCount = Math.ceil(total / input.perPage); + + return { data: data as ExtendedLoginSession[], pageCount }; + } catch (err) { + console.error("Failed to fetch login sessions:", err); + return { data: [], pageCount: 0 }; + } +}
\ No newline at end of file diff --git a/lib/login-session/table/login-sessions-table-columns.tsx b/lib/login-session/table/login-sessions-table-columns.tsx new file mode 100644 index 00000000..e3d8bc2f --- /dev/null +++ b/lib/login-session/table/login-sessions-table-columns.tsx @@ -0,0 +1,243 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +import { formatDate} from "@/lib/utils" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { ExtendedLoginSession } from "../validation" +import { Eye, Shield, LogOut, Ellipsis } from "lucide-react" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ExtendedLoginSession> | null>> +} + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ExtendedLoginSession>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + id: "사용자 정보", + header: "사용자 정보", + columns: [ + { + accessorKey: "userEmail", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="이메일" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-medium">{row.getValue("userEmail")}</span> + <span className="text-xs text-muted-foreground"> + {row.original.userName} + </span> + </div> + ), + }, + ], + }, + { + id: "세션 정보", + header: "세션 정보", + columns: [ + { + accessorKey: "loginAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="로그인 시간" /> + ), + cell: ({ row }) => { + const date = row.getValue("loginAt") as Date + return ( + <Tooltip> + <TooltipTrigger> + <div className="text-sm"> + {formatDate(date, "KR")} + </div> + </TooltipTrigger> + <TooltipContent> + {formatDate(date)} + </TooltipContent> + </Tooltip> + ) + }, + }, + { + accessorKey: "logoutAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="로그아웃 시간" /> + ), + cell: ({ row }) => { + const date = row.getValue("logoutAt") as Date | null + if (!date) { + return <span className="text-muted-foreground">-</span> + } + return ( + <Tooltip> + <TooltipTrigger> + <div className="text-sm"> + {formatDate(date, "KR")} + </div> + </TooltipTrigger> + <TooltipContent> + {formatDate(date)} + </TooltipContent> + </Tooltip> + ) + }, + }, + { + accessorKey: "sessionDuration", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="세션 지속시간" /> + ), + cell: ({ row }) => { + const duration = row.getValue("sessionDuration") as number | null + if (!duration) { + return <span className="text-muted-foreground">-</span> + } + + const hours = Math.floor(duration / 60) + const minutes = Math.floor(duration % 60) + + if (hours > 0) { + return `${hours}시간 ${minutes}분` + } + return `${minutes}분` + }, + }, + ], + }, + { + id: "인증 및 보안", + header: "인증 및 보안", + columns: [ + { + accessorKey: "authMethod", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="인증 방식" /> + ), + cell: ({ row }) => { + const authMethod = row.getValue("authMethod") as string + const variants = { + otp: "default", + email: "secondary", + sgips: "outline", + saml: "destructive", + } as const + + return ( + <Badge variant={variants[authMethod as keyof typeof variants] || "default"}> + {authMethod.toUpperCase()} + </Badge> + ) + }, + }, + { + accessorKey: "ipAddress", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="IP 주소" /> + ), + cell: ({ row }) => ( + <code className="text-xs bg-muted px-2 py-1 rounded"> + {row.getValue("ipAddress")} + </code> + ), + }, + { + accessorKey: "isCurrentlyActive", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const isActive = row.getValue("isCurrentlyActive") as boolean + return ( + <Badge variant={isActive ? "default" : "secondary"}> + {isActive ? "활성" : "비활성"} + </Badge> + ) + }, + }, + ], + }, + { + id: "actions", + cell: function Cell({ row }) { + const session = row.original + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ type: "view", row })} + > + <Eye className="mr-2 size-4" aria-hidden="true" /> + 상세 보기 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ type: "viewSecurity", row })} + > + <Shield className="mr-2 size-4" aria-hidden="true" /> + 보안 정보 + </DropdownMenuItem> + {session.isCurrentlyActive && ( + <DropdownMenuItem + onSelect={() => setRowAction({ type: "forceLogout", row })} + className="text-red-600" + > + <LogOut className="mr-2 size-4" aria-hidden="true" /> + 강제 로그아웃 + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + )} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + enableSorting: false, + enableHiding: false, + }, + ] +}
\ No newline at end of file diff --git a/lib/login-session/table/login-sessions-table-toolbar-actions.tsx b/lib/login-session/table/login-sessions-table-toolbar-actions.tsx new file mode 100644 index 00000000..36665bc0 --- /dev/null +++ b/lib/login-session/table/login-sessions-table-toolbar-actions.tsx @@ -0,0 +1,78 @@ +"use client" + +import { type Table } from "@tanstack/react-table" +import { Download, RotateCcw, Shield } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +import { ExtendedLoginSession } from "../validation" +import { exportTableToExcel } from "@/lib/export_all" + +interface LoginSessionsTableToolbarActionsProps { + table: Table<ExtendedLoginSession> +} + +export function LoginSessionsTableToolbarActions({ + table, +}: LoginSessionsTableToolbarActionsProps) { + return ( + <div className="flex items-center gap-2"> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "login-sessions", + excludeColumns: ["select", "actions"], + }) + } + > + <Download className="mr-2 size-4" aria-hidden="true" /> + Export + </Button> + </TooltipTrigger> + <TooltipContent> + <p>로그인 세션 데이터를 엑셀로 내보내기</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => window.location.reload()} + > + <RotateCcw className="mr-2 size-4" aria-hidden="true" /> + 새로고침 + </Button> + </TooltipTrigger> + <TooltipContent> + <p>데이터 새로고침</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => { + // 보안 리포트 생성 기능 + console.log("Generate security report") + }} + > + <Shield className="mr-2 size-4" aria-hidden="true" /> + 보안 리포트 + </Button> + </TooltipTrigger> + <TooltipContent> + <p>보안 분석 리포트 생성</p> + </TooltipContent> + </Tooltip> + </div> + ) +}
\ No newline at end of file diff --git a/lib/login-session/table/login-sessions-table.tsx b/lib/login-session/table/login-sessions-table.tsx new file mode 100644 index 00000000..43568f41 --- /dev/null +++ b/lib/login-session/table/login-sessions-table.tsx @@ -0,0 +1,137 @@ +"use client" + +import * as React from "react" +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 { useFeatureFlags } from "@/components/data-table/feature-flags-provider" + +import { getLoginSessions } from "../service" +import { LoginSessionsTableToolbarActions } from "./login-sessions-table-toolbar-actions" +import { getColumns } from "./login-sessions-table-columns" +import { ExtendedLoginSession } from "../validation" + +interface LoginSessionsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getLoginSessions>>, + ] + > +} + +export function LoginSessionsTable({ promises }: LoginSessionsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ExtendedLoginSession> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 기본 필터 필드 + const filterFields: DataTableFilterField<ExtendedLoginSession>[] = [ + { + id: "authMethod", + label: "인증 방식", + options: [ + { label: "OTP", value: "otp" }, + { label: "Email", value: "email" }, + { label: "SGIPS", value: "sgips" }, + { label: "SAML", value: "saml" }, + ], + }, + { + id: "isActive", + label: "세션 상태", + options: [ + { label: "활성", value: "true" }, + { label: "비활성", value: "false" }, + ], + }, + ] + + // 고급 필터 필드 + const advancedFilterFields: DataTableAdvancedFilterField<ExtendedLoginSession>[] = [ + { + id: "userEmail", + label: "사용자 이메일", + type: "text", + }, + { + id: "userName", + label: "사용자 이름", + type: "text", + }, + { + id: "authMethod", + label: "인증 방식", + type: "multi-select", + options: [ + { label: "OTP", value: "otp" }, + { label: "Email", value: "email" }, + { label: "SGIPS", value: "sgips" }, + { label: "SAML", value: "saml" }, + ], + }, + { + id: "ipAddress", + label: "IP 주소", + type: "text", + }, + { + id: "isActive", + label: "활성 상태", + type: "boolean", + }, + { + id: "loginAt", + label: "로그인 시간", + type: "date", + }, + { + id: "logoutAt", + label: "로그아웃 시간", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "loginAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <LoginSessionsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ No newline at end of file diff --git a/lib/login-session/validation.ts b/lib/login-session/validation.ts new file mode 100644 index 00000000..9c84fb4c --- /dev/null +++ b/lib/login-session/validation.ts @@ -0,0 +1,45 @@ +// app/admin/sessions/login-history/validation.ts +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + } from "nuqs/server" + import * as z from "zod" + + import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + import { loginSessions, users } from "@/db/schema" + + // 조인된 데이터 타입 정의 + export type ExtendedLoginSession = typeof loginSessions.$inferSelect & { + userEmail: string; + userName: string; + sessionDuration?: number; // 계산된 필드 + isCurrentlyActive: boolean; // 계산된 필드 + }; + + // 검색 파라미터 캐시 정의 + export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser<ExtendedLoginSession>().withDefault([ + { id: "loginAt", desc: true }, + ]), + + // 기본 필터 + userEmail: parseAsString.withDefault(""), + authMethod: parseAsString.withDefault(""), + isActive: parseAsString.withDefault(""), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + }); + + // 타입 내보내기 + export type GetLoginSessionsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
\ No newline at end of file diff --git a/lib/mail/layouts/base.hbs b/lib/mail/layouts/base.hbs deleted file mode 100644 index 2e18f035..00000000 --- a/lib/mail/layouts/base.hbs +++ /dev/null @@ -1,22 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <title>{{subject}}</title> - </head> - <body style="margin:0; padding:20px; background-color:#f5f5f5; font-family:Arial, sans-serif; color:#111827;"> - <table width="100%" cellpadding="0" cellspacing="0" border="0" align="center"> - <tr> - <td align="center"> - <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:#ffffff; border:1px solid #e5e7eb; border-radius:6px; padding:24px;"> - <tr> - <td> - {{{body}}} - </td> - </tr> - </table> - </td> - </tr> - </table> - </body> -</html> diff --git a/lib/mail/mailer.ts b/lib/mail/mailer.ts index 329e2e52..61201e99 100644 --- a/lib/mail/mailer.ts +++ b/lib/mail/mailer.ts @@ -15,18 +15,137 @@ const transporter = nodemailer.createTransport({ }, }); -// 템플릿 로더 함수 - 단순화된 버전 +// 헬퍼 함수들 등록 +function registerHandlebarsHelpers() { + // i18next 헬퍼 등록 + handlebars.registerHelper('t', function(key: string, options: { hash?: Record<string, unknown> }) { + // options.hash에는 Handlebars에서 넘긴 named parameter들이 들어있음 + return i18next.t(key, options.hash || {}); + }); + + // eq 헬퍼 등록 - 두 값을 비교 (블록 헬퍼) + handlebars.registerHelper('eq', function(a: any, b: any, options: any) { + if (a === b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + // 기타 유용한 헬퍼들 + handlebars.registerHelper('ne', function(a: any, b: any, options: any) { + if (a !== b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + handlebars.registerHelper('gt', function(a: any, b: any, options: any) { + if (a > b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + handlebars.registerHelper('gte', function(a: any, b: any, options: any) { + if (a >= b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + handlebars.registerHelper('lt', function(a: any, b: any, options: any) { + if (a < b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + handlebars.registerHelper('lte', function(a: any, b: any, options: any) { + if (a <= b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + // and 헬퍼 - 모든 조건이 true인지 확인 (블록 헬퍼) + handlebars.registerHelper('and', function(...args: any[]) { + // 마지막 인자는 Handlebars 옵션 + const options = args[args.length - 1]; + const values = args.slice(0, -1); + + if (values.every(Boolean)) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + // or 헬퍼 - 하나라도 true인지 확인 (블록 헬퍼) + handlebars.registerHelper('or', function(...args: any[]) { + // 마지막 인자는 Handlebars 옵션 + const options = args[args.length - 1]; + const values = args.slice(0, -1); + + if (values.some(Boolean)) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + // not 헬퍼 - 값 반전 (블록 헬퍼) + handlebars.registerHelper('not', function(value: any, options: any) { + if (!value) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + // formatDate 헬퍼 - 날짜 포맷팅 + handlebars.registerHelper('formatDate', function(date: string | Date, format: string = 'YYYY-MM-DD') { + if (!date) return ''; + const dateObj = new Date(date); + if (isNaN(dateObj.getTime())) return ''; + + // 간단한 날짜 포맷팅 (더 복잡한 경우 moment.js나 date-fns 사용) + const year = dateObj.getFullYear(); + const month = String(dateObj.getMonth() + 1).padStart(2, '0'); + const day = String(dateObj.getDate()).padStart(2, '0'); + + return format + .replace('YYYY', String(year)) + .replace('MM', month) + .replace('DD', day); + }); + + // formatNumber 헬퍼 - 숫자 포맷팅 + handlebars.registerHelper('formatNumber', function(number: number, locale: string = 'ko-KR') { + if (typeof number !== 'number') return number; + return new Intl.NumberFormat(locale).format(number); + }); +} + +// 헬퍼 등록 실행 +registerHandlebarsHelpers(); + +// 템플릿 로더 함수 function loadTemplate(templateName: string, data: Record<string, unknown>) { const templatePath = path.join(process.cwd(), 'lib', 'mail', 'templates', `${templateName}.hbs`); + + if (!fs.existsSync(templatePath)) { + throw new Error(`Template not found: ${templatePath}`); + } + const source = fs.readFileSync(templatePath, 'utf8'); const template = handlebars.compile(source); return template(data); } -// i18next 헬퍼 등록 -handlebars.registerHelper('t', function(key: string, options: { hash?: Record<string, unknown> }) { - // options.hash에는 Handlebars에서 넘긴 named parameter들이 들어있음 - return i18next.t(key, options.hash || {}); -}); - export { transporter, loadTemplate };
\ No newline at end of file diff --git a/lib/mail/partials/footer.hbs b/lib/mail/partials/footer.hbs deleted file mode 100644 index 06aae57d..00000000 --- a/lib/mail/partials/footer.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;"> - <tr> - <td align="center"> - <p style="font-size:16px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. {{t "email.vendor.invitation.copyright"}}</p> - <p style="font-size:16px; color:#6b7280; margin:4px 0;">{{t "email.vendor.invitation.no_reply"}}</p> - </td> - </tr> -</table> diff --git a/lib/mail/partials/header.hbs b/lib/mail/partials/header.hbs deleted file mode 100644 index 7898c82e..00000000 --- a/lib/mail/partials/header.hbs +++ /dev/null @@ -1,7 +0,0 @@ -<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;"> - <tr> - <td align="center"> - <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span> - </td> - </tr> -</table> diff --git a/lib/mail/sendEmail.ts b/lib/mail/sendEmail.ts index 97617e7a..3f88cb04 100644 --- a/lib/mail/sendEmail.ts +++ b/lib/mail/sendEmail.ts @@ -17,29 +17,42 @@ interface SendEmailOptions { }[] } -export async function sendEmail({ - to, - subject, - template, - context, +export async function sendEmail({ + to, + subject, + template, + context, cc, // cc 매개변수 추가 attachments = [] }: SendEmailOptions) { - const { t, i18n } = await useTranslation(context.language ?? "en", "translation"); + try { + // i18n 설정 + const { t, i18n } = await useTranslation(context.language ?? "en", "translation"); + + // t 헬퍼만 동적으로 등록 (이미 mailer.ts에서 기본 등록되어 있지만, 언어별로 다시 등록) + handlebars.registerHelper("t", function (key: string, options: any) { + // 여기서 i18n은 로컬 인스턴스 + return i18n.t(key, options.hash || {}); + }); - handlebars.registerHelper("t", function (key: string, options: any) { - // 여기서 i18n은 로컬 인스턴스 - return i18n.t(key, options.hash || {}); - }); + // 템플릿 컴파일 및 HTML 생성 + const html = loadTemplate(template, context); - const html = loadTemplate(template, context); + // 이메일 발송 + const result = await transporter.sendMail({ + from: `"${process.env.Email_From_Name}" <${process.env.Email_From_Address}>`, + to, + cc, // cc 필드 추가 + subject, + html, + attachments + }); - await transporter.sendMail({ - from: `"${process.env.Email_From_Name}" <${process.env.Email_From_Address}>`, - to, - cc, // cc 필드 추가 - subject, - html, - attachments - }); -} + console.log(`이메일 발송 성공: ${to}`, result.messageId); + return result; + + } catch (error) { + console.error(`이메일 발송 실패: ${to}`, error); + throw error; + } +}
\ No newline at end of file diff --git a/lib/mail/templates/evaluation-request.hbs b/lib/mail/templates/evaluation-request.hbs new file mode 100644 index 00000000..84aae0f5 --- /dev/null +++ b/lib/mail/templates/evaluation-request.hbs @@ -0,0 +1,285 @@ +<!DOCTYPE html> +<html lang="ko"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>협력업체 평가 요청</title> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans KR', Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 600px; + margin: 0 auto; + padding: 20px; + background-color: #f9f9f9; + } + .container { + background-color: white; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + .header { + text-align: center; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 2px solid #e5e5e5; + } + .header h1 { + color: #1f2937; + margin: 0; + font-size: 24px; + font-weight: 600; + } + .header .subtitle { + color: #6b7280; + font-size: 14px; + margin-top: 5px; + } + .greeting { + margin-bottom: 25px; + font-size: 16px; + } + .evaluation-info { + background-color: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 20px; + margin: 20px 0; + } + .evaluation-info h3 { + color: #1e40af; + margin: 0 0 15px 0; + font-size: 18px; + } + .info-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 15px; + margin-bottom: 15px; + } + .info-label { + font-weight: 600; + color: #374151; + } + .info-value { + color: #6b7280; + } + .status-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + } + .status-domestic { background-color: #dcfce7; color: #166534; } + .status-foreign { background-color: #dbeafe; color: #1d4ed8; } + .status-equipment { background-color: #fef3c7; color: #92400e; } + .status-bulk { background-color: #e0e7ff; color: #3730a3; } + .department-badge { + background-color: #1f2937; + color: white; + padding: 6px 12px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + display: inline-block; + margin-bottom: 15px; + } + .reviewers-section { + margin: 25px 0; + } + .reviewers-section h4 { + color: #374151; + margin-bottom: 15px; + font-size: 16px; + } + .reviewer-list { + background-color: #f1f5f9; + border-radius: 6px; + padding: 15px; + } + .reviewer-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #e2e8f0; + } + .reviewer-item:last-child { + border-bottom: none; + } + .reviewer-name { + font-weight: 500; + color: #1f2937; + } + .reviewer-dept { + font-size: 12px; + color: #6b7280; + background-color: #e5e7eb; + padding: 2px 6px; + border-radius: 3px; + } + .message-section { + background-color: #fffbeb; + border-left: 4px solid #f59e0b; + padding: 15px; + margin: 20px 0; + } + .message-section h4 { + color: #92400e; + margin: 0 0 10px 0; + font-size: 14px; + } + .message-text { + color: #78350f; + font-style: italic; + } + .action-button { + display: inline-block; + background-color: #1f2937; + color: white; + padding: 12px 24px; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + text-align: center; + margin: 25px 0; + } + .action-button:hover { + background-color: #374151; + } + .footer { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #e5e5e5; + text-align: center; + color: #6b7280; + font-size: 14px; + } + .deadline-notice { + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + padding: 15px; + margin: 20px 0; + } + .deadline-notice .deadline-label { + color: #dc2626; + font-weight: 600; + font-size: 14px; + } + .deadline-notice .deadline-date { + color: #991b1b; + font-size: 16px; + font-weight: 500; + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <h1>🏢 협력업체 정기평가 요청</h1> + <div class="subtitle">Vendor Performance Evaluation Request</div> + </div> + + <div class="greeting"> + 안녕하세요, <strong>{{reviewerName}}</strong>님 + </div> + + <p> + {{departmentLabel}}으로 지정되어 아래 협력업체에 대한 정기평가를 요청드립니다. + </p> + + <div class="department-badge"> + 📋 {{departmentLabel}} + </div> + + <!-- 평가 대상 정보 --> + <div class="evaluation-info"> + <h3>📊 평가 대상 정보</h3> + + <div class="info-grid"> + <span class="info-label">업체명:</span> + <span class="info-value"><strong>{{evaluation.vendorName}}</strong></span> + + <span class="info-label">업체코드:</span> + <span class="info-value">{{evaluation.vendorCode}}</span> + + <span class="info-label">평가년도:</span> + <span class="info-value">{{evaluation.evaluationYear}}년</span> + + + <span class="info-label">구분:</span> + <span class="info-value"> + {{#eq evaluation.division "SHIP"}}조선{{else}}{{#eq evaluation.division "PLANT"}}해양{{/eq}}{{/eq}} + </span> + + <span class="info-label">내외자:</span> + <span class="info-value"> + <span class="status-badge {{#eq evaluation.domesticForeign 'DOMESTIC'}}status-domestic{{else}}status-foreign{{/eq}}"> + {{#eq evaluation.domesticForeign "DOMESTIC"}}국내{{else}}해외{{/eq}} + </span> + </span> + + <span class="info-label">자재구분:</span> + <span class="info-value"> + <span class="status-badge {{#eq evaluation.materialType 'EQUIPMENT'}}status-equipment{{else}}{{#eq evaluation.materialType 'BULK'}}status-bulk{{else}}status-equipment{{/eq}}{{/eq}}"> + {{#eq evaluation.materialType "EQUIPMENT"}}기자재{{else}}{{#eq evaluation.materialType "BULK"}}벌크{{else}}{{#eq evaluation.materialType "EQUIPMENT_BULK"}}기자재+벌크{{else}}{{evaluation.materialType}}{{/eq}}{{/eq}}{{/eq}} + </span> + </span> + </div> + + </div> + + <!-- 함께 평가하는 다른 담당자들 --> + {{#if otherReviewers}} + <div class="reviewers-section"> + <h4>👥 함께 평가하는 다른 담당자</h4> + <div class="reviewer-list"> + {{#each otherReviewers}} + <div class="reviewer-item"> + <div> + <div class="reviewer-name">{{this.name}}</div> + <div style="font-size: 12px; color: #6b7280;">{{this.email}}</div> + </div> + <div class="reviewer-dept">{{this.department}}</div> + </div> + {{/each}} + </div> + </div> + {{/if}} + + <!-- 요청 메시지 --> + {{#if message}} + <div class="message-section"> + <h4>💬 요청 메시지</h4> + <div class="message-text">"{{message}}"</div> + </div> + {{/if}} + + <!-- 평가 시작 버튼 --> + <div style="text-align: center;"> + <a href="{{evaluationUrl}}" class="action-button"> + 🚀 평가 시작하기 + </a> + </div> + + <div style="margin-top: 25px; padding: 15px; background-color: #f8fafc; border-radius: 6px;"> + <p style="margin: 0; font-size: 14px; color: #6b7280;"> + <strong>📋 평가 진행 안내:</strong><br> + • 위 버튼을 클릭하여 온라인 평가 시스템에 접속하실 수 있습니다<br> + • 평가 기준에 따라 각 항목별로 점수를 입력해 주세요<br> + • 모든 평가가 완료되면 자동으로 최종 집계됩니다<br> + • 문의사항이 있으시면 시스템 관리자에게 연락해 주세요 + </p> + </div> + + <div class="footer"> + <p>본 메일은 협력업체 평가 시스템에서 자동 발송된 메일입니다.</p> + <p style="margin: 5px 0 0 0;">Samsung Heavy Industries Vendor Evaluation System</p> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/project-gtc/service.ts b/lib/project-gtc/service.ts index c65d9364..7ae09635 100644 --- a/lib/project-gtc/service.ts +++ b/lib/project-gtc/service.ts @@ -14,6 +14,7 @@ import { promises as fs } from "fs"; import path from "path"; import crypto from "crypto"; import { revalidatePath } from 'next/cache'; +import { deleteFile, saveFile } from "../file-stroage"; // Project GTC 목록 조회 export async function getProjectGtcList( @@ -119,52 +120,11 @@ export async function uploadProjectGtcFile( return { success: false, error: "파일은 필수입니다." }; } - // 허용된 파일 타입 검사 - const allowedTypes = [ - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'text/plain' - ]; - - if (!allowedTypes.includes(file.type)) { - return { success: false, error: "PDF, Word, 또는 텍스트 파일만 업로드 가능합니다." }; - } - - // 원본 파일 이름과 확장자 분리 - const originalFileName = file.name; - const fileExtension = path.extname(originalFileName); - const fileNameWithoutExt = path.basename(originalFileName, fileExtension); - - // 해시된 파일 이름 생성 - const timestamp = Date.now(); - const randomHash = crypto.createHash('md5') - .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`) - .digest('hex') - .substring(0, 8); - - const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`; - - // 저장 디렉토리 설정 - const uploadDir = path.join(process.cwd(), "public", "project-gtc"); - - // 디렉토리가 없으면 생성 - try { - await fs.mkdir(uploadDir, { recursive: true }); - } catch (err) { - console.log("Directory already exists or creation failed:", err); + const saveResult = await saveFile(file, 'proejctGTC'); + if (!saveResult.success) { + return { success: false, error: saveResult.error }; } - // 파일 경로 설정 - const filePath = path.join(uploadDir, hashedFileName); - const publicFilePath = `/project-gtc/${hashedFileName}`; - - // 파일을 ArrayBuffer로 변환 - const arrayBuffer = await file.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - - // 파일 저장 - await fs.writeFile(filePath, buffer); // 기존 파일이 있으면 삭제 const existingFile = await db.query.projectGtcFiles.findFirst({ @@ -172,14 +132,9 @@ export async function uploadProjectGtcFile( }); if (existingFile) { - // 기존 파일 삭제 - try { - const filePath = path.join(process.cwd(), "public", existingFile.filePath); - await fs.unlink(filePath); - } catch { - console.error("파일 삭제 실패"); - } + const deleted = await deleteFile(existingFile.filePath); + // DB에서 기존 파일 정보 삭제 await db.delete(projectGtcFiles) .where(eq(projectGtcFiles.id, existingFile.id)); @@ -188,9 +143,9 @@ export async function uploadProjectGtcFile( // DB에 새 파일 정보 저장 const newFile = await db.insert(projectGtcFiles).values({ projectId, - fileName: hashedFileName, - filePath: publicFilePath, - originalFileName, + fileName: saveResult.fileName!, + filePath: saveResult.publicPath!, + originalFileName:file.name, fileSize: file.size, mimeType: file.type, }).returning(); @@ -225,8 +180,8 @@ export async function deleteProjectGtcFile( // 파일 시스템에서 파일 삭제 try { - const filePath = path.join(process.cwd(), "public", existingFile.filePath); - await fs.unlink(filePath); + + const deleted = await deleteFile(existingFile.filePath); } catch (error) { console.error("파일 시스템에서 파일 삭제 실패:", error); throw new Error("파일 시스템에서 파일 삭제에 실패했습니다."); diff --git a/lib/project-gtc/table/project-gtc-table-columns.tsx b/lib/project-gtc/table/project-gtc-table-columns.tsx index dfdf1921..141d5737 100644 --- a/lib/project-gtc/table/project-gtc-table-columns.tsx +++ b/lib/project-gtc/table/project-gtc-table-columns.tsx @@ -21,48 +21,13 @@ import { import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { ProjectGtcView } from "@/db/schema" +import { FileActionsDropdown } from "@/components/ui/file-actions" interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProjectGtcView> | null>> } /** - * 파일 다운로드 함수 - */ -const handleFileDownload = async (projectId: number, fileName: string) => { - try { - // API를 통해 파일 다운로드 - const response = await fetch(`/api/project-gtc?action=download&projectId=${projectId}`, { - method: 'GET', - }); - - if (!response.ok) { - throw new Error('파일 다운로드에 실패했습니다.'); - } - - // 파일 blob 생성 - const blob = await response.blob(); - - // 다운로드 링크 생성 - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // 메모리 정리 - window.URL.revokeObjectURL(url); - - toast.success("파일 다운로드를 시작합니다."); - } catch (error) { - console.error("파일 다운로드 오류:", error); - toast.error("파일 다운로드 중 오류가 발생했습니다."); - } -}; - -/** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ProjectGtcView>[] { @@ -108,17 +73,14 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Project return null; } + return ( - <Button + <FileActionsDropdown + filePath={project.filePath} + fileName={project.originalFileName} variant="ghost" size="icon" - onClick={() => handleFileDownload(project.id, project.originalFileName!)} - title={`${project.originalFileName} 다운로드`} - className="hover:bg-muted" - > - <Paperclip className="h-4 w-4" /> - <span className="sr-only">다운로드</span> - </Button> + /> ); }, maxSize: 30, diff --git a/lib/rfqs-tech/service.ts b/lib/rfqs-tech/service.ts index fac18a43..6989188b 100644 --- a/lib/rfqs-tech/service.ts +++ b/lib/rfqs-tech/service.ts @@ -10,9 +10,6 @@ import { getErrorMessage } from "@/lib/handle-error"; import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, GetCBESchema, createCbeEvaluationSchema } from "./validations"; import { asc, desc, ilike, inArray, and, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; import path from "path"; -import fs from "fs/promises"; -import { randomUUID } from "crypto"; -import { writeFile, mkdir } from 'fs/promises' import { join } from 'path' import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, cbeEvaluations, vendorCommercialResponses, vendorResponseCBEView, RfqViewWithItems } from "@/db/schema/rfq"; @@ -27,6 +24,7 @@ import { headers } from 'next/headers'; // DRM 복호화 관련 유틸 import import { decryptWithServerAction } from "@/components/drm/drmUtils"; +import { deleteFile, saveDRMFile, saveFile } from "../file-stroage"; interface InviteVendorsInput { rfqId: number @@ -364,50 +362,21 @@ export async function processRfqAttachments(args: { // 1-3) 파일 삭제 for (const row of rows) { - // filePath: 예) "/rfq/123/...xyz" - const absolutePath = path.join( - process.cwd(), - "public", - row.filePath.replace(/^\/+/, "") // 슬래시 제거 - ); - try { - await fs.unlink(absolutePath); - } catch (err) { - console.error("File remove error:", err); - } + await deleteFile(row.filePath) } } // 2) 새 파일 업로드 if (newFiles.length > 0) { - const rfqDir = path.join("public", "rfq", String(rfqId)); - // 폴더 없으면 생성 - await fs.mkdir(rfqDir, { recursive: true }); - for (const file of newFiles) { - // 2-1) DRM 복호화 시도 ---------------------------------------------------------------------- - // decryptWithServerAction 함수는 오류 처리 및 원본 반환 로직을 포함하고 있음 (해제 실패시 원본 반환) - // 이후 코드가 buffer로 작업하므로 buffer로 전환한다. - const decryptedData = await decryptWithServerAction(file); - const buffer = Buffer.from(decryptedData); - // ----------------------------------------------------------------------------------------- - - - // 2-2) 고유 파일명 - const uniqueName = `${randomUUID()}-${file.name}`; - // 예) "rfq/123/xxx" - const relativePath = path.join("rfq", String(rfqId), uniqueName); - const absolutePath = path.join("public", relativePath); - - // 2-3) 파일 저장 - await fs.writeFile(absolutePath, buffer); + const saveResult = await saveDRMFile(file, decryptWithServerAction, `rfqTech/${rfqId}`) // 2-4) DB Insert await db.insert(rfqAttachments).values({ rfqId, vendorId, fileName: file.name, - filePath: "/" + relativePath.replace(/\\/g, "/"), + filePath: saveResult.publicPath!, // (Windows 경로 대비) }); } @@ -1410,15 +1379,6 @@ export async function inviteTbeVendorsAction(formData: FormData) { throw new Error("Invalid input or no files attached.") } - // /public/rfq/[rfqId] 경로 - const uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) - - // 디렉토리가 없다면 생성 - try { - await fs.mkdir(uploadDir, { recursive: true }) - } catch (err) { - console.error("디렉토리 생성 실패:", err) - } // DB 트랜잭션 await db.transaction(async (tx) => { @@ -1466,21 +1426,14 @@ export async function inviteTbeVendorsAction(formData: FormData) { // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 협력업체"에는 동일 파일 목록을 첨부한다는 예시. const savedFiles = [] for (const file of tbeFiles) { - const originalName = file.name || "tbe-sheet.xlsx" - // 파일명 충돌 방지를 위한 타임스탬프 추가 - const timestamp = new Date().getTime() - const fileName = `${timestamp}-${originalName}` - const savePath = path.join(uploadDir, fileName) - // 파일 ArrayBuffer → Buffer 변환 후 저장 - const arrayBuffer = await file.arrayBuffer() - await fs.writeFile(savePath, Buffer.from(arrayBuffer)) + const saveResult = await saveDRMFile(file, decryptWithServerAction, `rfqTech/${rfqId}`) // 저장 경로 & 파일명 기록 savedFiles.push({ - fileName: originalName, // 원본 파일명으로 첨부 - filePath: `/rfq/${rfqId}/${fileName}`, // public 이하 경로 - absolutePath: savePath, + fileName: file.name, // 원본 파일명으로 첨부 + filePath: saveResult.publicPath, // public 이하 경로 + absolutePath: saveResult.publicPath, }) } @@ -1652,22 +1605,9 @@ export async function createRfqCommentWithAttachments(params: { // 2) 첨부파일 처리 if (files && files.length > 0) { - const rfqDir = path.join(process.cwd(), "public", "rfq", String(rfqId)); - // 폴더 없으면 생성 - await fs.mkdir(rfqDir, { recursive: true }); - for (const file of files) { - const ab = await file.arrayBuffer(); - const buffer = Buffer.from(ab); - // 2-2) 고유 파일명 - const uniqueName = `${randomUUID()}-${file.name}`; - // 예) "rfq/123/xxx" - const relativePath = path.join("rfq", String(rfqId), uniqueName); - const absolutePath = path.join(process.cwd(), "public", relativePath); - - // 2-3) 파일 저장 - await fs.writeFile(absolutePath, buffer); + const saveResult = await saveDRMFile(file, decryptWithServerAction, `rfqTech/${rfqId}`) // DB에 첨부파일 row 생성 await db.insert(rfqAttachments).values({ @@ -1677,7 +1617,7 @@ export async function createRfqCommentWithAttachments(params: { cbeId: cbeId || null, commentId: insertedComment.id, // 새 코멘트와 연결 fileName: file.name, - filePath: "/" + relativePath.replace(/\\/g, "/"), + filePath: saveResult.publicPath!, }) } } @@ -2045,37 +1985,14 @@ export async function uploadTbeResponseFile(formData: FormData) { } } - // 타임스탬프 기반 고유 파일명 생성 - const timestamp = Date.now() - const originalName = file.name - const fileExtension = originalName.split(".").pop() - const fileName = `${originalName.split(".")[0]}-${timestamp}.${fileExtension}` - - // 업로드 디렉토리 및 경로 정의 - const uploadDir = join(process.cwd(), "public", "rfq", "tbe-responses") - - // 디렉토리가 없으면 생성 - try { - await mkdir(uploadDir, { recursive: true }) - } catch (error) { - // 이미 존재하면 무시 - } - - const filePath = join(uploadDir, fileName) - - // 파일을 버퍼로 변환 - const bytes = await file.arrayBuffer() - const buffer = Buffer.from(bytes) - - // 파일을 서버에 저장 - await writeFile(filePath, buffer) + const saveResult = await saveFile({file, directory:`rfqTech/${rfqId}/tbe-responses`}) // 먼저 vendorTechnicalResponses 테이블에 엔트리 생성 const technicalResponse = await db.insert(vendorTechnicalResponses) .values({ responseId: vendorResponseId, summary: "TBE 응답 파일 업로드", // 필요에 따라 수정 - notes: `파일명: ${originalName}`, + notes: `파일명: ${file.name}`, responseStatus:"SUBMITTED" }) .returning({ id: vendorTechnicalResponses.id }); @@ -2083,9 +2000,6 @@ export async function uploadTbeResponseFile(formData: FormData) { // 생성된 기술 응답 ID 가져오기 const technicalResponseId = technicalResponse[0].id; - // 파일 정보를 데이터베이스에 저장 - const dbFilePath = `rfq/tbe-responses/${fileName}` - // vendorResponseAttachments 테이블 스키마에 맞게 데이터 삽입 await db.insert(vendorResponseAttachments) .values({ @@ -2096,8 +2010,8 @@ export async function uploadTbeResponseFile(formData: FormData) { // vendorId와 evaluationId 필드가 테이블에 있다면 포함, 없다면 제거 // vendorId: vendorId, // evaluationId: evaluationId, - fileName: originalName, - filePath: dbFilePath, + fileName: file.name, + filePath: saveResult.publicPath!, uploadedAt: new Date(), }); @@ -2902,18 +2816,6 @@ export async function createCbeEvaluation(formData: FormData) { const files = formData.getAll("files") as File[] const hasFiles = files && files.length > 0 && files[0].size > 0 - // 파일 저장을 위한 디렉토리 생성 (파일이 있는 경우에만) - let uploadDir = "" - if (hasFiles) { - uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) - try { - await fs.mkdir(uploadDir, { recursive: true }) - } catch (err) { - console.error("디렉토리 생성 실패:", err) - return { error: "파일 업로드를 위한 디렉토리 생성에 실패했습니다." } - } - } - // 첨부 파일 정보를 저장할 배열 const attachments: { filename: string; path: string }[] = [] @@ -2921,23 +2823,13 @@ export async function createCbeEvaluation(formData: FormData) { if (hasFiles) { for (const file of files) { if (file.size > 0) { - const originalFilename = file.name - const fileExtension = path.extname(originalFilename) - const timestamp = new Date().getTime() - const safeFilename = `cbe-${rfqId}-${timestamp}${fileExtension}` - const filePath = path.join("rfq", String(rfqId), safeFilename) - const fullPath = path.join(process.cwd(), "public", filePath) - try { - // File을 ArrayBuffer로 변환하여 파일 시스템에 저장 - const arrayBuffer = await file.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - await fs.writeFile(fullPath, buffer) + const saveResult = await saveDRMFile(file, decryptWithServerAction, `rfqTech/${rfqId}`) // 첨부 파일 정보 추가 attachments.push({ - filename: originalFilename, - path: fullPath, // 이메일 첨부를 위한 전체 경로 + filename: file.name, + path: saveResult.publicPath!, // 이메일 첨부를 위한 전체 경로 }) } catch (err) { console.error(`파일 저장 실패:`, err) diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts index 820de294..38bf467c 100644 --- a/lib/rfqs/service.ts +++ b/lib/rfqs/service.ts @@ -11,8 +11,6 @@ import { getErrorMessage } from "@/lib/handle-error"; import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema, createCbeEvaluationSchema } from "./validations"; import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; import path from "path"; -import fs from "fs/promises"; -import { randomUUID } from "crypto"; import { writeFile, mkdir } from 'fs/promises' import { join } from 'path' @@ -29,6 +27,7 @@ import { headers } from 'next/headers'; // DRM 복호화 관련 유틸 import import { decryptWithServerAction } from "@/components/drm/drmUtils"; +import { deleteFile, saveDRMFile, saveFile } from "../file-stroage"; interface InviteVendorsInput { rfqId: number @@ -449,50 +448,22 @@ export async function processRfqAttachments(args: { // 1-3) 파일 삭제 for (const row of rows) { - // filePath: 예) "/rfq/123/...xyz" - const absolutePath = path.join( - process.cwd(), - "public", - row.filePath.replace(/^\/+/, "") // 슬래시 제거 - ); - try { - await fs.unlink(absolutePath); - } catch (err) { - console.error("File remove error:", err); - } + await deleteFile(row.filePath!); } } // 2) 새 파일 업로드 if (newFiles.length > 0) { - const rfqDir = path.join("public", "rfq", String(rfqId)); - // 폴더 없으면 생성 - await fs.mkdir(rfqDir, { recursive: true }); - for (const file of newFiles) { - // 2-1) DRM 복호화 시도 ---------------------------------------------------------------------- - // decryptWithServerAction 함수는 오류 처리 및 원본 반환 로직을 포함하고 있음 (해제 실패시 원본 반환) - // 이후 코드가 buffer로 작업하므로 buffer로 전환한다. - const decryptedData = await decryptWithServerAction(file); - const buffer = Buffer.from(decryptedData); - // ----------------------------------------------------------------------------------------- - - - // 2-2) 고유 파일명 - const uniqueName = `${randomUUID()}-${file.name}`; - // 예) "rfq/123/xxx" - const relativePath = path.join("rfq", String(rfqId), uniqueName); - const absolutePath = path.join("public", relativePath); - - // 2-3) 파일 저장 - await fs.writeFile(absolutePath, buffer); + const saveResult = await saveDRMFile(file, decryptWithServerAction,'rfq' ) + // 2-4) DB Insert await db.insert(rfqAttachments).values({ rfqId, vendorId, fileName: file.name, - filePath: "/" + relativePath.replace(/\\/g, "/"), + filePath: saveResult.publicPath!, // (Windows 경로 대비) }); } @@ -1521,16 +1492,6 @@ export async function inviteTbeVendorsAction(formData: FormData) { throw new Error("Invalid input or no files attached.") } - // /public/rfq/[rfqId] 경로 - const uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) - - // 디렉토리가 없다면 생성 - try { - await fs.mkdir(uploadDir, { recursive: true }) - } catch (err) { - console.error("디렉토리 생성 실패:", err) - } - // DB 트랜잭션 await db.transaction(async (tx) => { // (A) RFQ 기본 정보 조회 @@ -1577,21 +1538,13 @@ export async function inviteTbeVendorsAction(formData: FormData) { // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 협력업체"에는 동일 파일 목록을 첨부한다는 예시. const savedFiles = [] for (const file of tbeFiles) { - const originalName = file.name || "tbe-sheet.xlsx" - // 파일명 충돌 방지를 위한 타임스탬프 추가 - const timestamp = new Date().getTime() - const fileName = `${timestamp}-${originalName}` - const savePath = path.join(uploadDir, fileName) - - // 파일 ArrayBuffer → Buffer 변환 후 저장 - const arrayBuffer = await file.arrayBuffer() - await fs.writeFile(savePath, Buffer.from(arrayBuffer)) + const saveResult = await saveFile({file, directory:'rfb'}); // 저장 경로 & 파일명 기록 savedFiles.push({ - fileName: originalName, // 원본 파일명으로 첨부 - filePath: `/rfq/${rfqId}/${fileName}`, // public 이하 경로 - absolutePath: savePath, + fileName: file.name, // 원본 파일명으로 첨부 + filePath: saveResult.publicPath, // public 이하 경로 + absolutePath: saveResult.publicPath, }) } @@ -1769,22 +1722,9 @@ export async function createRfqCommentWithAttachments(params: { // 2) 첨부파일 처리 if (files && files.length > 0) { - const rfqDir = path.join(process.cwd(), "public", "rfq", String(rfqId)); - // 폴더 없으면 생성 - await fs.mkdir(rfqDir, { recursive: true }); - for (const file of files) { - const ab = await file.arrayBuffer(); - const buffer = Buffer.from(ab); - - // 2-2) 고유 파일명 - const uniqueName = `${randomUUID()}-${file.name}`; - // 예) "rfq/123/xxx" - const relativePath = path.join("rfq", String(rfqId), uniqueName); - const absolutePath = path.join(process.cwd(), "public", relativePath); - // 2-3) 파일 저장 - await fs.writeFile(absolutePath, buffer); + const saveResult = await saveFile({file, directory:'rfq'}) // DB에 첨부파일 row 생성 await db.insert(rfqAttachments).values({ @@ -1794,7 +1734,7 @@ export async function createRfqCommentWithAttachments(params: { cbeId: cbeId || null, commentId: insertedComment.id, // 새 코멘트와 연결 fileName: file.name, - filePath: "/" + relativePath.replace(/\\/g, "/"), + filePath:saveResult.publicPath!, }) } } @@ -3119,17 +3059,6 @@ export async function createCbeEvaluation(formData: FormData) { const files = formData.getAll("files") as File[] const hasFiles = files && files.length > 0 && files[0].size > 0 - // 파일 저장을 위한 디렉토리 생성 (파일이 있는 경우에만) - let uploadDir = "" - if (hasFiles) { - uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) - try { - await fs.mkdir(uploadDir, { recursive: true }) - } catch (err) { - console.error("디렉토리 생성 실패:", err) - return { error: "파일 업로드를 위한 디렉토리 생성에 실패했습니다." } - } - } // 첨부 파일 정보를 저장할 배열 const attachments: { filename: string; path: string }[] = [] @@ -3144,22 +3073,9 @@ export async function createCbeEvaluation(formData: FormData) { const safeFilename = `cbe-${rfqId}-${timestamp}${fileExtension}` const filePath = path.join("rfq", String(rfqId), safeFilename) const fullPath = path.join(process.cwd(), "public", filePath) + + const saveResult = await saveFile({file, directory:'rfq'}) - try { - // File을 ArrayBuffer로 변환하여 파일 시스템에 저장 - const arrayBuffer = await file.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - await fs.writeFile(fullPath, buffer) - - // 첨부 파일 정보 추가 - attachments.push({ - filename: originalFilename, - path: fullPath, // 이메일 첨부를 위한 전체 경로 - }) - } catch (err) { - console.error(`파일 저장 실패:`, err) - // 파일 저장 실패를 기록하지만 전체 프로세스는 계속 진행 - } } } } diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index 71d47e05..da4a44eb 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -36,11 +36,10 @@ import type { import { asc, desc, ilike, inArray, and, or, eq, isNull, not } from "drizzle-orm"; import path from "path"; -import fs from "fs/promises"; -import { randomUUID } from "crypto"; import { sql } from "drizzle-orm"; import { users } from "@/db/schema/users"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; +import { deleteFile, saveDRMFile } from "../file-stroage"; /* ----------------------------------------------------- 1) 조회 관련 @@ -266,34 +265,16 @@ async function storeTechVendorFiles( files: File[], attachmentType: string ) { - const vendorDir = path.join( - process.cwd(), - "public", - "tech-vendors", - String(vendorId) - ); - await fs.mkdir(vendorDir, { recursive: true }); for (const file of files) { - // Convert file to buffer - // DRM 복호화 시도 및 버퍼 변환 - const decryptedData = await decryptWithServerAction(file); - const buffer = Buffer.from(decryptedData); - - // Generate a unique filename - const uniqueName = `${randomUUID()}-${file.name}`; - const relativePath = path.join("tech-vendors", String(vendorId), uniqueName); - const absolutePath = path.join(process.cwd(), "public", relativePath); - - // Write to disk - await fs.writeFile(absolutePath, buffer); + const saveResult = await saveDRMFile(file, decryptWithServerAction, `tech-vendors/${vendorId}`) // Insert attachment record await tx.insert(techVendorAttachments).values({ vendorId, fileName: file.name, - filePath: "/" + relativePath.replace(/\\/g, "/"), + filePath: saveResult.publicPath, attachmentType, }); } @@ -1232,17 +1213,8 @@ export async function cleanupTechTempFiles(fileName: string) { 'use server'; try { - const tempDir = path.join(process.cwd(), 'tmp'); - const filePath = path.join(tempDir, fileName); - - try { - // 파일 존재 확인 - await fs.access(filePath, fs.constants.F_OK); - // 파일 삭제 - await fs.unlink(filePath); - } catch { - // 파일이 없으면 무시 - } + + await deleteFile(`tmp/${fileName}`) return { success: true }; } catch (error) { diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index d5cb8efe..14d7a45e 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -33,6 +33,7 @@ import { sendEmail } from "../mail/sendEmail"; import { formatDate } from "../utils"; import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; +import { deleteFile, saveDRMFile } from "../file-stroage"; // 정렬 타입 정의 // 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함 @@ -1377,24 +1378,9 @@ export async function createTechSalesRfqAttachments(params: { // 트랜잭션으로 처리 await db.transaction(async (tx) => { - const path = await import("path"); - const fs = await import("fs/promises"); - const { randomUUID } = await import("crypto"); - - // 파일 저장 디렉토리 생성 - const rfqDir = path.join(process.cwd(), "public", "techsales-rfq", String(techSalesRfqId)); - await fs.mkdir(rfqDir, { recursive: true }); for (const file of files) { - const decryptedBuffer = await decryptWithServerAction(file); - - // 고유 파일명 생성 - const uniqueName = `${randomUUID()}-${file.name}`; - const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName); - const absolutePath = path.join(process.cwd(), "public", relativePath); - - // 파일 저장 - await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer)); + const saveResult = await saveDRMFile(file, decryptWithServerAction,`techsales-rfq/${techSalesRfqId}` ) // DB에 첨부파일 레코드 생성 const [newAttachment] = await tx.insert(techSalesAttachments).values({ @@ -1402,7 +1388,7 @@ export async function createTechSalesRfqAttachments(params: { attachmentType, fileName: uniqueName, originalFileName: file.name, - filePath: "/" + relativePath.replace(/\\/g, "/"), + filePath: saveResult.publicPath, fileSize: file.size, fileType: file.type || undefined, description: description || undefined, @@ -1529,11 +1515,8 @@ export async function deleteTechSalesRfqAttachment(attachmentId: number) { // 파일 시스템에서 파일 삭제 try { - const path = await import("path"); - const fs = await import("fs/promises"); - - const absolutePath = path.join(process.cwd(), "public", attachment.filePath); - await fs.unlink(absolutePath); + await deleteFile(`${attachment.filePath}`) + } catch (fileError) { console.warn("파일 삭제 실패:", fileError); // 파일 삭제 실패는 심각한 오류가 아니므로 계속 진행 @@ -1592,9 +1575,6 @@ export async function processTechSalesRfqAttachments(params: { }; await db.transaction(async (tx) => { - const path = await import("path"); - const fs = await import("fs/promises"); - const { randomUUID } = await import("crypto"); // 1. 삭제할 첨부파일 처리 if (deleteAttachmentIds.length > 0) { @@ -1609,41 +1589,23 @@ export async function processTechSalesRfqAttachments(params: { .returning(); results.deleted.push(deletedAttachment); + await deleteFile(attachment.filePath) - // 파일 시스템에서 파일 삭제 - try { - const absolutePath = path.join(process.cwd(), "public", attachment.filePath); - await fs.unlink(absolutePath); - } catch (fileError) { - console.warn("파일 삭제 실패:", fileError); - } } } // 2. 새 파일 업로드 처리 if (newFiles.length > 0) { - const rfqDir = path.join(process.cwd(), "public", "techsales-rfq", String(techSalesRfqId)); - await fs.mkdir(rfqDir, { recursive: true }); - for (const { file, attachmentType, description } of newFiles) { - // 파일 복호화 - const decryptedBuffer = await decryptWithServerAction(file); - - // 고유 파일명 생성 - const uniqueName = `${randomUUID()}-${file.name}`; - const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName); - const absolutePath = path.join(process.cwd(), "public", relativePath); - - // 복호화된 파일 저장 - await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer)); + const saveResult = await saveDRMFile(file, decryptWithServerAction,`techsales-rfq/${techSalesRfqId}` ) // DB에 첨부파일 레코드 생성 const [newAttachment] = await tx.insert(techSalesAttachments).values({ techSalesRfqId, attachmentType, - fileName: uniqueName, + fileName: saveResult.fileName, originalFileName: file.name, - filePath: "/" + relativePath.replace(/\\/g, "/"), + filePath: saveResult.publicPath, fileSize: file.size, fileType: file.type || undefined, description: description || undefined, diff --git a/lib/users/auth/passwordUtil.ts b/lib/users/auth/passwordUtil.ts index ee4e13c2..54599761 100644 --- a/lib/users/auth/passwordUtil.ts +++ b/lib/users/auth/passwordUtil.ts @@ -380,7 +380,7 @@ async function sendSmsMessage(phoneNumber: string, message: string): Promise<boo const requestBody = { account: account, - type: 'SMS', + type: 'sms', from: fromNumber, to: to, country: country, diff --git a/lib/users/auth/verifyCredentails.ts b/lib/users/auth/verifyCredentails.ts index 1b67b874..ff3cd0e3 100644 --- a/lib/users/auth/verifyCredentails.ts +++ b/lib/users/auth/verifyCredentails.ts @@ -1,4 +1,5 @@ // lib/auth/verifyCredentials.ts +'use server' import bcrypt from 'bcryptjs'; import { eq, and, desc, gte, count } from 'drizzle-orm'; @@ -13,7 +14,7 @@ import { vendors } from '@/db/schema'; import { headers } from 'next/headers'; -import { generateAndSendSmsToken, verifySmsToken } from './passwordUtil'; +import { verifySmsToken } from './passwordUtil'; // 에러 타입 정의 export type AuthError = @@ -590,14 +591,6 @@ export async function authenticateWithSGips( const user = localUser[0]; - // 3. MFA 토큰 생성 (S-Gips 사용자는 항상 MFA 필요) - // const mfaToken = await generateMfaToken(user.id); - - // 4. SMS 전송 - if (user.phone) { - await generateAndSendSmsToken(user.id, user.phone); - } - return { success: true, user: { diff --git a/lib/users/middleware/page-tracking.ts b/lib/users/middleware/page-tracking.ts new file mode 100644 index 00000000..bd93fb82 --- /dev/null +++ b/lib/users/middleware/page-tracking.ts @@ -0,0 +1,98 @@ + +// lib/middleware/page-tracking.ts - 페이지 방문 추적 미들웨어 +import { NextRequest, NextResponse } from 'next/server' +import { getToken } from 'next-auth/jwt' +import { UAParser } from 'ua-parser-js'; +import { SessionRepository } from '../session/repository' + +export async function trackPageVisit(request: NextRequest) { + try { + const token = await getToken({ req: request }) + const url = new URL(request.url) + + // API 경로나 정적 파일은 추적하지 않음 + if (url.pathname.startsWith('/api') || + url.pathname.startsWith('/_next') || + url.pathname.includes('.')) { + return + } + + const userAgent = request.headers.get('user-agent') || '' + const parser = new UAParser(userAgent) + const result = parser.getResult() + + // 활성 세션 조회 + let sessionId = null + if (token?.id) { + const activeSession = await SessionRepository.getActiveSessionByUserId(token.id) + if (activeSession) { + sessionId = activeSession.id + // 세션 활동 시간 업데이트 + await SessionRepository.updateLoginSession(activeSession.id, { + lastActivityAt: new Date() + }) + } + } + + // 페이지 방문 기록 + await SessionRepository.recordPageVisit({ + userId: token?.id || undefined, + sessionId, + route: url.pathname, + pageTitle: extractPageTitle(url.pathname), // 구현 필요 + referrer: request.headers.get('referer') || undefined, + ipAddress: getClientIP(request), + userAgent, + queryParams: url.search ? url.search.substring(1) : undefined, + deviceType: getDeviceType(result.device.type), + browserName: result.browser.name, + osName: result.os.name, + }) + + } catch (error) { + console.error('Failed to track page visit:', error) + } +} + +function getClientIP(request: NextRequest): string { + const forwarded = request.headers.get('x-forwarded-for'); + const realIP = request.headers.get('x-real-ip'); + const cfConnectingIP = request.headers.get('cf-connecting-ip'); // Cloudflare + + if (cfConnectingIP) { + return cfConnectingIP; + } + + if (forwarded) { + return forwarded.split(',')[0].trim(); + } + + if (realIP) { + return realIP; + } + + // NextRequest에는 ip 프로퍼티가 없으므로 기본값 반환 + return '127.0.0.1'; +} + + +function getDeviceType(deviceType?: string): string { + if (!deviceType) return 'desktop' + if (deviceType === 'mobile') return 'mobile' + if (deviceType === 'tablet') return 'tablet' + return 'desktop' +} + +function extractPageTitle(pathname: string): string { + // 라우트 기반 페이지 제목 매핑 + const titleMap: Record<string, string> = { + '/': 'Home', + '/dashboard': 'Dashboard', + '/profile': 'Profile', + '/settings': 'Settings', + // 추가 필요 + } + + return titleMap[pathname] || pathname +} + diff --git a/lib/users/session/helper.ts b/lib/users/session/helper.ts new file mode 100644 index 00000000..439ab32d --- /dev/null +++ b/lib/users/session/helper.ts @@ -0,0 +1,62 @@ +import { authenticateWithSGips, verifyExternalCredentials } from "../auth/verifyCredentails"; +import { SessionRepository } from "./repository"; + +// lib/session/helpers.ts - NextAuth 헬퍼 함수들 개선 +export const authHelpers = { + // 1차 인증 검증 및 임시 키 생성 (DB 버전) + async performFirstAuth(username: string, password: string, provider: 'email' | 'sgips') { + console.log('performFirstAuth started:', { username, provider }) + + try { + let authResult; + + if (provider === 'sgips') { + authResult = await authenticateWithSGips(username, password) + } else { + authResult = await verifyExternalCredentials(username, password) + } + + if (!authResult.success || !authResult.user) { + return { success: false, error: 'Invalid credentials' } + } + + // DB에 임시 인증 세션 생성 + const expiresAt = new Date(Date.now() + (10 * 60 * 1000)) // 10분 후 만료 + const tempAuthKey = await SessionRepository.createTempAuthSession({ + userId: authResult.user.id, + email: authResult.user.email, + authMethod: provider, + expiresAt + }) + + console.log('Temp auth stored in DB:', { + tempAuthKey, + userId: authResult.user.id, + email: authResult.user.email, + authMethod: provider, + expiresAt + }) + + return { + success: true, + tempAuthKey, + userId: authResult.user.id, + email: authResult.user.email + } + } catch (error) { + console.error('First auth error:', error) + return { success: false, error: 'Authentication failed' } + } + }, + + // 임시 인증 정보 조회 (DB 버전) + async getTempAuth(tempAuthKey: string) { + return await SessionRepository.getTempAuthSession(tempAuthKey) + }, + + // 임시 인증 정보 삭제 (DB 버전) + async clearTempAuth(tempAuthKey: string) { + await SessionRepository.markTempAuthSessionAsUsed(tempAuthKey) + } + } +
\ No newline at end of file diff --git a/lib/users/session/repository.ts b/lib/users/session/repository.ts new file mode 100644 index 00000000..a3b44fbf --- /dev/null +++ b/lib/users/session/repository.ts @@ -0,0 +1,460 @@ +// lib/session/repository.ts +import db from '@/db/db' +import { + loginSessions, + tempAuthSessions, + pageVisits, + type NewLoginSession, + type NewTempAuthSession, + type NewPageVisit, + type LoginSession +} from '@/db/schema' +import { eq, and, desc, lt } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' + + +// 성능 최적화를 위한 캐시 +const sessionCache = new Map<string, { data: any; timestamp: number }>() +const CACHE_TTL = 5 * 60 * 1000 // 5분 캐시 + +export class SessionRepository { + // 임시 인증 세션 관리 (기존 메모리 저장소 대체) + static async createTempAuthSession(data: { + userId: number + email: string + authMethod: string + expiresAt: Date + }): Promise<string> { + const tempAuthKey = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + + try { + await db.insert(tempAuthSessions).values({ + tempAuthKey, + userId: data.userId, + email: data.email, + authMethod: data.authMethod, + expiresAt: data.expiresAt, + }) + + return tempAuthKey + } catch (error) { + console.error('Failed to create temp auth session:', error) + throw error + } + } + + static async getTempAuthSession(tempAuthKey: string) { + try { + const result = await db + .select() + .from(tempAuthSessions) + .where( + and( + eq(tempAuthSessions.tempAuthKey, tempAuthKey), + eq(tempAuthSessions.isUsed, false) + ) + ) + .limit(1) + + const session = result[0] + if (!session || new Date() > session.expiresAt) { + return null + } + + return session + } catch (error) { + console.error('Failed to get temp auth session:', error) + return null + } + } + + static async markTempAuthSessionAsUsed(tempAuthKey: string) { + try { + await db + .update(tempAuthSessions) + .set({ isUsed: true }) + .where(eq(tempAuthSessions.tempAuthKey, tempAuthKey)) + } catch (error) { + console.error('Failed to mark temp auth session as used:', error) + } + } + + static async cleanupExpiredTempSessions() { + try { + await db + .delete(tempAuthSessions) + .where(lt(tempAuthSessions.expiresAt, new Date())) + } catch (error) { + console.error('Failed to cleanup expired temp sessions:', error) + } + } + + // 로그인 세션 관리 + static async createLoginSession(data: { + userId: number + ipAddress: string + userAgent?: string + authMethod: string + sessionExpiredAt?: Date + nextAuthSessionId?: string + }): Promise<LoginSession> { + try { + const sessionData: NewLoginSession = { + userId: data.userId, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + authMethod: data.authMethod, + sessionExpiredAt: data.sessionExpiredAt, + nextAuthSessionId: data.nextAuthSessionId, + } + + const result = await db.insert(loginSessions).values(sessionData).returning() + + // 캐시에서 해당 사용자의 활성 세션 정보 제거 + sessionCache.delete(`active_session_${data.userId}`) + + return result[0] + } catch (error) { + console.error('Failed to create login session:', error) + throw error + } + } + + static async updateLoginSession(sessionId: string, updates: { + lastActivityAt?: Date + sessionExpiredAt?: Date + logoutAt?: Date + isActive?: boolean + }) { + try { + await db + .update(loginSessions) + .set({ + ...updates, + updatedAt: new Date() + }) + .where(eq(loginSessions.id, sessionId)) + + // 캐시 무효화 (세션이 업데이트되었으므로) + for (const [key] of sessionCache) { + if (key.includes(sessionId)) { + sessionCache.delete(key) + } + } + } catch (error) { + console.error('Failed to update login session:', error) + } + } + + // 캐시를 활용한 활성 세션 조회 + static async getActiveSessionByUserId(userId: number) { + const cacheKey = `active_session_${userId}` + const cached = sessionCache.get(cacheKey) + + // 캐시가 유효한 경우 반환 + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data + } + + try { + const result = await db + .select() + .from(loginSessions) + .where( + and( + eq(loginSessions.userId, userId), + eq(loginSessions.isActive, true) + ) + ) + .orderBy(desc(loginSessions.loginAt)) + .limit(1) + + const session = result[0] || null + + // 캐시에 저장 + sessionCache.set(cacheKey, { + data: session, + timestamp: Date.now() + }) + + return session + } catch (error) { + console.error('Failed to get active session:', error) + return null + } + } + + static async logoutSession(sessionId: string) { + try { + await db + .update(loginSessions) + .set({ + logoutAt: new Date(), + isActive: false, + updatedAt: new Date() + }) + .where(eq(loginSessions.id, sessionId)) + + // 캐시에서 관련된 세션 정보 제거 + for (const [key] of sessionCache) { + if (key.includes(sessionId)) { + sessionCache.delete(key) + } + } + } catch (error) { + console.error('Failed to logout session:', error) + } + } + + static async logoutAllUserSessions(userId: string) { + try { + await db + .update(loginSessions) + .set({ + logoutAt: new Date(), + isActive: false, + updatedAt: new Date() + }) + .where( + and( + eq(loginSessions.userId, userId), + eq(loginSessions.isActive, true) + ) + ) + + // 해당 사용자의 캐시 제거 + sessionCache.delete(`active_session_${userId}`) + } catch (error) { + console.error('Failed to logout all user sessions:', error) + } + } + + // 배치 처리를 위한 페이지 방문 기록 (성능 최적화) + private static visitQueue: NewPageVisit[] = [] + private static isProcessingQueue = false + + static async recordPageVisit(data: { + userId?: number + sessionId?: string + route: string + pageTitle?: string + referrer?: string + ipAddress: string + userAgent?: string + queryParams?: string + deviceType?: string + browserName?: string + osName?: string + }) { + const visitData: NewPageVisit = { + userId: data.userId, + sessionId: data.sessionId, + route: data.route, + pageTitle: data.pageTitle, + referrer: data.referrer, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + queryParams: data.queryParams, + deviceType: data.deviceType, + browserName: data.browserName, + osName: data.osName, + } + + // 큐에 추가 + this.visitQueue.push(visitData) + + // 큐가 20개 이상이거나 3초마다 배치 처리 + if (this.visitQueue.length >= 20 || !this.isProcessingQueue) { + this.processVisitQueue() + } + } + + // 배치 처리로 성능 최적화 + private static async processVisitQueue() { + if (this.isProcessingQueue || this.visitQueue.length === 0) { + return + } + + this.isProcessingQueue = true + + try { + const batch = this.visitQueue.splice(0, 100) // 최대 100개씩 처리 + + if (batch.length > 0) { + await db.insert(pageVisits).values(batch) + } + } catch (error) { + console.error('Failed to process visit queue:', error) + } finally { + this.isProcessingQueue = false + + // 더 처리할 데이터가 있다면 재귀 호출 + if (this.visitQueue.length > 0) { + setTimeout(() => this.processVisitQueue(), 100) + } + } + } + + // 3초마다 큐 처리 (백그라운드) + static { + if (typeof setInterval !== 'undefined') { + setInterval(() => { + this.processVisitQueue() + }, 3000) + } + } + + // 세션 활동 업데이트 (논블로킹, 에러 무시) + static updateSessionActivity(sessionId: string): Promise<void> { + return new Promise((resolve) => { + // 비동기로 실행하되 메인 플로우를 블로킹하지 않음 + setImmediate(async () => { + try { + await this.updateLoginSession(sessionId, { + lastActivityAt: new Date() + }) + } catch (error) { + // 에러를 로그만 남기고 무시 + console.error('Failed to update session activity (non-blocking):', error) + } + resolve() + }) + }) + } + + static async updatePageVisitDuration(visitId: string, duration: number) { + try { + await db + .update(pageVisits) + .set({ duration }) + .where(eq(pageVisits.id, visitId)) + } catch (error) { + console.error('Failed to update page visit duration:', error) + } + } + + // 캐시 정리 (메모리 관리) + static cleanupCache() { + const now = Date.now() + + for (const [key, value] of sessionCache) { + if (now - value.timestamp > CACHE_TTL) { + sessionCache.delete(key) + } + } + } + + // 모니터링을 위한 통계 정보 제공 + static getRepositoryStats() { + return { + cacheSize: sessionCache.size, + queueSize: this.visitQueue?.length || 0, + cacheTTL: CACHE_TTL, + isProcessingQueue: this.isProcessingQueue + } + } + + // 캐시 크기 조회 (모니터링용) + static getCacheSize(): number { + return sessionCache.size + } + + // 큐 크기 조회 (모니터링용) + static getQueueSize(): number { + return this.visitQueue?.length || 0 + } + + // 정기적인 캐시 정리 (10분마다) + static { + if (typeof setInterval !== 'undefined') { + setInterval(() => { + this.cleanupCache() + }, 10 * 60 * 1000) + } + } +} + +// 에러 처리를 위한 래퍼 함수들 +export const safeSessionOperations = { + async recordPageVisit(data: Parameters<typeof SessionRepository.recordPageVisit>[0]) { + try { + await SessionRepository.recordPageVisit(data) + } catch (error) { + console.error('Safe page visit recording failed:', error) + } + }, + + async updateSessionActivity(sessionId: string) { + try { + await SessionRepository.updateSessionActivity(sessionId) + } catch (error) { + console.error('Safe session activity update failed:', error) + } + }, + + async getActiveSession(userId: number) { + try { + return await SessionRepository.getActiveSessionByUserId(userId) + } catch (error) { + console.error('Safe get active session failed:', error) + return null + } + } +} + +// lib/session/monitoring.ts - 성능 모니터링 (수정된 버전) +export class SessionMonitoring { + private static metrics = { + pageVisitRecords: 0, + sessionUpdates: 0, + cacheHits: 0, + cacheMisses: 0, + errors: 0 + } + + static incrementMetric(metric: keyof typeof this.metrics) { + this.metrics[metric]++ + } + + static getMetrics() { + return { ...this.metrics } + } + + static resetMetrics() { + Object.keys(this.metrics).forEach(key => { + this.metrics[key as keyof typeof this.metrics] = 0 + }) + } + + // 성능 통계 로깅 (수정된 버전) + static logPerformanceStats() { + const repoStats = SessionRepository.getRepositoryStats() + + console.log('Session Repository Performance:', { + ...this.metrics, + cacheHitRate: this.metrics.cacheHits / (this.metrics.cacheHits + this.metrics.cacheMisses) * 100 || 0, + ...repoStats // cacheSize, queueSize 등 포함 + }) + } + + // 상세 성능 리포트 생성 + static getDetailedPerformanceReport() { + const repoStats = SessionRepository.getRepositoryStats() + const totalRequests = this.metrics.cacheHits + this.metrics.cacheMisses + + return { + metrics: this.getMetrics(), + repository: repoStats, + performance: { + cacheHitRate: totalRequests > 0 ? (this.metrics.cacheHits / totalRequests) * 100 : 0, + errorRate: this.metrics.pageVisitRecords > 0 ? (this.metrics.errors / this.metrics.pageVisitRecords) * 100 : 0, + queueUtilization: repoStats.queueSize / 100 * 100, // 100이 최대 큐 크기라고 가정 + }, + status: { + healthy: this.metrics.errors / Math.max(this.metrics.pageVisitRecords, 1) < 0.01, // 1% 미만 에러율 + cacheEfficient: totalRequests > 0 ? (this.metrics.cacheHits / totalRequests) > 0.8 : true, // 80% 이상 캐시 히트율 + queueManageable: repoStats.queueSize < 50 // 큐 크기가 50 미만 + } + } + } +}
\ No newline at end of file diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts index 8e9d386b..54672f33 100644 --- a/lib/vendor-document-list/dolce-upload-service.ts +++ b/lib/vendor-document-list/dolce-upload-service.ts @@ -16,6 +16,11 @@ export interface DOLCEUploadResult { } } +interface FileReaderConfig { + baseDir: string; + isProduction: boolean; +} + interface DOLCEDocument { Mode: "ADD" | "MOD" Status: string @@ -65,6 +70,24 @@ interface DOLCEFileMapping { UploadId: string } +function getFileReaderConfig(): FileReaderConfig { + const isProduction = process.env.NODE_ENV === "production"; + + if (isProduction) { + return { + baseDir: process.env.NAS_PATH || "/evcp_nas", // NAS 기본 경로 + isProduction: true, + }; + } else { + return { + baseDir: path.join(process.cwd(), "public"), // 개발환경 public 폴더 + isProduction: false, + }; + } +} + + + class DOLCEUploadService { private readonly BASE_URL = process.env.DOLCE_API_URL || 'http://60.100.99.217:1111' private readonly UPLOAD_SERVICE_URL = process.env.DOLCE_UPLOAD_URL || 'http://60.100.99.217:1111/PWPUploadService.ashx' @@ -80,13 +103,13 @@ class DOLCEUploadService { ): Promise<DOLCEUploadResult> { try { console.log(`Starting DOLCE upload for contract ${contractId}, revisions: ${revisionIds.join(', ')}`) - + // 1. 계약 정보 조회 (프로젝트 코드, 벤더 코드 등) const contractInfo = await this.getContractInfo(contractId) if (!contractInfo) { throw new Error(`Contract info not found for ID: ${contractId}`) } - + // 2. 업로드할 리비전 정보 조회 const revisionsToUpload = await this.getRevisionsForUpload(revisionIds) if (revisionsToUpload.length === 0) { @@ -96,9 +119,7 @@ class DOLCEUploadService { uploadedFiles: 0 } } - - // 3. 각 issueStageId별로 첫 번째 revision 정보를 미리 조회 (Mode 결정용) - + let uploadedDocuments = 0 let uploadedFiles = 0 const errors: string[] = [] @@ -107,69 +128,63 @@ class DOLCEUploadService { fileResults: [], mappingResults: [] } - - // 4. 각 리비전별로 처리 - // 4. 각 리비전별로 처리 + + // 3. 각 리비전별로 처리 for (const revision of revisionsToUpload) { try { console.log(`Processing revision ${revision.revision} for document ${revision.documentNo}`) - - // 4-1. 파일이 있는 경우 먼저 업로드 + + // 3-1. UploadId 미리 생성 (파일이 있는 경우에만) let uploadId: string | undefined if (revision.attachments && revision.attachments.length > 0) { - // ✅ userId를 uploadFiles 메서드에 전달 - const fileUploadResults = await this.uploadFiles(revision.attachments, userId) - - if (fileUploadResults.length > 0) { - uploadId = fileUploadResults[0].uploadId // 첫 번째 파일의 UploadId 사용 - uploadedFiles += fileUploadResults.length - results.fileResults.push(...fileUploadResults) - } + uploadId = uuidv4() // 문서 업로드 시 사용할 UploadId 미리 생성 + console.log(`Generated UploadId for document upload: ${uploadId}`) } - - // 4-2. 문서 정보 업로드 + + // 3-2. 문서 정보 업로드 (UploadId 포함) const dolceDoc = this.transformToDoLCEDocument( revision, contractInfo, - uploadId, + uploadId, // 미리 생성된 UploadId 사용 contractInfo.vendorCode, ) - + const docResult = await this.uploadDocument([dolceDoc], userId) - if (docResult.success) { - uploadedDocuments++ - results.documentResults.push(docResult) - - // 4-3. 파일이 있는 경우 매핑 정보 전송 - if (uploadId && revision.attachments && revision.attachments.length > 0) { - const mappingData = this.transformToFileMapping( - revision, - contractInfo, - uploadId, - revision.attachments[0].fileName + if (!docResult.success) { + errors.push(`Document upload failed for ${revision.documentNo}: ${docResult.error}`) + continue // 문서 업로드 실패 시 다음 리비전으로 넘어감 + } + + uploadedDocuments++ + results.documentResults.push(docResult) + console.log(`✅ Document uploaded successfully: ${revision.documentNo}`) + + // 3-3. 파일 업로드 (이미 생성된 UploadId 사용) + if (uploadId && revision.attachments && revision.attachments.length > 0) { + try { + // 파일 업로드 시 이미 생성된 UploadId 사용 + const fileUploadResults = await this.uploadFiles( + revision.attachments, + userId, + uploadId // 이미 생성된 UploadId 전달 ) - - const mappingResult = await this.uploadFileMapping([mappingData], userId) - if (mappingResult.success) { - results.mappingResults.push(mappingResult) - } else { - errors.push(`File mapping failed for ${revision.documentNo}: ${mappingResult.error}`) - } + + } catch (fileError) { + errors.push(`File upload failed for ${revision.documentNo}: ${fileError instanceof Error ? fileError.message : 'Unknown error'}`) + console.error(`❌ File upload failed for ${revision.documentNo}:`, fileError) } - - // 4-4. 성공한 리비전의 상태 업데이트 - await this.updateRevisionStatus(revision.id, 'SUBMITTED', uploadId) - - } else { - errors.push(`Document upload failed for ${revision.documentNo}: ${docResult.error}`) } - + + // 3-5. 성공한 리비전의 상태 업데이트 + await this.updateRevisionStatus(revision.id, 'SUBMITTED', uploadId) + } catch (error) { const errorMessage = `Failed to process revision ${revision.revision}: ${error instanceof Error ? error.message : 'Unknown error'}` errors.push(errorMessage) console.error(errorMessage, error) } } + return { success: errors.length === 0, uploadedDocuments, @@ -177,13 +192,12 @@ class DOLCEUploadService { errors: errors.length > 0 ? errors : undefined, results } - + } catch (error) { console.error('DOLCE upload failed:', error) throw error } } - /** * 계약 정보 조회 */ @@ -312,75 +326,77 @@ class DOLCEUploadService { return revisionsWithAttachments } - /** - * 파일 업로드 (PWPUploadService.ashx) - */ - /** - * 파일 업로드 (PWPUploadService.ashx) - DB 업데이트 포함 - */ - private async uploadFiles( - attachments: any[], - userId: string - ): Promise<Array<{ uploadId: string, fileId: string, filePath: string }>> { - const uploadResults = [] - - for (const attachment of attachments) { - try { - // UploadId와 FileId 생성 (UUID 형태) - const uploadId = uuidv4() - const fileId = uuidv4() - - // 파일 데이터 읽기 (실제 구현에서는 파일 시스템이나 S3에서 읽어옴) - const fileBuffer = await this.getFileBuffer(attachment.filePath) - - const uploadUrl = `${this.UPLOAD_SERVICE_URL}?UploadId=${uploadId}&FileId=${fileId}` - - const response = await fetch(uploadUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/octet-stream', - }, - body: fileBuffer - }) +/** + * 파일 업로드 (PWPUploadService.ashx) - 수정된 버전 + * @param attachments 업로드할 첨부파일 목록 + * @param userId 사용자 ID + * @param uploadId 이미 생성된 UploadId (문서 업로드 시 생성됨) + */ +private async uploadFiles( + attachments: any[], + userId: string, + uploadId: string // 이미 생성된 UploadId를 매개변수로 받음 +): Promise<Array<{ uploadId: string, fileId: string, filePath: string }>> { + const uploadResults = [] - if (!response.ok) { - const errorText = await response.text() - throw new Error(`File upload failed: HTTP ${response.status} - ${errorText}`) - } + for (const attachment of attachments) { + try { + // FileId만 새로 생성 (UploadId는 이미 생성된 것 사용) + const fileId = uuidv4() - const dolceFilePath = await response.text() // DOLCE에서 반환하는 파일 경로 - - // ✅ 업로드 성공 후 documentAttachments 테이블 업데이트 - await db - .update(documentAttachments) - .set({ - uploadId: uploadId, - fileId: fileId, - uploadedBy: userId, - dolceFilePath: dolceFilePath, - uploadedAt: new Date(), - updatedAt: new Date() - }) - .where(eq(documentAttachments.id, attachment.id)) - - uploadResults.push({ - uploadId, - fileId, - filePath: dolceFilePath - }) + console.log(`Uploading file with predefined UploadId: ${uploadId}, FileId: ${fileId}`) + + // 파일 데이터 읽기 + const fileBuffer = await this.getFileBuffer(attachment.filePath) - console.log(`✅ File uploaded successfully: ${attachment.fileName} -> ${dolceFilePath}`) - console.log(`✅ DB updated for attachment ID: ${attachment.id}`) + const uploadUrl = `${this.UPLOAD_SERVICE_URL}?UploadId=${uploadId}&FileId=${fileId}` - } catch (error) { - console.error(`❌ File upload failed for ${attachment.fileName}:`, error) - throw error + const response = await fetch(uploadUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + }, + body: fileBuffer + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`File upload failed: HTTP ${response.status} - ${errorText}`) } - } - return uploadResults + const dolceFilePath = await response.text() // DOLCE에서 반환하는 파일 경로 + + // 업로드 성공 후 documentAttachments 테이블 업데이트 + await db + .update(documentAttachments) + .set({ + uploadId: uploadId, // 이미 생성된 UploadId 사용 + fileId: fileId, + uploadedBy: userId, + dolceFilePath: dolceFilePath, + uploadedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(documentAttachments.id, attachment.id)) + + uploadResults.push({ + uploadId, + fileId, + filePath: dolceFilePath + }) + + console.log(`✅ File uploaded successfully: ${attachment.fileName} -> ${dolceFilePath}`) + console.log(`✅ DB updated for attachment ID: ${attachment.id}`) + + } catch (error) { + console.error(`❌ File upload failed for ${attachment.fileName}:`, error) + throw error + } } + return uploadResults +} + /** * 문서 정보 업로드 (DetailDwgReceiptMgmtEdit) */ @@ -468,98 +484,125 @@ class DOLCEUploadService { /** * 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용) */ - private transformToDoLCEDocument( - revision: any, - contractInfo: any, - uploadId?: string, - vendorCode?: string, - ): DOLCEDocument { - // Mode 결정: 해당 issueStageId의 첫 번째 revision인지 확인 - let mode: "ADD" | "MOD" = "MOD" // 기본값은 MOD\ - - - if(revision.registerId){ - mode = "MOD" - } else{ - mode = "ADD" - } - - // RegisterKind 결정: stageName에 따라 설정 - let registerKind = "APPC" // 기본값 +/** + * 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용) + */ +private transformToDoLCEDocument( + revision: any, + contractInfo: any, + uploadId?: string, + vendorCode?: string, +): DOLCEDocument { + // Mode 결정: registerId가 있으면 MOD, 없으면 ADD + let mode: "ADD" | "MOD" = "ADD" // 기본값은 ADD + + if (revision.registerId) { + mode = "MOD" + } else { + mode = "ADD" + } - if (revision.stageName) { - const stageNameLower = revision.stageName.toLowerCase() + // RegisterKind 결정: usage와 usageType에 따라 설정 + let registerKind = "APPR" // 기본값 - if (revision.drawingKind === "B4") { - // B4: 기존 로직 - if (stageNameLower.includes("pre")) { - registerKind = "RECP" - } else if (stageNameLower.includes("working")) { - registerKind = "RECW" - } - } else if (revision.drawingKind === "B5") { - // B5: FMEA 관련 - if (stageNameLower.includes("pre")) { - registerKind = "FMEA-R1" - } else if (stageNameLower.includes("working")) { - registerKind = "FMEA-R2" + if (revision.usage && revision.usage !== 'DEFAULT') { + switch (revision.usage) { + case "APPROVAL": + if (revision.usageType === "Full") { + registerKind = "APPR" + } else if (revision.usageType === "Partial") { + registerKind = "APPR-P" + } else { + registerKind = "APPR" // 기본값 } - } else if (revision.drawingKind === "B3") { - // B3: WORK/APPC - if (stageNameLower.includes("work") && revision.usage.includes('Partial')) { - registerKind = "WORK-P" - } else if (stageNameLower.includes("work") && revision.usage.includes('Full')) { + break + + case "WORKING": + if (revision.usageType === "Full") { registerKind = "WORK" - } else if (stageNameLower.includes("approval") && revision.usage.includes('Partial')) { - registerKind = "APPC-P" - } - else if (stageNameLower.includes("approval") && revision.usage.includes('Full')) { - registerKind = "APPC" + } else if (revision.usageType === "Partial") { + registerKind = "WORK-P" + } else { + registerKind = "WORK" // 기본값 } - } - } + break - const getSerialNumber = (revisionValue: string): number => { - // 먼저 숫자인지 확인 - const numericValue = parseInt(revisionValue) - if (!isNaN(numericValue)) { - return numericValue - } + case "The 1st": + registerKind = "FMEA-1" + break - // 문자인 경우 (a=1, b=2, c=3, ...) - if (typeof revisionValue === 'string' && revisionValue.length === 1) { - const charCode = revisionValue.toLowerCase().charCodeAt(0) - if (charCode >= 97 && charCode <= 122) { // a-z - return charCode - 96 // a=1, b=2, c=3, ... - } - } + case "The 2nd": + registerKind = "FMEA-2" + break + + case "Pre": + registerKind = "RECP" + break + + case "Working": + registerKind = "RECW" + break - // 기본값 + case "Mark-Up": + registerKind = "CMTM" + break + + default: + console.warn(`Unknown usage type: ${revision.usage}, using default APPR`) + registerKind = "APPR" // 기본값 + break + } + } else { + console.warn(`No usage specified for revision ${revision.revision}, using default APPR`) + } + + // Serial Number 계산 함수 + const getSerialNumber = (revisionValue: string): number => { + if (!revisionValue) { return 1 } + // 먼저 숫자인지 확인 + const numericValue = parseInt(revisionValue) + if (!isNaN(numericValue)) { + return numericValue + } - return { - Mode: mode, - Status: revision.revisionStatus || "Standby", - RegisterId: revision.externalRegisterId, // 업데이트된 필드 사용 - ProjectNo: contractInfo.projectCode, - Discipline: revision.discipline, - DrawingKind: revision.drawingKind, - DrawingNo: revision.documentNo, - DrawingName: revision.documentName, - RegisterGroupId: revision.registerGroupId || 0, - RegisterSerialNo: getSerialNumber(revision.revision || "1"), - RegisterKind: registerKind, // stageName에 따라 동적 설정 - DrawingRevNo: revision.revision || "-", - Category: revision.category || "TS", - Receiver: null, - Manager: revision.managerNo || "202206", // 담당자 번호 사용 - RegisterDesc: revision.comment || "System upload", - UploadId: uploadId, - RegCompanyCode: vendorCode || "A0005531" // 벤더 코드 + // 문자인 경우 (a=1, b=2, c=3, ...) + if (typeof revisionValue === 'string' && revisionValue.length === 1) { + const charCode = revisionValue.toLowerCase().charCodeAt(0) + if (charCode >= 97 && charCode <= 122) { // a-z + return charCode - 96 // a=1, b=2, c=3, ... + } } + + // 기본값 + return 1 } + + console.log(`Transform to DOLCE: Mode=${mode}, RegisterKind=${registerKind}, Usage=${revision.usage}, UsageType=${revision.usageType}`) + + return { + Mode: mode, + Status: revision.revisionStatus || "Standby", + RegisterId: revision.registerId || 0, // registerId가 없으면 0 (ADD 모드) + ProjectNo: contractInfo.projectCode, + Discipline: revision.discipline || "DL", + DrawingKind: revision.drawingKind || "B3", + DrawingNo: revision.documentNo, + DrawingName: revision.documentName, + RegisterGroupId: revision.registerGroupId || 0, + RegisterSerialNo: getSerialNumber(revision.revision || "1"), + RegisterKind: registerKind, // usage/usageType에 따라 동적 설정 + DrawingRevNo: revision.revision || "-", + Category: revision.category || "TS", + Receiver: null, + Manager: revision.managerNo || "202206", // 담당자 번호 사용 + RegisterDesc: revision.comment || "System upload", + UploadId: uploadId, + RegCompanyCode: vendorCode || "A0005531" // 벤더 코드 + } +} /** * 파일 매핑 데이터 변환 */ @@ -598,54 +641,74 @@ class DOLCEUploadService { } } + + /** * 파일 버퍼 읽기 (실제 파일 시스템 기반) - 타입 에러 수정 */ private async getFileBuffer(filePath: string): Promise<ArrayBuffer> { try { - console.log(`Reading file from path: ${filePath}`) - + console.log(`📂 파일 읽기 요청: ${filePath}`); + if (filePath.startsWith('http')) { - // URL인 경우 직접 다운로드 - const response = await fetch(filePath) + // ✅ URL인 경우 직접 다운로드 (기존과 동일) + console.log(`🌐 HTTP URL에서 파일 다운로드: ${filePath}`); + + const response = await fetch(filePath); if (!response.ok) { - throw new Error(`Failed to download file: ${response.status}`) + throw new Error(`파일 다운로드 실패: ${response.status}`); } - return await response.arrayBuffer() + + const arrayBuffer = await response.arrayBuffer(); + console.log(`✅ HTTP 다운로드 완료: ${arrayBuffer.byteLength} bytes`); + + return arrayBuffer; } else { - // 로컬 파일 경로인 경우 - const fs = await import('fs') - const path = await import('path') - - let actualFilePath: string - + // ✅ 로컬/NAS 파일 경로 처리 (환경별 분기) + const fs = await import('fs'); + const path = await import('path'); + const config = getFileReaderConfig(); + + let actualFilePath: string; + + // 경로 형태별 처리 if (filePath.startsWith('/documents/')) { - // DB에 저장된 경로 형태: "/documents/[uuid].ext" - // 실제 파일 시스템 경로로 변환: "public/documents/[uuid].ext" - actualFilePath = path.join(process.cwd(), 'public', filePath) - } else if (filePath.startsWith('/')) { - // 절대 경로인 경우 public 디렉토리 기준으로 변환 - actualFilePath = path.join(process.cwd(), 'public', filePath) - } else { - // 상대 경로인 경우 그대로 사용 - actualFilePath = filePath + // ✅ DB에 저장된 경로 형태: "/documents/[uuid].ext" + // 개발: public/documents/[uuid].ext + // 프로덕션: /evcp_nas/documents/[uuid].ext + actualFilePath = path.join(config.baseDir, filePath.substring(1)); // 앞의 '/' 제거 + console.log(`📁 documents 경로 처리: ${filePath} → ${actualFilePath}`); + } + + else { + // ✅ 상대 경로는 현재 디렉토리 기준 + actualFilePath = filePath; + console.log(`📂 상대 경로 사용: ${actualFilePath}`); } - + + console.log(`🔍 실제 파일 경로: ${actualFilePath}`); + console.log(`🏠 환경: ${config.isProduction ? 'PRODUCTION (NAS)' : 'DEVELOPMENT (public)'}`); + // 파일 존재 여부 확인 if (!fs.existsSync(actualFilePath)) { - throw new Error(`File not found: ${actualFilePath}`) + console.error(`❌ 파일 없음: ${actualFilePath}`); + throw new Error(`파일을 찾을 수 없습니다: ${actualFilePath}`); } - + // 파일 읽기 - const fileBuffer = fs.readFileSync(actualFilePath) - console.log(`✅ File read successfully: ${actualFilePath} (${fileBuffer.length} bytes)`) - - // Buffer를 ArrayBuffer로 변환 (타입 안전성 보장) - return new ArrayBuffer(fileBuffer.length).slice(0).constructor(fileBuffer) + const fileBuffer = fs.readFileSync(actualFilePath); + console.log(`✅ 파일 읽기 성공: ${actualFilePath} (${fileBuffer.length} bytes)`); + + // ✅ Buffer를 ArrayBuffer로 정확히 변환 + const arrayBuffer = new ArrayBuffer(fileBuffer.length); + const uint8Array = new Uint8Array(arrayBuffer); + uint8Array.set(fileBuffer); + + return arrayBuffer; } } catch (error) { - console.error(`❌ Failed to read file: ${filePath}`, error) - throw error + console.error(`❌ 파일 읽기 실패: ${filePath}`, error); + throw error; } } diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts index 9a4e44db..bc384ea2 100644 --- a/lib/vendor-document-list/import-service.ts +++ b/lib/vendor-document-list/import-service.ts @@ -890,22 +890,67 @@ class ImportService { detailDoc: DOLCEDetailDocument, sourceSystem: string ): Promise<'NEW' | 'UPDATED' | 'SKIPPED'> { - // 기존 revision 조회 (registerId로) - const existingRevision = await db - .select() - .from(revisions) - .where(and( - eq(revisions.issueStageId, issueStageId), - eq(revisions.registerId, detailDoc.RegisterId) - )) - .limit(1) - + + // 🆕 여러 조건으로 기존 revision 조회 + let existingRevision = null + + // 1차: registerId로 조회 (가장 정확한 매칭) + if (detailDoc.RegisterId) { + const results = await db + .select() + .from(revisions) + .where(and( + eq(revisions.issueStageId, issueStageId), + eq(revisions.registerId, detailDoc.RegisterId) + )) + .limit(1) + + if (results.length > 0) { + existingRevision = results[0] + console.log(`Found revision by registerId: ${detailDoc.RegisterId}`) + } + } + + // 2차: externalUploadId로 조회 (업로드했던 revision 매칭) + if (!existingRevision && detailDoc.UploadId) { + const results = await db + .select() + .from(revisions) + .where(and( + eq(revisions.issueStageId, issueStageId), + eq(revisions.externalUploadId, detailDoc.UploadId) + )) + .limit(1) + + if (results.length > 0) { + existingRevision = results[0] + console.log(`Found revision by externalUploadId: ${detailDoc.UploadId}`) + } + } + + // 3차: DrawingRevNo로 조회 (같은 issueStage 내에서 revision 번호 매칭) + if (!existingRevision && detailDoc.DrawingRevNo) { + const results = await db + .select() + .from(revisions) + .where(and( + eq(revisions.issueStageId, issueStageId), + eq(revisions.revision, detailDoc.DrawingRevNo) + )) + .limit(1) + + if (results.length > 0) { + existingRevision = results[0] + console.log(`Found revision by DrawingRevNo: ${detailDoc.DrawingRevNo}`) + } + } + // Category에 따른 uploaderType 매핑 const uploaderType = this.mapCategoryToUploaderType(detailDoc.Category) - // RegisterKind에 따른 usage, usageType 매핑 (기본 로직, 추후 개선) + // RegisterKind에 따른 usage, usageType 매핑 const { usage, usageType } = this.mapRegisterKindToUsage(detailDoc.RegisterKind) - + // DOLCE 상세 데이터를 revisions 스키마에 맞게 변환 const revisionData = { issueStageId, @@ -916,27 +961,27 @@ class ImportService { usageType, revisionStatus: detailDoc.Status, externalUploadId: detailDoc.UploadId, - registerId: detailDoc.RegisterId, + registerId: detailDoc.RegisterId, // 🆕 항상 최신 registerId로 업데이트 comment: detailDoc.RegisterDesc, submittedDate: this.convertDolceDateToDate(detailDoc.CreateDt), updatedAt: new Date() } - - if (existingRevision.length > 0) { + + if (existingRevision) { // 업데이트 필요 여부 확인 - const existing = existingRevision[0] const hasChanges = - existing.revision !== revisionData.revision || - existing.revisionStatus !== revisionData.revisionStatus || - existing.uploaderName !== revisionData.uploaderName - + existingRevision.revision !== revisionData.revision || + existingRevision.revisionStatus !== revisionData.revisionStatus || + existingRevision.uploaderName !== revisionData.uploaderName || + existingRevision.registerId !== revisionData.registerId // 🆕 registerId 변경 확인 + if (hasChanges) { await db .update(revisions) .set(revisionData) - .where(eq(revisions.id, existing.id)) - - console.log(`Updated revision: ${detailDoc.RegisterId}`) + .where(eq(revisions.id, existingRevision.id)) + + console.log(`Updated revision: ${detailDoc.RegisterId} (local ID: ${existingRevision.id})`) return 'UPDATED' } else { return 'SKIPPED' @@ -949,12 +994,11 @@ class ImportService { ...revisionData, createdAt: new Date() }) - + console.log(`Created new revision: ${detailDoc.RegisterId}`) return 'NEW' } } - /** * Category를 uploaderType으로 매핑 */ @@ -972,12 +1016,75 @@ class ImportService { /** * RegisterKind를 usage/usageType으로 매핑 */ - private mapRegisterKindToUsage(registerKind: string): { usage: string; usageType: string } { - // TODO: 추후 비즈니스 로직에 맞게 구현 - // 현재는 기본 매핑만 제공 - return { - usage: registerKind || 'DEFAULT', - usageType: registerKind || 'DEFAULT' + private mapRegisterKindToUsage(registerKind: string): { usage: string; usageType: string | null } { + if (!registerKind) { + return { + usage: 'DEFAULT', + usageType: 'DEFAULT' + } + } + + switch (registerKind.toUpperCase()) { + case 'APPR': + return { + usage: 'APPROVAL', + usageType: 'Full' + } + + case 'APPR-P': + return { + usage: 'APPROVAL', + usageType: 'Partial' + } + + case 'WORK': + return { + usage: 'WORKING', + usageType: 'Full' + } + + case 'WORK-P': + return { + usage: 'WORKING', + usageType: 'Partial' + } + + case 'FMEA-1': + return { + usage: 'The 1st', + usageType: null + } + + case 'FMEA-2': + return { + usage: 'The 2nd', + usageType: null + } + + case 'RECP': + return { + usage: 'Pre', + usageType: null + } + + case 'RECW': + return { + usage: 'Working', + usageType: null + } + + case 'CMTM': + return { + usage: 'Mark-Up', + usageType: null + } + + default: + console.warn(`Unknown RegisterKind: ${registerKind}`) + return { + usage: registerKind, + usageType: 'DEFAULT' + } } } diff --git a/lib/vendor-document/service.ts b/lib/vendor-document/service.ts index a0ae6f76..bf2b0b7a 100644 --- a/lib/vendor-document/service.ts +++ b/lib/vendor-document/service.ts @@ -9,10 +9,9 @@ import { filterColumns } from "@/lib/filter-columns"; import { getErrorMessage } from "@/lib/handle-error"; import { asc, desc, ilike, inArray, and, gte, lte, not, or , isNotNull, isNull} from "drizzle-orm"; import { countVendorDocuments, selectVendorDocuments } from "./repository" -import path from "path"; -import fs from "fs/promises"; -import { v4 as uuidv4 } from "uuid" import { contractItems } from "@/db/schema" +import { saveFile } from "../file-stroage" +import path from "path" /** * 특정 vendorId에 속한 문서 목록 조회 @@ -340,23 +339,17 @@ export async function createRevisionAction(formData: FormData) { let attachmentRecord: typeof documentAttachments.$inferSelect | null = null; if (file && file.size > 0) { - const originalName = customFileName - const ext = path.extname(originalName) - const uniqueName = uuidv4() + ext - const baseDir = path.join(process.cwd(), "public", "documents") - const savePath = path.join(baseDir, uniqueName) - - const arrayBuffer = await file.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - await fs.writeFile(savePath, buffer) + + const ext = path.extname(customFileName) + const saveResult = await saveFile({file,directory:`documents`, originalName:customFileName}) // 파일 정보를 documentAttachments 테이블에 저장 const result = await tx .insert(documentAttachments) .values({ revisionId, - fileName: originalName, - filePath: "/documents/" + uniqueName, + fileName: customFileName, + filePath: saveResult.publicPath!, fileSize: file.size, fileType: ext.replace('.', '').toLowerCase(), updatedAt: new Date(), diff --git a/lib/vendor-evaluation-submit/service.ts b/lib/vendor-evaluation-submit/service.ts index 5ab1206e..63a6bdb6 100644 --- a/lib/vendor-evaluation-submit/service.ts +++ b/lib/vendor-evaluation-submit/service.ts @@ -42,7 +42,7 @@ export type EvaluationSubmissionWithVendor = EvaluationSubmission & { /** * 평가 제출 목록을 조회합니다 */ -export async function getEvaluationSubmissions(input: GetEvaluationsSubmitSchema) { +export async function getEvaluationSubmissions(input: GetEvaluationsSubmitSchema, vendorId: number) { try { const offset = (input.page - 1) * input.perPage; @@ -69,7 +69,8 @@ export async function getEvaluationSubmissions(input: GetEvaluationsSubmitSchema const finalWhere = and( advancedWhere, globalWhere, - eq(evaluationSubmissions.isActive, true) + eq(evaluationSubmissions.isActive, true), + eq(evaluationSubmissions.companyId, vendorId), ); // 정렬 diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts index bcf9efd4..7c486fc9 100644 --- a/lib/vendor-investigation/service.ts +++ b/lib/vendor-investigation/service.ts @@ -14,6 +14,7 @@ import path from "path" import { v4 as uuid } from "uuid" import { vendorsLogs } from "@/db/schema"; import { cache } from "react" +import { deleteFile } from "../file-stroage"; export async function getVendorsInvestigation(input: GetVendorsInvestigationSchema) { return unstable_cache( @@ -340,11 +341,7 @@ export async function deleteInvestigationAttachment(attachmentId: number) { return { success: false, error: "첨부파일을 찾을 수 없습니다." } } - // 실제 파일 삭제 - const fullFilePath = path.join(process.cwd(), "public", attachment.filePath) - if (fs.existsSync(fullFilePath)) { - fs.unlinkSync(fullFilePath) - } + await deleteFile(attachment.filePath) // 데이터베이스에서 레코드 삭제 await db @@ -379,11 +376,7 @@ export async function getAttachmentDownloadInfo(attachmentId: number) { return { success: false, error: "첨부파일을 찾을 수 없습니다." } } - const fullFilePath = path.join(process.cwd(), "public", attachment.filePath) - if (!fs.existsSync(fullFilePath)) { - return { success: false, error: "파일이 존재하지 않습니다." } - } - + return { success: true, downloadInfo: { diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 7c6ac15d..2328752b 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -50,10 +50,6 @@ import type { import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count, sql } from "drizzle-orm"; import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq"; import path from "path"; -import fs from "fs/promises"; -import { randomUUID } from "crypto"; -import JSZip from 'jszip'; -import { promises as fsPromises } from 'fs'; import { sendEmail } from "../mail/sendEmail"; import { PgTransaction } from "drizzle-orm/pg-core"; import { items, materials } from "@/db/schema/items"; @@ -61,7 +57,7 @@ import { roles, userRoles, users } from "@/db/schema/users"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { contracts, contractsDetailView, projects, vendorPQSubmissions, vendorProjectPQs, vendorsLogs } from "@/db/schema"; -import { Hospital } from "lucide-react"; +import { deleteFile, saveFile } from "../file-stroage"; /* ----------------------------------------------------- @@ -235,32 +231,17 @@ async function storeVendorFiles( files: File[], attachmentType: string ) { - const vendorDir = path.join( - process.cwd(), - "public", - "vendors", - String(vendorId) - ) - await fs.mkdir(vendorDir, { recursive: true }) + for (const file of files) { - // Convert file to buffer - const ab = await file.arrayBuffer() - const buffer = Buffer.from(ab) - // Generate a unique filename - const uniqueName = `${randomUUID()}-${file.name}` - const relativePath = path.join("vendors", String(vendorId), uniqueName) - const absolutePath = path.join(process.cwd(), "public", relativePath) - - // Write to disk - await fs.writeFile(absolutePath, buffer) + const saveResult = await saveFile({file, directory:`vendors/${vendorId}` }) // Insert attachment record await tx.insert(vendorAttachments).values({ vendorId, fileName: file.name, - filePath: "/" + relativePath.replace(/\\/g, "/"), + filePath: saveResult.publicPath, attachmentType, // "GENERAL", "CREDIT_RATING", "CASH_FLOW_RATING", ... }) } @@ -1440,17 +1421,8 @@ export async function cleanupTempFiles(fileName: string) { 'use server'; try { - const tempDir = path.join(process.cwd(), 'tmp'); - const filePath = path.join(tempDir, fileName); - - try { - // 파일 존재 확인 - await fsPromises.access(filePath, fs.constants.F_OK); - // 파일 삭제 - await fsPromises.unlink(filePath); - } catch { - // 파일이 없으면 무시 - } + + await deleteFile(`tmp/${fileName}`) return { success: true }; } catch (error) { @@ -2318,10 +2290,9 @@ export async function updateVendorInfo(params: { // 3-2. 파일 시스템에서 파일 삭제 for (const attachment of attachmentsToDelete) { try { - // 파일 경로는 /public 기준이므로 process.cwd()/public을 앞에 붙임 - const filePath = path.join(process.cwd(), 'public', attachment.filePath.replace(/^\//, '')) - await fs.access(filePath, fs.constants.F_OK) // 파일 존재 확인 - await fs.unlink(filePath) // 파일 삭제 + + await deleteFile(attachment.filePath) + } catch (error) { console.warn(`Failed to delete file for attachment ${attachment.id}:`, error) // 파일 삭제 실패해도 DB에서는 삭제 진행 diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts index 07eaae83..6c106600 100644 --- a/lib/vendors/validations.ts +++ b/lib/vendors/validations.ts @@ -208,6 +208,7 @@ export const createVendorSchema = z .max(255, "Max length 255"), vendorTypeId: z.number({ required_error: "업체유형을 선택해주세요" }), + representativeWorkExpirence: z.boolean(), email: z.string().email("Invalid email").max(255), |
