summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-10 09:55:45 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-10 09:55:45 +0000
commitc657ef972feeafff16ab0e07cb4771f7dd141ba0 (patch)
treebefabd884b00d3cc632c628b3e3810f61cc9f38d /lib
parentb8a03c9d130435a71c5d6217d06ccb0beb9697e5 (diff)
(대표님) 20250710 작업사항 - 평가 첨부, 로그인, SEDP 변경 요구사항 반영
Diffstat (limited to 'lib')
-rw-r--r--lib/evaluation-submit/evaluation-form.tsx453
-rw-r--r--lib/evaluation-submit/service.ts385
-rw-r--r--lib/evaluation-target-list/service.ts195
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table.tsx2
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-columns.tsx66
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx181
-rw-r--r--lib/evaluation-target-list/table/update-evaluation-target.tsx10
-rw-r--r--lib/evaluation/service.ts124
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx71
-rw-r--r--lib/evaluation/table/evaluation-details-dialog.tsx591
-rw-r--r--lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx16
-rw-r--r--lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx2
-rw-r--r--lib/items/service.ts3
-rw-r--r--lib/items/table/items-table.tsx50
-rw-r--r--lib/sedp/sync-form.ts44
-rw-r--r--lib/sedp/sync-package.ts30
-rw-r--r--lib/vendor-data/services copy.ts99
-rw-r--r--lib/vendor-data/services.ts35
18 files changed, 1780 insertions, 577 deletions
diff --git a/lib/evaluation-submit/evaluation-form.tsx b/lib/evaluation-submit/evaluation-form.tsx
index 65da72b6..fbdcee69 100644
--- a/lib/evaluation-submit/evaluation-form.tsx
+++ b/lib/evaluation-submit/evaluation-form.tsx
@@ -27,20 +27,28 @@ import {
Send,
ArrowLeft,
AlertCircle,
- FileText
+ FileText,
+ Upload,
+ File,
+ X,
+ Download,
+ Paperclip
} from "lucide-react"
import { useRouter } from "next/navigation"
import { useToast } from "@/hooks/use-toast"
import {
updateEvaluationResponse,
+ updateVariableEvaluationResponse,
completeEvaluation
} from "./service"
import {
- type EvaluationFormData,
type EvaluationQuestionItem,
EVALUATION_CATEGORIES
} from "./validation"
import { DEPARTMENT_CODE_LABELS, divisionMap, vendortypeMap } from "@/types/evaluation"
+import { EvaluationFormData } from "@/types/evaluation-form"
+// 파일 다운로드 유틸리티 import
+import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download"
interface EvaluationFormProps {
formData: EvaluationFormData
@@ -53,6 +61,15 @@ interface QuestionResponse {
comment: string
}
+interface AttachmentInfo {
+ id: number
+ originalFileName: string
+ publicPath: string
+ fileSize: number
+ description?: string
+ createdAt: Date
+}
+
/**
* 평가 폼 메인 컴포넌트 (테이블 레이아웃)
*/
@@ -63,6 +80,7 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
const [isSaving, setIsSaving] = React.useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false)
const [showCompleteDialog, setShowCompleteDialog] = React.useState(false)
+ const [uploadingFiles, setUploadingFiles] = React.useState<Set<number>>(new Set())
const { evaluationInfo, questions } = formData
@@ -85,6 +103,44 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
return initial
})
+ // 첨부파일 상태 관리 (서버에서 받은 데이터로 초기화)
+ const [attachments, setAttachments] = React.useState<Record<number, AttachmentInfo[]>>(() => {
+ console.log('Initializing attachments from server data...')
+ const initial: Record<number, AttachmentInfo[]> = {}
+ questions.forEach(question => {
+ const questionAttachments = question.attachments || []
+ initial[question.criteriaId] = questionAttachments
+ if (questionAttachments.length > 0) {
+ console.log(`Question ${question.criteriaId} has ${questionAttachments.length} attachments:`, questionAttachments)
+ }
+ })
+ console.log('Initial attachments state:', initial)
+ return initial
+ })
+
+ // 첨부파일 다운로드 핸들러 - downloadFile 사용
+ const handleDownloadAttachment = async (attachment: AttachmentInfo) => {
+ try {
+ await downloadFile(
+ attachment.publicPath,
+ attachment.originalFileName,
+ {
+ action: 'download',
+ showToast: true,
+ showSuccessToast: true,
+ onError: (error) => {
+ console.error("파일 다운로드 실패:", error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : '')
+ }
+ }
+ )
+ } catch (error) {
+ console.error("다운로드 처리 중 오류:", error)
+ }
+ }
+
// 카테고리별 질문 그룹화
const questionsByCategory = React.useMemo(() => {
const grouped = questions.reduce((acc, question) => {
@@ -146,6 +202,130 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
setHasUnsavedChanges(true)
}
+ // 파일 업로드 핸들러
+ const handleFileUpload = async (questionId: number, file: File, description?: string) => {
+ try {
+ console.log('Starting file upload for question:', questionId, 'file:', file.name)
+ setUploadingFiles(prev => new Set(prev).add(questionId))
+
+ // 질문 정보 확인
+ const question = questions.find(q => q.criteriaId === questionId)
+ if (!question) {
+ throw new Error('질문을 찾을 수 없습니다.')
+ }
+
+ // 답변이 있는지 확인
+ const response = responses[questionId]
+ const isVariable = question.scoreType === 'variable'
+ const isAnswered = isVariable ?
+ (response.score !== null) :
+ (response.detailId !== null && response.detailId > 0)
+
+ if (!isAnswered) {
+ throw new Error('먼저 답변을 선택해주세요.')
+ }
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append('file', file)
+ formData.append('questionId', questionId.toString())
+ formData.append('evaluationId', evaluationInfo.id.toString())
+
+ if (description) {
+ formData.append('description', description)
+ }
+
+ if (isVariable) {
+ formData.append('isVariable', 'true')
+ }
+
+ console.log('Sending upload request...')
+
+ // 파일 업로드 API 호출
+ const response_api = await fetch('/api/evaluation/attachments', {
+ method: 'POST',
+ body: formData,
+ })
+
+ console.log('Upload response status:', response_api.status)
+
+ if (!response_api.ok) {
+ const errorText = await response_api.text()
+ console.error('Upload failed with status:', response_api.status, 'error:', errorText)
+ throw new Error(`파일 업로드에 실패했습니다. (${response_api.status})`)
+ }
+
+ const result = await response_api.json()
+ console.log('Upload result:', result)
+
+ if (result.success && result.attachment) {
+ // 첨부파일 목록 업데이트
+ setAttachments(prev => ({
+ ...prev,
+ [questionId]: [...(prev[questionId] || []), {
+ id: result.attachment.id,
+ originalFileName: result.attachment.originalFileName,
+ publicPath: result.attachment.publicPath,
+ fileSize: result.attachment.fileSize,
+ description: result.attachment.description,
+ createdAt: new Date(result.attachment.createdAt),
+ }]
+ }))
+
+ toast({
+ title: "파일 업로드 완료",
+ description: `${file.name}이 성공적으로 업로드되었습니다.`,
+ })
+ } else {
+ throw new Error(result.error || '파일 업로드에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('File upload failed:', error)
+ toast({
+ title: "업로드 실패",
+ description: error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setUploadingFiles(prev => {
+ const newSet = new Set(prev)
+ newSet.delete(questionId)
+ return newSet
+ })
+ }
+ }
+
+ // 첨부파일 삭제 핸들러
+ const handleDeleteAttachment = async (questionId: number, attachmentId: number) => {
+ try {
+ const response = await fetch(`/api/evaluation/attachments/${attachmentId}`, {
+ method: 'DELETE',
+ })
+
+ if (!response.ok) {
+ throw new Error('파일 삭제에 실패했습니다.')
+ }
+
+ // 첨부파일 목록에서 제거
+ setAttachments(prev => ({
+ ...prev,
+ [questionId]: prev[questionId]?.filter(att => att.id !== attachmentId) || []
+ }))
+
+ toast({
+ title: "파일 삭제 완료",
+ description: "첨부파일이 삭제되었습니다.",
+ })
+ } catch (error) {
+ console.error('File deletion failed:', error)
+ toast({
+ title: "삭제 실패",
+ description: "파일 삭제 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ }
+
// 임시저장
const handleSave = async () => {
try {
@@ -166,12 +346,23 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
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
- )
+ if (isVariable) {
+ // Variable 타입은 별도 함수 사용
+ return updateVariableEvaluationResponse(
+ evaluationInfo.id,
+ parseInt(questionId), // criteriaId
+ response.score!,
+ response.comment || undefined
+ )
+ } else {
+ // 일반 타입
+ return updateEvaluationResponse(
+ evaluationInfo.id,
+ response.detailId!,
+ response.comment || undefined,
+ response.score || undefined
+ )
+ }
})
await Promise.all(promises)
@@ -285,35 +476,58 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
</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>
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-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>
+
+ {/* 📎 첨부파일 통계 정보 */}
+ {formData.attachmentStats && formData.attachmentStats.totalFiles > 0 && (
+ <div className="border-t pt-4">
+ <div className="flex items-center gap-4 text-sm">
+ <div className="flex items-center gap-2">
+ <Paperclip className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium">첨부파일 현황</span>
+ </div>
+ <div className="flex items-center gap-4">
+ <div className="text-muted-foreground">
+ 총 <span className="font-medium text-foreground">{formData.attachmentStats.totalFiles}</span>개 파일
+ </div>
+ <div className="text-muted-foreground">
+ 크기: <span className="font-medium text-foreground">{formatFileSize(formData.attachmentStats.totalSize)}</span>
+ </div>
+ <div className="text-muted-foreground">
+ 첨부 질문: <span className="font-medium text-foreground">{formData.attachmentStats.questionsWithAttachments}</span>개
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
</Card>
{/* 평가 테이블 - 카테고리별 */}
@@ -370,12 +584,14 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
<TableHead className="w-[200px]">답변 선택</TableHead>
<TableHead className="w-[80px]">점수</TableHead>
<TableHead className="w-[250px]">추가 의견</TableHead>
+ <TableHead className="w-[200px]">첨부파일</TableHead>
<TableHead className="w-[80px]">상태</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categoryQuestions.map((question) => {
const response = responses[question.criteriaId]
+ const questionAttachments = attachments[question.criteriaId] || []
const isVariable = question.scoreType === 'variable'
const isAnswered = isVariable ?
@@ -480,6 +696,80 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
</TableCell>
<TableCell>
+ <div className="space-y-2">
+ {/* 파일 업로드 버튼 */}
+ <div className="flex items-center gap-2">
+ <input
+ type="file"
+ id={`file-${question.criteriaId}`}
+
+ className="hidden"
+ accept=".pdf,.doc,.docx,.hwp,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
+ onChange={(e) => {
+ const file = e.target.files?.[0]
+ if (file && isAnswered) handleFileUpload(question.criteriaId, file)
+ }}
+ disabled={!isAnswered || isLoading || isSaving || uploadingFiles.has(question.criteriaId)}
+ />
+ <Button
+ asChild /* shadcn/ui -> 내부에 <button> 대신 원하는 태그로 감싸 줌 */
+ variant="outline"
+ size="sm"
+ disabled={!isAnswered || isLoading || isSaving || uploadingFiles.has(question.criteriaId)}
+ className="flex items-center gap-1"
+ >
+ <label htmlFor={`file-${question.criteriaId}`} className="cursor-pointer">
+ <Upload className="h-3 w-3" />
+ {uploadingFiles.has(question.criteriaId) ? "업로드 중..." : "파일 첨부"}
+ </label>
+ </Button>
+ </div>
+
+ {/* 첨부된 파일 목록 - 개선된 버전 */}
+ {questionAttachments.length > 0 && (
+ <div className="space-y-1">
+ {questionAttachments.map((attachment) => {
+ const fileInfo = getFileInfo(attachment.originalFileName)
+ return (
+ <div key={attachment.id} className="flex items-center justify-between p-2 bg-muted rounded-md">
+ <div className="flex items-center gap-2 flex-1">
+ <span className="text-sm">{fileInfo.icon}</span>
+ <div className="flex-1 min-w-0">
+ <div className="text-xs font-medium truncate">
+ {attachment.originalFileName}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ {formatFileSize(attachment.fileSize)}
+ </div>
+ </div>
+ </div>
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadAttachment(attachment)}
+ className="h-6 w-6 p-0"
+ >
+ <Download className="h-3 w-3" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteAttachment(question.criteriaId, attachment.id)}
+ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ </div>
+ )
+ })}
+ </div>
+ )}
+ </div>
+ </TableCell>
+
+ <TableCell>
{isAnswered ? (
<Badge variant="default" className="text-xs">
완료
@@ -503,54 +793,51 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
{/* 하단 액션 버튼 */}
<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>
+ {!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-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 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>
diff --git a/lib/evaluation-submit/service.ts b/lib/evaluation-submit/service.ts
index 84d356e7..99c5cb5e 100644
--- a/lib/evaluation-submit/service.ts
+++ b/lib/evaluation-submit/service.ts
@@ -12,11 +12,14 @@ import {
evaluationTargetReviewers,
evaluationTargets,
regEvalCriteria,
- periodicEvaluations
+ periodicEvaluations,
+ reviewerEvaluationAttachments,
+ users
} 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";
+import { DEPARTMENT_CATEGORY_MAPPING, EvaluationFormData, GetSHIEvaluationsSubmitSchema, REVIEWER_TYPES, ReviewerType } from "./validation";
+import { AttachmentInfo, EvaluationQuestionItem } from "@/types/evaluation-form";
// ===============================================================================
@@ -128,7 +131,6 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
const reviewerType = calculateReviewerType(evaluation.division, evaluation.materialType);
// 2. 부서에 따른 카테고리 필터링 로직
- // const categoryFilter = getCategoryFilterByDepartment("admin");
const categoryFilter = getCategoryFilterByDepartment(evaluation.departmentCode);
// 3. 해당 부서에 맞는 평가 기준들과 답변 옵션들 조회
@@ -179,15 +181,90 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
updatedAt: reviewerEvaluationDetails.updatedAt,
})
.from(reviewerEvaluationDetails)
- .where(
- and(
- eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId),
- // ✅ null이 아닌 실제 응답만 조회
- isNotNull(reviewerEvaluationDetails.regEvalCriteriaDetailsId)
- )
- );
+ .where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId));
+
+ // 📎 5. 첨부파일 정보 조회
+ const attachmentsData = await db
+ .select({
+ // 첨부파일 정보
+ attachmentId: reviewerEvaluationAttachments.id,
+ originalFileName: reviewerEvaluationAttachments.originalFileName,
+ storedFileName: reviewerEvaluationAttachments.storedFileName,
+ publicPath: reviewerEvaluationAttachments.publicPath,
+ fileSize: reviewerEvaluationAttachments.fileSize,
+ mimeType: reviewerEvaluationAttachments.mimeType,
+ fileExtension: reviewerEvaluationAttachments.fileExtension,
+ description: reviewerEvaluationAttachments.description,
+ uploadedBy: reviewerEvaluationAttachments.uploadedBy,
+ attachmentCreatedAt: reviewerEvaluationAttachments.createdAt,
+ attachmentUpdatedAt: reviewerEvaluationAttachments.updatedAt,
+
+ // 업로드한 사용자 정보
+ uploadedByName: users.name,
+
+ // 평가 세부사항 정보 (어떤 질문에 대한 첨부파일인지 확인)
+ evaluationDetailId: reviewerEvaluationDetails.id,
+ regEvalCriteriaDetailsId: reviewerEvaluationDetails.regEvalCriteriaDetailsId,
+
+ // 평가 기준 정보 (질문 식별용)
+ criteriaId: regEvalCriteriaDetails.criteriaId,
+ category: regEvalCriteria.category,
+ })
+ .from(reviewerEvaluationAttachments)
+ .innerJoin(
+ reviewerEvaluationDetails,
+ eq(reviewerEvaluationAttachments.reviewerEvaluationDetailId, reviewerEvaluationDetails.id)
+ )
+ .leftJoin(
+ regEvalCriteriaDetails,
+ eq(reviewerEvaluationDetails.regEvalCriteriaDetailsId, regEvalCriteriaDetails.id)
+ )
+ .leftJoin(
+ regEvalCriteria,
+ eq(regEvalCriteriaDetails.criteriaId, regEvalCriteria.id)
+ )
+ .leftJoin(
+ users,
+ eq(reviewerEvaluationAttachments.uploadedBy, users.id)
+ )
+ .where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId))
+ .orderBy(desc(reviewerEvaluationAttachments.createdAt));
+
+ // 📎 6. 첨부파일을 질문별로 그룹화
+ const attachmentsByQuestion = new Map<number, AttachmentInfo[]>();
+ const attachmentsByCategory = new Map<string, number>();
+
+ attachmentsData.forEach(attachment => {
+ // Variable 타입 질문의 경우 criteriaId가 null일 수 있으므로 처리
+ const questionKey = attachment.criteriaId || attachment.evaluationDetailId;
+
+ if (!attachmentsByQuestion.has(questionKey)) {
+ attachmentsByQuestion.set(questionKey, []);
+ }
+
+ const attachmentInfo: AttachmentInfo = {
+ id: attachment.attachmentId,
+ originalFileName: attachment.originalFileName,
+ storedFileName: attachment.storedFileName,
+ publicPath: attachment.publicPath,
+ fileSize: attachment.fileSize,
+ mimeType: attachment.mimeType || undefined,
+ fileExtension: attachment.fileExtension || undefined,
+ description: attachment.description || undefined,
+ uploadedBy: attachment.uploadedBy,
+ uploadedByName: attachment.uploadedByName || undefined,
+ createdAt: new Date(attachment.attachmentCreatedAt),
+ updatedAt: new Date(attachment.attachmentUpdatedAt),
+ };
+
+ attachmentsByQuestion.get(questionKey)!.push(attachmentInfo);
+
+ // 카테고리별 파일 수 집계
+ const category = attachment.category || '기타';
+ attachmentsByCategory.set(category, (attachmentsByCategory.get(category) || 0) + 1);
+ });
- // 5. 질문별로 그룹화하고 답변 옵션들 정리
+ // 7. 질문별로 그룹화하고 답변 옵션들 정리
const questionsMap = new Map<number, EvaluationQuestionItem>();
criteriaWithDetails.forEach(record => {
@@ -201,6 +278,8 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
// 질문이 이미 존재하는지 확인
if (!questionsMap.has(criteriaId)) {
+ const questionAttachments = attachmentsByQuestion.get(criteriaId) || [];
+
questionsMap.set(criteriaId, {
criteriaId: record.criteriaId,
category: record.category,
@@ -215,6 +294,11 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
selectedDetailId: null, // ✅ 초기값은 null (아직 선택하지 않음)
currentScore: null,
currentComment: null,
+
+ // 📎 첨부파일 정보 추가
+ attachments: questionAttachments,
+ attachmentCount: questionAttachments.length,
+ attachmentTotalSize: questionAttachments.reduce((sum, att) => sum + att.fileSize, 0),
});
}
@@ -228,14 +312,45 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
});
});
- // 6. ✅ 초기 응답 생성하지 않음 - 사용자가 실제로 답변할 때만 생성
+ // 8. ✅ Variable 타입 질문 처리 (첨부파일은 있지만 criteriaId가 null인 경우)
+ const variableTypeAttachments = new Map<number, AttachmentInfo[]>();
+ attachmentsData.forEach(attachment => {
+ if (!attachment.criteriaId && attachment.regEvalCriteriaDetailsId === null) {
+ // Variable 타입 질문의 첨부파일
+ if (!variableTypeAttachments.has(attachment.evaluationDetailId)) {
+ variableTypeAttachments.set(attachment.evaluationDetailId, []);
+ }
+ variableTypeAttachments.get(attachment.evaluationDetailId)!.push({
+ id: attachment.attachmentId,
+ originalFileName: attachment.originalFileName,
+ storedFileName: attachment.storedFileName,
+ publicPath: attachment.publicPath,
+ fileSize: attachment.fileSize,
+ mimeType: attachment.mimeType || undefined,
+ fileExtension: attachment.fileExtension || undefined,
+ description: attachment.description || undefined,
+ uploadedBy: attachment.uploadedBy,
+ uploadedByName: attachment.uploadedByName || undefined,
+ createdAt: new Date(attachment.attachmentCreatedAt),
+ updatedAt: new Date(attachment.attachmentUpdatedAt),
+ });
+ }
+ });
- // 7. 기존 응답 데이터를 질문에 매핑
- const existingResponsesMap = new Map(
- existingResponses.map(r => [r.regEvalCriteriaDetailsId, r])
- );
+ // 9. 기존 응답 데이터를 질문에 매핑
+ const existingResponsesMap = new Map<number | null, typeof existingResponses[0]>();
+ const responseDetailMap = new Map<number, typeof existingResponses[0]>();
- // 8. 각 질문에 현재 응답 정보 매핑
+ existingResponses.forEach(response => {
+ if (response.regEvalCriteriaDetailsId) {
+ existingResponsesMap.set(response.regEvalCriteriaDetailsId, response);
+ } else {
+ // Variable 타입 응답 (regEvalCriteriaDetailsId가 null)
+ responseDetailMap.set(response.id, response);
+ }
+ });
+
+ // 10. 각 질문에 현재 응답 정보 매핑
const questions: EvaluationQuestionItem[] = [];
questionsMap.forEach(question => {
// 현재 선택된 답변 찾기 (실제 응답이 있는 경우에만)
@@ -251,7 +366,7 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
if (selectedResponse) {
question.responseId = selectedResponse.id;
- question.currentScore = selectedResponse.score;
+ question.currentScore = selectedResponse.score ? Number(selectedResponse.score) : null;
question.currentComment = selectedResponse.comment;
}
// ✅ else 케이스: 아직 답변하지 않은 상태 (모든 값이 null)
@@ -259,12 +374,21 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
questions.push(question);
});
+ // 📎 11. 전체 첨부파일 통계 계산
+ const attachmentStats = {
+ totalFiles: attachmentsData.length,
+ totalSize: attachmentsData.reduce((sum, att) => sum + att.fileSize, 0),
+ questionsWithAttachments: attachmentsByQuestion.size + variableTypeAttachments.size,
+ filesByCategory: Object.fromEntries(attachmentsByCategory),
+ };
+
return {
evaluationInfo: {
...evaluation,
reviewerType
},
questions,
+ attachmentStats,
};
} catch (err) {
@@ -274,7 +398,6 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
}
-
/**
* 평가 제출 목록을 조회합니다
*/
@@ -395,22 +518,33 @@ export async function getSHIEvaluationSubmissionById(id: number) {
/**
* 평가 응답을 업데이트합니다
*/
+// 기존 updateEvaluationResponse 함수를 확장하여 첨부파일 처리 지원
+
export async function updateEvaluationResponse(
reviewerEvaluationId: number,
selectedDetailId: number,
- comment?: string
+ comment?: string,
+ customScore?: number
) {
try {
- await db.transaction(async (tx) => {
- // 1. 선택된 답변 옵션의 정보 조회
- const selectedDetail = await tx
- .select()
- .from(regEvalCriteriaDetails)
- .where(eq(regEvalCriteriaDetails.id, selectedDetailId))
- .limit(1);
+ let reviewerEvaluationDetailId: number | null = null;
- if (selectedDetail.length === 0) {
- throw new Error('Selected detail not found');
+ await db.transaction(async (tx) => {
+ // 1. 선택된 답변 옵션의 정보 조회 (variable 타입이 아닌 경우)
+ let selectedDetail = null;
+ let score: number;
+
+ if (selectedDetailId !== -1) {
+ const detailResult = await tx
+ .select()
+ .from(regEvalCriteriaDetails)
+ .where(eq(regEvalCriteriaDetails.id, selectedDetailId))
+ .limit(1);
+
+ if (detailResult.length === 0) {
+ throw new Error('Selected detail not found');
+ }
+ selectedDetail = detailResult[0];
}
// 2. reviewerEvaluation 정보 조회 (periodicEvaluationId 포함)
@@ -447,20 +581,37 @@ export async function updateEvaluationResponse(
.where(eq(periodicEvaluations.id, periodicEvaluationId));
}
- // 4. 리뷰어 타입 정보 조회
- const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId);
- if (!evaluationInfo) {
- throw new Error('Evaluation not found');
+ // 4. 점수 결정
+ if (selectedDetailId === -1) {
+ // variable 타입인 경우 customScore 사용
+ if (customScore === undefined) {
+ throw new Error('Custom score is required for variable type');
+ }
+ score = customScore;
+ } else {
+ // 일반 타입인 경우 리뷰어 타입에 맞는 점수 가져오기
+ const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId);
+ if (!evaluationInfo) {
+ throw new Error('Evaluation not found');
+ }
+
+ const calculatedScore = getScoreByReviewerType(selectedDetail!, evaluationInfo.evaluationInfo.reviewerType);
+ if (calculatedScore === null) {
+ throw new Error('Score not found for this reviewer type');
+ }
+ score = calculatedScore;
}
- // 5. 해당 리뷰어 타입에 맞는 점수 가져오기
- const score = getScoreByReviewerType(selectedDetail[0], evaluationInfo.evaluationInfo.reviewerType);
- if (score === null) {
- throw new Error('Score not found for this reviewer type');
+ // 5. 해당 평가 기준에 대한 기존 응답들 삭제
+ let criteriaId: number;
+ if (selectedDetailId === -1) {
+ // variable 타입인 경우, criteriaId를 별도로 조회해야 함
+ // 이 부분은 실제 데이터 구조에 따라 조정 필요
+ throw new Error('Variable type criteria ID lookup not implemented');
+ } else {
+ criteriaId = selectedDetail!.criteriaId;
}
- // 6. 같은 질문에 대한 기존 응답들 삭제
- const criteriaId = selectedDetail[0].criteriaId;
await tx
.delete(reviewerEvaluationDetails)
.where(
@@ -472,27 +623,173 @@ export async function updateEvaluationResponse(
)
);
- // 7. 새로운 응답 생성
- await tx
+ // 6. 새로운 응답 생성
+ const [newDetail] = await tx
.insert(reviewerEvaluationDetails)
.values({
reviewerEvaluationId,
- regEvalCriteriaDetailsId: selectedDetailId,
+ regEvalCriteriaDetailsId: selectedDetailId === -1 ? null : selectedDetailId,
score: score.toString(),
comment,
+ })
+ .returning({
+ id: reviewerEvaluationDetails.id,
});
- // 8. 카테고리별 점수 계산 및 총점 업데이트
+ reviewerEvaluationDetailId = newDetail.id;
+
+ // 7. 카테고리별 점수 계산 및 총점 업데이트
await recalculateEvaluationScores(tx, reviewerEvaluationId);
});
- return { success: true };
+ return {
+ success: true,
+ reviewerEvaluationDetailId
+ };
} catch (err) {
console.error('Error in updateEvaluationResponse:', err);
throw err;
}
}
+// variable 타입을 위한 별도 함수
+export async function updateVariableEvaluationResponse(
+ reviewerEvaluationId: number,
+ criteriaId: number,
+ score: number,
+ comment?: string
+) {
+ try {
+ let reviewerEvaluationDetailId: number | null = null;
+
+ await db.transaction(async (tx) => {
+ // 1. reviewerEvaluation 정보 조회
+ 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];
+
+ // 2. 상태 업데이트
+ 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));
+ }
+
+ // 3. 해당 평가 기준에 대한 기존 응답들 삭제
+ 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}
+ )`
+ )
+ );
+
+ // 4. 새로운 응답 생성 (variable 타입은 regEvalCriteriaDetailsId가 null)
+ const [newDetail] = await tx
+ .insert(reviewerEvaluationDetails)
+ .values({
+ reviewerEvaluationId,
+ regEvalCriteriaDetailsId: null, // variable 타입은 null
+ score: score.toString(),
+ comment,
+ })
+ .returning({
+ id: reviewerEvaluationDetails.id,
+ });
+
+ reviewerEvaluationDetailId = newDetail.id;
+
+ // 5. 점수 재계산
+ await recalculateEvaluationScores(tx, reviewerEvaluationId);
+ });
+
+ return {
+ success: true,
+ reviewerEvaluationDetailId
+ };
+ } catch (err) {
+ console.error('Error in updateVariableEvaluationResponse:', err);
+ throw err;
+ }
+}
+
+// 첨부파일과 함께 평가 응답 업데이트
+// export async function updateEvaluationResponseWithAttachment(
+// reviewerEvaluationId: number,
+// selectedDetailId: number,
+// comment?: string,
+// customScore?: number,
+// attachmentFile?: File,
+// attachmentDescription?: string
+// ) {
+// try {
+// // 1. 먼저 평가 응답 업데이트
+// const updateResult = selectedDetailId === -1 && customScore !== undefined ?
+// await updateVariableEvaluationResponse(reviewerEvaluationId, /* criteriaId 필요 */, customScore, comment) :
+// await updateEvaluationResponse(reviewerEvaluationId, selectedDetailId, comment, customScore);
+
+// if (!updateResult.success || !updateResult.reviewerEvaluationDetailId) {
+// throw new Error('Failed to update evaluation response');
+// }
+
+// // 2. 첨부파일이 있으면 저장
+// if (attachmentFile) {
+// const fileResult = await saveFile({
+// file: attachmentFile,
+// directory: "evaluation-attachments",
+// originalName: attachmentFile.name,
+// });
+
+// if (!fileResult.success) {
+// throw new Error(fileResult.error || "파일 저장에 실패했습니다.");
+// }
+
+// // 3. DB에 첨부파일 정보 저장
+// await db.insert(reviewerEvaluationAttachments).values({
+// reviewerEvaluationDetailId: updateResult.reviewerEvaluationDetailId,
+// originalFileName: attachmentFile.name,
+// storedFileName: fileResult.fileName!,
+// filePath: fileResult.filePath!,
+// publicPath: fileResult.publicPath!,
+// fileSize: attachmentFile.size,
+// mimeType: attachmentFile.type,
+// fileExtension: attachmentFile.name.split('.').pop()?.toLowerCase() || '',
+// description: attachmentDescription || null,
+// uploadedBy: /* session.user.id 필요 */,
+// });
+// }
+
+// return { success: true };
+// } catch (err) {
+// console.error('Error in updateEvaluationResponseWithAttachment:', err);
+// throw err;
+// }
+// }
/**
* 평가 점수 재계산
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts
index 9e21dc51..6de00329 100644
--- a/lib/evaluation-target-list/service.ts
+++ b/lib/evaluation-target-list/service.ts
@@ -1,6 +1,6 @@
'use server'
-import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray } from "drizzle-orm";
+import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray, gte, lte } from "drizzle-orm";
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
import { filterColumns } from "@/lib/filter-columns";
@@ -22,7 +22,9 @@ import {
reviewerEvaluations,
evaluationSubmissions,
generalEvaluations,
- esgEvaluationItems
+ esgEvaluationItems,
+ contracts,
+ projects
} from "@/db/schema";
@@ -33,6 +35,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { sendEmail } from "../mail/sendEmail";
import type { SQL } from "drizzle-orm"
import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation";
+import { revalidatePath } from "next/cache";
export async function selectEvaluationTargetsFromView(
tx: PgTransaction<any, any, any>,
@@ -214,8 +217,8 @@ export async function getEvaluationTargetsStats(evaluationYear: number) {
consensusTrue: sql<number>`sum(case when consensus_status = true then 1 else 0 end)`,
consensusFalse: sql<number>`sum(case when consensus_status = false then 1 else 0 end)`,
consensusNull: sql<number>`sum(case when consensus_status is null then 1 else 0 end)`,
- oceanDivision: sql<number>`sum(case when division = 'OCEAN' then 1 else 0 end)`,
- shipyardDivision: sql<number>`sum(case when division = 'SHIPYARD' then 1 else 0 end)`,
+ oceanDivision: sql<number>`sum(case when division = 'PLANT' then 1 else 0 end)`,
+ shipyardDivision: sql<number>`sum(case when division = 'SHIP' then 1 else 0 end)`,
})
.from(evaluationTargetsWithDepartments)
.where(eq(evaluationTargetsWithDepartments.evaluationYear, evaluationYear));
@@ -1165,4 +1168,188 @@ export async function requestEvaluationReview(targetIds: number[], message?: str
error: error instanceof Error ? error.message : "의견 요청 중 오류가 발생했습니다."
}
}
+}
+
+
+
+interface AutoGenerateResult {
+ success: boolean
+ message: string
+ error?: string
+ generatedCount?: number
+ skippedCount?: number
+ details?: {
+ shipTargets: number
+ plantTargets: number
+ duplicateSkipped: number
+ }
+}
+
+/**
+ * 자동으로 평가 대상을 생성하는 서버 액션
+ * 전년도 10월부터 현재년도 9월까지의 계약을 기준으로 평가 대상을 생성
+ */
+export async function autoGenerateEvaluationTargets(
+ evaluationYear: number,
+ adminUserId: number
+): Promise<AutoGenerateResult> {
+ try {
+ // 평가 기간 계산 (전년도 10월 ~ 현재년도 9월)
+ const startDate = `${evaluationYear - 1}-10-01`
+ const endDate = `${evaluationYear}-09-30`
+
+ console.log(`Generating evaluation targets for period: ${startDate} to ${endDate}`)
+
+ // 1. 해당 기간의 계약들과 관련 정보를 조회
+ const contractsWithDetails = await db
+ .select({
+ contractId: contracts.id,
+ vendorId: contracts.vendorId,
+ projectId: contracts.projectId,
+ startDate: contracts.startDate,
+ // vendor 정보
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ vendorType: vendors.country ==="KR"? "DOMESTIC":"FOREIGN", // DOMESTIC | FOREIGN
+ // project 정보
+ projectType: projects.type, // ship | plant
+ })
+ .from(contracts)
+ .innerJoin(vendors, eq(contracts.vendorId, vendors.id))
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .where(
+ and(
+ gte(contracts.startDate, startDate),
+ lte(contracts.startDate, endDate)
+ )
+ )
+
+ if (contractsWithDetails.length === 0) {
+ return {
+ success: true,
+ message: "해당 기간에 생성할 평가 대상이 없습니다.",
+ generatedCount: 0,
+ skippedCount: 0
+ }
+ }
+
+ console.log(`Found ${contractsWithDetails.length} contracts in the period`)
+
+ // 2. 벤더별, 구분별로 그룹화하여 중복 제거
+ const targetGroups = new Map<string, {
+ vendorId: number
+ vendorCode: string
+ vendorName: string
+ domesticForeign: "DOMESTIC" | "FOREIGN"
+ division: "SHIP" | "PLANT"
+ materialType: "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK"
+ }>()
+
+ contractsWithDetails.forEach(contract => {
+ const division = contract.projectType === "ship" ? "SHIP" : "PLANT"
+ const key = `${contract.vendorId}-${division}`
+
+ if (!targetGroups.has(key)) {
+ targetGroups.set(key, {
+ vendorId: contract.vendorId,
+ vendorCode: contract.vendorCode,
+ vendorName: contract.vendorName,
+ domesticForeign: contract.vendorType === "DOMESTIC" ? "DOMESTIC" : "FOREIGN",
+ division: division as "SHIP" | "PLANT",
+ // 기본값으로 EQUIPMENT 설정 (추후 더 정교한 로직 필요시 수정)
+ materialType: "EQUIPMENT" as const
+ })
+ }
+ })
+
+ console.log(`Created ${targetGroups.size} unique vendor-division combinations`)
+
+ // 3. 이미 존재하는 평가 대상 확인
+ const existingTargetsKeys = new Set<string>()
+ if (targetGroups.size > 0) {
+ const vendorIds = Array.from(targetGroups.values()).map(t => t.vendorId)
+ const existingTargets = await db
+ .select({
+ vendorId: evaluationTargets.vendorId,
+ division: evaluationTargets.division
+ })
+ .from(evaluationTargets)
+ .where(
+ and(
+ eq(evaluationTargets.evaluationYear, evaluationYear),
+ inArray(evaluationTargets.vendorId, vendorIds)
+ )
+ )
+
+ existingTargets.forEach(target => {
+ existingTargetsKeys.add(`${target.vendorId}-${target.division}`)
+ })
+ }
+
+ console.log(`Found ${existingTargetsKeys.size} existing targets`)
+
+ // 4. 새로운 평가 대상만 필터링
+ const newTargets = Array.from(targetGroups.entries())
+ .filter(([key]) => !existingTargetsKeys.has(key))
+ .map(([_, target]) => target)
+
+ if (newTargets.length === 0) {
+ return {
+ success: true,
+ message: "이미 모든 평가 대상이 생성되어 있습니다.",
+ generatedCount: 0,
+ skippedCount: targetGroups.size
+ }
+ }
+
+ // 5. 평가 대상 생성
+ const evaluationTargetsToInsert = newTargets.map(target => ({
+ evaluationYear,
+ division: target.division,
+ vendorId: target.vendorId,
+ vendorCode: target.vendorCode,
+ vendorName: target.vendorName,
+ domesticForeign: target.domesticForeign,
+ materialType: target.materialType,
+ status: "PENDING" as const,
+ adminUserId,
+ ldClaimCount: 0,
+ ldClaimAmount: "0",
+ ldClaimCurrency: "KRW" as const
+ }))
+
+ // 배치로 삽입
+ await db.insert(evaluationTargets).values(evaluationTargetsToInsert)
+
+ // 통계 계산
+ const shipTargets = newTargets.filter(t => t.division === "SHIP").length
+ const plantTargets = newTargets.filter(t => t.division === "PLANT").length
+ const duplicateSkipped = existingTargetsKeys.size
+
+ console.log(`Successfully created ${newTargets.length} evaluation targets`)
+
+ // 캐시 무효화
+ revalidatePath("/evcp/evaluation-target-list")
+ revalidatePath("/procurement/evaluation-target-list")
+
+ return {
+ success: true,
+ message: `${newTargets.length}개의 평가 대상이 성공적으로 생성되었습니다.`,
+ generatedCount: newTargets.length,
+ skippedCount: duplicateSkipped,
+ details: {
+ shipTargets,
+ plantTargets,
+ duplicateSkipped
+ }
+ }
+
+ } catch (error) {
+ console.error("Error auto generating evaluation targets:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ message: "평가 대상 자동 생성에 실패했습니다."
+ }
+ }
} \ No newline at end of file
diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx
index b140df0e..5560d3ff 100644
--- a/lib/evaluation-target-list/table/evaluation-target-table.tsx
+++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx
@@ -271,6 +271,8 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
const [promiseData] = React.use(promises);
const tableData = promiseData;
+ console.log(tableData)
+
/* ---------------------- 검색 파라미터 안전 처리 ---------------------- */
const searchString = React.useMemo(
() => searchParams.toString(), // query가 바뀔 때만 새로 계산
diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
index 60f1af39..c3aa9d71 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
@@ -16,7 +16,7 @@ interface GetColumnsProps {
}
// ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동
-const getStatusBadgeVariant = (status: string) => {
+export const getStatusBadgeVariant = (status: string) => {
switch (status) {
case "PENDING": return "secondary";
case "CONFIRMED": return "default";
@@ -43,14 +43,14 @@ const getDivisionBadge = (division: string) => {
);
};
-const getMaterialTypeBadge = (materialType: string) => {
+export const getMaterialTypeBadge = (materialType: string) => {
return <Badge variant="outline">{vendortypeMap[materialType] || materialType}</Badge>;
};
const getDomesticForeignBadge = (domesticForeign: string) => {
return (
<Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}>
- {domesticForeign === "DOMESTIC" ? "내자" : "외자"}
+ {domesticForeign === "DOMESTIC" ? "D" : "F"}
</Badge>
);
};
@@ -72,6 +72,16 @@ const getEvaluationTargetBadge = (isTarget: boolean | null) => {
);
};
+const getStatusLabel = (status: string) => {
+ const statusMap = {
+ PENDING: "미확정",
+ EXCLUDED: "제외",
+ CONFIRMED: "확정"
+ };
+ return statusMap[status] || status;
+};
+
+
// ✅ 모든 cell 렌더러 함수들을 미리 정의 (매번 새로 생성 방지)
const renderEvaluationYear = ({ row }: any) => (
<span className="font-medium">{row.getValue("evaluationYear")}</span>
@@ -83,7 +93,7 @@ const renderStatus = ({ row }: any) => {
const status = row.getValue<string>("status");
return (
<Badge variant={getStatusBadgeVariant(status)}>
- {status}
+ {getStatusLabel(status)}
</Badge>
);
};
@@ -213,12 +223,7 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col
cell: renderStatus,
size: 100,
},
- {
- accessorKey: "consensusStatus",
- header: createHeaderRenderer("의견 일치"),
- cell: renderConsensusStatus,
- size: 100,
- },
+
// 벤더 정보
{
@@ -252,6 +257,47 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col
]
},
+ {
+ accessorKey: "consensusStatus",
+ header: createHeaderRenderer("의견 일치"),
+ cell: renderConsensusStatus,
+ size: 100,
+ },
+
+ {
+ id: "claim",
+ header: "L/D, Claim",
+ columns:[
+ {
+ accessorKey: "ldClaimCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="건수" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.ldClaimCount}</span>
+ ),
+ size: 80,
+ },
+
+ {
+ accessorKey: "ldClaimAmount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="금액" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{(Number(row.original.ldClaimAmount).toLocaleString())}</span>
+ ),
+ size: 80,
+ },
+ {
+ accessorKey: "ldClaimCurrency",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="단위" />,
+
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.ldClaimCurrency}</span>
+ ),
+ size: 80,
+ },
+ ]
+
+ },
+
// 발주 담당자
{
id: "orderReviewer",
diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
index 8bc5254c..d1c7e500 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
@@ -14,6 +14,7 @@ import {
} from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
import { Button } from "@/components/ui/button"
import {
@@ -31,6 +32,8 @@ import {
} from "./evaluation-target-action-dialogs"
import { EvaluationTargetWithDepartments } from "@/db/schema"
import { exportTableToExcel } from "@/lib/export"
+import { autoGenerateEvaluationTargets } from "../service" // 서버 액션 import
+import { useAuthRole } from "@/hooks/use-auth-role"
interface EvaluationTargetsTableToolbarActionsProps {
table: Table<EvaluationTargetWithDepartments>
@@ -47,6 +50,16 @@ export function EvaluationTargetsTableToolbarActions({
const [excludeDialogOpen, setExcludeDialogOpen] = React.useState(false)
const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false)
const router = useRouter()
+ const { data: session } = useSession()
+
+ // 권한 체크
+ const { hasRole, isLoading: roleLoading } = useAuthRole()
+ const canManageEvaluations = hasRole('정기평가') || hasRole('admin')
+
+ // 사용자 ID 가져오기
+ const userId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : 1;
+ }, [session]);
// 선택된 행들
const selectedRows = table.getFilteredSelectedRowModel().rows
@@ -141,16 +154,36 @@ export function EvaluationTargetsTableToolbarActions({
const handleAutoGenerate = React.useCallback(async () => {
setIsLoading(true)
try {
- // TODO: 발주실적에서 자동 추출 API 호출
- toast.success("평가 대상이 자동으로 생성되었습니다.")
- router.refresh()
+ // 현재 년도를 기준으로 평가 대상 자동 생성
+ const currentYear = new Date().getFullYear()
+ const result = await autoGenerateEvaluationTargets(currentYear, userId)
+
+ if (result.success) {
+ if (result.generatedCount === 0) {
+ toast.info(result.message, {
+ description: result.skippedCount
+ ? `이미 존재하는 평가 대상: ${result.skippedCount}개`
+ : undefined
+ })
+ } else {
+ toast.success(result.message, {
+ description: result.details
+ ? `해양: ${result.details.shipTargets}개, 조선: ${result.details.plantTargets}개 생성${result.details.duplicateSkipped > 0 ? `, 중복 건너뜀: ${result.details.duplicateSkipped}개` : ''}`
+ : undefined
+ })
+ }
+ onRefresh?.()
+ router.refresh()
+ } else {
+ toast.error(result.error || "자동 생성 중 오류가 발생했습니다.")
+ }
} catch (error) {
console.error('Error auto generating targets:', error)
toast.error("자동 생성 중 오류가 발생했습니다.")
} finally {
setIsLoading(false)
}
- }, [router])
+ }, [router, onRefresh, userId])
// ----------------------------------------------------------------
// 신규 평가 대상 생성 (수동)
@@ -178,33 +211,54 @@ export function EvaluationTargetsTableToolbarActions({
})
}, [table])
+ // 권한이 없거나 로딩 중인 경우 내보내기 버튼만 표시
+ if (roleLoading) {
+ return (
+ <div className="flex items-center gap-2">
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ <Button
+ variant="outline"
+ size="sm"
+ disabled
+ className="gap-2"
+ >
+ <Download className="size-4 animate-spin" aria-hidden="true" />
+ <span className="hidden sm:inline">로딩중...</span>
+ </Button>
+ </div>
+ </div>
+ )
+ }
+
return (
<>
<div className="flex items-center gap-2">
- {/* 신규 생성 드롭다운 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="default"
- size="sm"
- className="gap-2"
- disabled={isLoading}
- >
- <Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">신규 생성</span>
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="start">
- <DropdownMenuItem onClick={handleAutoGenerate} disabled={isLoading}>
- <RefreshCw className="size-4 mr-2" />
- 자동 생성 (발주실적 기반)
- </DropdownMenuItem>
- <DropdownMenuItem onClick={handleManualCreate}>
- <Plus className="size-4 mr-2" />
- 수동 생성
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
+ {/* 신규 생성 드롭다운 - 정기평가 권한이 있는 경우만 표시 */}
+ {canManageEvaluations && (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ disabled={isLoading}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">신규 생성</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ <DropdownMenuItem onClick={handleAutoGenerate} disabled={isLoading}>
+ <RefreshCw className={`size-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
+ 자동 생성 (발주실적 기반)
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleManualCreate}>
+ <Plus className="size-4 mr-2" />
+ 수동 생성
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )}
{/* 유틸리티 버튼들 */}
<div className="flex items-center gap-1 border-l pl-2 ml-2">
@@ -219,8 +273,8 @@ export function EvaluationTargetsTableToolbarActions({
</Button>
</div>
- {/* 선택된 항목 액션 버튼들 */}
- {hasSelection && (
+ {/* 선택된 항목 액션 버튼들 - 정기평가 권한이 있는 경우만 표시 */}
+ {canManageEvaluations && hasSelection && (
<div className="flex items-center gap-1 border-l pl-2 ml-2">
{/* 확정 버튼 */}
{selectedStats.canConfirm && (
@@ -271,37 +325,52 @@ export function EvaluationTargetsTableToolbarActions({
)}
</div>
)}
+
+ {/* 권한이 없는 경우 안내 메시지 (선택사항) */}
+ {!canManageEvaluations && hasSelection && (
+ <div className="flex items-center gap-1 border-l pl-2 ml-2">
+ <div className="text-xs text-muted-foreground px-2 py-1">
+ 평가 관리 권한이 필요합니다
+ </div>
+ </div>
+ )}
</div>
- {/* 수동 생성 다이얼로그 */}
- <ManualCreateEvaluationTargetDialog
- open={manualCreateDialogOpen}
- onOpenChange={setManualCreateDialogOpen}
- />
+ {/* 다이얼로그들 - 권한이 있는 경우만 렌더링 */}
+ {canManageEvaluations && (
+ <>
+ {/* 수동 생성 다이얼로그 */}
+ <ManualCreateEvaluationTargetDialog
+ open={manualCreateDialogOpen}
+ onOpenChange={setManualCreateDialogOpen}
+ onSuccess={handleActionSuccess}
+ />
- {/* 확정 컨펌 다이얼로그 */}
- <ConfirmTargetsDialog
- open={confirmDialogOpen}
- onOpenChange={setConfirmDialogOpen}
- targets={selectedTargets}
- onSuccess={handleActionSuccess}
- />
+ {/* 확정 컨펌 다이얼로그 */}
+ <ConfirmTargetsDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ targets={selectedTargets}
+ onSuccess={handleActionSuccess}
+ />
- {/* 제외 컨펌 다이얼로그 */}
- <ExcludeTargetsDialog
- open={excludeDialogOpen}
- onOpenChange={setExcludeDialogOpen}
- targets={selectedTargets}
- onSuccess={handleActionSuccess}
- />
+ {/* 제외 컨펌 다이얼로그 */}
+ <ExcludeTargetsDialog
+ open={excludeDialogOpen}
+ onOpenChange={setExcludeDialogOpen}
+ targets={selectedTargets}
+ onSuccess={handleActionSuccess}
+ />
- {/* 의견 요청 다이얼로그 */}
- <RequestReviewDialog
- open={reviewDialogOpen}
- onOpenChange={setReviewDialogOpen}
- targets={selectedTargets}
- onSuccess={handleActionSuccess}
- />
+ {/* 의견 요청 다이얼로그 */}
+ <RequestReviewDialog
+ open={reviewDialogOpen}
+ onOpenChange={setReviewDialogOpen}
+ targets={selectedTargets}
+ onSuccess={handleActionSuccess}
+ />
+ </>
+ )}
</>
)
} \ No newline at end of file
diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx
index 9f9b7af4..8ea63a1a 100644
--- a/lib/evaluation-target-list/table/update-evaluation-target.tsx
+++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx
@@ -58,6 +58,8 @@ import {
type UpdateEvaluationTargetInput,
} from "../service"
import { EvaluationTargetWithDepartments } from "@/db/schema"
+import { getMaterialTypeBadge } from "./evaluation-targets-columns"
+import { getStatusLabel } from "../validation"
// 편집 가능한 필드들에 대한 스키마
const editEvaluationTargetSchema = z.object({
@@ -123,10 +125,10 @@ export function EditEvaluationTargetSheet({
}
const userEmail = session.user.email
- const userRole = session.user.role
+ const userRole = session.user?.roles
// 평가관리자는 모든 권한
- if (userRole === "평가관리자") {
+ if (userRole?.some(role => role.includes('정기평가'))|| userRole?.some(role => role.toLocaleLowerCase().includes('admin'))) {
return {
level: "admin",
editableApprovals: [
@@ -372,10 +374,10 @@ export function EditEvaluationTargetSheet({
<span className="font-medium">벤더명:</span> {evaluationTarget.vendorName}
</div>
<div>
- <span className="font-medium">자재구분:</span> {evaluationTarget.materialType}
+ <span className="font-medium">자재구분:</span> {getMaterialTypeBadge(evaluationTarget.materialType)}
</div>
<div>
- <span className="font-medium">상태:</span> {evaluationTarget.status}
+ <span className="font-medium">상태:</span> {getStatusLabel(evaluationTarget.status)}
</div>
</div>
</CardContent>
diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts
index 76811753..8e394f88 100644
--- a/lib/evaluation/service.ts
+++ b/lib/evaluation/service.ts
@@ -9,6 +9,7 @@ import {
periodicEvaluationsView,
regEvalCriteria,
regEvalCriteriaDetails,
+ reviewerEvaluationAttachments,
reviewerEvaluationDetails,
reviewerEvaluations,
roles,
@@ -32,6 +33,7 @@ import { revalidatePath } from "next/cache"
import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation"
import { getServerSession } from "next-auth"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { AttachmentDetail, EvaluationDetailResponse } from "@/types/evaluation-form"
export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) {
try {
@@ -989,17 +991,7 @@ export interface EvaluationDetailData {
/**
* 특정 정기평가의 상세 정보를 조회합니다
*/
-export async function getEvaluationDetails(periodicEvaluationId: number): Promise<{
- evaluationInfo: {
- id: number
- vendorName: string
- vendorCode: string
- evaluationYear: number
- division: string
- status: string
- }
- reviewerDetails: EvaluationDetailData[]
-}> {
+export async function getEvaluationDetails(periodicEvaluationId: number): Promise<EvaluationDetailResponse> {
try {
// 1. 평가 기본 정보 조회
const evaluationInfo = await db
@@ -1060,11 +1052,90 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis
.where(eq(reviewerEvaluations.periodicEvaluationId, periodicEvaluationId))
.orderBy(evaluationTargetReviewers.departmentCode, regEvalCriteria.category, regEvalCriteria.classification)
- // 3. 리뷰어별로 그룹화
+ // 📎 3. 첨부파일 정보 조회
+ const attachmentsData = await db
+ .select({
+ // 첨부파일 정보
+ attachmentId: reviewerEvaluationAttachments.id,
+ originalFileName: reviewerEvaluationAttachments.originalFileName,
+ storedFileName: reviewerEvaluationAttachments.storedFileName,
+ publicPath: reviewerEvaluationAttachments.publicPath,
+ fileSize: reviewerEvaluationAttachments.fileSize,
+ mimeType: reviewerEvaluationAttachments.mimeType,
+ fileExtension: reviewerEvaluationAttachments.fileExtension,
+ description: reviewerEvaluationAttachments.description,
+ uploadedBy: reviewerEvaluationAttachments.uploadedBy,
+ attachmentCreatedAt: reviewerEvaluationAttachments.createdAt,
+
+ // 업로드한 사용자 정보
+ uploadedByName: users.name,
+
+ // 평가 세부사항 정보
+ evaluationDetailId: reviewerEvaluationDetails.id,
+ reviewerEvaluationId: reviewerEvaluationDetails.reviewerEvaluationId,
+
+ // 평가 기준 정보 (질문 식별용)
+ criteriaId: regEvalCriteriaDetails.criteriaId,
+ })
+ .from(reviewerEvaluationAttachments)
+ .innerJoin(
+ reviewerEvaluationDetails,
+ eq(reviewerEvaluationAttachments.reviewerEvaluationDetailId, reviewerEvaluationDetails.id)
+ )
+ .innerJoin(
+ reviewerEvaluations,
+ eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluations.id)
+ )
+ .leftJoin(
+ regEvalCriteriaDetails,
+ eq(reviewerEvaluationDetails.regEvalCriteriaDetailsId, regEvalCriteriaDetails.id)
+ )
+ .leftJoin(
+ users,
+ eq(reviewerEvaluationAttachments.uploadedBy, users.id)
+ )
+ .where(eq(reviewerEvaluations.periodicEvaluationId, periodicEvaluationId))
+ .orderBy(desc(reviewerEvaluationAttachments.createdAt))
+
+ // 📎 4. 첨부파일을 평가 세부사항별로 그룹화
+ const attachmentsByDetailId = new Map<number, AttachmentDetail[]>()
+ const attachmentsByReviewerId = new Map<number, AttachmentDetail[]>()
+
+ attachmentsData.forEach(attachment => {
+ const attachmentInfo: AttachmentDetail = {
+ id: attachment.attachmentId,
+ originalFileName: attachment.originalFileName,
+ storedFileName: attachment.storedFileName,
+ publicPath: attachment.publicPath,
+ fileSize: attachment.fileSize,
+ mimeType: attachment.mimeType || undefined,
+ fileExtension: attachment.fileExtension || undefined,
+ description: attachment.description || undefined,
+ uploadedBy: attachment.uploadedBy,
+ uploadedByName: attachment.uploadedByName || undefined,
+ createdAt: new Date(attachment.attachmentCreatedAt),
+ }
+
+ // 평가 세부사항별 그룹화
+ if (!attachmentsByDetailId.has(attachment.evaluationDetailId)) {
+ attachmentsByDetailId.set(attachment.evaluationDetailId, [])
+ }
+ attachmentsByDetailId.get(attachment.evaluationDetailId)!.push(attachmentInfo)
+
+ // 리뷰어별 그룹화
+ if (!attachmentsByReviewerId.has(attachment.reviewerEvaluationId)) {
+ attachmentsByReviewerId.set(attachment.reviewerEvaluationId, [])
+ }
+ attachmentsByReviewerId.get(attachment.reviewerEvaluationId)!.push(attachmentInfo)
+ })
+
+ // 5. 리뷰어별로 그룹화하고 첨부파일 정보 포함
const reviewerDetailsMap = new Map<number, EvaluationDetailData>()
reviewerDetailsRaw.forEach(row => {
if (!reviewerDetailsMap.has(row.reviewerEvaluationId)) {
+ const reviewerAttachments = attachmentsByReviewerId.get(row.reviewerEvaluationId) || []
+
reviewerDetailsMap.set(row.reviewerEvaluationId, {
reviewerEvaluationId: row.reviewerEvaluationId,
reviewerName: row.reviewerName || "",
@@ -1074,13 +1145,22 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis
isCompleted: row.isCompleted || false,
completedAt: row.completedAt,
reviewerComment: row.reviewerComment,
- evaluationItems: []
+ evaluationItems: [],
+
+ // 📎 리뷰어별 첨부파일 통계
+ totalAttachments: reviewerAttachments.length,
+ totalAttachmentSize: reviewerAttachments.reduce((sum, att) => sum + att.fileSize, 0),
+ questionsWithAttachments: new Set(reviewerAttachments.map(att =>
+ attachmentsData.find(a => a.attachmentId === att.id)?.criteriaId
+ ).filter(Boolean)).size,
})
}
// 평가 항목이 있는 경우에만 추가
if (row.criteriaId && row.detailId) {
const reviewer = reviewerDetailsMap.get(row.reviewerEvaluationId)!
+ const itemAttachments = attachmentsByDetailId.get(row.detailId) || []
+
reviewer.evaluationItems.push({
criteriaId: row.criteriaId,
category: row.category || "",
@@ -1093,14 +1173,28 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis
selectedDetailId: row.selectedDetailId,
selectedDetail: row.selectedDetail,
score: row.score ? Number(row.score) : null,
- comment: row.comment
+ comment: row.comment,
+
+ // 📎 항목별 첨부파일 정보
+ attachments: itemAttachments,
+ attachmentCount: itemAttachments.length,
+ attachmentTotalSize: itemAttachments.reduce((sum, att) => sum + att.fileSize, 0),
})
}
})
+ // 📎 6. 전체 첨부파일 통계 계산
+ const attachmentStats = {
+ totalFiles: attachmentsData.length,
+ totalSize: attachmentsData.reduce((sum, att) => sum + att.fileSize, 0),
+ reviewersWithAttachments: attachmentsByReviewerId.size,
+ questionsWithAttachments: new Set(attachmentsData.map(att => att.criteriaId).filter(Boolean)).size,
+ }
+
return {
evaluationInfo: evaluationInfo[0],
- reviewerDetails: Array.from(reviewerDetailsMap.values())
+ reviewerDetails: Array.from(reviewerDetailsMap.values()),
+ attachmentStats,
}
} catch (error) {
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
index 4b7d9a80..315ec66b 100644
--- a/lib/evaluation/table/evaluation-columns.tsx
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -8,7 +8,7 @@ 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, Circle } from "lucide-react";
+import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis } from "lucide-react";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { PeriodicEvaluationView } from "@/db/schema";
import { DataTableRowAction } from "@/types/table";
@@ -40,11 +40,11 @@ const getStatusBadgeVariant = (status: string) => {
const getStatusLabel = (status: string) => {
const statusMap = {
- PENDING_SUBMISSION: "제출대기",
+ PENDING_SUBMISSION: "자료접수중",
SUBMITTED: "제출완료",
- IN_REVIEW: "검토중",
- REVIEW_COMPLETED: "검토완료",
- FINALIZED: "최종확정"
+ IN_REVIEW: "평가중",
+ REVIEW_COMPLETED: "평가완료",
+ FINALIZED: "결과확정"
};
return statusMap[status] || status;
};
@@ -215,6 +215,14 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
size: 80,
},
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Status" />,
+ cell: ({ row }) => getStatusLabel(row.original.status || ""),
+ size: 80,
+ },
+
+
// ═══════════════════════════════════════════════════════════════
// 협력업체 정보
// ═══════════════════════════════════════════════════════════════
@@ -329,6 +337,32 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
},
]
},
+
+ {
+ // id: "평가상세",
+ // accessorKey: "평가상세",
+ header: "평가상세",
+ enableHiding: true,
+ size: 80,
+ minSize: 80,
+ 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="상세보기"
+ >
+ <Ellipsis className="size-4" />
+ </Button>
+
+ </div>
+ );
+ },
+ },
// ═══════════════════════════════════════════════════════════════
// 제출 현황
// ═══════════════════════════════════════════════════════════════
@@ -549,32 +583,5 @@ 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>
-
- </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
index df4ef016..2f682402 100644
--- a/lib/evaluation/table/evaluation-details-dialog.tsx
+++ b/lib/evaluation/table/evaluation-details-dialog.tsx
@@ -10,7 +10,11 @@ import {
Clock,
MessageSquare,
Award,
- FileText
+ FileText,
+ Paperclip,
+ Download,
+ File,
+ BarChart3
} from "lucide-react"
import {
@@ -19,6 +23,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -26,7 +36,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { PeriodicEvaluationView } from "@/db/schema"
-import { getEvaluationDetails, type EvaluationDetailData } from "../service"
+import { getEvaluationDetails} from "../service"
+import { AttachmentDetail, EvaluationDetailResponse } from "@/types/evaluation-form"
+// 파일 다운로드 유틸리티 import
+import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download"
interface EvaluationDetailsDialogProps {
open: boolean
@@ -75,10 +88,30 @@ export function EvaluationDetailsDialog({
evaluation,
}: EvaluationDetailsDialogProps) {
const [isLoading, setIsLoading] = React.useState(false)
- const [evaluationDetails, setEvaluationDetails] = React.useState<{
- evaluationInfo: any
- reviewerDetails: EvaluationDetailData[]
- } | null>(null)
+ const [evaluationDetails, setEvaluationDetails] = React.useState<EvaluationDetailResponse | null>(null)
+
+ // 첨부파일 다운로드 핸들러 - downloadFile 사용
+ const handleDownloadAttachment = async (attachment: AttachmentDetail) => {
+ try {
+ await downloadFile(
+ attachment.publicPath,
+ attachment.originalFileName,
+ {
+ action: 'download',
+ showToast: true,
+ showSuccessToast: true,
+ onError: (error) => {
+ console.error("파일 다운로드 실패:", error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : '')
+ }
+ }
+ )
+ } catch (error) {
+ console.error("다운로드 처리 중 오류:", error)
+ }
+ }
// 평가 상세 정보 로드
React.useEffect(() => {
@@ -109,258 +142,346 @@ export function EvaluationDetailsDialog({
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>
+ <TooltipProvider>
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl h-[90vh] flex flex-col">
+ {/* 고정 헤더 */}
+ <DialogHeader className="space-y-4 flex-shrink-0">
+ <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 mb-4">
+ {/* 협력업체 */}
+ <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>
+ <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="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>
+ <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.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 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>
- </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>
+ </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)}
+ </DialogHeader>
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto min-h-0">
+ {isLoading ? (
+ <div className="space-y-4 p-1">
+ <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 p-1">
+ {/* 통합 평가 테이블 */}
+ <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-[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="w-[150px]">첨부파일</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>
+ <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>
+ {item.attachments.length > 0 ? (
+ <div className="space-y-1">
+ {item.attachments.map((attachment) => {
+ const fileInfo = getFileInfo(attachment.originalFileName)
+ return (
+ <div key={attachment.id} className="flex items-center gap-1 p-1 bg-muted rounded">
+ <span className="text-sm">{fileInfo.icon}</span>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="text-xs truncate max-w-[80px] cursor-help">
+ {attachment.originalFileName}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="text-xs space-y-1">
+ <div className="font-medium">{attachment.originalFileName}</div>
+ <div>크기: {formatFileSize(attachment.fileSize)}</div>
+ <div>타입: {fileInfo.type}</div>
+ {attachment.description && (
+ <div>설명: {attachment.description}</div>
+ )}
+ <div>업로드: {attachment.uploadedByName}</div>
+ <div className="text-muted-foreground">
+ {fileInfo.canPreview ? "미리보기 가능" : "다운로드만 가능"}
+ </div>
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0"
+ onClick={() => handleDownloadAttachment(attachment)}
+ >
+ <Download className="h-3 w-3" />
+ </Button>
+ </div>
+ )
+ })}
+ {item.attachments.length > 1 && (
+ <div className="text-xs text-muted-foreground">
+ 총 {formatFileSize(item.attachmentTotalSize)}
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="text-xs text-muted-foreground">
+ 첨부파일 없음
+ </div>
+ )}
+ </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>
+ {reviewer.totalAttachments > 0 && (
+ <Badge variant="secondary" className="text-xs">
+ <Paperclip className="h-3 w-3 mr-1" />
+ {reviewer.totalAttachments}개 파일
</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>
+ </div>
+ <div className="bg-muted p-3 rounded-md text-sm">
+ {reviewer.reviewerComment}
+ </div>
+ </div>
+ ))}
+ </CardContent>
+ </Card>
)}
- </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>
+ {/* 📎 첨부파일 요약 (파일이 많은 경우) */}
+ {evaluationDetails.attachmentStats.totalFiles > 5 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <BarChart3 className="h-5 w-5" />
+ 첨부파일 요약
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
+ <div className="space-y-1">
+ <div className="text-muted-foreground">전체 파일 수</div>
+ <div className="font-bold text-lg">{evaluationDetails.attachmentStats.totalFiles}개</div>
</div>
- <div className="bg-muted p-3 rounded-md text-sm">
- {reviewer.reviewerComment}
+ <div className="space-y-1">
+ <div className="text-muted-foreground">전체 파일 크기</div>
+ <div className="font-bold text-lg">{formatFileSize(evaluationDetails.attachmentStats.totalSize)}</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">첨부 질문 수</div>
+ <div className="font-bold text-lg">{evaluationDetails.attachmentStats.questionsWithAttachments}개</div>
+ </div>
+ <div className="space-y-1">
+ <div className="text-muted-foreground">첨부 담당자 수</div>
+ <div className="font-bold text-lg">{evaluationDetails.attachmentStats.reviewersWithAttachments}명</div>
</div>
</div>
- ))}
- </CardContent>
- </Card>
- )}
+ </CardContent>
+ </Card>
+ )}
- {evaluationDetails.reviewerDetails.length === 0 && (
- <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 className="m-1">
<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>
+ {/* 고정 푸터 */}
+ <div className="flex justify-end pt-4 border-t flex-shrink-0">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </TooltipProvider>
)
} \ No newline at end of file
diff --git a/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx
index d6784754..84651350 100644
--- a/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx
+++ b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx
@@ -40,18 +40,16 @@ 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: "A", label: "A등급 (95점 이상)" },
+ { value: "B", label: "B등급 (90-95점 미만)" },
+ { value: "C", label: "C등급 (60-90점 미만)" },
{ value: "D", label: "D등급 (60점 미만)" },
] as const
// 점수에 따른 등급 계산
-const calculateGrade = (score: number): "S" | "A" | "B" | "C" | "D" => {
- if (score >= 90) return "S"
- if (score >= 80) return "A"
- if (score >= 70) return "B"
+const calculateGrade = (score: number): "A" | "B" | "C" | "D" => {
+ if (score >= 95) return "A"
+ if (score >= 90) return "B"
if (score >= 60) return "C"
return "D"
}
@@ -65,7 +63,7 @@ const evaluationItemSchema = z.object({
finalScore: z.coerce.number()
.min(0, "점수는 0 이상이어야 합니다"),
// .max(100, "점수는 100 이하여야 합니다"),
- finalGrade: z.enum(["S", "A", "B", "C", "D"]),
+ finalGrade: z.enum(["A", "B", "C", "D"]),
})
// 전체 폼 스키마
diff --git a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
index 39a95cc7..d910f916 100644
--- a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
+++ b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
@@ -49,7 +49,7 @@ export function PeriodicEvaluationsTableToolbarActions({
// 권한 체크 (방법 1 또는 방법 2 중 선택)
const { hasRole, isLoading: roleLoading } = useAuthRole()
- const canManageEvaluations = hasRole('정기평가')
+ const canManageEvaluations = hasRole('정기평가') || hasRole('admin')
// 선택된 행들
const selectedRows = table.getFilteredSelectedRowModel().rows
diff --git a/lib/items/service.ts b/lib/items/service.ts
index c841efad..1b6d7e09 100644
--- a/lib/items/service.ts
+++ b/lib/items/service.ts
@@ -42,13 +42,12 @@ export async function getItems(input: GetItemsSchema) {
if (input.search) {
const s = `%${input.search}%`;
globalWhere = or(
- ilike(items.itemLevel, s),
+ ilike(items.ProjectNo, s),
ilike(items.itemCode, s),
ilike(items.itemName, s),
ilike(items.smCode, s),
ilike(items.packageCode, s),
ilike(items.description, s),
- ilike(items.changeDate, s)
);
}
diff --git a/lib/items/table/items-table.tsx b/lib/items/table/items-table.tsx
index 92f805eb..8baaf69c 100644
--- a/lib/items/table/items-table.tsx
+++ b/lib/items/table/items-table.tsx
@@ -59,60 +59,36 @@ export function ItemsTable({ promises }: ItemsTableProps) {
const advancedFilterFields: DataTableAdvancedFilterField<Item>[] = [
{
- id: "itemLevel",
- label: "레벨",
- type: "number",
- },
- {
- id: "itemCode",
- label: "자재그룹코드",
+ id: "ProjectNo",
+ label: "Project No",
type: "text",
},
{
- id: "itemName",
- label: "자재그룹이름",
+ id: "itemCode",
+ label: "PKG Code(PK)",
type: "text",
},
{
id: "description",
- label: "상세",
+ label: "PKG Code Decription",
type: "text",
},
{
- id: "parentItemCode",
- label: "부모 아이템 코드",
+ id: "packageCode",
+ label: "PKG Code",
type: "text",
},
{
- id: "deleteFlag",
- label: "삭제 플래그",
- type: "text",
- },
- {
- id: "unitOfMeasure",
- label: "단위",
- type: "text",
- },
- {
- id: "steelType",
- label: "강종",
- type: "text",
- },
- {
- id: "gradeMaterial",
- label: "등급 재질",
- type: "text",
- },
- {
- id: "changeDate",
- label: "변경일자",
- type: "text",
+ id: "itemName",
+ label: "패키지 이름",
+ type: "text"
},
{
- id: "baseUnitOfMeasure",
- label: "기본단위",
+ id: "smCode",
+ label: "SM Code",
type: "text",
},
+
]
// 확장된 useDataTable 훅 사용 (pageSize 기반 자동 전환)
diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts
index c293c98e..0606f4a9 100644
--- a/lib/sedp/sync-form.ts
+++ b/lib/sedp/sync-form.ts
@@ -851,17 +851,18 @@ async function getUomById(projectCode: string, uomId: string): Promise<UOM | nul
}
// contractItemId 조회 함수
-async function getContractItemsByItemCodes(itemCodes: string[], projectId: number): Promise<Map<string, number>> {
+async function getContractItemsByItemCodes(itemCodes: string[], projectId: number): Promise<Map<string, number[]>> {
try {
if (!itemCodes.length) return new Map();
// 먼저 itemCodes에 해당하는 item 레코드를 조회
const itemRecords = await db.select({
id: items.id,
- itemCode: items.itemCode
+ itemCode: items.itemCode,
+ packageCode: items.packageCode
})
.from(items)
- .where(inArray(items.itemCode, itemCodes));
+ .where(inArray(items.packageCode, itemCodes));
if (!itemRecords.length) {
console.log(`No items found for itemCodes: ${itemCodes.join(', ')}`);
@@ -885,21 +886,22 @@ async function getContractItemsByItemCodes(itemCodes: string[], projectId: numbe
)
);
- // itemCode와 contractItemId의 매핑 생성
- const itemCodeToContractItemId = new Map<string, number>();
+ // itemCode와 contractItemId 배열의 매핑 생성
+ const itemCodeToContractItemIds = new Map<string, number[]>();
for (const item of itemRecords) {
// itemCode가 null이 아닌 경우에만 처리
if (item.itemCode) {
const matchedContractItems = contractItemRecords.filter(ci => ci.itemId === item.id);
if (matchedContractItems.length > 0) {
- // 일치하는 첫 번째 contractItem 사용
- itemCodeToContractItemId.set(item.itemCode, matchedContractItems[0].id);
+ // 일치하는 모든 contractItem을 배열로 저장
+ const contractItemIds = matchedContractItems.map(ci => ci.id);
+ itemCodeToContractItemIds.set(item.itemCode, contractItemIds);
}
}
}
- return itemCodeToContractItemId;
+ return itemCodeToContractItemIds;
} catch (error) {
console.error('ContractItems 조회 중 오류 발생:', error);
return new Map();
@@ -938,10 +940,10 @@ export async function saveFormMappingsAndMetas(
const defaultAttributes = await getDefaulTAttributes();
/* ------------------------------------------------------------------ */
- /* 2. Contract‑item look‑up (TOOL_TYPE) */
+ /* 2. Contract‑item look‑up (TOOL_TYPE) - 수정된 부분 */
/* ------------------------------------------------------------------ */
const uniqueItemCodes = [...new Set(newRegisters.filter(nr => nr.TOOL_TYPE).map(nr => nr.TOOL_TYPE as string))];
- const itemCodeToContractItemId = await getContractItemsByItemCodes(uniqueItemCodes, projectId);
+ const itemCodeToContractItemIds = await getContractItemsByItemCodes(uniqueItemCodes, projectId);
/* ------------------------------------------------------------------ */
/* 3. Buffers for bulk insert */
@@ -1027,11 +1029,25 @@ export async function saveFormMappingsAndMetas(
mappingsToSave.push({ projectId, tagTypeLabel: tp.description, classLabel: cls.label, formCode, formName: legacy?.DESC || formCode, remark: newReg.TOOL_TYPE || null, ep: newReg.EP_ID || legacy?.EP_ID || "", createdAt: new Date(), updatedAt: new Date() });
});
- /* ---------- 4‑d. contractItem ↔ form --------------------------- */
+ /* ---------- 4‑d. contractItem ↔ form - 수정된 부분 -------------- */
if (newReg.TOOL_TYPE) {
- const cId = itemCodeToContractItemId.get(newReg.TOOL_TYPE);
- if (cId) { contractItemIdsWithForms.add(cId); formsToSave.push({ contractItemId: cId, formCode, formName: legacy?.DESC || formCode, eng: true, createdAt: new Date(), updatedAt: new Date() }); }
- else console.warn(`itemCode ${newReg.TOOL_TYPE} 의 contractItemId 없음`);
+ const contractItemIds = itemCodeToContractItemIds.get(newReg.TOOL_TYPE);
+ if (contractItemIds && contractItemIds.length > 0) {
+ // 모든 contractItemId에 대해 form 생성
+ contractItemIds.forEach(cId => {
+ contractItemIdsWithForms.add(cId);
+ formsToSave.push({
+ contractItemId: cId,
+ formCode,
+ formName: legacy?.DESC || formCode,
+ eng: true,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ });
+ });
+ } else {
+ console.warn(`itemCode ${newReg.TOOL_TYPE} 의 contractItemId 없음`);
+ }
}
}
diff --git a/lib/sedp/sync-package.ts b/lib/sedp/sync-package.ts
index c8f39ad8..cdbb5987 100644
--- a/lib/sedp/sync-package.ts
+++ b/lib/sedp/sync-package.ts
@@ -71,7 +71,7 @@ async function getCodeLists(projectCode: string): Promise<Map<string, CodeList>>
interface CodeValue {
VALUE: string;
- DESCC: string;
+ DESC: string;
ATTRIBUTES: Array<{
ATT_ID: string;
VALUE: string;
@@ -129,16 +129,17 @@ export async function syncItemsFromCodeLists(): Promise<void> {
for (const codeValue of pkgNoCodeList.VALUES) {
try {
// ATTRIBUTES에서 필요한 값들 추출
- const packageCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SHI_PACK_NO');
+ const packageCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'PROJ_PACK_NO');
+ const packageNameAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'PROJ_PACK_DESC');
const smCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SM_code');
const itemData = {
ProjectNo: project.code,
itemCode: codeValue.VALUE,
- itemName: codeValue.DESCC || '',
+ itemName: packageNameAttr?.VALUE || '' ,
packageCode: packageCodeAttr?.VALUE || '',
smCode: smCodeAttr?.VALUE || null,
- description: null, // 필요시 추가 매핑
+ description: codeValue.DESC || "", // 필요시 추가 매핑
parentItemCode: null, // 필요시 추가 매핑
itemLevel: null, // 필요시 추가 매핑
deleteFlag: 'N', // 기본값
@@ -229,22 +230,23 @@ export async function syncItemsForProject(projectCode: string): Promise<void> {
// ATTRIBUTES에서 필요한 값들 추출
const packageCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SHI_PACK_NO');
const smCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SM_code');
+ const packageNameAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'PROJ_PACK_DESC');
const itemData = {
ProjectNo: projectCode,
itemCode: codeValue.VALUE,
- itemName: codeValue.DESCC || '',
+ itemName: packageNameAttr?.VALUE || '' ,
packageCode: packageCodeAttr?.VALUE || '',
smCode: smCodeAttr?.VALUE || null,
- description: null,
- parentItemCode: null,
- itemLevel: null,
- deleteFlag: 'N',
- unitOfMeasure: null,
- steelType: null,
- gradeMaterial: null,
- changeDate: null,
- baseUnitOfMeasure: null,
+ description: codeValue.DESC || "", // 필요시 추가 매핑
+ parentItemCode: null, // 필요시 추가 매핑
+ itemLevel: null, // 필요시 추가 매핑
+ deleteFlag: 'N', // 기본값
+ unitOfMeasure: null, // 필요시 추가 매핑
+ steelType: null, // 필요시 추가 매핑
+ gradeMaterial: null, // 필요시 추가 매핑
+ changeDate: null, // 필요시 추가 매핑
+ baseUnitOfMeasure: null, // 필요시 추가 매핑
updatedAt: new Date()
};
diff --git a/lib/vendor-data/services copy.ts b/lib/vendor-data/services copy.ts
new file mode 100644
index 00000000..7f0c47c1
--- /dev/null
+++ b/lib/vendor-data/services copy.ts
@@ -0,0 +1,99 @@
+"use server";
+
+import db from "@/db/db"
+import { items } from "@/db/schema/items"
+import { projects } from "@/db/schema/projects"
+import { Tag, tags } from "@/db/schema/vendorData"
+import { eq } from "drizzle-orm"
+import { revalidateTag, unstable_noStore } from "next/cache";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { contractItems, contracts } from "@/db/schema/contract";
+
+// 스키마 import
+
+export interface ProjectWithContracts {
+ projectId: number
+ projectCode: string
+ projectName: string
+ projectType: string
+
+ contracts: {
+ contractId: number
+ contractNo: string
+ contractName: string
+ // contractName 등 필요한 필드 추가
+ packages: {
+ itemId: number
+ itemName: string
+ }[]
+ }[]
+}
+
+
+export async function getVendorProjectsAndContracts(
+ vendorId: number
+): Promise<ProjectWithContracts[]> {
+ const rows = await db
+ .select({
+ projectId: projects.id,
+ projectCode: projects.code,
+ projectName: projects.name,
+ projectType: projects.type,
+
+ contractId: contracts.id,
+ contractNo: contracts.contractNo,
+ contractName: contracts.contractName,
+
+ itemId: contractItems.id,
+ itemName: items.itemName,
+ })
+ .from(contracts)
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .innerJoin(contractItems, eq(contractItems.contractId, contracts.id))
+ .innerJoin(items, eq(contractItems.itemId, items.id))
+ .where(eq(contracts.vendorId, vendorId))
+
+ const projectMap = new Map<number, ProjectWithContracts>()
+
+ for (const row of rows) {
+ // 1) 프로젝트 그룹 찾기
+ let projectEntry = projectMap.get(row.projectId)
+ if (!projectEntry) {
+ // 새 프로젝트 항목 생성
+ projectEntry = {
+ projectId: row.projectId,
+ projectCode: row.projectCode,
+ projectName: row.projectName,
+ projectType: row.projectType,
+ contracts: [],
+ }
+ projectMap.set(row.projectId, projectEntry)
+ }
+
+ // 2) 프로젝트 안에서 계약(contractId) 찾기
+ let contractEntry = projectEntry.contracts.find(
+ (c) => c.contractId === row.contractId
+ )
+ if (!contractEntry) {
+ // 새 계약 항목
+ contractEntry = {
+ contractId: row.contractId,
+ contractNo: row.contractNo,
+ contractName: row.contractName,
+ packages: [],
+ }
+ projectEntry.contracts.push(contractEntry)
+ }
+
+ // 3) 계약의 packages 배열에 아이템 추가
+ contractEntry.packages.push({
+ itemId: row.itemId,
+ itemName: row.itemName,
+ })
+ }
+
+ return Array.from(projectMap.values())
+}
+
+
+// 1) 태그 조회
diff --git a/lib/vendor-data/services.ts b/lib/vendor-data/services.ts
index 7f0c47c1..0ec935b9 100644
--- a/lib/vendor-data/services.ts
+++ b/lib/vendor-data/services.ts
@@ -3,25 +3,22 @@
import db from "@/db/db"
import { items } from "@/db/schema/items"
import { projects } from "@/db/schema/projects"
-import { Tag, tags } from "@/db/schema/vendorData"
+import { Tag, tags } from "@/db/schema/vendorData"
import { eq } from "drizzle-orm"
import { revalidateTag, unstable_noStore } from "next/cache";
import { unstable_cache } from "@/lib/unstable-cache";
import { contractItems, contracts } from "@/db/schema/contract";
-// 스키마 import
-
export interface ProjectWithContracts {
projectId: number
projectCode: string
projectName: string
projectType: string
-
+
contracts: {
contractId: number
contractNo: string
contractName: string
- // contractName 등 필요한 필드 추가
packages: {
itemId: number
itemName: string
@@ -29,7 +26,6 @@ export interface ProjectWithContracts {
}[]
}
-
export async function getVendorProjectsAndContracts(
vendorId: number
): Promise<ProjectWithContracts[]> {
@@ -39,11 +35,11 @@ export async function getVendorProjectsAndContracts(
projectCode: projects.code,
projectName: projects.name,
projectType: projects.type,
-
+
contractId: contracts.id,
contractNo: contracts.contractNo,
contractName: contracts.contractName,
-
+
itemId: contractItems.id,
itemName: items.itemName,
})
@@ -85,15 +81,20 @@ export async function getVendorProjectsAndContracts(
projectEntry.contracts.push(contractEntry)
}
- // 3) 계약의 packages 배열에 아이템 추가
- contractEntry.packages.push({
- itemId: row.itemId,
- itemName: row.itemName,
- })
+ // 3) 계약의 packages 배열에 아이템 추가 (중복 체크)
+ // itemName이 같은 항목이 이미 존재하는지 확인
+ const existingItem = contractEntry.packages.find(
+ (pkg) => pkg.itemName === row.itemName
+ )
+
+ // 같은 itemName이 없는 경우에만 추가
+ if (!existingItem) {
+ contractEntry.packages.push({
+ itemId: row.itemId,
+ itemName: row.itemName,
+ })
+ }
}
return Array.from(projectMap.values())
-}
-
-
-// 1) 태그 조회
+} \ No newline at end of file