diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-30 06:41:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-30 06:41:26 +0000 |
| commit | 9e3458481a65bb5572b7f1916e7c068b54a434c5 (patch) | |
| tree | 27cc8dfd5fc0ed2efba4b87998caf6b2747ad312 /lib | |
| parent | f9afa89a4f27283f5b115cd89ececa08145b5c89 (diff) | |
(최겸) 구매 협력업체 정기평가, 가입승인, 기본계약 리비전 등
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/basic-contract/service.ts | 16 | ||||
| -rw-r--r-- | lib/basic-contract/template/create-revision-dialog.tsx | 112 | ||||
| -rw-r--r-- | lib/evaluation-criteria/service.ts | 7 | ||||
| -rw-r--r-- | lib/evaluation-criteria/table/reg-eval-criteria-table.tsx | 8 | ||||
| -rw-r--r-- | lib/evaluation-submit/evaluation-form.tsx | 12 | ||||
| -rw-r--r-- | lib/evaluation-submit/evaluation-page.tsx | 31 | ||||
| -rw-r--r-- | lib/evaluation-submit/service.ts | 213 | ||||
| -rw-r--r-- | lib/evaluation-submit/table/evaluation-submit-dialog.tsx | 353 | ||||
| -rw-r--r-- | lib/evaluation-target-list/service.ts | 4 | ||||
| -rw-r--r-- | lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx | 15 | ||||
| -rw-r--r-- | lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx | 40 | ||||
| -rw-r--r-- | lib/vendors/service.ts | 160 | ||||
| -rw-r--r-- | lib/vendors/table/approve-vendor-dialog.tsx | 154 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table-toolbar-actions.tsx | 4 |
14 files changed, 520 insertions, 609 deletions
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)} /> |
