summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor/rfq-vendor-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/vendor/rfq-vendor-table.tsx')
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx463
1 files changed, 410 insertions, 53 deletions
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index b2ea7588..830fd448 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -25,7 +25,9 @@ import {
Package,
MapPin,
Info,
- Loader2
+ Loader2,
+ Router,
+ Shield
} from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
@@ -52,14 +54,18 @@ import { toast } from "sonner";
import { AddVendorDialog } from "./add-vendor-dialog";
import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog";
import { SendRfqDialog } from "./send-rfq-dialog";
-// import { VendorDetailDialog } from "./vendor-detail-dialog";
-// import { sendRfqToVendors } from "@/app/actions/rfq/send-rfq.action";
+
import {
getRfqSendData,
getSelectedVendorsWithEmails,
+ sendRfqToVendors,
type RfqSendData,
type VendorEmailInfo
} from "../service"
+import { VendorResponseDetailDialog } from "./vendor-detail-dialog";
+import { DeleteVendorDialog } from "./delete-vendor-dialog";
+import { useRouter } from "next/navigation"
+import { EditContractDialog } from "./edit-contract-dialog";
// 타입 정의
interface RfqDetail {
@@ -91,20 +97,64 @@ interface RfqDetail {
sparepartDescription?: string | null;
updatedAt?: Date | null;
updatedByUserName?: string | null;
+ emailSentAt: string | null;
+ emailSentTo: string | null; // JSON string
+ emailResentCount: number;
+ lastEmailSentAt: string | null;
+ emailStatus: string | null;
}
interface VendorResponse {
id: number;
- vendorId: number;
- status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소";
+ rfqsLastId: number;
+ rfqLastDetailsId: number;
responseVersion: number;
isLatest: boolean;
- submittedAt: Date | null;
- totalAmount: number | null;
- currency: string | null;
- vendorDeliveryDate: Date | null;
- quotedItemCount?: number;
- attachmentCount?: number;
+ status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소";
+ vendor: {
+ id: number;
+ code: string | null;
+ name: string;
+ email: string;
+ };
+ submission: {
+ submittedAt: Date | null;
+ submittedBy: string | null;
+ submittedByName: string | null;
+ };
+ pricing: {
+ totalAmount: number | null;
+ currency: string | null;
+ vendorCurrency: string | null;
+ };
+ vendorTerms: {
+ paymentTermsCode: string | null;
+ incotermsCode: string | null;
+ deliveryDate: Date | null;
+ contractDuration: string | null;
+ };
+ additionalRequirements: {
+ firstArticle: {
+ required: boolean | null;
+ acceptance: boolean | null;
+ };
+ sparePart: {
+ required: boolean | null;
+ acceptance: boolean | null;
+ };
+ };
+ counts: {
+ quotedItems: number;
+ attachments: number;
+ };
+ remarks: {
+ general: string | null;
+ technical: string | null;
+ };
+ timestamps: {
+ createdAt: string;
+ updatedAt: string;
+ };
}
// Props 타입 정의
@@ -178,7 +228,7 @@ const mergeVendorData = (
): (RfqDetail & { response?: VendorResponse; rfqCode?: string })[] => {
return rfqDetails.map(detail => {
const response = vendorResponses.find(
- r => r.vendorId === detail.vendorId && r.isLatest
+ r => r.vendor.id === detail.vendorId && r.isLatest
);
return { ...detail, response, rfqCode };
});
@@ -208,6 +258,14 @@ export function RfqVendorTable({
const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null);
const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false);
const [isLoadingSendData, setIsLoadingSendData] = React.useState(false);
+ const [deleteVendorData, setDeleteVendorData] = React.useState<{
+ detailId: number;
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ hasResponse?: boolean;
+ responseStatus?: string | null;
+ } | null>(null);
const [sendDialogData, setSendDialogData] = React.useState<{
rfqInfo: RfqSendData['rfqInfo'] | null;
@@ -219,12 +277,19 @@ export function RfqVendorTable({
selectedVendors: [],
});
+ const [editContractVendor, setEditContractVendor] = React.useState<any | null>(null);
+
+
+ const router = useRouter()
+
// 데이터 병합
const mergedData = React.useMemo(
() => mergeVendorData(rfqDetails, vendorResponses, rfqCode),
[rfqDetails, vendorResponses, rfqCode]
);
+ console.log(mergedData, "mergedData")
+
// 일괄 발송 핸들러
const handleBulkSend = React.useCallback(async () => {
if (selectedRows.length === 0) {
@@ -277,6 +342,11 @@ export function RfqVendorTable({
contactsByPosition: v.contactsByPosition || {},
primaryEmail: v.primaryEmail,
currency: v.currency,
+ ndaYn: v.ndaYn,
+ generalGtcYn: v.generalGtcYn,
+ projectGtcYn: v.projectGtcYn,
+ agreementYn: v.agreementYn,
+ sendVersion: v.sendVersion
})),
});
@@ -297,25 +367,38 @@ export function RfqVendorTable({
vendorName: string;
vendorCode?: string | null;
vendorCountry?: string | null;
- vendorEmail?: string | null;
+ selectedMainEmail: string;
+ additionalEmails: string[];
+ customEmails?: Array<{ email: string; name?: string }>;
currency?: string | null;
- additionalRecipients: string[];
+ contractRequirements?: {
+ ndaYn: boolean;
+ generalGtcYn: boolean;
+ projectGtcYn: boolean;
+ agreementYn: boolean;
+ projectCode?: string;
+ };
+ isResend: boolean;
+ sendVersion?: number;
}>;
attachments: number[];
message?: string;
+ generatedPdfs?: Array<{ // 타입 추가
+ key: string;
+ buffer: number[];
+ fileName: string;
+ }>;
}) => {
try {
// 서버 액션 호출
- // const result = await sendRfqToVendors({
- // rfqId,
- // rfqCode,
- // vendors: data.vendors,
- // attachmentIds: data.attachments,
- // message: data.message,
- // });
-
- // 임시 성공 처리
- console.log("RFQ 발송 데이터:", data);
+ const result = await sendRfqToVendors({
+ rfqId,
+ rfqCode,
+ vendors: data.vendors,
+ attachmentIds: data.attachments,
+ message: data.message,
+ generatedPdfs: data.generatedPdfs,
+ });
// 성공 후 처리
setSelectedRows([]);
@@ -324,14 +407,23 @@ export function RfqVendorTable({
attachments: [],
selectedVendors: [],
});
+
+ // 기본계약 생성 결과 표시
+ if (result.contractResults && result.contractResults.length > 0) {
+ const totalContracts = result.contractResults.reduce((acc, r) => acc + r.totalCreated, 0);
+ toast.success(`${data.vendors.length}개 업체에 RFQ를 발송하고 ${totalContracts}개의 기본계약을 생성했습니다.`);
+ } else {
+ toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`);
+ }
- toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`);
+ // 페이지 새로고침
+ router.refresh();
} catch (error) {
console.error("RFQ 발송 실패:", error);
toast.error("RFQ 발송에 실패했습니다.");
throw error;
}
- }, [rfqId, rfqCode]);
+ }, [rfqId, rfqCode, router]);
// 액션 처리
const handleAction = React.useCallback(async (action: string, vendor: any) => {
@@ -344,7 +436,7 @@ export function RfqVendorTable({
// 개별 RFQ 발송
try {
setIsLoadingSendData(true);
-
+
const [rfqSendData, vendorEmailInfos] = await Promise.all([
getRfqSendData(rfqId),
getSelectedVendorsWithEmails(rfqId, [vendor.vendorId])
@@ -369,6 +461,11 @@ export function RfqVendorTable({
contactsByPosition: v.contactsByPosition || {},
primaryEmail: v.primaryEmail,
currency: v.currency,
+ ndaYn: v.ndaYn,
+ generalGtcYn: v.generalGtcYn,
+ projectGtcYn: v.projectGtcYn,
+ agreementYn: v.agreementYn,
+ sendVersion: v.sendVersion,
})),
});
@@ -385,10 +482,29 @@ export function RfqVendorTable({
toast.info("수정 기능은 준비중입니다.");
break;
+ case "edit-contract":
+ // 기본계약 수정
+ setEditContractVendor(vendor);
+ break;
+
case "delete":
- if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) {
- toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`);
+ // quotationStatus 체크
+ const hasQuotation = !!vendor.quotationStatus;
+
+ if (hasQuotation) {
+ // 견적서가 있으면 즉시 에러 토스트 표시
+ toast.error("이미 발송된 벤더는 삭제할 수 없습니다.");
+ return;
}
+
+ // 삭제 다이얼로그 열기
+ setDeleteVendorData({
+ detailId: vendor.detailId,
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode,
+ hasQuotation: hasQuotation,
+ });
break;
case "response-detail":
@@ -486,12 +602,188 @@ export function RfqVendorTable({
},
size: 100,
},
+
{
- accessorKey: "basicContract",
- header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />,
- cell: ({ row }) => row.original.basicContract || "-",
- size: 100,
+ accessorKey: "contractRequirements",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />,
+ cell: ({ row }) => {
+ const vendor = row.original;
+ const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국";
+
+ // 기본계약 상태 확인
+ const requirements = [];
+
+ // 필수 계약들
+ if (vendor.agreementYn) {
+ requirements.push({
+ name: "기술자료",
+ icon: <FileText className="h-3 w-3" />,
+ color: "text-blue-500"
+ });
+ }
+
+ if (vendor.ndaYn) {
+ requirements.push({
+ name: "NDA",
+ icon: <Shield className="h-3 w-3" />,
+ color: "text-green-500"
+ });
+ }
+
+ // GTC (국외 업체만)
+ if (!isKorean) {
+ if (vendor.generalGtcYn || vendor.gtcType === "general") {
+ requirements.push({
+ name: "General GTC",
+ icon: <Globe className="h-3 w-3" />,
+ color: "text-purple-500"
+ });
+ } else if (vendor.projectGtcYn || vendor.gtcType === "project") {
+ requirements.push({
+ name: "Project GTC",
+ icon: <Globe className="h-3 w-3" />,
+ color: "text-indigo-500"
+ });
+ }
+ }
+
+ if (requirements.length === 0) {
+ return <span className="text-xs text-muted-foreground">없음</span>;
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {requirements.map((req, idx) => (
+ <TooltipProvider key={idx}>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Badge variant="outline" className="text-xs px-1.5 py-0">
+ <span className={cn("mr-1", req.color)}>
+ {req.icon}
+ </span>
+ {req.name}
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="text-xs">
+ {req.name === "기술자료" && "기술자료 제공 동의서"}
+ {req.name === "NDA" && "비밀유지 계약서"}
+ {req.name === "General GTC" && "일반 거래 약관"}
+ {req.name === "Project GTC" && "프로젝트별 거래 약관"}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ ))}
+ </div>
+ );
+ },
+ size: 150,
},
+
+ {
+ accessorKey: "sendVersion",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="발송 회차" />,
+ cell: ({ row }) => {
+ const version = row.original.sendVersion;
+
+
+ return <span>{version}</span>;
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "emailStatus",
+ header: "이메일 상태",
+ cell: ({ row }) => {
+ const response = row.original;
+ const emailSentAt = response?.emailSentAt;
+ const emailResentCount = response?.emailResentCount || 0;
+ const emailStatus = response?.emailStatus;
+ const status = response?.status;
+
+ if (!emailSentAt) {
+ return (
+ <Badge variant="outline" className="bg-gray-50">
+ <Mail className="h-3 w-3 mr-1" />
+ 미발송
+ </Badge>
+ );
+ }
+
+ // 이메일 상태 표시 (failed인 경우 특별 처리)
+ const getEmailStatusBadge = () => {
+ if (emailStatus === "failed") {
+ return (
+ <Badge variant="destructive">
+ <XCircle className="h-3 w-3 mr-1" />
+ 발송 실패
+ </Badge>
+ );
+ }
+ return (
+ <Badge variant={status === "제출완료" ? "success" : "default"}>
+ {getStatusIcon(status || "")}
+ {status}
+ </Badge>
+ );
+ };
+
+ // emailSentTo JSON 파싱
+ let recipients = { to: [], cc: [], sentBy: "" };
+ try {
+ if (response?.email?.emailSentTo) {
+ recipients = JSON.parse(response.email.emailSentTo);
+ }
+ } catch (e) {
+ console.error("Failed to parse emailSentTo", e);
+ }
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <div className="flex flex-col gap-1">
+ {getEmailStatusBadge()}
+ {emailResentCount > 1 && (
+ <Badge variant="secondary" className="text-xs">
+ 재발송 {emailResentCount - 1}회
+ </Badge>
+ )}
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="space-y-1">
+ <p>최초 발송: {format(new Date(emailSentAt), "yyyy-MM-dd HH:mm")}</p>
+ {response?.email?.lastEmailSentAt && (
+ <p>최근 발송: {format(new Date(response.email.lastEmailSentAt), "yyyy-MM-dd HH:mm")}</p>
+ )}
+ {recipients.to.length > 0 && (
+ <p>수신자: {recipients.to.join(", ")}</p>
+ )}
+ {recipients.cc.length > 0 && (
+ <p>참조: {recipients.cc.join(", ")}</p>
+ )}
+ {recipients.sentBy && (
+ <p>발신자: {recipients.sentBy}</p>
+ )}
+ {emailStatus === "failed" && (
+ <p className="text-red-500 font-semibold">⚠️ 이메일 발송 실패</p>
+ )}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+ },
+ size: 120,
+ },
+ // {
+ // accessorKey: "basicContract",
+ // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />,
+ // cell: ({ row }) => row.original.basicContract || "-",
+ // size: 100,
+ // },
{
accessorKey: "currency",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />,
@@ -641,20 +933,23 @@ export function RfqVendorTable({
size: 120,
},
{
- accessorKey: "response.submittedAt",
+ accessorKey: "response.submission.submittedAt",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />,
cell: ({ row }) => {
- const submittedAt = row.original.response?.submittedAt;
+ const participationRepliedAt = row.original.response?.attend?.participationRepliedAt;
- if (!submittedAt) {
- return <Badge variant="outline">미참여</Badge>;
+ if (!participationRepliedAt) {
+ return <Badge variant="outline">미응답</Badge>;
}
+
+ const participationStatus = row.original.response?.attend?.participationStatus;
+
return (
<div className="flex flex-col gap-0.5">
- <Badge variant="default" className="text-xs">참여</Badge>
+ <Badge variant="default" className="text-xs">{participationStatus}</Badge>
<span className="text-xs text-muted-foreground">
- {format(new Date(submittedAt), "MM-dd")}
+ {format(new Date(participationRepliedAt), "yyyy-MM-dd")}
</span>
</div>
);
@@ -665,7 +960,7 @@ export function RfqVendorTable({
id: "responseDetail",
header: "회신상세",
cell: ({ row }) => {
- const hasResponse = !!row.original.response?.submittedAt;
+ const hasResponse = !!row.original.response?.submission?.submittedAt;
if (!hasResponse) {
return <span className="text-muted-foreground text-xs">-</span>;
@@ -731,6 +1026,10 @@ export function RfqVendorTable({
cell: ({ row }) => {
const vendor = row.original;
const hasResponse = !!vendor.response;
+ const emailSentAt = vendor.response?.email?.emailSentAt;
+ const emailResentCount = vendor.response?.email?.emailResentCount || 0;
+ const hasQuotation = !!vendor.quotationStatus;
+ const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국";
return (
<DropdownMenu>
@@ -747,8 +1046,33 @@ export function RfqVendorTable({
<Eye className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
- {!hasResponse && (
- <DropdownMenuItem
+
+ {/* 기본계약 수정 메뉴 추가 */}
+ <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}>
+ <FileText className="mr-2 h-4 w-4" />
+ 기본계약 수정
+ </DropdownMenuItem>
+
+ {emailSentAt && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => handleAction("resend", vendor)}
+ disabled={isLoadingSendData}
+ >
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 이메일 재발송
+ {emailResentCount > 0 && (
+ <Badge variant="outline" className="ml-2 text-xs">
+ {emailResentCount}
+ </Badge>
+ )}
+ </DropdownMenuItem>
+ </>
+ )}
+
+ {!emailSentAt && (
+ <DropdownMenuItem
onClick={() => handleAction("send", vendor)}
disabled={isLoadingSendData}
>
@@ -756,24 +1080,28 @@ export function RfqVendorTable({
RFQ 발송
</DropdownMenuItem>
)}
- <DropdownMenuItem onClick={() => handleAction("edit", vendor)}>
- <Edit className="mr-2 h-4 w-4" />
- 조건 수정
- </DropdownMenuItem>
+
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAction("delete", vendor)}
- className="text-red-600"
+ className={cn(
+ "text-red-600",
+ hasQuotation && "opacity-50 cursor-not-allowed"
+ )}
+ disabled={hasQuotation}
>
<Trash2 className="mr-2 h-4 w-4" />
삭제
+ {hasQuotation && (
+ <span className="ml-2 text-xs">(불가)</span>
+ )}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
size: 60,
- },
+ }
], [handleAction, rfqCode, isLoadingSendData]);
const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
@@ -850,7 +1178,7 @@ export function RfqVendorTable({
) : (
<>
<Send className="h-4 w-4 mr-2" />
- 선택 발송 ({selectedRows.length})
+ RFQ 발송 ({selectedRows.length})
</>
)}
</Button>
@@ -924,14 +1252,43 @@ export function RfqVendorTable({
/>
{/* 벤더 상세 다이얼로그 */}
- {/* {selectedVendor && (
- <VendorDetailDialog
+ {selectedVendor && (
+ <VendorResponseDetailDialog
open={!!selectedVendor}
onOpenChange={(open) => !open && setSelectedVendor(null)}
- vendor={selectedVendor}
+ data={selectedVendor}
+ rfqId={rfqId}
+ />
+ )}
+
+ {/* 삭제 다이얼로그 추가 */}
+ {deleteVendorData && (
+ <DeleteVendorDialog
+ open={!!deleteVendorData}
+ onOpenChange={(open) => !open && setDeleteVendorData(null)}
rfqId={rfqId}
+ vendorData={deleteVendorData}
+ onSuccess={() => {
+ setDeleteVendorData(null);
+ router.refresh();
+ // 데이터 새로고침
+ }}
/>
- )} */}
+ )}
+
+ {/* 기본계약 수정 다이얼로그 - 새로 추가 */}
+ {editContractVendor && (
+ <EditContractDialog
+ open={!!editContractVendor}
+ onOpenChange={(open) => !open && setEditContractVendor(null)}
+ rfqId={rfqId}
+ vendor={editContractVendor}
+ onSuccess={() => {
+ setEditContractVendor(null);
+ router.refresh();
+ }}
+ />
+ )}
</>
);
} \ No newline at end of file