summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/service.ts147
-rw-r--r--lib/basic-contract/status/basic-contract-columns.tsx153
-rw-r--r--lib/dashboard/dashboard-client.tsx91
-rw-r--r--lib/dashboard/service.ts10
-rw-r--r--lib/evaluation-submit/evaluation-form.tsx592
-rw-r--r--lib/evaluation-submit/evaluation-page.tsx258
-rw-r--r--lib/evaluation-submit/service.ts562
-rw-r--r--lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx556
-rw-r--r--lib/evaluation-submit/table/evaluation-submit-dialog.tsx353
-rw-r--r--lib/evaluation-submit/table/submit-table.tsx281
-rw-r--r--lib/evaluation-submit/validation.ts161
-rw-r--r--lib/evaluation-target-list/service.ts80
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table copy.tsx508
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-columns.tsx405
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx119
-rw-r--r--lib/evaluation-target-list/validation.ts318
-rw-r--r--lib/evaluation/service.ts1348
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx213
-rw-r--r--lib/evaluation/table/evaluation-details-dialog.tsx366
-rw-r--r--lib/evaluation/table/evaluation-table.tsx11
-rw-r--r--lib/evaluation/table/periodic-evaluation-action-dialogs.tsx231
-rw-r--r--lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx305
-rw-r--r--lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx179
-rw-r--r--lib/file-download.ts260
-rw-r--r--lib/file-stroage.ts283
-rw-r--r--lib/forms/services.ts50
-rw-r--r--lib/information/table/update-information-dialog.tsx6
-rw-r--r--lib/login-session/service.ts118
-rw-r--r--lib/login-session/table/login-sessions-table-columns.tsx243
-rw-r--r--lib/login-session/table/login-sessions-table-toolbar-actions.tsx78
-rw-r--r--lib/login-session/table/login-sessions-table.tsx137
-rw-r--r--lib/login-session/validation.ts45
-rw-r--r--lib/mail/layouts/base.hbs22
-rw-r--r--lib/mail/mailer.ts133
-rw-r--r--lib/mail/partials/footer.hbs8
-rw-r--r--lib/mail/partials/header.hbs7
-rw-r--r--lib/mail/sendEmail.ts53
-rw-r--r--lib/mail/templates/evaluation-request.hbs285
-rw-r--r--lib/project-gtc/service.ts67
-rw-r--r--lib/project-gtc/table/project-gtc-table-columns.tsx50
-rw-r--r--lib/rfqs-tech/service.ts142
-rw-r--r--lib/rfqs/service.ts110
-rw-r--r--lib/tech-vendors/service.ts38
-rw-r--r--lib/techsales-rfq/service.ts56
-rw-r--r--lib/users/auth/passwordUtil.ts2
-rw-r--r--lib/users/auth/verifyCredentails.ts11
-rw-r--r--lib/users/middleware/page-tracking.ts98
-rw-r--r--lib/users/session/helper.ts62
-rw-r--r--lib/users/session/repository.ts460
-rw-r--r--lib/vendor-document-list/dolce-upload-service.ts501
-rw-r--r--lib/vendor-document-list/import-service.ts169
-rw-r--r--lib/vendor-document/service.ts21
-rw-r--r--lib/vendor-evaluation-submit/service.ts5
-rw-r--r--lib/vendor-investigation/service.ts13
-rw-r--r--lib/vendors/service.ts47
-rw-r--r--lib/vendors/validations.ts1
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),