summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/api/basicContract/create-revision/route.ts10
-rw-r--r--lib/basic-contract/service.ts16
-rw-r--r--lib/basic-contract/template/create-revision-dialog.tsx112
-rw-r--r--lib/evaluation-criteria/service.ts7
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-table.tsx8
-rw-r--r--lib/evaluation-submit/evaluation-form.tsx12
-rw-r--r--lib/evaluation-submit/evaluation-page.tsx31
-rw-r--r--lib/evaluation-submit/service.ts213
-rw-r--r--lib/evaluation-submit/table/evaluation-submit-dialog.tsx353
-rw-r--r--lib/evaluation-target-list/service.ts4
-rw-r--r--lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx15
-rw-r--r--lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx40
-rw-r--r--lib/vendors/service.ts160
-rw-r--r--lib/vendors/table/approve-vendor-dialog.tsx154
-rw-r--r--lib/vendors/table/vendors-table-toolbar-actions.tsx4
15 files changed, 521 insertions, 618 deletions
diff --git a/app/api/basicContract/create-revision/route.ts b/app/api/basicContract/create-revision/route.ts
index 69dc4c8f..19d0ceb1 100644
--- a/app/api/basicContract/create-revision/route.ts
+++ b/app/api/basicContract/create-revision/route.ts
@@ -7,18 +7,10 @@ import { createBasicContractTemplateRevision } from "@/lib/basic-contract/servic
// 리비전 생성 스키마
const createRevisionSchema = z.object({
- baseTemplateId: z.string().uuid(),
+ baseTemplateId: z.coerce.number().int().positive(),
templateName: z.string().min(1),
revision: z.number().int().min(1),
legalReviewRequired: z.boolean(),
- shipBuildingApplicable: z.boolean(),
- windApplicable: z.boolean(),
- pcApplicable: z.boolean(),
- nbApplicable: z.boolean(),
- rcApplicable: z.boolean(),
- gyApplicable: z.boolean(),
- sysApplicable: z.boolean(),
- infraApplicable: z.boolean(),
fileName: z.string().min(1),
filePath: z.string().min(1),
});
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index 52669948..8999a109 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -1139,7 +1139,7 @@ export async function refreshTemplatePage(templateId: string) {
}
// 새 리비전 생성 함수
-export async function createBasicContractTemplateRevision(input: CreateRevisionSchema) {
+export async function createBasicContractTemplateRevision(input: any) {
unstable_noStore();
try {
@@ -1193,14 +1193,6 @@ export async function createBasicContractTemplateRevision(input: CreateRevisionS
templateName: input.templateName,
revision: input.revision,
legalReviewRequired: input.legalReviewRequired,
- shipBuildingApplicable: input.shipBuildingApplicable,
- windApplicable: input.windApplicable,
- pcApplicable: input.pcApplicable,
- nbApplicable: input.nbApplicable,
- rcApplicable: input.rcApplicable,
- gyApplicable: input.gyApplicable,
- sysApplicable: input.sysApplicable,
- infraApplicable: input.infraApplicable,
status: "ACTIVE",
fileName: input.fileName,
filePath: input.filePath,
@@ -1208,6 +1200,12 @@ export async function createBasicContractTemplateRevision(input: CreateRevisionS
});
return row;
});
+ //기존 템플릿의 이전 리비전은 비활성으로 변경
+ await db.update(basicContractTemplates).set({
+ status: "DISPOSED",
+ }).where(and(eq(basicContractTemplates.templateName, input.templateName),ne(basicContractTemplates.revision, input.revision)));
+ //캐시 무효화
+ revalidateTag("basic-contract-templates");
return { data: newRevision, error: null };
} catch (error) {
diff --git a/lib/basic-contract/template/create-revision-dialog.tsx b/lib/basic-contract/template/create-revision-dialog.tsx
index 6ae03cc2..f1a48c46 100644
--- a/lib/basic-contract/template/create-revision-dialog.tsx
+++ b/lib/basic-contract/template/create-revision-dialog.tsx
@@ -41,7 +41,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { useRouter } from "next/navigation";
-import { BUSINESS_UNITS } from "@/config/basicContractColumnsConfig";
import { BasicContractTemplate } from "@/db/schema";
// 템플릿 이름 옵션 정의
@@ -115,14 +114,7 @@ export function CreateRevisionDialog({
return {
revision: suggestedRevision,
legalReviewRequired: baseTemplate.legalReviewRequired,
- shipBuildingApplicable: baseTemplate.shipBuildingApplicable,
- windApplicable: baseTemplate.windApplicable,
- pcApplicable: baseTemplate.pcApplicable,
- nbApplicable: baseTemplate.nbApplicable,
- rcApplicable: baseTemplate.rcApplicable,
- gyApplicable: baseTemplate.gyApplicable,
- sysApplicable: baseTemplate.sysApplicable,
- infraApplicable: baseTemplate.infraApplicable,
+
};
}, [baseTemplate, suggestedRevision]);
@@ -149,26 +141,6 @@ export function CreateRevisionDialog({
}
};
- // 모든 적용 범위 선택/해제
- const handleSelectAllScopes = (checked: boolean) => {
- BUSINESS_UNITS.forEach(unit => {
- form.setValue(unit.key as keyof CreateRevisionFormValues, checked);
- });
- };
-
- // 이전 설정 복사
- const handleCopyPreviousSettings = () => {
- if (!baseTemplate) return;
-
- BUSINESS_UNITS.forEach(unit => {
- const value = baseTemplate[unit.key as keyof BasicContractTemplate] as boolean;
- form.setValue(unit.key as keyof CreateRevisionFormValues, value);
- });
-
- form.setValue("legalReviewRequired", baseTemplate.legalReviewRequired);
- toast.success("이전 설정이 복사되었습니다.");
- };
-
// 청크 크기 설정 (1MB)
const CHUNK_SIZE = 1 * 1024 * 1024;
@@ -248,14 +220,6 @@ export function CreateRevisionDialog({
templateName: baseTemplate.templateName,
revision: formData.revision,
legalReviewRequired: formData.legalReviewRequired,
- shipBuildingApplicable: formData.shipBuildingApplicable,
- windApplicable: formData.windApplicable,
- pcApplicable: formData.pcApplicable,
- nbApplicable: formData.nbApplicable,
- rcApplicable: formData.rcApplicable,
- gyApplicable: formData.gyApplicable,
- sysApplicable: formData.sysApplicable,
- infraApplicable: formData.infraApplicable,
fileName: uploadResult.fileName,
filePath: uploadResult.filePath,
}),
@@ -293,11 +257,6 @@ export function CreateRevisionDialog({
}
}, [open, form]);
- // 현재 선택된 적용 범위 수
- const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
- form.watch(unit.key as keyof CreateRevisionFormValues)
- ).length;
-
if (!baseTemplate) return null;
return (
@@ -386,74 +345,6 @@ export function CreateRevisionDialog({
</CardContent>
</Card>
- {/* 적용 범위 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">
- 적용 범위 <span className="text-red-500">*</span>
- </CardTitle>
- <CardDescription>
- 이 리비전이 적용될 사업부를 선택하세요. ({selectedScopesCount}개 선택됨)
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="flex items-center justify-between">
- <div className="flex items-center space-x-2">
- <Checkbox
- id="select-all"
- checked={selectedScopesCount === BUSINESS_UNITS.length}
- onCheckedChange={handleSelectAllScopes}
- />
- <label htmlFor="select-all" className="text-sm font-medium">
- 전체 선택
- </label>
- </div>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={handleCopyPreviousSettings}
- >
- <Copy className="h-4 w-4 mr-1" />
- 이전 설정 복사
- </Button>
- </div>
-
- <Separator />
-
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
- {BUSINESS_UNITS.map((unit) => (
- <FormField
- key={unit.key}
- control={form.control}
- name={unit.key as keyof CreateRevisionFormValues}
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value as boolean}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none">
- <FormLabel className="text-sm font-normal">
- {unit.label}
- </FormLabel>
- </div>
- </FormItem>
- )}
- />
- ))}
- </div>
-
- {form.formState.errors.shipBuildingApplicable && (
- <p className="text-sm text-destructive">
- {form.formState.errors.shipBuildingApplicable.message}
- </p>
- )}
- </CardContent>
- </Card>
-
{/* 파일 업로드 */}
<Card>
<CardHeader>
@@ -529,7 +420,6 @@ export function CreateRevisionDialog({
disabled={
isLoading ||
!form.watch("file") ||
- !BUSINESS_UNITS.some(unit => form.watch(unit.key as keyof CreateRevisionFormValues)) ||
form.watch("revision") <= baseTemplate.revision
}
>
diff --git a/lib/evaluation-criteria/service.ts b/lib/evaluation-criteria/service.ts
index 9cb0126f..9288e05c 100644
--- a/lib/evaluation-criteria/service.ts
+++ b/lib/evaluation-criteria/service.ts
@@ -82,11 +82,10 @@ async function getRegEvalCriteria(input: GetRegEvalCriteriaSchema) {
// Sorting
const orderBy = input.sort.length > 0
? input.sort.map((item) => {
- return item.desc
- ? desc(regEvalCriteria[item.id])
- : asc(regEvalCriteria[item.id]);
+ const column = regEvalCriteria[item.id];
+ return item.desc ? desc(column) : asc(column);
})
- : [asc(regEvalCriteria.id)];
+ : [desc(regEvalCriteria.createdAt)];
// Getting Data - 메인 기준 데이터만 가져오기
const { data, total } = await db.transaction(async (tx) => {
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx
index e2d614e0..86a22eaf 100644
--- a/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx
@@ -94,6 +94,8 @@ function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
]
},
{ id: 'remarks', label: '비고', type: 'text' },
+ { id: 'createdAt', label: '생성일', type: 'date' },
+ { id: 'updatedAt', label: '수정일', type: 'date' },
];
// Data Table Setting
@@ -105,14 +107,10 @@ function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
enablePinning: true,
enableAdvancedFilter: true,
initialState: {
- sorting: [
- { id: 'id', desc: false },
- ],
+ sorting: [{ id: "createdAt", desc: true }],
columnPinning: { left: ['select'], right: ['actions'] },
columnVisibility: {
id: false,
- createdAt: false,
- updatedAt: false,
createdBy: false,
updatedBy: false,
variableScoreMin: false,
diff --git a/lib/evaluation-submit/evaluation-form.tsx b/lib/evaluation-submit/evaluation-form.tsx
index fbdcee69..d51a0369 100644
--- a/lib/evaluation-submit/evaluation-form.tsx
+++ b/lib/evaluation-submit/evaluation-form.tsx
@@ -90,13 +90,15 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
questions.forEach(question => {
const isVariable = question.scoreType === 'variable'
+ // 선택된 답변 옵션 찾기
+ const selectedOption = question.selectedDetailId ?
+ question.availableOptions.find(opt => opt.detailId === question.selectedDetailId) : null;
+
initial[question.criteriaId] = {
detailId: isVariable ? -1 : question.selectedDetailId,
score: isVariable ?
- question.currentScore || null :
- (question.selectedDetailId ?
- question.availableOptions.find(opt => opt.detailId === question.selectedDetailId)?.score || question.currentScore || null
- : question.currentScore || null),
+ (question.currentScore ? Number(question.currentScore) : null) :
+ (selectedOption?.score ?? (question.currentScore ? Number(question.currentScore) : null)),
comment: question.currentComment || "",
}
})
@@ -108,7 +110,7 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) {
console.log('Initializing attachments from server data...')
const initial: Record<number, AttachmentInfo[]> = {}
questions.forEach(question => {
- const questionAttachments = question.attachments || []
+ const questionAttachments = Array.isArray(question.attachments) ? question.attachments : []
initial[question.criteriaId] = questionAttachments
if (questionAttachments.length > 0) {
console.log(`Question ${question.criteriaId} has ${questionAttachments.length} attachments:`, questionAttachments)
diff --git a/lib/evaluation-submit/evaluation-page.tsx b/lib/evaluation-submit/evaluation-page.tsx
index 810ed03e..4497b9ef 100644
--- a/lib/evaluation-submit/evaluation-page.tsx
+++ b/lib/evaluation-submit/evaluation-page.tsx
@@ -8,7 +8,9 @@ import { Skeleton } from "@/components/ui/skeleton"
import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
-import { getEvaluationFormData, EvaluationFormData } from "./service"
+import { getEvaluationFormData } from "./service"
+import { EvaluationFormData } from "@/types/evaluation-form"
+
import { EvaluationForm } from "./evaluation-form"
/**
@@ -174,7 +176,7 @@ export function EvaluationPage() {
const [isLoading, setIsLoading] = React.useState(true)
const [error, setError] = React.useState<string | null>(null)
- const reviewerEvaluationId = params.id ? parseInt(params.id as string) : null
+ const reviewerEvaluationId = params?.id ? parseInt(params.id as string) : null
// 평가 데이터 로드
const loadEvaluationData = React.useCallback(async () => {
@@ -187,24 +189,25 @@ export function EvaluationPage() {
try {
setIsLoading(true)
setError(null)
-
+ console.log(`[CLIENT] Loading evaluation data for ID: ${reviewerEvaluationId}`)
const data = await getEvaluationFormData(reviewerEvaluationId)
if (!data) {
- setError("평가 데이터를 찾을 수 없습니다.")
+ console.warn(`[CLIENT] No evaluation data returned for ID: ${reviewerEvaluationId}`)
+ setError("평가 데이터를 찾을 수 없습니다. 해당 평가가 존재하지 않거나 접근 권한이 없을 수 있습니다.")
return
}
+
+ console.log(`[CLIENT] Successfully loaded evaluation data for ID: ${reviewerEvaluationId}`)
- setFormData(data)
- } catch (err) {
- console.error('Failed to load evaluation data:', err)
- setError(
- err instanceof Error
- ? err.message
- : "평가 데이터를 불러오는 중 오류가 발생했습니다."
- )
- } finally {
- setIsLoading(false)
+ setFormData(data as EvaluationFormData)
+ } catch (err) {
+ console.error('[CLIENT] Failed to load evaluation data:', err)
+ const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류"
+ setError(`평가 데이터를 불러오는 중 오류가 발생했습니다: ${errorMessage}`)
+ } finally {
+ setIsLoading(false)
+ console.log('[CLIENT] Loading completed')
}
}, [reviewerEvaluationId])
diff --git a/lib/evaluation-submit/service.ts b/lib/evaluation-submit/service.ts
index 21ceb36f..023961de 100644
--- a/lib/evaluation-submit/service.ts
+++ b/lib/evaluation-submit/service.ts
@@ -94,39 +94,76 @@ function getCategoryFilterByDepartment(departmentCode: string): SQL<unknown> {
*/
export async function getEvaluationFormData(reviewerEvaluationId: number): Promise<EvaluationFormData | null> {
try {
+ console.log(`[SERVER] getEvaluationFormData called with ID: ${reviewerEvaluationId}`);
+
+ // reviewerEvaluationId 유효성 검사
+ if (!reviewerEvaluationId || reviewerEvaluationId <= 0) {
+ console.error(`[SERVER] Invalid reviewerEvaluationId: ${reviewerEvaluationId}`);
+ return null;
+ }
+
// 1. 리뷰어 평가 정보 조회 (부서 정보 + 평가 대상 정보 포함)
- const reviewerEvaluationInfo = await db
- .select({
- id: reviewerEvaluations.id,
- periodicEvaluationId: reviewerEvaluations.periodicEvaluationId,
- evaluationTargetReviewerId: reviewerEvaluations.evaluationTargetReviewerId,
- isCompleted: reviewerEvaluations.isCompleted,
- // evaluationTargetReviewers 테이블에서 부서 정보
- departmentCode: evaluationTargetReviewers.departmentCode,
- // evaluationTargets 테이블에서 division과 materialType 정보
- division: evaluationTargets.division,
- materialType: evaluationTargets.materialType,
- vendorName: evaluationTargets.vendorName,
- vendorCode: evaluationTargets.vendorCode,
- })
- .from(reviewerEvaluations)
- .leftJoin(
- evaluationTargetReviewers,
- eq(reviewerEvaluations.evaluationTargetReviewerId, evaluationTargetReviewers.id)
- )
- .leftJoin(
- evaluationTargets,
- eq(evaluationTargetReviewers.evaluationTargetId, evaluationTargets.id)
- )
- .where(eq(reviewerEvaluations.id, reviewerEvaluationId))
- .limit(1);
+ let reviewerEvaluationInfo;
+ try {
+ reviewerEvaluationInfo = await db
+ .select({
+ id: reviewerEvaluations.id,
+ periodicEvaluationId: reviewerEvaluations.periodicEvaluationId,
+ evaluationTargetReviewerId: reviewerEvaluations.evaluationTargetReviewerId,
+ isCompleted: reviewerEvaluations.isCompleted,
+ // evaluationTargetReviewers 테이블에서 부서 정보
+ departmentCode: evaluationTargetReviewers.departmentCode,
+ // evaluationTargets 테이블에서 division과 materialType 정보
+ division: evaluationTargets.division,
+ materialType: evaluationTargets.materialType,
+ vendorName: evaluationTargets.vendorName,
+ vendorCode: evaluationTargets.vendorCode,
+ })
+ .from(reviewerEvaluations)
+ .leftJoin(
+ evaluationTargetReviewers,
+ eq(reviewerEvaluations.evaluationTargetReviewerId, evaluationTargetReviewers.id)
+ )
+ .leftJoin(
+ evaluationTargets,
+ eq(evaluationTargetReviewers.evaluationTargetId, evaluationTargets.id)
+ )
+ .where(eq(reviewerEvaluations.id, reviewerEvaluationId))
+ .limit(1);
+ } catch (dbError) {
+ console.error(`[SERVER] Database query failed for ID ${reviewerEvaluationId}:`, dbError);
+ throw new Error(`데이터베이스 조회 중 오류가 발생했습니다: ${dbError instanceof Error ? dbError.message : 'Unknown database error'}`);
+ }
if (reviewerEvaluationInfo.length === 0) {
- throw new Error('Reviewer evaluation not found');
+ console.warn(`[SERVER] Reviewer evaluation not found for ID: ${reviewerEvaluationId}`);
+ return null;
}
const evaluation = reviewerEvaluationInfo[0];
+ // 필수 필드 검증 및 상세 로그
+ console.log(`[SERVER] Found evaluation data:`, {
+ id: evaluation.id,
+ division: evaluation.division,
+ materialType: evaluation.materialType,
+ departmentCode: evaluation.departmentCode,
+ vendorName: evaluation.vendorName,
+ vendorCode: evaluation.vendorCode
+ });
+
+ if (!evaluation.division || !evaluation.materialType || !evaluation.departmentCode) {
+ console.error(`[SERVER] Missing required evaluation data for ID ${reviewerEvaluationId}:`, {
+ id: evaluation.id,
+ division: evaluation.division,
+ materialType: evaluation.materialType,
+ departmentCode: evaluation.departmentCode,
+ vendorName: evaluation.vendorName,
+ vendorCode: evaluation.vendorCode
+ });
+ return null;
+ }
+
// 1-1. division과 materialType을 기반으로 reviewerType 계산
const reviewerType = calculateReviewerType(evaluation.division, evaluation.materialType);
@@ -134,7 +171,9 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
const categoryFilter = getCategoryFilterByDepartment(evaluation.departmentCode);
// 3. 해당 부서에 맞는 평가 기준들과 답변 옵션들 조회
- const criteriaWithDetails = await db
+ let criteriaWithDetails;
+ try {
+ criteriaWithDetails = await db
.select({
// 질문 정보 (실제 스키마 기준)
criteriaId: regEvalCriteria.id,
@@ -168,23 +207,40 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
regEvalCriteria.id,
regEvalCriteriaDetails.orderIndex
);
+ } catch (criteriaError) {
+ console.error(`[SERVER] Failed to fetch evaluation criteria for ID ${reviewerEvaluationId}:`, criteriaError);
+ throw new Error(`평가 기준 조회 중 오류가 발생했습니다: ${criteriaError instanceof Error ? criteriaError.message : 'Unknown criteria error'}`);
+ }
+
+ if (!criteriaWithDetails || criteriaWithDetails.length === 0) {
+ console.warn(`[SERVER] No evaluation criteria found for ID ${reviewerEvaluationId} with department ${evaluation.departmentCode}`);
+ return null;
+ }
// 4. 기존 응답 데이터 조회 (실제 답변만)
- const existingResponses = await db
- .select({
- id: reviewerEvaluationDetails.id,
- reviewerEvaluationId: reviewerEvaluationDetails.reviewerEvaluationId,
- regEvalCriteriaDetailsId: reviewerEvaluationDetails.regEvalCriteriaDetailsId,
- score: reviewerEvaluationDetails.score,
- comment: reviewerEvaluationDetails.comment,
- createdAt: reviewerEvaluationDetails.createdAt,
- updatedAt: reviewerEvaluationDetails.updatedAt,
- })
- .from(reviewerEvaluationDetails)
- .where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId));
+ let existingResponses;
+ try {
+ existingResponses = await db
+ .select({
+ id: reviewerEvaluationDetails.id,
+ reviewerEvaluationId: reviewerEvaluationDetails.reviewerEvaluationId,
+ regEvalCriteriaDetailsId: reviewerEvaluationDetails.regEvalCriteriaDetailsId,
+ score: reviewerEvaluationDetails.score,
+ comment: reviewerEvaluationDetails.comment,
+ createdAt: reviewerEvaluationDetails.createdAt,
+ updatedAt: reviewerEvaluationDetails.updatedAt,
+ })
+ .from(reviewerEvaluationDetails)
+ .where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId));
+ } catch (responseError) {
+ console.error(`[SERVER] Failed to fetch existing responses for ID ${reviewerEvaluationId}:`, responseError);
+ existingResponses = []; // 기본값 설정
+ }
// 📎 5. 첨부파일 정보 조회
- const attachmentsData = await db
+ let attachmentsData;
+ try {
+ attachmentsData = await db
.select({
// 첨부파일 정보
attachmentId: reviewerEvaluationAttachments.id,
@@ -229,6 +285,10 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
)
.where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId))
.orderBy(desc(reviewerEvaluationAttachments.createdAt));
+ } catch (attachmentError) {
+ console.error(`[SERVER] Failed to fetch attachments for ID ${reviewerEvaluationId}:`, attachmentError);
+ attachmentsData = []; // 기본값 설정
+ }
// 📎 6. 첨부파일을 질문별로 그룹화
const attachmentsByQuestion = new Map<number, AttachmentInfo[]>();
@@ -286,7 +346,7 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
category2: record.category2,
item: record.item,
classification: record.classification,
- range: record.range,
+ range: record.range || null,
scoreType: record.scoreType,
remarks: record.remarks,
availableOptions: [],
@@ -305,10 +365,10 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
// 답변 옵션 추가
const question = questionsMap.get(criteriaId)!;
question.availableOptions.push({
- detailId: record.detailId,
- detail: record.detail,
+ detailId: record.detailId || 0,
+ detail: record.detail || '',
score: score,
- orderIndex: record.orderIndex,
+ orderIndex: record.orderIndex || 0,
});
});
@@ -392,7 +452,15 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi
};
} catch (err) {
- console.error('Error in getEvaluationFormData:', err);
+ console.error(`[SERVER] Error in getEvaluationFormData for ID ${reviewerEvaluationId}:`, err);
+ // 데이터베이스 연결 오류나 쿼리 실행 오류 등은 여기서 처리
+ if (err instanceof Error) {
+ if (err.message.includes('Connection') || err.message.includes('timeout')) {
+ console.error(`[SERVER] Database connection error: ${err.message}`);
+ } else if (err.message.includes('syntax') || err.message.includes('column')) {
+ console.error(`[SERVER] Database query error: ${err.message}`);
+ }
+ }
return null;
}
}
@@ -546,12 +614,25 @@ export async function updateEvaluationResponse(
selectedDetail = detailResult[0];
}
- // 2. reviewerEvaluation 정보 조회 (periodicEvaluationId 포함)
+ // 2. reviewerEvaluation 정보 조회 (periodicEvaluationId, division, materialType 포함)
const reviewerEvaluationInfo = await tx
.select({
periodicEvaluationId: reviewerEvaluations.periodicEvaluationId,
+ // evaluationTargetReviewers 테이블에서 부서 정보
+ departmentCode: evaluationTargetReviewers.departmentCode,
+ // evaluationTargets 테이블에서 division과 materialType 정보
+ division: evaluationTargets.division,
+ materialType: evaluationTargets.materialType,
})
.from(reviewerEvaluations)
+ .leftJoin(
+ evaluationTargetReviewers,
+ eq(reviewerEvaluations.evaluationTargetReviewerId, evaluationTargetReviewers.id)
+ )
+ .leftJoin(
+ evaluationTargets,
+ eq(evaluationTargetReviewers.evaluationTargetId, evaluationTargets.id)
+ )
.where(eq(reviewerEvaluations.id, reviewerEvaluationId))
.limit(1);
@@ -559,7 +640,14 @@ export async function updateEvaluationResponse(
throw new Error('Reviewer evaluation not found');
}
- const { periodicEvaluationId } = reviewerEvaluationInfo[0];
+ const evaluation = reviewerEvaluationInfo[0];
+
+ // 필수 필드 검증
+ if (!evaluation.division || !evaluation.materialType || !evaluation.departmentCode) {
+ throw new Error('Missing required evaluation data');
+ }
+
+ const { periodicEvaluationId } = evaluation;
// 3. periodicEvaluation의 현재 상태 확인 및 업데이트
const currentStatus = await tx
@@ -589,12 +677,13 @@ export async function updateEvaluationResponse(
score = customScore;
} else {
// 일반 타입인 경우 리뷰어 타입에 맞는 점수 가져오기
- const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId);
- if (!evaluationInfo) {
- throw new Error('Evaluation not found');
+ if (!selectedDetail) {
+ throw new Error('Selected detail not found');
}
- const calculatedScore = getScoreByReviewerType(selectedDetail!, evaluationInfo.evaluationInfo.reviewerType);
+ // reviewerType 계산
+ const reviewerTypeForScore = calculateReviewerType(evaluation.division, evaluation.materialType);
+ const calculatedScore = getScoreByReviewerType(selectedDetail, reviewerTypeForScore);
if (calculatedScore === null) {
throw new Error('Score not found for this reviewer type');
}
@@ -697,16 +786,18 @@ export async function updateVariableEvaluationResponse(
}
// 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}
- )`
- )
- );
+ if (criteriaId) {
+ await tx
+ .delete(reviewerEvaluationDetails)
+ .where(
+ and(
+ eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId),
+ sql`${reviewerEvaluationDetails.regEvalCriteriaDetailsId} IN (
+ SELECT id FROM reg_eval_criteria_details WHERE criteria_id = ${criteriaId}
+ )`
+ )
+ );
+ }
// 4. 새로운 응답 생성 (variable 타입은 regEvalCriteriaDetailsId가 null)
const [newDetail] = await tx
diff --git a/lib/evaluation-submit/table/evaluation-submit-dialog.tsx b/lib/evaluation-submit/table/evaluation-submit-dialog.tsx
deleted file mode 100644
index 20ed5f30..00000000
--- a/lib/evaluation-submit/table/evaluation-submit-dialog.tsx
+++ /dev/null
@@ -1,353 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- AlertTriangleIcon,
- CheckCircleIcon,
- SendIcon,
- XCircleIcon,
- FileTextIcon,
- ClipboardListIcon,
- LoaderIcon
-} from "lucide-react"
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Alert,
- AlertDescription,
- AlertTitle,
-} from "@/components/ui/alert"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { toast } from "sonner"
-
-// Progress 컴포넌트 (간단한 구현)
-function Progress({ value, className }: { value: number; className?: string }) {
- return (
- <div className={`w-full bg-gray-200 rounded-full overflow-hidden ${className}`}>
- <div
- className={`h-full bg-blue-600 transition-all duration-300 ${
- value === 100 ? 'bg-green-500' : value >= 50 ? 'bg-blue-500' : 'bg-yellow-500'
- }`}
- style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
- />
- </div>
- )
-}
-
-import {
- getEvaluationSubmissionCompleteness,
- updateEvaluationSubmissionStatus
-} from "../service"
-import type { EvaluationSubmissionWithVendor } from "../service"
-
-interface EvaluationSubmissionDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- submission: EvaluationSubmissionWithVendor | null
- onSuccess: () => void
-}
-
-type CompletenessData = {
- general: {
- total: number
- completed: number
- percentage: number
- isComplete: boolean
- }
- esg: {
- total: number
- completed: number
- percentage: number
- averageScore: number
- isComplete: boolean
- }
- overall: {
- isComplete: boolean
- totalItems: number
- completedItems: number
- }
-}
-
-export function EvaluationSubmissionDialog({
- open,
- onOpenChange,
- submission,
- onSuccess,
-}: EvaluationSubmissionDialogProps) {
- const [isLoading, setIsLoading] = React.useState(false)
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [completeness, setCompleteness] = React.useState<CompletenessData | null>(null)
-
- // 완성도 데이터 로딩
- React.useEffect(() => {
- if (open && submission?.id) {
- loadCompleteness()
- }
- }, [open, submission?.id])
-
- const loadCompleteness = async () => {
- if (!submission?.id) return
-
- setIsLoading(true)
- try {
- const data = await getEvaluationSubmissionCompleteness(submission.id)
- setCompleteness(data)
- } catch (error) {
- console.error('Error loading completeness:', error)
- toast.error('완성도 정보를 불러오는데 실패했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
-
- // 제출하기
- const handleSubmit = async () => {
- if (!submission?.id || !completeness) return
-
- if (!completeness.overall.isComplete) {
- toast.error('모든 평가 항목을 완료해야 제출할 수 있습니다.')
- return
- }
-
- setIsSubmitting(true)
- try {
- await updateEvaluationSubmissionStatus(submission.id, 'submitted')
- toast.success('평가가 성공적으로 제출되었습니다.')
- onSuccess()
- } catch (error: any) {
- console.error('Error submitting evaluation:', error)
- toast.error(error.message || '제출에 실패했습니다.')
- } finally {
- setIsSubmitting(false)
- }
- }
-
- const isKorean = submission?.vendor.countryCode === 'KR'
-
- if (isLoading) {
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
- <div className="flex items-center justify-center py-8">
- <div className="text-center space-y-4">
- <LoaderIcon className="h-8 w-8 animate-spin mx-auto" />
- <p>완성도를 확인하는 중...</p>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <SendIcon className="h-5 w-5" />
- 평가 제출하기
- </DialogTitle>
- <DialogDescription>
- {submission?.vendor.vendorName}의 {submission?.evaluationYear}년 평가를 제출합니다.
- </DialogDescription>
- </DialogHeader>
-
- {completeness && (
- <div className="space-y-6">
- {/* 전체 완성도 카드 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-base flex items-center justify-between">
- <span>전체 완성도</span>
- <Badge
- variant={completeness.overall.isComplete ? "default" : "secondary"}
- className={
- completeness.overall.isComplete
- ? "bg-green-100 text-green-800 border-green-200"
- : ""
- }
- >
- {completeness.overall.isComplete ? "완료" : "미완료"}
- </Badge>
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="space-y-2">
- <div className="flex items-center justify-between text-sm">
- <span>전체 진행률</span>
- <span className="font-medium">
- {completeness.overall.completedItems}/{completeness.overall.totalItems}개 완료
- </span>
- </div>
- <Progress
- value={
- completeness.overall.totalItems > 0
- ? (completeness.overall.completedItems / completeness.overall.totalItems) * 100
- : 0
- }
- className="h-2"
- />
- <p className="text-xs text-muted-foreground">
- {completeness.overall.totalItems > 0
- ? Math.round((completeness.overall.completedItems / completeness.overall.totalItems) * 100)
- : 0}% 완료
- </p>
- </div>
- </CardContent>
- </Card>
-
- {/* 세부 완성도 */}
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- {/* 일반평가 */}
- <Card>
- <CardHeader className="pb-3">
- <CardTitle className="text-sm flex items-center gap-2">
- <FileTextIcon className="h-4 w-4" />
- 일반평가
- {completeness.general.isComplete ? (
- <CheckCircleIcon className="h-4 w-4 text-green-600" />
- ) : (
- <XCircleIcon className="h-4 w-4 text-red-600" />
- )}
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-3">
- <div className="space-y-1">
- <div className="flex items-center justify-between text-xs">
- <span>응답 완료</span>
- <span className="font-medium">
- {completeness.general.completed}/{completeness.general.total}개
- </span>
- </div>
- <Progress value={completeness.general.percentage} className="h-1" />
- <p className="text-xs text-muted-foreground">
- {completeness.general.percentage.toFixed(0)}% 완료
- </p>
- </div>
-
- {!completeness.general.isComplete && (
- <p className="text-xs text-red-600">
- {completeness.general.total - completeness.general.completed}개 항목이 미완료입니다.
- </p>
- )}
- </CardContent>
- </Card>
-
- {/* ESG평가 */}
- {isKorean ? (
- <Card>
- <CardHeader className="pb-3">
- <CardTitle className="text-sm flex items-center gap-2">
- <ClipboardListIcon className="h-4 w-4" />
- ESG평가
- {completeness.esg.isComplete ? (
- <CheckCircleIcon className="h-4 w-4 text-green-600" />
- ) : (
- <XCircleIcon className="h-4 w-4 text-red-600" />
- )}
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-3">
- <div className="space-y-1">
- <div className="flex items-center justify-between text-xs">
- <span>응답 완료</span>
- <span className="font-medium">
- {completeness.esg.completed}/{completeness.esg.total}개
- </span>
- </div>
- <Progress value={completeness.esg.percentage} className="h-1" />
- <p className="text-xs text-muted-foreground">
- {completeness.esg.percentage.toFixed(0)}% 완료
- </p>
- </div>
-
- {completeness.esg.completed > 0 && (
- <div className="text-xs">
- <span className="text-muted-foreground">평균 점수: </span>
- <span className="font-medium text-blue-600">
- {completeness.esg.averageScore.toFixed(1)}점
- </span>
- </div>
- )}
-
- {!completeness.esg.isComplete && (
- <p className="text-xs text-red-600">
- {completeness.esg.total - completeness.esg.completed}개 항목이 미완료입니다.
- </p>
- )}
- </CardContent>
- </Card>
- ) : (
- <Card>
- <CardHeader className="pb-3">
- <CardTitle className="text-sm flex items-center gap-2">
- <ClipboardListIcon className="h-4 w-4" />
- ESG평가
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="text-center text-muted-foreground">
- <Badge variant="outline">해당없음</Badge>
- <p className="text-xs mt-2">한국 업체가 아니므로 ESG 평가가 제외됩니다.</p>
- </div>
- </CardContent>
- </Card>
- )}
- </div>
-
- {/* 제출 상태 알림 */}
- {completeness.overall.isComplete ? (
- <Alert>
- <CheckCircleIcon className="h-4 w-4" />
- <AlertTitle>제출 준비 완료</AlertTitle>
- <AlertDescription>
- 모든 평가 항목이 완료되었습니다. 제출하시겠습니까?
- </AlertDescription>
- </Alert>
- ) : (
- <Alert variant="destructive">
- <AlertTriangleIcon className="h-4 w-4" />
- <AlertTitle>제출 불가</AlertTitle>
- <AlertDescription>
- 아직 완료되지 않은 평가 항목이 있습니다. 모든 항목을 완료한 후 제출해 주세요.
- </AlertDescription>
- </Alert>
- )}
- </div>
- )}
-
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 취소
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={!completeness?.overall.isComplete || isSubmitting}
- className="min-w-[100px]"
- >
- {isSubmitting ? (
- <>
- <LoaderIcon className="mr-2 h-4 w-4 animate-spin" />
- 제출 중...
- </>
- ) : (
- <>
- <SendIcon className="mr-2 h-4 w-4" />
- 제출하기
- </>
- )}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts
index 36251c2d..6e2dbfe6 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, gte, lte } from "drizzle-orm";
+import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray, gte, lte, ne } from "drizzle-orm";
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
import { filterColumns } from "@/lib/filter-columns";
@@ -674,8 +674,10 @@ export async function getAvailableReviewers(departmentCode?: string) {
// departmentName: "API로 추후", // ✅ 부서명도 반환
})
.from(users)
+ .where(ne(users.domain, "partners"))
.orderBy(users.name)
// .limit(100);
+ //partners가 아닌 domain에 따라서 필터링
return reviewers;
} catch (error) {
diff --git a/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx b/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx
index 8261bb7b..0ebe1f8c 100644
--- a/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx
+++ b/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx
@@ -196,11 +196,11 @@ export function EsgEvaluationFormSheet({
// 평균 점수 재계산
await recalculateEvaluationProgress(submission.id)
- toast.success('모든 ESG 평가가 저장되었습니다.')
+ toast.success('모든 ESG 자가평가서가 저장되었습니다.')
onSuccess()
} catch (error) {
console.error('Error saving all ESG responses:', error)
- toast.error('ESG 평가 저장에 실패했습니다.')
+ toast.error('ESG 자가평가서 저장에 실패했습니다.')
} finally {
setIsSaving(false)
}
@@ -285,7 +285,7 @@ const handleExportData = async () => {
link.click()
document.body.removeChild(link)
- toast.success('ESG 평가 문항이 다운로드되었습니다.')
+ toast.success('ESG 자가평가서 문항이 다운로드되었습니다.')
}
// 진행률 및 점수 계산
@@ -352,7 +352,7 @@ const handleExportData = async () => {
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto"></div>
- <p>ESG 평가 데이터를 불러오는 중...</p>
+ <p>ESG 자가평가서 데이터를 불러오는 중...</p>
</div>
</div>
</SheetContent>
@@ -366,9 +366,10 @@ const handleExportData = async () => {
<SheetHeader>
<div className="flex items-center justify-between">
<div>
- <SheetTitle>ESG 평가 작성</SheetTitle>
+ <SheetTitle>ESG 자가평가서 작성</SheetTitle>
<SheetDescription>
- {formData?.submission.vendorName}의 ESG 평가를 작성해주세요.
+ {formData?.submission.vendorName}의 ESG 자가평가서를 작성해주세요. <br/>
+ 우측의 "내보내기" 버튼을 클릭하시면 전체 질문을 엑셀로 다운로드 받아 답변 작성에 참고할 수 있습니다.
</SheetDescription>
</div>
<Button
@@ -590,7 +591,7 @@ const handleExportData = async () => {
{progress.percentage === 100 ? (
<div className="flex items-center gap-2 text-green-600">
<CheckIcon className="h-4 w-4" />
- 모든 ESG 평가가 완료되었습니다
+ 모든 ESG 자가평가서가 완료되었습니다
</div>
) : (
<div className="flex items-center gap-2">
diff --git a/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx b/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx
index bda087bb..685530e6 100644
--- a/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx
+++ b/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx
@@ -389,8 +389,33 @@ export function GeneralEvaluationFormSheet({
<div className="flex-1 overflow-y-auto min-h-0">
<ScrollArea className="h-full pr-4">
<div className="space-y-6">
- {formData.evaluations.map((item, index) => (
- <Card key={item.evaluation.id}>
+ {Object.entries(
+ formData.evaluations.reduce((groups, item) => {
+ const category = item.evaluation.category || '기타'
+ if (!groups[category]) {
+ groups[category] = []
+ }
+ groups[category].push(item)
+ return groups
+ }, {} as Record<string, typeof formData.evaluations>)
+ )
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([category, items]) => (
+ <div key={category} className="mb-6">
+ <div className="mb-3 pb-2 border-b border-gray-300">
+ <h3 className="text-lg font-semibold text-gray-800">
+ {category}
+ </h3>
+ </div>
+ <div className="space-y-4">
+ {[...items]
+ .sort((a, b) => {
+ const aNum = parseInt(String(a.evaluation.serialNumber).replace(/^\D+/g, '') || '0')
+ const bNum = parseInt(String(b.evaluation.serialNumber).replace(/^\D+/g, '') || '0')
+ return aNum - bNum
+ })
+ .map((item, index) => (
+ <Card key={item.evaluation.id}>
<CardHeader>
<CardTitle className="text-base flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -554,11 +579,14 @@ export function GeneralEvaluationFormSheet({
))}
</div>
)}
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </div>
+ ))}
</div>
- </CardContent>
- </Card>
- ))}
- </div>
</ScrollArea>
</div>
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 6132832f..de88ae72 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -1818,12 +1818,168 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb
}
/**
+ * 선택된 벤더의 상태를 REJECTED로 변경하고 이메일 알림을 발송하는 서버 액션
+ */
+export async function rejectVendors(input: ApproveVendorsInput & { userId: number }) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 협력업체 상태 업데이트 및 이메일 발송
+ const result = await db.transaction(async (tx) => {
+ // 0. 업데이트 전 협력업체 상태 조회
+ const vendorsBeforeUpdate = await tx
+ .select({
+ id: vendors.id,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 1. 협력업체 상태 업데이트
+ const [updated] = await tx
+ .update(vendors)
+ .set({
+ status: "REJECTED",
+ updatedAt: new Date()
+ })
+ .where(inArray(vendors.id, input.ids))
+ .returning();
+
+ // 2. 업데이트된 협력업체 정보 조회 (국가 정보 포함)
+ const updatedVendors = await tx
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ country: vendors.country, // 언어 설정용 국가 정보
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 3. 각 벤더에 대한 유저 계정 비활성화 처리
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ // 기존 유저 확인
+ const existingUser = await tx
+ .select({
+ id: users.id,
+ isActive: users.isActive,
+ language: users.language,
+ })
+ .from(users)
+ .where(eq(users.email, vendor.email))
+ .limit(1);
+
+ if (existingUser.length > 0) {
+ // 기존 사용자 존재 시 - 비활성화
+ const user = existingUser[0];
+ console.log(`👤 기존 사용자 발견: ${vendor.email} (활성상태: ${user.isActive})`);
+
+ if (user.isActive) {
+ // 활성 사용자 비활성화
+ await tx
+ .update(users)
+ .set({
+ isActive: false,
+ updatedAt: new Date(),
+ })
+ .where(eq(users.id, user.id));
+
+ console.log(`❌ 사용자 비활성화 완료: ${vendor.email} (ID: ${user.id})`);
+ } else {
+ console.log(`ℹ️ 사용자가 이미 비활성 상태: ${vendor.email}`);
+ }
+ }
+ })
+ );
+
+ // 4. 로그 기록
+ await Promise.all(
+ vendorsBeforeUpdate.map(async (vendorBefore) => {
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendorBefore.id,
+ userId: input.userId,
+ action: "status_change",
+ oldStatus: vendorBefore.status,
+ newStatus: "REJECTED",
+ comment: "Vendor rejected",
+ });
+ })
+ );
+
+ // 5. 각 벤더에게 거절 이메일 발송
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ try {
+ // 사용자 언어 확인
+ const userInfo = await tx
+ .select({
+ id: users.id,
+ language: users.language
+ })
+ .from(users)
+ .where(eq(users.email, vendor.email))
+ .limit(1);
+
+ const userLang = userInfo.length > 0 ? userInfo[0].language :
+ (vendor.country === 'KR' ? 'ko' : 'en');
+
+ const subject = userLang === 'ko'
+ ? "[eVCP] 업체 등록 거절 안내"
+ : "[eVCP] Vendor Registration Rejected";
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const protocol = headersList.get('x-forwarded-proto') || 'http';
+ const baseUrl = `${protocol}://${host}`;
+ const loginUrl = `${baseUrl}/${userLang}/login`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "vendor-rejected", // 거절 템플릿
+ context: {
+ vendorName: vendor.vendorName,
+ loginUrl,
+ language: userLang,
+ },
+ });
+
+ console.log(`📧 거절 이메일 발송: ${vendor.email}`);
+ } catch (emailError) {
+ console.error(`이메일 발송 실패 - 업체 ${vendor.id}:`, emailError);
+ // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음
+ }
+ })
+ );
+
+ console.log(`❌ 협력업체 거절 완료: ${updatedVendors.length}개 업체`);
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("users"); // 유저 캐시도 무효화
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("협력업체 거절 처리 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
* 유니크한 PQ 번호 생성 함수
- *
+ *
* 형식: PQ-YYMMDD-XXXXX
* YYMMDD: 연도(YY), 월(MM), 일(DD)
* XXXXX: 시퀀스 번호 (00001부터 시작)
- *
+ *
* 예: PQ-240520-00001, PQ-240520-00002, ...
*/
export async function generatePQNumber(isProject: boolean = false) {
diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx
index 940710f5..980953aa 100644
--- a/lib/vendors/table/approve-vendor-dialog.tsx
+++ b/lib/vendors/table/approve-vendor-dialog.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Row } from "@tanstack/react-table"
-import { Loader, Check } from "lucide-react"
+import { Loader, Check, X } from "lucide-react"
import { toast } from "sonner"
import { useMediaQuery } from "@/hooks/use-media-query"
@@ -28,23 +28,24 @@ import {
DrawerTrigger,
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
-import { approveVendors } from "../service"
+import { approveVendors, rejectVendors } from "../service"
import { useSession } from "next-auth/react"
-interface ApprovalVendorDialogProps
+interface VendorDecisionDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
vendors: Row<Vendor>["original"][]
showTrigger?: boolean
onSuccess?: () => void
}
-export function ApproveVendorsDialog({
+export function VendorDecisionDialog({
vendors,
showTrigger = true,
onSuccess,
...props
-}: ApprovalVendorDialogProps) {
+}: VendorDecisionDialogProps) {
const [isApprovePending, startApproveTransition] = React.useTransition()
+ const [isRejectPending, startRejectTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
const { data: session } = useSession()
@@ -58,10 +59,10 @@ export function ApproveVendorsDialog({
try {
console.log("🔍 [DEBUG] 승인 요청 시작 - vendors:", vendors.map(v => ({ id: v.id, vendorName: v.vendorName, email: v.email })));
console.log("🔍 [DEBUG] 세션 정보:", { userId: session.user.id, userType: typeof session.user.id });
-
+
const { error } = await approveVendors({
ids: vendors.map((vendor) => vendor.id),
- userId: Number(session.user.id)
+ userId: Number(session.user.id)
})
if (error) {
@@ -72,7 +73,40 @@ export function ApproveVendorsDialog({
console.log("✅ [DEBUG] 승인 처리 성공");
props.onOpenChange?.(false)
- toast.success("Vendors successfully approved for review")
+ toast.success("협력업체 등록이 승인되었습니다.")
+ onSuccess?.()
+ } catch (error) {
+ console.error("🚨 [DEBUG] 예상치 못한 에러:", error);
+ toast.error("예상치 못한 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ function onReject() {
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ startRejectTransition(async () => {
+ try {
+ console.log("🔍 [DEBUG] 거절 요청 시작 - vendors:", vendors.map(v => ({ id: v.id, vendorName: v.vendorName, email: v.email })));
+ console.log("🔍 [DEBUG] 세션 정보:", { userId: session.user.id, userType: typeof session.user.id });
+
+ const { error } = await rejectVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ userId: Number(session.user.id)
+ })
+
+ if (error) {
+ console.error("🚨 [DEBUG] 거절 처리 에러:", error);
+ toast.error(error)
+ return
+ }
+
+ console.log("✅ [DEBUG] 거절 처리 성공");
+ props.onOpenChange?.(false)
+ toast.success("협력업체 등록이 거절되었습니다.")
onSuccess?.()
} catch (error) {
console.error("🚨 [DEBUG] 예상치 못한 에러:", error);
@@ -88,29 +122,58 @@ export function ApproveVendorsDialog({
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Check className="size-4" aria-hidden="true" />
- 가입 Approve ({vendors.length})
+ 가입 결정 ({vendors.length})
</Button>
</DialogTrigger>
) : null}
- <DialogContent>
+ <DialogContent className="max-w-2xl">
<DialogHeader>
- <DialogTitle>Confirm Vendor Approval</DialogTitle>
+ <DialogTitle>협력업체 가입 결정</DialogTitle>
<DialogDescription>
- Are you sure you want to approve{" "}
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? " vendor" : " vendors"}?
- After approval, vendors will be notified and can login to submit PQ information.
+ 선택한 <span className="font-medium">{vendors.length}</span>개 협력업체에 대한 가입 결정을 해주세요.
</DialogDescription>
</DialogHeader>
+
+ {/* 선택한 벤더 목록 표시 */}
+ <div className="max-h-64 overflow-y-auto border rounded-md p-4">
+ <h4 className="font-medium mb-2">선택된 협력업체:</h4>
+ <div className="space-y-2">
+ {vendors.map((vendor) => (
+ <div key={vendor.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
+ <div>
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-sm text-gray-600">{vendor.email}</div>
+ </div>
+ <div className="text-sm text-gray-500">ID: {vendor.id}</div>
+ </div>
+ ))}
+ </div>
+ </div>
+
<DialogFooter className="gap-2 sm:space-x-0">
<DialogClose asChild>
- <Button variant="outline">Cancel</Button>
+ <Button variant="outline">취소</Button>
</DialogClose>
<Button
+ aria-label="Reject selected vendors"
+ variant="destructive"
+ onClick={onReject}
+ disabled={isRejectPending || isApprovePending}
+ >
+ {isRejectPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ <X className="mr-2 size-4" aria-hidden="true" />
+ 거절
+ </Button>
+ <Button
aria-label="Approve selected vendors"
variant="default"
onClick={onApprove}
- disabled={isApprovePending}
+ disabled={isApprovePending || isRejectPending}
>
{isApprovePending && (
<Loader
@@ -118,7 +181,8 @@ export function ApproveVendorsDialog({
aria-hidden="true"
/>
)}
- Approve
+ <Check className="mr-2 size-4" aria-hidden="true" />
+ 승인
</Button>
</DialogFooter>
</DialogContent>
@@ -132,34 +196,66 @@ export function ApproveVendorsDialog({
<DrawerTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Check className="size-4" aria-hidden="true" />
- Approve ({vendors.length})
+ 가입 결정 ({vendors.length})
</Button>
</DrawerTrigger>
) : null}
- <DrawerContent>
+ <DrawerContent className="max-h-[80vh]">
<DrawerHeader>
- <DrawerTitle>Confirm Vendor Approval</DrawerTitle>
+ <DrawerTitle>협력업체 가입 결정</DrawerTitle>
<DrawerDescription>
- Are you sure you want to approve{" "}
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? " vendor" : " vendors"}?
- After approval, vendors will be notified and can login to submit PQ information.
+ 선택한 <span className="font-medium">{vendors.length}</span>개 협력업체에 대한 가입 결정을 해주세요.
</DrawerDescription>
</DrawerHeader>
+
+ {/* 선택한 벤더 목록 표시 */}
+ <div className="max-h-48 overflow-y-auto px-4">
+ <h4 className="font-medium mb-2">선택된 협력업체:</h4>
+ <div className="space-y-2">
+ {vendors.map((vendor) => (
+ <div key={vendor.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
+ <div>
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-sm text-gray-600">{vendor.email}</div>
+ </div>
+ <div className="text-sm text-gray-500">ID: {vendor.id}</div>
+ </div>
+ ))}
+ </div>
+ </div>
+
<DrawerFooter className="gap-2 sm:space-x-0">
<DrawerClose asChild>
- <Button variant="outline">Cancel</Button>
+ <Button variant="outline">취소</Button>
</DrawerClose>
<Button
+ aria-label="Reject selected vendors"
+ variant="destructive"
+ onClick={onReject}
+ disabled={isRejectPending || isApprovePending}
+ >
+ {isRejectPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ <X className="mr-2 size-4" aria-hidden="true" />
+ 거절
+ </Button>
+ <Button
aria-label="Approve selected vendors"
variant="default"
onClick={onApprove}
- disabled={isApprovePending}
+ disabled={isApprovePending || isRejectPending}
>
{isApprovePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ <Loader className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
)}
- Approve
+ <Check className="mr-2 size-4" aria-hidden="true" />
+ 승인
</Button>
</DrawerFooter>
</DrawerContent>
diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx
index 3d77486d..def46168 100644
--- a/lib/vendors/table/vendors-table-toolbar-actions.tsx
+++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx
@@ -15,7 +15,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { VendorWithType } from "@/db/schema/vendors"
-import { ApproveVendorsDialog } from "./approve-vendor-dialog"
+import { VendorDecisionDialog } from "./approve-vendor-dialog"
import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog"
import { RequestPQDialog } from "./request-pq-dialog"
import { RequestProjectPQDialog } from "./request-project-pq-dialog"
@@ -147,7 +147,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
<div className="flex items-center gap-2">
{/* 승인 다이얼로그: PENDING_REVIEW 상태인 협력업체가 있을 때만 표시 */}
{pendingReviewVendors.length > 0 && (
- <ApproveVendorsDialog
+ <VendorDecisionDialog
vendors={pendingReviewVendors}
onSuccess={() => table.toggleAllRowsSelected(false)}
/>