summaryrefslogtreecommitdiff
path: root/lib/vendors
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendors')
-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
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)}
/>