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/vendors | |
| parent | f9afa89a4f27283f5b115cd89ececa08145b5c89 (diff) | |
(최겸) 구매 협력업체 정기평가, 가입승인, 기본계약 리비전 등
Diffstat (limited to 'lib/vendors')
| -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 |
3 files changed, 285 insertions, 33 deletions
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)} /> |
