diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
| commit | ef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch) | |
| tree | 345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib/vendors/table | |
| parent | 9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff) | |
~20250428 작업사항
Diffstat (limited to 'lib/vendors/table')
| -rw-r--r-- | lib/vendors/table/approve-vendor-dialog.tsx | 11 | ||||
| -rw-r--r-- | lib/vendors/table/request-additional-Info-dialog.tsx | 22 | ||||
| -rw-r--r-- | lib/vendors/table/request-basicContract-dialog.tsx | 548 | ||||
| -rw-r--r-- | lib/vendors/table/request-project-pq-dialog.tsx | 24 | ||||
| -rw-r--r-- | lib/vendors/table/request-vendor-investigate-dialog.tsx | 243 | ||||
| -rw-r--r-- | lib/vendors/table/request-vendor-pg-dialog.tsx | 11 | ||||
| -rw-r--r-- | lib/vendors/table/update-vendor-sheet.tsx | 710 | ||||
| -rw-r--r-- | lib/vendors/table/vendor-all-export.ts | 486 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table-columns.tsx | 393 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table-toolbar-actions.tsx | 154 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table.tsx | 99 | ||||
| -rw-r--r-- | lib/vendors/table/view-vendors_logs-dialog.tsx | 244 |
12 files changed, 2570 insertions, 375 deletions
diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx index 253c2830..9c175dc5 100644 --- a/lib/vendors/table/approve-vendor-dialog.tsx +++ b/lib/vendors/table/approve-vendor-dialog.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" import { approveVendors } from "../service" +import { useSession } from "next-auth/react" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -45,11 +46,19 @@ export function ApproveVendorsDialog({ }: ApprovalVendorDialogProps) { const [isApprovePending, startApproveTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + startApproveTransition(async () => { const { error } = await approveVendors({ ids: vendors.map((vendor) => vendor.id), + userId: Number(session.user.id) + }) if (error) { @@ -70,7 +79,7 @@ export function ApproveVendorsDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <Check className="size-4" aria-hidden="true" /> - Approve ({vendors.length}) + 가입 Approve ({vendors.length}) </Button> </DialogTrigger> ) : null} diff --git a/lib/vendors/table/request-additional-Info-dialog.tsx b/lib/vendors/table/request-additional-Info-dialog.tsx index 872162dd..2e39a527 100644 --- a/lib/vendors/table/request-additional-Info-dialog.tsx +++ b/lib/vendors/table/request-additional-Info-dialog.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" import { requestInfo } from "../service" +import { useSession } from "next-auth/react" interface RequestInfoDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -45,11 +46,18 @@ export function RequestInfoDialog({ }: RequestInfoDialogProps) { const [isRequestPending, startRequestTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } startRequestTransition(async () => { const { error, success } = await requestInfo({ ids: vendors.map((vendor) => vendor.id), + userId: Number(session.user.id) + }) if (error) { @@ -58,7 +66,7 @@ export function RequestInfoDialog({ } props.onOpenChange?.(false) - toast.success("추가 정보 요청이 성공적으로 벤더에게 발송되었습니다.") + toast.success("추가 정보 요청이 성공적으로 협력업체에게 발송되었습니다.") onSuccess?.() }) } @@ -76,12 +84,12 @@ export function RequestInfoDialog({ ) : null} <DialogContent> <DialogHeader> - <DialogTitle>벤더 추가 정보 요청 확인</DialogTitle> + <DialogTitle>협력업체 추가 정보 요청 확인</DialogTitle> <DialogDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까? + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 추가 정보를 요청하시겠습니까? <br /><br /> - 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 + 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 추가 정보를 입력하게 됩니다. </DialogDescription> </DialogHeader> @@ -121,12 +129,12 @@ export function RequestInfoDialog({ ) : null} <DrawerContent> <DrawerHeader> - <DrawerTitle>벤더 추가 정보 요청 확인</DrawerTitle> + <DrawerTitle>협력업체 추가 정보 요청 확인</DrawerTitle> <DrawerDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까? + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 추가 정보를 요청하시겠습니까? <br /><br /> - 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 + 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 추가 정보를 입력하게 됩니다. </DrawerDescription> </DrawerHeader> diff --git a/lib/vendors/table/request-basicContract-dialog.tsx b/lib/vendors/table/request-basicContract-dialog.tsx new file mode 100644 index 00000000..8d05fbbe --- /dev/null +++ b/lib/vendors/table/request-basicContract-dialog.tsx @@ -0,0 +1,548 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Send, AlertCircle, Clock, RefreshCw } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Vendor } from "@/db/schema/vendors" +import { useSession } from "next-auth/react" +import { getAllTemplates } from "@/lib/basic-contract/service" +import { useState, useEffect } from "react" +import { requestBasicContractInfo } from "@/lib/basic-contract/service" +import { checkContractRequestStatus } from "@/lib/basic-contract/service" +import { BasicContractTemplate } from "@/db/schema" + +interface RequestInfoDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +// 계약 요청 상태 인터페이스 +interface VendorTemplateStatus { + vendorId: number; + vendorName: string; + templateId: number; + templateName: string; + status: string; + createdAt: Date; + completedAt?: Date; // 계약 체결 날짜 추가 + isExpired: boolean; // 요청 만료 (30일) + isUpdated: boolean; // 템플릿 업데이트 여부 + isContractExpired: boolean; // 계약 유효기간 만료 여부 (1년) 추가 +} +export function RequestContractDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: RequestInfoDialogProps) { + const [isRequestPending, startRequestTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() + const [templates, setTemplates] = useState<BasicContractTemplate[]>([]) + const [selectedTemplateIds, setSelectedTemplateIds] = useState<number[]>([]) + const [isLoading, setIsLoading] = useState(false) + const [statusLoading, setStatusLoading] = useState(false) + const [statusData, setStatusData] = useState<VendorTemplateStatus[]>([]) + const [forceResend, setForceResend] = useState<Set<string>>(new Set()) + + // 템플릿 및 상태 로드 + useEffect(() => { + loadTemplatesAndStatus(); + }, [vendors]); + + // 템플릿과 현재 요청 상태를 로드하는 함수 + const loadTemplatesAndStatus = async () => { + console.log("loadTemplatesAndStatus") + setIsLoading(true); + setStatusLoading(true); + + try { + // 1. 템플릿 로드 + const allTemplates = await getAllTemplates(); + const activeTemplates = allTemplates.filter(t => t.status === 'ACTIVE'); + setTemplates(activeTemplates); + + // 기본 템플릿 선택 설정 + const allActiveTemplateIds = activeTemplates.map(t => t.id); + setSelectedTemplateIds(allActiveTemplateIds); + + // 2. 현재 계약 요청 상태 확인 + if (vendors.length > 0 && allActiveTemplateIds.length > 0) { + const vendorIds = vendors.map(v => v.id); + const { data } = await checkContractRequestStatus(vendorIds, allActiveTemplateIds); + setStatusData(data || []); + } + } catch (error) { + console.error("데이터 로딩 오류:", error); + toast.error("템플릿 또는 상태 정보를 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + setStatusLoading(false); + } + }; + + // 체크박스 상태 변경 핸들러 + const handleTemplateToggle = (templateId: number, checked: boolean) => { + if (checked) { + setSelectedTemplateIds(prev => [...prev, templateId]); + } else { + setSelectedTemplateIds(prev => prev.filter(id => id !== templateId)); + } + }; + + // 강제 재전송 토글 + const toggleForceResend = (vendorId: number, templateId: number) => { + const key = `${vendorId}-${templateId}`; + const newForceResend = new Set(forceResend); + + if (newForceResend.has(key)) { + newForceResend.delete(key); + } else { + newForceResend.add(key); + } + + setForceResend(newForceResend); + }; + + const renderStatusBadge = (vendorId: number, templateId: number) => { + const status = statusData.find( + s => s.vendorId === vendorId && s.templateId === templateId + ); + + if (!status || status.status === "NONE") return null; + + // 상태에 따른 배지 스타일 설정 + let badgeVariant = "outline"; + let badgeLabel = ""; + let icon = null; + let tooltip = ""; + + switch (status.status) { + case "PENDING": + badgeVariant = "secondary"; + badgeLabel = "대기중"; + + if (status.isExpired) { + icon = <Clock className="h-3 w-3 mr-1" />; + tooltip = "요청이 만료되었습니다. 재전송이 필요합니다."; + } else if (status.isUpdated) { + icon = <RefreshCw className="h-3 w-3 mr-1" />; + tooltip = "템플릿이 업데이트되었습니다. 재전송이 필요합니다."; + } else { + tooltip = "서명 요청이 진행 중입니다."; + } + break; + + case "COMPLETED": + // 계약 유효기간 만료 확인 + if (status.isContractExpired) { + badgeVariant = "warning"; // 경고 스타일 적용 + badgeLabel = "재계약 필요"; + icon = <Clock className="h-3 w-3 mr-1" />; + tooltip = "계약 유효기간이 만료되었습니다. 재계약이 필요합니다."; + } else { + badgeVariant = "success"; + badgeLabel = "완료됨"; + tooltip = "이미 서명이 완료되었습니다."; + } + break; + + case "REJECTED": + badgeVariant = "destructive"; + badgeLabel = "거부됨"; + tooltip = "협력업체가 서명을 거부했습니다."; + break; + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Badge variant={badgeVariant as any} className="ml-2 text-xs"> + {icon} + {badgeLabel} + </Badge> + </TooltipTrigger> + <TooltipContent> + <p>{tooltip}</p> + + {/* 재전송 조건에 계약 유효기간 만료 추가 */} + {(status.isExpired || status.isUpdated || status.status === "REJECTED" || + (status.status === "COMPLETED" && status.isContractExpired)) && ( + <p className="text-xs mt-1"> + <Button + variant="link" + size="sm" + className="h-4 p-0" + onClick={() => toggleForceResend(vendorId, templateId)} + > + {forceResend.has(`${vendorId}-${templateId}`) ? "재전송 취소" : "재전송 하기"} + </Button> + </p> + )} + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + }; + + // 유효한 요청인지 확인 함수 개선 + const isValidRequest = (vendorId: number, templateId: number) => { + const status = statusData.find( + s => s.vendorId === vendorId && s.templateId === templateId + ); + + if (!status || status.status === "NONE") return true; + + // 만료되었거나 템플릿이 업데이트되었거나 거부된 경우 재전송 가능 + // 계약 유효기간 만료도 조건에 추가 + if (status.isExpired || + status.isUpdated || + status.status === "REJECTED" || + (status.status === "COMPLETED" && status.isContractExpired)) { + return forceResend.has(`${vendorId}-${templateId}`); + } + + // PENDING(비만료) 또는 COMPLETED(유효기간 내)는 재전송 불가 + return false; + }; + + + // 요청 발송 처리 + function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + + if (selectedTemplateIds.length === 0) { + toast.error("최소 하나 이상의 계약서 템플릿을 선택해주세요.") + return + } + + // 모든 협력업체-템플릿 조합 생성 + const validRequests: { vendorId: number, templateId: number }[] = []; + const skippedRequests: { vendorId: number, templateId: number, reason: string }[] = []; + + vendors.forEach(vendor => { + selectedTemplateIds.forEach(templateId => { + if (isValidRequest(vendor.id, templateId)) { + validRequests.push({ + vendorId: vendor.id, + templateId + }); + } else { + // 유효하지 않은 요청은 건너뜀 + const status = statusData.find( + s => s.vendorId === vendor.id && s.templateId === templateId + ); + + let reason = "알 수 없음"; + if (status) { + if (status.status === "PENDING") reason = "이미 대기 중"; + if (status.status === "COMPLETED") reason = "이미 완료됨"; + } + + skippedRequests.push({ + vendorId: vendor.id, + templateId, + reason + }); + } + }); + }); + + if (validRequests.length === 0) { + toast.error("전송 가능한 요청이 없습니다. 재전송이 필요한 항목을 '재전송 하기' 버튼으로 활성화하세요."); + return; + } + + startRequestTransition(async () => { + // 유효한 요청만 처리 + const requests = validRequests.map(req => + requestBasicContractInfo({ + vendorIds: [req.vendorId], + requestedBy: Number(session.user.id), + templateId: req.templateId + }) + ); + + try { + const results = await Promise.all(requests); + + // 오류 확인 + const errors = results.filter(r => r.error); + if (errors.length > 0) { + toast.error(`${errors.length}개의 요청에서 오류가 발생했습니다.`); + return; + } + + // 상태 메시지 생성 + let successMessage = "기본계약서 서명 요청이 성공적으로 발송되었습니다."; + if (skippedRequests.length > 0) { + successMessage += ` (${skippedRequests.length}개 요청 건너뜀)`; + } + + props.onOpenChange?.(false); + toast.success(successMessage); + onSuccess?.(); + } catch (error) { + console.error("요청 처리 중 오류:", error); + toast.error("서명 요청 처리 중 오류가 발생했습니다."); + } + }); + } + + // 선택된 템플릿 수 표시 + const selectedCount = selectedTemplateIds.length; + const totalCount = templates.length; + + // UI 렌더링 + const renderTemplateList = () => ( + <div className="space-y-3"> + {templates.map((template) => ( + <div key={template.id} className="pb-2 border-b last:border-b-0"> + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + <Checkbox + id={`template-${template.id}`} + checked={selectedTemplateIds.includes(template.id)} + onCheckedChange={(checked) => handleTemplateToggle(template.id, checked as boolean)} + /> + <label + htmlFor={`template-${template.id}`} + className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer" + > + {template.templateName} + </label> + + {/* 상태 배지를 템플릿 이름 옆에 나란히 배치 */} + {vendors.length === 1 && renderStatusBadge(vendors[0].id, template.id)} + </div> + + + {vendors.length === 1 && (() => { + const status = statusData.find( + s => s.vendorId === vendors[0].id && s.templateId === template.id + ); + + // 계약 유효기간 만료 조건 추가 + if (status && (status.isExpired || + status.isUpdated || + status.status === "REJECTED" || + (status.status === "COMPLETED" && status.isContractExpired))) { + const key = `${vendors[0].id}-${template.id}`; + + // 계약 유효기간 만료인 경우 다른 텍스트 표시 + const buttonText = status.status === "COMPLETED" && status.isContractExpired + ? (forceResend.has(key) ? "재계약 취소" : "재계약하기") + : (forceResend.has(key) ? "재전송 취소" : "재전송하기"); + + return ( + <Button + variant="ghost" + size="sm" + className="h-7 px-2 text-xs" + onClick={() => toggleForceResend(vendors[0].id, template.id)} + > + {buttonText} + </Button> + ); + } + return null; + })()} + + </div> + + {/* 추가 정보 표시 (파일명 등) */} + <div className="mt-1 pl-6 text-xs text-muted-foreground"> + {template.fileName} + </div> + </div> + ))} + </div> + ); + + // 내용 영역 렌더링 + const renderContentArea = () => ( + <div className="space-y-4"> + <div className="space-y-2"> + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">계약서 템플릿 선택</h3> + <span className="text-xs text-muted-foreground"> + {selectedCount}/{totalCount} 선택됨 + </span> + </div> + + {isLoading ? ( + <div className="flex items-center justify-center py-4"> + <Loader className="size-4 animate-spin mr-2" /> + <span>템플릿 로딩 중...</span> + </div> + ) : templates.length === 0 ? ( + <div className="text-sm text-muted-foreground p-2 border rounded-md"> + 활성 상태의 템플릿이 없습니다. 템플릿을 먼저 등록해주세요. + </div> + ) : ( + // ScrollArea 대신 네이티브 스크롤 사용 + <div className="border rounded-md p-3 overflow-y-auto h-[200px]"> + {renderTemplateList()} + </div> + )} + </div> + + {statusLoading && ( + <div className="flex items-center text-sm text-muted-foreground"> + <Loader className="size-3 animate-spin mr-2" /> + <span>계약 상태 확인 중...</span> + </div> + )} + + {/* 선택된 템플릿 정보 (ScrollArea 대신 네이티브 스크롤 사용) */} + {selectedTemplateIds.length > 0 && ( + <div className="space-y-2 text-sm"> + <h3 className="font-medium">선택된 템플릿 정보</h3> + <div className="overflow-y-auto max-h-[150px] border rounded-md p-2"> + <div className="space-y-2"> + {selectedTemplateIds.map(id => { + const template = templates.find(t => t.id === id); + if (!template) return null; + + return ( + <div key={id} className="p-2 border rounded-md bg-muted/50"> + <p><span className="font-medium">이름:</span> {template.templateName}</p> + <p><span className="font-medium">파일:</span> {template.fileName}</p> + </div> + ); + })} + </div> + </div> + </div> + )} + + <div className="text-sm text-muted-foreground mt-4"> + 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 기본계약서와 기타 관련 서류들에 대해서 서명을 하게 됩니다. + </div> + </div> + ); + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Send className="size-4" aria-hidden="true" /> + 기본계약서 서명 요청 ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>협력업체 기본계약서 요청 확인</DialogTitle> + <DialogDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 기본계약서 서명을 요청하시겠습니까? + </DialogDescription> + </DialogHeader> + + <div className="py-4"> + {renderContentArea()} + </div> + + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="Send request to selected vendors" + variant="default" + onClick={onApprove} + disabled={isRequestPending || isLoading || selectedTemplateIds.length === 0} + > + {isRequestPending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 요청 발송 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Send className="size-4" aria-hidden="true" /> + 기본계약서 서명 요청 ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>협력업체 기본계약서 요청 확인</DrawerTitle> + <DrawerDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 기본계약서 서명을 요청하시겠습니까? + </DrawerDescription> + </DrawerHeader> + + <div className="px-4"> + {renderContentArea()} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="Send request to selected vendors" + variant="default" + onClick={onApprove} + disabled={isRequestPending || isLoading || selectedTemplateIds.length === 0} + > + {isRequestPending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 요청 발송 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ); +}
\ No newline at end of file diff --git a/lib/vendors/table/request-project-pq-dialog.tsx b/lib/vendors/table/request-project-pq-dialog.tsx index c590d7ec..a9fe0e1a 100644 --- a/lib/vendors/table/request-project-pq-dialog.tsx +++ b/lib/vendors/table/request-project-pq-dialog.tsx @@ -44,6 +44,7 @@ import { Label } from "@/components/ui/label" import { Vendor } from "@/db/schema/vendors" import { requestPQVendors } from "../service" import { getProjects, type Project } from "@/lib/rfqs/service" +import { useSession } from "next-auth/react" interface RequestProjectPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -63,6 +64,7 @@ export function RequestProjectPQDialog({ const [projects, setProjects] = React.useState<Project[]>([]) const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null) const [isLoadingProjects, setIsLoadingProjects] = React.useState(false) + const { data: session } = useSession() // 프로젝트 목록 로드 React.useEffect(() => { @@ -95,15 +97,23 @@ export function RequestProjectPQDialog({ } function onApprove() { + if (!selectedProjectId) { toast.error("프로젝트를 선택해주세요.") return } + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + startApproveTransition(async () => { const { error } = await requestPQVendors({ ids: vendors.map((vendor) => vendor.id), projectId: selectedProjectId, + userId: Number(session.user.id) + }) if (error) { @@ -113,7 +123,7 @@ export function RequestProjectPQDialog({ props.onOpenChange?.(false) - toast.success(`벤더에게 프로젝트 PQ가 성공적으로 요청되었습니다.`) + toast.success(`협력업체에게 프로젝트 PQ가 성공적으로 요청되었습니다.`) onSuccess?.() }) } @@ -165,8 +175,8 @@ export function RequestProjectPQDialog({ <DialogTitle>프로젝트 PQ 요청 확인</DialogTitle> <DialogDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? - 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? + 요청을 보내면 협력업체에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. </DialogDescription> </DialogHeader> @@ -177,7 +187,7 @@ export function RequestProjectPQDialog({ <Button variant="outline">취소</Button> </DialogClose> <Button - aria-label="선택한 벤더에게 요청하기" + aria-label="선택한 협력업체에게 요청하기" variant="default" onClick={onApprove} disabled={isApprovePending || !selectedProjectId} @@ -211,8 +221,8 @@ export function RequestProjectPQDialog({ <DrawerTitle>프로젝트 PQ 요청 확인</DrawerTitle> <DrawerDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? - 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? + 요청을 보내면 협력업체에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. </DrawerDescription> </DrawerHeader> @@ -225,7 +235,7 @@ export function RequestProjectPQDialog({ <Button variant="outline">취소</Button> </DrawerClose> <Button - aria-label="선택한 벤더에게 요청하기" + aria-label="선택한 협력업체에게 요청하기" variant="default" onClick={onApprove} disabled={isApprovePending || !selectedProjectId} diff --git a/lib/vendors/table/request-vendor-investigate-dialog.tsx b/lib/vendors/table/request-vendor-investigate-dialog.tsx index 0309ee4a..b3deafce 100644 --- a/lib/vendors/table/request-vendor-investigate-dialog.tsx +++ b/lib/vendors/table/request-vendor-investigate-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Row } from "@tanstack/react-table" -import { Loader, Check, SendHorizonal } from "lucide-react" +import { Loader, Check, SendHorizonal, AlertCircle, AlertTriangle } from "lucide-react" import { toast } from "sonner" import { useMediaQuery } from "@/hooks/use-media-query" @@ -27,8 +27,29 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" + import { Vendor } from "@/db/schema/vendors" -import { requestInvestigateVendors } from "@/lib/vendor-investigation/service" +import { requestInvestigateVendors, getExistingInvestigationsForVendors } from "@/lib/vendor-investigation/service" +import { useSession } from "next-auth/react" +import { formatDate } from "@/lib/utils" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -37,21 +58,98 @@ interface ApprovalVendorDialogProps onSuccess?: () => void } +// Helper function to get status badge variant and text +function getStatusBadge(status: string) { + switch (status) { + case "REQUESTED": + return { variant: "secondary", text: "Requested" } + case "SCHEDULED": + return { variant: "warning", text: "Scheduled" } + case "IN_PROGRESS": + return { variant: "default", text: "In Progress" } + case "COMPLETED": + return { variant: "success", text: "Completed" } + case "CANCELLED": + return { variant: "destructive", text: "Cancelled" } + default: + return { variant: "outline", text: status } + } +} + export function RequestVendorsInvestigateDialog({ vendors, showTrigger = true, onSuccess, ...props }: ApprovalVendorDialogProps) { - - console.log(vendors) const [isApprovePending, startApproveTransition] = React.useTransition() + const [isLoading, setIsLoading] = React.useState(true) + const [existingInvestigations, setExistingInvestigations] = React.useState<any[]>([]) const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() + + // Fetch existing investigations when dialog opens + React.useEffect(() => { + if (vendors.length > 0) { + setIsLoading(true) + const fetchExistingInvestigations = async () => { + try { + const vendorIds = vendors.map(vendor => vendor.id) + const result = await getExistingInvestigationsForVendors(vendorIds) + setExistingInvestigations(result) + } catch (error) { + console.error("Failed to fetch existing investigations:", error) + toast.error("Failed to fetch existing investigations") + } finally { + setIsLoading(false) + } + } + + fetchExistingInvestigations() + } + }, [vendors]) + + // Group vendors by investigation status + const vendorsWithInvestigations = React.useMemo(() => { + if (!existingInvestigations.length) return { withInvestigations: [], withoutInvestigations: vendors } + + const vendorMap = new Map(vendors.map(v => [v.id, v])) + const withInvestigations: Array<{ vendor: typeof vendors[0], investigation: any }> = [] + + // Find vendors with existing investigations + existingInvestigations.forEach(inv => { + const vendor = vendorMap.get(inv.vendorId) + if (vendor) { + withInvestigations.push({ vendor, investigation: inv }) + vendorMap.delete(inv.vendorId) + } + }) + + // Remaining vendors don't have investigations + const withoutInvestigations = Array.from(vendorMap.values()) + + return { withInvestigations, withoutInvestigations } + }, [vendors, existingInvestigations]) function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + + // Only request investigations for vendors without existing ones + const vendorsToRequest = vendorsWithInvestigations.withoutInvestigations + + if (vendorsToRequest.length === 0) { + toast.info("모든 선택된 업체에 이미 실사 요청이 있습니다.") + props.onOpenChange?.(false) + return + } + startApproveTransition(async () => { const { error } = await requestInvestigateVendors({ - ids: vendors.map((vendor) => vendor.id), + ids: vendorsToRequest.map((vendor) => vendor.id), + userId: Number(session.user.id) }) if (error) { @@ -60,11 +158,102 @@ export function RequestVendorsInvestigateDialog({ } props.onOpenChange?.(false) - toast.success("Vendor Investigation successfully sent to 벤더실사담당자") + toast.success(`${vendorsToRequest.length}개 업체에 대한 실사 요청을 보냈습니다.`) onSuccess?.() }) } + const renderContent = () => { + return ( + <> + <div className="space-y-4"> + {isLoading ? ( + <div className="flex items-center justify-center py-4"> + <Loader className="size-6 animate-spin text-muted-foreground" /> + </div> + ) : ( + <> + {vendorsWithInvestigations.withInvestigations.length > 0 && ( + <Alert> + <AlertTriangle className="h-4 w-4" /> + <AlertTitle>기존 실사 요청 정보가 있습니다</AlertTitle> + <AlertDescription> + 선택한 {vendors.length}개 업체 중 {vendorsWithInvestigations.withInvestigations.length}개 업체에 대한 + 기존 실사 요청이 있습니다. 새로운 요청은 기존 데이터가 없는 업체에만 적용됩니다. + </AlertDescription> + </Alert> + )} + + {vendorsWithInvestigations.withInvestigations.length > 0 && ( + <Accordion type="single" collapsible className="w-full"> + <AccordionItem value="existing-investigations"> + <AccordionTrigger className="font-medium"> + 기존 실사 요청 ({vendorsWithInvestigations.withInvestigations.length}) + </AccordionTrigger> + <AccordionContent> + <ScrollArea className="max-h-[200px]"> + <Table> + <TableHeader> + <TableRow> + <TableHead>업체명</TableHead> + <TableHead>상태</TableHead> + <TableHead>요청일</TableHead> + <TableHead>예정 일정</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {vendorsWithInvestigations.withInvestigations.map(({ vendor, investigation }) => { + const status = getStatusBadge(investigation.investigationStatus) + return ( + <TableRow key={investigation.investigationId}> + <TableCell className="font-medium">{vendor.vendorName}</TableCell> + <TableCell> + <Badge variant={status.variant as any}>{status.text}</Badge> + </TableCell> + <TableCell>{formatDate(investigation.createdAt)}</TableCell> + <TableCell> + {investigation.scheduledStartAt + ? formatDate(investigation.scheduledStartAt) + : "미정"} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </ScrollArea> + </AccordionContent> + </AccordionItem> + </Accordion> + )} + + <div> + <h3 className="text-sm font-medium mb-2"> + 새로운 실사가 요청될 업체 ({vendorsWithInvestigations.withoutInvestigations.length}) + </h3> + {vendorsWithInvestigations.withoutInvestigations.length > 0 ? ( + <ScrollArea className="max-h-[200px]"> + <ul className="space-y-1"> + {vendorsWithInvestigations.withoutInvestigations.map((vendor) => ( + <li key={vendor.id} className="text-sm py-1 px-2 border-b"> + {vendor.vendorName} ({vendor.vendorCode || "코드 없음"}) + </li> + ))} + </ul> + </ScrollArea> + ) : ( + <p className="text-sm text-muted-foreground py-2"> + 모든 선택된 업체에 이미 실사 요청이 있습니다. + </p> + )} + </div> + </> + )} + </div> + </> + ) + } + if (isDesktop) { return ( <Dialog {...props}> @@ -72,29 +261,30 @@ export function RequestVendorsInvestigateDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <SendHorizonal className="size-4" aria-hidden="true" /> - Vendor Investigation Request ({vendors.length}) + 실사 요청 ({vendors.length}) </Button> </DialogTrigger> ) : null} - <DialogContent> + <DialogContent className="max-w-md"> <DialogHeader> - <DialogTitle>Confirm Vendor Investigation Requst</DialogTitle> + <DialogTitle>Confirm Vendor Investigation Request</DialogTitle> <DialogDescription> - Are you sure you want to request{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}? - After sent, 벤더실사담당자 will be notified and can manage it. + 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다. + 요청 후 협력업체실사담당자에게 알림이 전송됩니다. </DialogDescription> </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> + + {renderContent()} + + <DialogFooter className="gap-2 sm:space-x-0 mt-4"> <DialogClose asChild> - <Button variant="outline">Cancel</Button> + <Button variant="outline">취소</Button> </DialogClose> <Button aria-label="Request selected vendors" variant="default" onClick={onApprove} - disabled={isApprovePending} + disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0} > {isApprovePending && ( <Loader @@ -102,7 +292,7 @@ export function RequestVendorsInvestigateDialog({ aria-hidden="true" /> )} - Request + 요청하기 </Button> </DialogFooter> </DialogContent> @@ -124,26 +314,29 @@ export function RequestVendorsInvestigateDialog({ <DrawerHeader> <DrawerTitle>Confirm Vendor Investigation</DrawerTitle> <DrawerDescription> - Are you sure you want to request{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}? - After sent, 벤더실사담당자 will be notified and can manage it. + 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다. + 요청 후 협력업체실사담당자에게 알림이 전송됩니다. </DrawerDescription> </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> + + <div className="px-4"> + {renderContent()} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0 mt-4"> <DrawerClose asChild> - <Button variant="outline">Cancel</Button> + <Button variant="outline">취소</Button> </DrawerClose> <Button aria-label="Request selected vendors" variant="default" onClick={onApprove} - disabled={isApprovePending} + disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0} > {isApprovePending && ( <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> )} - Request + 요청하기 </Button> </DrawerFooter> </DrawerContent> diff --git a/lib/vendors/table/request-vendor-pg-dialog.tsx b/lib/vendors/table/request-vendor-pg-dialog.tsx index de23ad9b..4bc4e909 100644 --- a/lib/vendors/table/request-vendor-pg-dialog.tsx +++ b/lib/vendors/table/request-vendor-pg-dialog.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" import { requestPQVendors } from "../service" +import { useSession } from "next-auth/react" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -45,11 +46,21 @@ export function RequestPQVendorsDialog({ }: ApprovalVendorDialogProps) { const [isApprovePending, startApproveTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() function onApprove() { + + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + + startApproveTransition(async () => { const { error } = await requestPQVendors({ ids: vendors.map((vendor) => vendor.id), + userId: Number(session.user.id) + }) if (error) { diff --git a/lib/vendors/table/update-vendor-sheet.tsx b/lib/vendors/table/update-vendor-sheet.tsx index e65c4b1c..08994b6a 100644 --- a/lib/vendors/table/update-vendor-sheet.tsx +++ b/lib/vendors/table/update-vendor-sheet.tsx @@ -3,7 +3,25 @@ import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { Loader } from "lucide-react" +import { + Loader, + Activity, + AlertCircle, + AlertTriangle, + ClipboardList, + FilePenLine, + XCircle, + ClipboardCheck, + FileCheck2, + FileX2, + BadgeCheck, + CheckCircle2, + Circle as CircleIcon, + User, + Building, + AlignLeft, + Calendar +} from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -14,6 +32,7 @@ import { FormItem, FormLabel, FormMessage, + FormDescription } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { @@ -33,27 +52,143 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { useSession } from "next-auth/react" // Import useSession -import { Vendor } from "@/db/schema/vendors" +import { VendorWithType, vendors } from "@/db/schema/vendors" import { updateVendorSchema, type UpdateVendorSchema } from "../validations" import { modifyVendor } from "../service" -// 예: import { modifyVendor } from "@/lib/vendors/service" interface UpdateVendorSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - vendor: Vendor | null + vendor: VendorWithType | null +} +type StatusType = (typeof vendors.status.enumValues)[number]; + +type StatusConfig = { + Icon: React.ElementType; + className: string; + label: string; +}; + +// 상태 표시 유틸리티 함수 +const getStatusConfig = (status: StatusType): StatusConfig => { + switch(status) { + case "PENDING_REVIEW": + return { + Icon: ClipboardList, + className: "text-yellow-600", + label: "가입 신청 중" + }; + case "IN_REVIEW": + return { + Icon: FilePenLine, + className: "text-blue-600", + label: "심사 중" + }; + case "REJECTED": + return { + Icon: XCircle, + className: "text-red-600", + label: "심사 거부됨" + }; + case "IN_PQ": + return { + Icon: ClipboardCheck, + className: "text-purple-600", + label: "PQ 진행 중" + }; + case "PQ_SUBMITTED": + return { + Icon: FileCheck2, + className: "text-indigo-600", + label: "PQ 제출" + }; + case "PQ_FAILED": + return { + Icon: FileX2, + className: "text-red-600", + label: "PQ 실패" + }; + case "PQ_APPROVED": + return { + Icon: BadgeCheck, + className: "text-green-600", + label: "PQ 통과" + }; + case "APPROVED": + return { + Icon: CheckCircle2, + className: "text-green-600", + label: "승인됨" + }; + case "READY_TO_SEND": + return { + Icon: CheckCircle2, + className: "text-emerald-600", + label: "MDG 송부대기" + }; + case "ACTIVE": + return { + Icon: Activity, + className: "text-emerald-600", + label: "활성 상태" + }; + case "INACTIVE": + return { + Icon: AlertCircle, + className: "text-gray-600", + label: "비활성 상태" + }; + case "BLACKLISTED": + return { + Icon: AlertTriangle, + className: "text-slate-800", + label: "거래 금지" + }; + default: + return { + Icon: CircleIcon, + className: "text-gray-600", + label: status + }; + } +}; + +// 신용평가기관 목록 +const creditAgencies = [ + { value: "NICE", label: "NICE평가정보" }, + { value: "KIS", label: "KIS (한국신용평가)" }, + { value: "KED", label: "KED (한국기업데이터)" }, + { value: "SCI", label: "SCI평가정보" }, +] + +// 신용등급 스케일 +const creditRatingScaleMap: Record<string, string[]> = { + NICE: ["AAA", "AA", "A", "BBB", "BB", "B", "C", "D"], + KIS: ["AAA", "AA+", "AA", "A+", "A", "BBB+", "BBB", "BB", "B", "C"], + KED: ["AAA", "AA", "A", "BBB", "BB", "B", "CCC", "CC", "C", "D"], + SCI: ["AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB-", "B"], +} + +// 현금흐름등급 스케일 +const cashFlowRatingScaleMap: Record<string, string[]> = { + NICE: ["우수", "양호", "보통", "미흡", "불량"], + KIS: ["A+", "A", "B+", "B", "C", "D"], + KED: ["1등급", "2등급", "3등급", "4등급", "5등급"], + SCI: ["Level 1", "Level 2", "Level 3", "Level 4"], } // 폼 컴포넌트 export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) { const [isPending, startTransition] = React.useTransition() + const [selectedAgency, setSelectedAgency] = React.useState<string>(vendor?.creditAgency || "NICE") - console.log(vendor) - - // RHF + Zod + // 폼 정의 - UpdateVendorSchema 타입을 직접 사용 const form = useForm<UpdateVendorSchema>({ resolver: zodResolver(updateVendorSchema), defaultValues: { + // 업체 기본 정보 vendorName: vendor?.vendorName ?? "", vendorCode: vendor?.vendorCode ?? "", address: vendor?.address ?? "", @@ -61,7 +196,18 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) phone: vendor?.phone ?? "", email: vendor?.email ?? "", website: vendor?.website ?? "", + creditRating: vendor?.creditRating ?? "", + cashFlowRating: vendor?.cashFlowRating ?? "", status: vendor?.status ?? "ACTIVE", + vendorTypeId: vendor?.vendorTypeId ?? undefined, + + // 구매담당자 정보 (기본값은 비어있음) + buyerName: "", + buyerDepartment: "", + contractStartDate: undefined, + contractEndDate: undefined, + internalNotes: "", + // evaluationScore: "", }, }) @@ -75,191 +221,439 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) phone: vendor?.phone ?? "", email: vendor?.email ?? "", website: vendor?.website ?? "", + creditRating: vendor?.creditRating ?? "", + cashFlowRating: vendor?.cashFlowRating ?? "", status: vendor?.status ?? "ACTIVE", + vendorTypeId: vendor?.vendorTypeId ?? undefined, + + // 구매담당자 필드는 유지 + buyerName: form.getValues("buyerName"), + buyerDepartment: form.getValues("buyerDepartment"), + contractStartDate: form.getValues("contractStartDate"), + contractEndDate: form.getValues("contractEndDate"), + internalNotes: form.getValues("internalNotes"), + // evaluationScore: form.getValues("evaluationScore"), }); } }, [vendor, form]); - console.log(form.getValues()) + // 신용평가기관 변경 시 등급 필드를 초기화하는 효과 + React.useEffect(() => { + // 선택된 평가기관에 따라 현재 선택된 등급이 유효한지 확인 + const currentCreditRating = form.getValues("creditRating"); + const currentCashFlowRating = form.getValues("cashFlowRating"); + + // 선택된 기관에 따른 유효한 등급 목록 + const validCreditRatings = creditRatingScaleMap[selectedAgency] || []; + const validCashFlowRatings = cashFlowRatingScaleMap[selectedAgency] || []; + + // 현재 등급이 유효하지 않으면 초기화 + if (currentCreditRating && !validCreditRatings.includes(currentCreditRating)) { + form.setValue("creditRating", ""); + } + + if (currentCashFlowRating && !validCashFlowRatings.includes(currentCashFlowRating)) { + form.setValue("cashFlowRating", ""); + } + + // 신용평가기관 필드 업데이트 + if(selectedAgency){ + form.setValue("creditAgency", selectedAgency as "NICE" | "KIS" | "KED" | "SCI"); + } + + }, [selectedAgency, form]); + // 제출 핸들러 async function onSubmit(data: UpdateVendorSchema) { if (!vendor) return + const { data: session } = useSession() - startTransition(async () => { - // 서버 액션 or API - // const { error } = await modifyVendor({ id: vendor.id, ...data }) - // 여기선 간단 예시 - try { - // 예시: - const { error } = await modifyVendor({ id: String(vendor.id), ...data }) - if (error) throw new Error(error) - - toast.success("Vendor updated!") - form.reset() - props.onOpenChange?.(false) - } catch (err: any) { - toast.error(String(err)) - } - }) - } + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + startTransition(async () => { + try { + // Add status change comment if status has changed + const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined + const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined + + const statusComment = + oldStatus !== newStatus + ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}` + : "" // Empty string instead of undefined + + // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가 + const { error } = await modifyVendor({ + id: String(vendor.id), + userId: Number(session.user.id), // Add user ID from session + comment: statusComment, // Add comment for status changes + ...data // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리 + }) + + if (error) throw new Error(error) + + toast.success("업체 정보가 업데이트되었습니다!") + form.reset() + props.onOpenChange?.(false) + } catch (err: any) { + toast.error(String(err)) + } + }) +} return ( <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto"> <SheetHeader className="text-left"> - <SheetTitle>Update Vendor</SheetTitle> + <SheetTitle>업체 정보 수정</SheetTitle> <SheetDescription> - Update the vendor details and save the changes + 업체 세부 정보를 수정하고 변경 사항을 저장하세요 </SheetDescription> </SheetHeader> <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - {/* vendorName */} - <FormField - control={form.control} - name="vendorName" - render={({ field }) => ( - <FormItem> - <FormLabel>Vendor Name</FormLabel> - <FormControl> - <Input placeholder="Vendor Name" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> + {/* 업체 기본 정보 섹션 */} + <div className="space-y-4"> + <div className="flex items-center"> + <Building className="mr-2 h-5 w-5 text-muted-foreground" /> + <h3 className="text-sm font-medium">업체 기본 정보</h3> + </div> + <FormDescription> + 업체가 제공한 기본 정보입니다. 필요시 수정하세요. + </FormDescription> + <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> + {/* vendorName */} + <FormField + control={form.control} + name="vendorName" + render={({ field }) => ( + <FormItem> + <FormLabel>업체명</FormLabel> + <FormControl> + <Input placeholder="업체명 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* vendorCode */} + <FormField + control={form.control} + name="vendorCode" + render={({ field }) => ( + <FormItem> + <FormLabel>업체 코드</FormLabel> + <FormControl> + <Input placeholder="예: ABC123" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* address */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem className="md:col-span-2"> + <FormLabel>주소</FormLabel> + <FormControl> + <Input placeholder="주소 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* country */} + <FormField + control={form.control} + name="country" + render={({ field }) => ( + <FormItem> + <FormLabel>국가</FormLabel> + <FormControl> + <Input placeholder="예: 대한민국" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* phone */} + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel>전화번호</FormLabel> + <FormControl> + <Input placeholder="예: 010-1234-5678" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* email */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>이메일</FormLabel> + <FormControl> + <Input placeholder="예: info@company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* website */} + <FormField + control={form.control} + name="website" + render={({ field }) => ( + <FormItem> + <FormLabel>웹사이트</FormLabel> + <FormControl> + <Input placeholder="예: https://www.company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - {/* vendorCode */} - <FormField - control={form.control} - name="vendorCode" - render={({ field }) => ( - <FormItem> - <FormLabel>Vendor Code</FormLabel> - <FormControl> - <Input placeholder="Code123" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* status with icons */} + <FormField + control={form.control} + name="status" + render={({ field }) => { + // 현재 선택된 상태의 구성 정보 가져오기 + const selectedConfig = getStatusConfig(field.value ?? "ACTIVE"); + const SelectedIcon = selectedConfig?.Icon || CircleIcon; - {/* address */} - <FormField - control={form.control} - name="address" - render={({ field }) => ( - <FormItem> - <FormLabel>Address</FormLabel> - <FormControl> - <Input placeholder="123 Main St" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + return ( + <FormItem> + <FormLabel>업체승인상태</FormLabel> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue> + {field.value && ( + <div className="flex items-center"> + <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} /> + <span>{selectedConfig.label}</span> + </div> + )} + </SelectValue> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {vendors.status.enumValues.map((status) => { + const config = getStatusConfig(status); + const StatusIcon = config.Icon; + return ( + <SelectItem key={status} value={status}> + <div className="flex items-center"> + <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} /> + <span>{config.label}</span> + </div> + </SelectItem> + ); + })} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> - {/* country */} - <FormField - control={form.control} - name="country" - render={({ field }) => ( - <FormItem> - <FormLabel>Country</FormLabel> - <FormControl> - <Input placeholder="USA" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + + {/* 신용평가기관 선택 */} + <FormField + control={form.control} + name="creditAgency" + render={({ field }) => ( + <FormItem> + <FormLabel>신용평가기관</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={(value) => { + field.onChange(value); + setSelectedAgency(value); + }} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="평가기관 선택" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {creditAgencies.map((agency) => ( + <SelectItem key={agency.value} value={agency.value}> + {agency.label} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 평가년도 - 나중에 추가 가능 */} + + {/* 신용등급 - 선택된 기관에 따라 옵션 변경 */} + <FormField + control={form.control} + name="creditRating" + render={({ field }) => ( + <FormItem> + <FormLabel>신용등급</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={field.onChange} + disabled={!selectedAgency} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="신용등급 선택" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {(creditRatingScaleMap[selectedAgency] || []).map((rating) => ( + <SelectItem key={rating} value={rating}> + {rating} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 현금흐름등급 - 선택된 기관에 따라 옵션 변경 */} + <FormField + control={form.control} + name="cashFlowRating" + render={({ field }) => ( + <FormItem> + <FormLabel>현금흐름등급</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={field.onChange} + disabled={!selectedAgency} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="현금흐름등급 선택" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {(cashFlowRatingScaleMap[selectedAgency] || []).map((rating) => ( + <SelectItem key={rating} value={rating}> + {rating} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - {/* phone */} - <FormField - control={form.control} - name="phone" - render={({ field }) => ( - <FormItem> - <FormLabel>Phone</FormLabel> - <FormControl> - <Input placeholder="+1 555-1234" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + </div> + </div> - {/* email */} - <FormField - control={form.control} - name="email" - render={({ field }) => ( - <FormItem> - <FormLabel>Email</FormLabel> - <FormControl> - <Input placeholder="vendor@example.com" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* 구분선 */} + <Separator className="my-2" /> - {/* website */} - <FormField - control={form.control} - name="website" - render={({ field }) => ( - <FormItem> - <FormLabel>Website</FormLabel> - <FormControl> - <Input placeholder="https://www.vendor.com" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* 구매담당자 입력 섹션 */} + <div className="space-y-4 bg-slate-50 p-4 rounded-md border border-slate-200"> + <div className="flex items-center"> + <User className="mr-2 h-5 w-5 text-blue-600" /> + <h3 className="text-sm font-medium text-blue-800">구매담당자 정보</h3> + </div> + <FormDescription> + 구매담당자가 관리하는 추가 정보입니다. 이 정보는 내부용으로만 사용됩니다. + </FormDescription> + + <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> + {/* 여기에 구매담당자 필드 추가 */} + <FormField + control={form.control} + name="buyerName" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 이름</FormLabel> + <FormControl> + <Input placeholder="담당자 이름" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="buyerDepartment" + render={({ field }) => ( + <FormItem> + <FormLabel>담당 부서</FormLabel> + <FormControl> + <Input placeholder="예: 구매부" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - {/* status */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <FormControl> - <Select - value={field.value} - onValueChange={field.onChange} - > - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select a status" /> - </SelectTrigger> - <SelectContent> - <SelectGroup> - {/* enum ["ACTIVE","INACTIVE","BLACKLISTED"] */} - <SelectItem value="ACTIVE">ACTIVE</SelectItem> - <SelectItem value="INACTIVE">INACTIVE</SelectItem> - <SelectItem value="BLACKLISTED">BLACKLISTED</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + + <FormField + control={form.control} + name="internalNotes" + render={({ field }) => ( + <FormItem className="md:col-span-2"> + <FormLabel>내부 메모</FormLabel> + <FormControl> + <Input placeholder="내부 참고사항을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> <SheetFooter className="gap-2 pt-2 sm:space-x-0"> <SheetClose asChild> <Button type="button" variant="outline"> - Cancel + 취소 </Button> </SheetClose> <Button disabled={isPending}> {isPending && ( <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> )} - Save + 저장 </Button> </SheetFooter> </form> diff --git a/lib/vendors/table/vendor-all-export.ts b/lib/vendors/table/vendor-all-export.ts new file mode 100644 index 00000000..cef801fd --- /dev/null +++ b/lib/vendors/table/vendor-all-export.ts @@ -0,0 +1,486 @@ +// /lib/vendor-export.ts +import ExcelJS from "exceljs" +import { VendorWithType } from "@/db/schema/vendors" +import { exportVendorDetails } from "../service"; + +// 연락처 인터페이스 정의 +interface VendorContact { + contactName: string; + contactPosition?: string | null; + contactEmail: string; + contactPhone?: string | null; + isPrimary: boolean; +} + +// 아이템 인터페이스 정의 +interface VendorItem { + itemCode: string; + itemName: string; + description?: string | null; + createdAt?: Date | string; +} + +// RFQ 인터페이스 정의 +interface VendorRFQ { + rfqNumber: string; + title: string; + status: string; + requestDate?: Date | string | null; + dueDate?: Date | string | null; + description?: string | null; +} + +// 계약 인터페이스 정의 +interface VendorContract { + projectCode: string; + projectName: string; + contractNo: string; + contractName: string; + status: string; + paymentTerms: string; + deliveryTerms: string; + deliveryDate: Date | string; + deliveryLocation: string; + startDate?: Date | string | null; + endDate?: Date | string | null; + currency: string; + totalAmount?: number | null; +} + +// 서비스에서 반환하는 실제 데이터 구조 +interface VendorData { + id: number; + vendorName: string; + vendorCode: string | null; + taxId: string; + address: string | null; + country: string | null; + phone: string | null; + email: string | null; + website: string | null; + status: string; + representativeName: string | null; + representativeBirth: string | null; + representativeEmail: string | null; + representativePhone: string | null; + corporateRegistrationNumber: string | null; + creditAgency: string | null; + creditRating: string | null; + cashFlowRating: string | null; +// items: string | null; + createdAt: Date; + updatedAt: Date; + vendorContacts: VendorContact[]; + vendorItems: VendorItem[]; + vendorRfqs: VendorRFQ[]; + vendorContracts: VendorContract[]; +} + +/** + * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수 + * - 기본정보 시트 + * - 연락처 시트 + * - 아이템 시트 + * - RFQ 시트 + * - 계약 시트 + * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨 + */ +export async function exportVendorsWithRelatedData( + vendors: VendorWithType[], + filename = "vendors-detailed" +): Promise<void> { + if (!vendors.length) return; + + // 선택된 벤더 ID 목록 + const vendorIds = vendors.map(vendor => vendor.id); + + try { + // 서버로부터 모든 관련 데이터 가져오기 + const vendorsWithDetails = await exportVendorDetails(vendorIds); + + if (!vendorsWithDetails.length) { + throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다."); + } + + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + + // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태) + const vendorData = vendorsWithDetails as unknown as any[]; + + // ===== 1. 기본 정보 시트 ===== + createBasicInfoSheet(workbook, vendorData); + + // ===== 2. 연락처 시트 ===== + createContactsSheet(workbook, vendorData); + + // ===== 3. 아이템 시트 ===== + createItemsSheet(workbook, vendorData); + + // ===== 4. RFQ 시트 ===== + createRFQsSheet(workbook, vendorData); + + // ===== 5. 계약 시트 ===== + createContractsSheet(workbook, vendorData); + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`; + link.click(); + URL.revokeObjectURL(url); + + return; + } catch (error) { + console.error("Export error:", error); + throw error; + } +} + +// 기본 정보 시트 생성 함수 +function createBasicInfoSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const basicInfoSheet = workbook.addWorksheet("기본정보"); + + // 기본 정보 시트 헤더 설정 + basicInfoSheet.columns = [ + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + { header: "국가", key: "country", width: 10 }, + { header: "상태", key: "status", width: 15 }, + { header: "이메일", key: "email", width: 20 }, + { header: "전화번호", key: "phone", width: 15 }, + { header: "웹사이트", key: "website", width: 20 }, + { header: "주소", key: "address", width: 30 }, + { header: "대표자명", key: "representativeName", width: 15 }, + { header: "신용등급", key: "creditRating", width: 10 }, + { header: "현금흐름등급", key: "cashFlowRating", width: 10 }, + { header: "생성일", key: "createdAt", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(basicInfoSheet); + + // 벤더 데이터 추가 + vendors.forEach((vendor: VendorData) => { + basicInfoSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + country: vendor.country, + status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환 + email: vendor.email, + phone: vendor.phone, + website: vendor.website, + address: vendor.address, + representativeName: vendor.representativeName, + creditRating: vendor.creditRating, + cashFlowRating: vendor.cashFlowRating, + createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "", + }); + }); +} + +// 연락처 시트 생성 함수 +function createContactsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const contactsSheet = workbook.addWorksheet("연락처"); + + contactsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 연락처 정보 + { header: "이름", key: "contactName", width: 15 }, + { header: "직책", key: "contactPosition", width: 15 }, + { header: "이메일", key: "contactEmail", width: 25 }, + { header: "전화번호", key: "contactPhone", width: 15 }, + { header: "주요 연락처", key: "isPrimary", width: 10 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(contactsSheet); + + // 벤더별 연락처 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorContacts && vendor.vendorContacts.length > 0) { + vendor.vendorContacts.forEach((contact: VendorContact) => { + contactsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 연락처 정보 + contactName: contact.contactName, + contactPosition: contact.contactPosition || "", + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || "", + isPrimary: contact.isPrimary ? "예" : "아니오", + }); + }); + } else { + // 연락처가 없는 경우에도 벤더 정보만 추가 + contactsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + isPrimary: "", + }); + } + }); +} + +// 아이템 시트 생성 함수 +function createItemsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const itemsSheet = workbook.addWorksheet("아이템"); + + itemsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 아이템 정보 + { header: "아이템 코드", key: "itemCode", width: 15 }, + { header: "아이템명", key: "itemName", width: 25 }, + { header: "설명", key: "description", width: 30 }, + { header: "등록일", key: "createdAt", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(itemsSheet); + + // 벤더별 아이템 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorItems && vendor.vendorItems.length > 0) { + vendor.vendorItems.forEach((item: VendorItem) => { + itemsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 아이템 정보 + itemCode: item.itemCode, + itemName: item.itemName, + description: item.description || "", + createdAt: item.createdAt ? formatDate(item.createdAt) : "", + }); + }); + } else { + // 아이템이 없는 경우에도 벤더 정보만 추가 + itemsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + itemCode: "", + itemName: "", + description: "", + createdAt: "", + }); + } + }); +} + +// RFQ 시트 생성 함수 +function createRFQsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const rfqsSheet = workbook.addWorksheet("RFQ"); + + rfqsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // RFQ 정보 + { header: "RFQ 번호", key: "rfqNumber", width: 15 }, + { header: "제목", key: "title", width: 25 }, + { header: "상태", key: "status", width: 15 }, + { header: "요청일", key: "requestDate", width: 15 }, + { header: "마감일", key: "dueDate", width: 15 }, + { header: "설명", key: "description", width: 30 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(rfqsSheet); + + // 벤더별 RFQ 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorRfqs && vendor.vendorRfqs.length > 0) { + vendor.vendorRfqs.forEach((rfq: VendorRFQ) => { + rfqsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // RFQ 정보 + rfqNumber: rfq.rfqNumber, + title: rfq.title, + status: rfq.status, + requestDate: rfq.requestDate ? formatDate(rfq.requestDate) : "", + dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : "", + description: rfq.description || "", + }); + }); + } else { + // RFQ가 없는 경우에도 벤더 정보만 추가 + rfqsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + rfqNumber: "", + title: "", + status: "", + requestDate: "", + dueDate: "", + description: "", + }); + } + }); +} + +// 계약 시트 생성 함수 +function createContractsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const contractsSheet = workbook.addWorksheet("계약"); + + contractsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 계약 정보 + { header: "프로젝트 코드", key: "projectCode", width: 15 }, + { header: "프로젝트명", key: "projectName", width: 20 }, + { header: "계약 번호", key: "contractNo", width: 15 }, + { header: "계약명", key: "contractName", width: 25 }, + { header: "상태", key: "status", width: 15 }, + { header: "지급 조건", key: "paymentTerms", width: 15 }, + { header: "납품 조건", key: "deliveryTerms", width: 15 }, + { header: "납품 일자", key: "deliveryDate", width: 15 }, + { header: "납품 위치", key: "deliveryLocation", width: 20 }, + { header: "계약 시작일", key: "startDate", width: 15 }, + { header: "계약 종료일", key: "endDate", width: 15 }, + { header: "통화", key: "currency", width: 10 }, + { header: "총액", key: "totalAmount", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(contractsSheet); + + // 벤더별 계약 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorContracts && vendor.vendorContracts.length > 0) { + vendor.vendorContracts.forEach((contract: VendorContract) => { + contractsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 계약 정보 + projectCode: contract.projectCode, + projectName: contract.projectName, + contractNo: contract.contractNo, + contractName: contract.contractName, + status: contract.status, + paymentTerms: contract.paymentTerms, + deliveryTerms: contract.deliveryTerms, + deliveryDate: contract.deliveryDate ? formatDate(contract.deliveryDate) : "", + deliveryLocation: contract.deliveryLocation, + startDate: contract.startDate ? formatDate(contract.startDate) : "", + endDate: contract.endDate ? formatDate(contract.endDate) : "", + currency: contract.currency, + totalAmount: contract.totalAmount ? formatAmount(contract.totalAmount) : "", + }); + }); + } else { + // 계약이 없는 경우에도 벤더 정보만 추가 + contractsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + projectCode: "", + projectName: "", + contractNo: "", + contractName: "", + status: "", + paymentTerms: "", + deliveryTerms: "", + deliveryDate: "", + deliveryLocation: "", + startDate: "", + endDate: "", + currency: "", + totalAmount: "", + }); + } + }); +} + +// 헤더 스타일 적용 함수 +function applyHeaderStyle(sheet: ExcelJS.Worksheet): void { + const headerRow = sheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; + headerRow.eachCell((cell: ExcelJS.Cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); +} + +// 날짜 포맷 함수 +function formatDate(date: Date | string): string { + if (!date) return ""; + if (typeof date === 'string') { + date = new Date(date); + } + return date.toISOString().split('T')[0]; +} + +// 금액 포맷 함수 +function formatAmount(amount: number): string { + return amount.toLocaleString(); +} + +// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수 +function getStatusText(status: string): string { + const statusMap: Record<string, string> = { + "PENDING_REVIEW": "검토 대기중", + "IN_REVIEW": "검토 중", + "REJECTED": "거부됨", + "IN_PQ": "PQ 진행 중", + "PQ_SUBMITTED": "PQ 제출됨", + "PQ_FAILED": "PQ 실패", + "PQ_APPROVED": "PQ 승인됨", + "APPROVED": "승인됨", + "READY_TO_SEND": "발송 준비됨", + "ACTIVE": "활성", + "INACTIVE": "비활성", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; +}
\ No newline at end of file diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx index 77750c47..c768b587 100644 --- a/lib/vendors/table/vendors-table-columns.tsx +++ b/lib/vendors/table/vendors-table-columns.tsx @@ -27,30 +27,41 @@ import { import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" import { useRouter } from "next/navigation" -import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors" +import { VendorWithType, vendors, VendorWithAttachments } from "@/db/schema/vendors" import { modifyVendor } from "../service" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { vendorColumnsConfig } from "@/config/vendorColumnsConfig" import { Separator } from "@/components/ui/separator" import { AttachmentsButton } from "./attachmentButton" +import { getVendorStatusIcon } from "../utils" +// 타입 정의 추가 +type StatusType = (typeof vendors.status.enumValues)[number]; +type BadgeVariantType = "default" | "secondary" | "destructive" | "outline"; +type StatusConfig = { + variant: BadgeVariantType; + className: string; +}; +type StatusDisplayMap = { + [key in StatusType]: string; +}; type NextRouter = ReturnType<typeof useRouter>; - interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>; + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorWithType> | null>>; router: NextRouter; + userId: number; } /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ -export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] { +export function getColumns({ setRowAction, router, userId }: GetColumnsProps): ColumnDef<VendorWithType>[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- - const selectColumn: ColumnDef<Vendor> = { + const selectColumn: ColumnDef<VendorWithType> = { id: "select", header: ({ table }) => ( <Checkbox @@ -79,102 +90,103 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef // ---------------------------------------------------------------- // 2) actions 컬럼 (Dropdown 메뉴) // ---------------------------------------------------------------- -// ---------------------------------------------------------------- -// 2) actions 컬럼 (Dropdown 메뉴) -// ---------------------------------------------------------------- -const actionsColumn: ColumnDef<Vendor> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const isApproved = row.original.status === "APPROVED"; - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => { - // 1) 만약 rowAction을 열고 싶다면 - // setRowAction({ row, type: "update" }) - - // 2) 자세히 보기 페이지로 클라이언트 라우팅 - router.push(`/evcp/vendors/${row.original.id}/info`); - }} - > - Details - </DropdownMenuItem> - - {/* APPROVED 상태일 때만 추가 정보 기입 메뉴 표시 */} - {isApproved && ( + const actionsColumn: ColumnDef<VendorWithType> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const isApproved = row.original.status === "PQ_APPROVED"; + const afterApproved = row.original.status === "ACTIVE"; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + {(isApproved ||afterApproved) && ( <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "requestInfo" })} - className="text-blue-600 font-medium" + onSelect={() => setRowAction({ row, type: "update" })} > - 추가 정보 기입 + 레코드 편집 </DropdownMenuItem> - )} - - <Separator /> - <DropdownMenuSub> - <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> - <DropdownMenuSubContent> - <DropdownMenuRadioGroup - value={row.original.status} - onValueChange={(value) => { - startUpdateTransition(() => { - toast.promise( - modifyVendor({ - id: String(row.original.id), - status: value as Vendor["status"], - }), - { - loading: "Updating...", - success: "Label updated", - error: (err) => getErrorMessage(err), - } - ) - }) - }} - > - {vendors.status.enumValues.map((status) => ( - <DropdownMenuRadioItem - key={status} - value={status} - className="capitalize" - disabled={isUpdatePending} - > - {status} - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - </DropdownMenuSubContent> - </DropdownMenuSub> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, -} + )} + + <DropdownMenuItem + onSelect={() => { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) + + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/vendors/${row.original.id}/info`); + }} + > + 상세보기 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "log" })} + > + 감사 로그 보기 + </DropdownMenuItem> + + <Separator /> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.status} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + modifyVendor({ + id: String(row.original.id), + status: value as VendorWithType["status"], + userId, + vendorName: row.original.vendorName, // Required field from UpdateVendorSchema + comment: `Status changed to ${value}` + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {vendors.status.enumValues.map((status) => ( + <DropdownMenuRadioItem + key={status} + value={status} + className="capitalize" + disabled={isUpdatePending} + > + {status} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + + + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } // ---------------------------------------------------------------- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] } - const groupMap: Record<string, ColumnDef<Vendor>[]> = {} + // 3-1) groupMap: { [groupName]: ColumnDef<VendorWithType>[] } + const groupMap: Record<string, ColumnDef<VendorWithType>[]> = {} vendorColumnsConfig.forEach((cfg) => { // 만약 group가 없으면 "_noGroup" 처리 @@ -185,7 +197,7 @@ const actionsColumn: ColumnDef<Vendor> = { } // child column 정의 - const childCol: ColumnDef<Vendor> = { + const childCol: ColumnDef<VendorWithType> = { accessorKey: cfg.id, enableResizing: true, header: ({ column }) => ( @@ -197,20 +209,158 @@ const actionsColumn: ColumnDef<Vendor> = { type: cfg.type, }, cell: ({ row, cell }) => { + // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용 + if (cfg.id === "status") { + const statusVal = row.original.status as StatusType; + if (!statusVal) return null; + // Status badge variant mapping - 더 뚜렷한 색상으로 변경 + const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => { + switch (status) { + case "PENDING_REVIEW": + return { + variant: "outline", + className: "bg-yellow-100 text-yellow-800 border-yellow-300", + iconColor: "text-yellow-600" + }; + case "IN_REVIEW": + return { + variant: "outline", + className: "bg-blue-100 text-blue-800 border-blue-300", + iconColor: "text-blue-600" + }; + case "REJECTED": + return { + variant: "outline", + className: "bg-red-100 text-red-800 border-red-300", + iconColor: "text-red-600" + }; + case "IN_PQ": + return { + variant: "outline", + className: "bg-purple-100 text-purple-800 border-purple-300", + iconColor: "text-purple-600" + }; + case "PQ_SUBMITTED": + return { + variant: "outline", + className: "bg-indigo-100 text-indigo-800 border-indigo-300", + iconColor: "text-indigo-600" + }; + case "PQ_FAILED": + return { + variant: "outline", + className: "bg-red-100 text-red-800 border-red-300", + iconColor: "text-red-600" + }; + case "PQ_APPROVED": + return { + variant: "outline", + className: "bg-green-100 text-green-800 border-green-300", + iconColor: "text-green-600" + }; + case "APPROVED": + return { + variant: "outline", + className: "bg-green-100 text-green-800 border-green-300", + iconColor: "text-green-600" + }; + case "READY_TO_SEND": + return { + variant: "outline", + className: "bg-emerald-100 text-emerald-800 border-emerald-300", + iconColor: "text-emerald-600" + }; + case "ACTIVE": + return { + variant: "outline", + className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold", + iconColor: "text-emerald-600" + }; + case "INACTIVE": + return { + variant: "outline", + className: "bg-gray-100 text-gray-800 border-gray-300", + iconColor: "text-gray-600" + }; + case "BLACKLISTED": + return { + variant: "outline", + className: "bg-slate-800 text-white border-slate-900", + iconColor: "text-white" + }; + default: + return { + variant: "outline", + className: "bg-gray-100 text-gray-800 border-gray-300", + iconColor: "text-gray-600" + }; + } + }; + + // Translate status for display + const getStatusDisplay = (status: StatusType): string => { + const statusMap: StatusDisplayMap = { + "PENDING_REVIEW": "가입 신청 중", + "IN_REVIEW": "심사 중", + "REJECTED": "심사 거부됨", + "IN_PQ": "PQ 진행 중", + "PQ_SUBMITTED": "PQ 제출", + "PQ_FAILED": "PQ 실패", + "PQ_APPROVED": "PQ 통과", + "APPROVED": "승인됨", + "READY_TO_SEND": "MDG 송부대기", + "ACTIVE": "활성 상태", + "INACTIVE": "비활성 상태", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; + }; + + const config = getStatusConfig(statusVal); + const displayText = getStatusDisplay(statusVal); + const StatusIcon = getVendorStatusIcon(statusVal); - if (cfg.id === "status") { - const statusVal = row.original.status - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) return ( - <div className="flex w-[6.25rem] items-center"> - {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */} - <span className="capitalize">{statusVal}</span> - </div> - ) + <Badge variant={config.variant} className={`flex items-center px-2 py-1 ${config.className}`}> + <StatusIcon className={`mr-1 h-3.5 w-3.5 ${config.iconColor}`} /> + <span>{displayText}</span> + </Badge> + ); } + // 업체 유형 컬럼 처리 + if (cfg.id === "vendorTypeName") { + const typeVal = row.original.vendorTypeName as string | null; + return typeVal ? ( + <span className="text-sm font-medium"> + {typeVal} + </span> + ) : ( + <span className="text-sm text-gray-400">미지정</span> + ); + } + + // 업체 분류 컬럼 처리 (별도로 표시하고 싶은 경우) + if (cfg.id === "vendorCategory") { + const categoryVal = row.original.vendorCategory as string | null; + if (!categoryVal) return null; + + let badgeClass = ""; + + if (categoryVal === "정규업체") { + badgeClass = "bg-green-50 text-green-700 border-green-200"; + } else if (categoryVal === "잠재업체") { + badgeClass = "bg-blue-50 text-blue-700 border-blue-200"; + } + + return ( + <Badge variant="outline" className={badgeClass}> + {categoryVal} + </Badge> + ); + } if (cfg.id === "createdAt") { const dateVal = cell.getValue() as Date @@ -222,10 +372,10 @@ const actionsColumn: ColumnDef<Vendor> = { return formatDate(dateVal) } - // code etc... return row.getValue(cfg.id) ?? "" }, + minSize: 150 } groupMap[groupName].push(childCol) @@ -234,7 +384,7 @@ const actionsColumn: ColumnDef<Vendor> = { // ---------------------------------------------------------------- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<Vendor>[] = [] + const nestedColumns: ColumnDef<VendorWithType>[] = [] // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 // 여기서는 그냥 Object.entries 순서 @@ -252,34 +402,35 @@ const actionsColumn: ColumnDef<Vendor> = { } }) - const attachmentsColumn: ColumnDef<VendorWithAttachments> = { + // attachments 컬럼 타입 문제 해결을 위한 타입 단언 + const attachmentsColumn: ColumnDef<VendorWithType> = { id: "attachments", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="" /> ), cell: ({ row }) => { // hasAttachments 및 attachmentsList 속성이 추가되었다고 가정 - const hasAttachments = row.original.hasAttachments; - const attachmentsList = row.original.attachmentsList || []; - - if(hasAttachments){ + const hasAttachments = (row.original as VendorWithAttachments).hasAttachments; + const attachmentsList = (row.original as VendorWithAttachments).attachmentsList || []; - // 서버 액션을 사용하는 컴포넌트로 교체 - return ( - <AttachmentsButton - vendorId={row.original.id} - hasAttachments={hasAttachments} - attachmentsList={attachmentsList} - /> - );}{ - return null + if (hasAttachments) { + // 서버 액션을 사용하는 컴포넌트로 교체 + return ( + <AttachmentsButton + vendorId={row.original.id} + hasAttachments={hasAttachments} + attachmentsList={attachmentsList} + /> + ); + } else { + return null; } }, enableSorting: false, enableHiding: false, minSize: 45, }; - + // ---------------------------------------------------------------- // 4) 최종 컬럼 배열: select, nestedColumns, actions diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx index 3cb2c552..1c788911 100644 --- a/lib/vendors/table/vendors-table-toolbar-actions.tsx +++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Check, BuildingIcon } from "lucide-react" +import { Download, FileSpreadsheet, Upload, Check, BuildingIcon, FileText } from "lucide-react" import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" @@ -11,25 +11,29 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Vendor } from "@/db/schema/vendors" +import { VendorWithType } from "@/db/schema/vendors" import { ApproveVendorsDialog } from "./approve-vendor-dialog" import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog" import { RequestProjectPQDialog } from "./request-project-pq-dialog" import { SendVendorsDialog } from "./send-vendor-dialog" import { RequestVendorsInvestigateDialog } from "./request-vendor-investigate-dialog" import { RequestInfoDialog } from "./request-additional-Info-dialog" +import { RequestContractDialog } from "./request-basicContract-dialog" +import { exportVendorsWithRelatedData } from "./vendor-all-export" interface VendorsTableToolbarActionsProps { - table: Table<Vendor> + table: Table<VendorWithType> } export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) { + const [isExporting, setIsExporting] = React.useState(false); // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 const fileInputRef = React.useRef<HTMLInputElement>(null) - // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링 + // 선택된 협력업체 중 PENDING_REVIEW 상태인 협력업체만 필터링 const pendingReviewVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -38,7 +42,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .filter(vendor => vendor.status === "PENDING_REVIEW"); }, [table.getFilteredSelectedRowModel().rows]); - // 선택된 벤더 중 IN_REVIEW 상태인 벤더만 필터링 + // 선택된 협력업체 중 IN_REVIEW 상태인 협력업체만 필터링 const inReviewVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -71,7 +75,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .filter(vendor => vendor.status === "PQ_APPROVED"); }, [table.getFilteredSelectedRowModel().rows]); - // 프로젝트 PQ를 보낼 수 있는 벤더 상태 필터링 + // 프로젝트 PQ를 보낼 수 있는 협력업체 상태 필터링 const projectPQEligibleVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -81,10 +85,66 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions ["PENDING_REVIEW", "IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status) ); }, [table.getFilteredSelectedRowModel().rows]); + + // 선택된 모든 벤더 가져오기 + const selectedVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original); + }, [table.getFilteredSelectedRowModel().rows]); + + // 테이블의 모든 벤더 가져오기 (필터링된 결과) + const allFilteredVendors = React.useMemo(() => { + return table + .getFilteredRowModel() + .rows + .map(row => row.original); + }, [table.getFilteredRowModel().rows]); + + // 선택된 벤더 통합 내보내기 함수 실행 + const handleSelectedExport = async () => { + if (selectedVendors.length === 0) { + toast.warning("내보낼 협력업체를 선택해주세요."); + return; + } + + try { + setIsExporting(true); + toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`); + await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed"); + toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`); + } catch (error) { + console.error("상세 정보 내보내기 오류:", error); + toast.error("상세 정보 내보내기 중 오류가 발생했습니다."); + } finally { + setIsExporting(false); + } + }; + + // 모든 벤더 통합 내보내기 함수 실행 + const handleAllFilteredExport = async () => { + if (allFilteredVendors.length === 0) { + toast.warning("내보낼 협력업체가 없습니다."); + return; + } + + try { + setIsExporting(true); + toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`); + await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed"); + toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`); + } catch (error) { + console.error("상세 정보 내보내기 오류:", error); + toast.error("상세 정보 내보내기 중 오류가 발생했습니다."); + } finally { + setIsExporting(false); + } + }; return ( <div className="flex items-center gap-2"> - {/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */} + {/* 승인 다이얼로그: PENDING_REVIEW 상태인 협력업체가 있을 때만 표시 */} {pendingReviewVendors.length > 0 && ( <ApproveVendorsDialog vendors={pendingReviewVendors} @@ -92,7 +152,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions /> )} - {/* 일반 PQ 요청: IN_REVIEW 상태인 벤더가 있을 때만 표시 */} + {/* 일반 PQ 요청: IN_REVIEW 상태인 협력업체가 있을 때만 표시 */} {inReviewVendors.length > 0 && ( <RequestPQVendorsDialog vendors={inReviewVendors} @@ -100,7 +160,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions /> )} - {/* 프로젝트 PQ 요청: 적격 상태의 벤더가 있을 때만 표시 */} + {/* 프로젝트 PQ 요청: 적격 상태의 협력업체가 있을 때만 표시 */} {projectPQEligibleVendors.length > 0 && ( <RequestProjectPQDialog vendors={projectPQEligibleVendors} @@ -109,13 +169,13 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions )} {approvedVendors.length > 0 && ( - <RequestInfoDialog + <RequestContractDialog vendors={approvedVendors} onSuccess={() => table.toggleAllRowsSelected(false)} /> )} - {sendVendors.length > 0 && ( + {pqApprovedVendors.length > 0 && ( <RequestInfoDialog vendors={sendVendors} onSuccess={() => table.toggleAllRowsSelected(false)} @@ -129,21 +189,63 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions /> )} - {/** 4) Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "vendors", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> + {/* Export 드롭다운 메뉴로 변경 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + size="sm" + className="gap-2" + disabled={isExporting} + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + {isExporting ? "내보내는 중..." : "Export"} + </span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */} + <DropdownMenuItem + onClick={() => + exportTableToExcel(table, { + filename: "vendors", + excludeColumns: ["select", "actions"], + }) + } + disabled={isExporting} + > + <FileText className="mr-2 size-4" /> + <span>현재 테이블 데이터 내보내기</span> + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + {/* 선택된 벤더만 상세 내보내기 */} + <DropdownMenuItem + onClick={handleSelectedExport} + disabled={selectedVendors.length === 0 || isExporting} + > + <FileSpreadsheet className="mr-2 size-4" /> + <span>선택한 업체 상세 정보 내보내기</span> + {selectedVendors.length > 0 && ( + <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span> + )} + </DropdownMenuItem> + + {/* 모든 필터링된 벤더 상세 내보내기 */} + <DropdownMenuItem + onClick={handleAllFilteredExport} + disabled={allFilteredVendors.length === 0 || isExporting} + > + <Download className="mr-2 size-4" /> + <span>모든 업체 상세 정보 내보내기</span> + {allFilteredVendors.length > 0 && ( + <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span> + )} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> ) }
\ No newline at end of file diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx index 36fd45bd..02768f32 100644 --- a/lib/vendors/table/vendors-table.tsx +++ b/lib/vendors/table/vendors-table.tsx @@ -8,19 +8,18 @@ import type { DataTableRowAction, } from "@/types/table" -import { toSentenceCase } from "@/lib/utils" import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { useFeatureFlags } from "./feature-flags-provider" import { getColumns } from "./vendors-table-columns" import { getVendors, getVendorStatusCounts } from "../service" -import { Vendor, vendors } from "@/db/schema/vendors" +import { VendorWithType, vendors } from "@/db/schema/vendors" import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" -import { VendorsTableFloatingBar } from "./vendors-table-floating-bar" -import { UpdateTaskSheet } from "@/lib/tasks/table/update-task-sheet" import { UpdateVendorSheet } from "./update-vendor-sheet" import { getVendorStatusIcon } from "@/lib/vendors/utils" +import { ViewVendorLogsDialog } from "./view-vendors_logs-dialog" +import { useSession } from "next-auth/react" interface VendorsTableProps { promises: Promise< @@ -32,58 +31,83 @@ interface VendorsTableProps { } export function VendorsTable({ promises }: VendorsTableProps) { - const { featureFlags } = useFeatureFlags() - + const { data: session } = useSession() + const userId = Number(session?.user.id) + // Suspense로 받아온 데이터 const [{ data, pageCount }, statusCounts] = React.use(promises) + const [isCompact, setIsCompact] = React.useState<boolean>(false) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null) - + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithType> | null>(null) + // **router** 획득 const router = useRouter() - + // getColumns() 호출 시, router를 주입 const columns = React.useMemo( - () => getColumns({ setRowAction, router }), - [setRowAction, router] + () => getColumns({ setRowAction, router , userId}), + [setRowAction, router, userId] ) - - const filterFields: DataTableFilterField<Vendor>[] = [ + + // 상태 한글 변환 유틸리티 함수 + const getStatusDisplay = (status: string): string => { + const statusMap: Record<string, string> = { + "PENDING_REVIEW": "가입 신청 중", + "IN_REVIEW": "심사 중", + "REJECTED": "심사 거부됨", + "IN_PQ": "PQ 진행 중", + "PQ_SUBMITTED": "PQ 제출", + "PQ_FAILED": "PQ 실패", + "PQ_APPROVED": "PQ 통과", + "APPROVED": "승인됨", + "READY_TO_SEND": "MDG 송부대기", + "ACTIVE": "활성 상태", + "INACTIVE": "비활성 상태", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; + }; + + const filterFields: DataTableFilterField<VendorWithType>[] = [ { id: "status", - label: "Status", + label: "상태", options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), + label: getStatusDisplay(status), value: status, count: statusCounts[status], })), }, - - { id: "vendorCode", label: "Vendor Code" }, - + + { id: "vendorCode", label: "업체 코드" }, ] - - const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, + + const advancedFilterFields: DataTableAdvancedFilterField<VendorWithType>[] = [ + { id: "vendorName", label: "업체명", type: "text" }, + { id: "vendorCode", label: "업체코드", type: "text" }, + { id: "email", label: "이메일", type: "text" }, + { id: "country", label: "국가", type: "text" }, + { id: "vendorTypeName", label: "업체 유형", type: "text" }, + { id: "vendorCategory", label: "업체 분류", type: "select", options: [ + { label: "정규업체", value: "정규업체" }, + { label: "잠재업체", value: "잠재업체" }, + ]}, { id: "status", - label: "Status", + label: "업체승인상태", type: "multi-select", options: vendors.status.enumValues.map((status) => ({ - label: (status), + label: getStatusDisplay(status), value: status, count: statusCounts[status], icon: getVendorStatusIcon(status), - })), }, - { id: "createdAt", label: "Created at", type: "date" }, - { id: "updatedAt", label: "Updated at", type: "date" }, + { id: "createdAt", label: "등록일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, ] - + const { table } = useDataTable({ data, columns, @@ -100,16 +124,25 @@ export function VendorsTable({ promises }: VendorsTableProps) { clearOnDefault: true, }) + const handleCompactChange = React.useCallback((compact: boolean) => { + setIsCompact(compact) + }, []) + + return ( <> <DataTable table={table} + compact={isCompact} // floatingBar={<VendorsTableFloatingBar table={table} />} > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} shallow={false} + enableCompactToggle={true} + compactStorageKey="vendorsTableCompact" + onCompactChange={handleCompactChange} > <VendorsTableToolbarActions table={table} /> </DataTableAdvancedToolbar> @@ -119,6 +152,12 @@ export function VendorsTable({ promises }: VendorsTableProps) { onOpenChange={() => setRowAction(null)} vendor={rowAction?.row.original ?? null} /> + + <ViewVendorLogsDialog + open={rowAction?.type === "log"} + onOpenChange={() => setRowAction(null)} + vendorId={rowAction?.row.original?.id ?? null} + /> </> ) }
\ No newline at end of file diff --git a/lib/vendors/table/view-vendors_logs-dialog.tsx b/lib/vendors/table/view-vendors_logs-dialog.tsx new file mode 100644 index 00000000..7402ae55 --- /dev/null +++ b/lib/vendors/table/view-vendors_logs-dialog.tsx @@ -0,0 +1,244 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { formatDateTime } from "@/lib/utils" +import { useToast } from "@/hooks/use-toast" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { Download, Search, User } from "lucide-react" +import { VendorsLogWithUser, getVendorLogs } from "../service" + +interface VendorLogsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorId: number | null +} + +export function ViewVendorLogsDialog({ + open, + onOpenChange, + vendorId, +}: VendorLogsDialogProps) { + const [logs, setLogs] = React.useState<VendorsLogWithUser[]>([]) + const [filteredLogs, setFilteredLogs] = React.useState<VendorsLogWithUser[]>([]) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState<string | null>(null) + const [searchQuery, setSearchQuery] = React.useState("") + const [actionFilter, setActionFilter] = React.useState<string>("all") + const { toast } = useToast() + + // Get unique action types for filter dropdown + const actionTypes = React.useMemo(() => { + if (!logs.length) return [] + return Array.from(new Set(logs.map(log => log.action))) + }, [logs]) + + // Fetch logs when dialog opens + React.useEffect(() => { + if (open && vendorId) { + setLoading(true) + setError(null) + getVendorLogs(vendorId) + .then((res) => { + setLogs(res) + setFilteredLogs(res) + }) + .catch((err) => { + console.error(err) + setError("Failed to load logs. Please try again.") + toast({ + variant: "destructive", + title: "Error", + description: "Failed to load candidate logs", + }) + }) + .finally(() => setLoading(false)) + } else { + // Reset state when dialog closes + setSearchQuery("") + setActionFilter("all") + } + }, [open, vendorId, toast]) + + // Filter logs based on search query and action filter + React.useEffect(() => { + if (!logs.length) return + + let result = [...logs] + + // Apply action filter + if (actionFilter !== "all") { + result = result.filter(log => log.action === actionFilter) + } + + // Apply search filter (case insensitive) + if (searchQuery) { + const query = searchQuery.toLowerCase() + result = result.filter(log => + log.action.toLowerCase().includes(query) || + (log.comment && log.comment.toLowerCase().includes(query)) || + (log.oldStatus && log.oldStatus.toLowerCase().includes(query)) || + (log.newStatus && log.newStatus.toLowerCase().includes(query)) || + (log.userName && log.userName.toLowerCase().includes(query)) || + (log.userEmail && log.userEmail.toLowerCase().includes(query)) + ) + } + + setFilteredLogs(result) + }, [logs, searchQuery, actionFilter]) + + // Export logs as CSV + const exportLogs = () => { + if (!filteredLogs.length) return + + const headers = ["Action", "Old Status", "New Status", "Comment", "User", "Email", "Date"] + const csvContent = [ + headers.join(","), + ...filteredLogs.map(log => [ + `"${log.action}"`, + `"${log.oldStatus || ''}"`, + `"${log.newStatus || ''}"`, + `"${log.comment?.replace(/"/g, '""') || ''}"`, + `"${log.userName || ''}"`, + `"${log.userEmail || ''}"`, + `"${formatDateTime(log.createdAt)}"` + ].join(",")) + ].join("\n") + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.setAttribute("href", url) + link.setAttribute("download", `vendor-logs-${vendorId}-${new Date().toISOString().split('T')[0]}.csv`) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + // Render status change with appropriate badge + const renderStatusChange = (oldStatus: string, newStatus: string) => { + return ( + <div className="text-sm flex flex-wrap gap-2 items-center"> + <strong>Status:</strong> + <Badge variant="outline" className="text-xs">{oldStatus}</Badge> + <span>→</span> + <Badge variant="outline" className="bg-primary/10 text-xs">{newStatus}</Badge> + </div> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px]"> + <DialogHeader> + <DialogTitle>Audit Logs</DialogTitle> + </DialogHeader> + + {/* Filters and search */} + <div className="flex items-center gap-2 mb-4"> + <div className="relative flex-1"> + <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none"> + <Search className="h-4 w-4 text-muted-foreground" /> + </div> + <Input + placeholder="Search logs..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + <Select + value={actionFilter} + onValueChange={setActionFilter} + > + <SelectTrigger className="w-[180px]"> + <SelectValue placeholder="Filter by action" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All actions</SelectItem> + {actionTypes.map(action => ( + <SelectItem key={action} value={action}>{action}</SelectItem> + ))} + </SelectContent> + </Select> + + <Button + size="icon" + variant="outline" + onClick={exportLogs} + disabled={filteredLogs.length === 0} + title="Export to CSV" + > + <Download className="h-4 w-4" /> + </Button> + </div> + + <div className="space-y-2"> + {loading && ( + <div className="flex justify-center py-8"> + <p className="text-muted-foreground">Loading logs...</p> + </div> + )} + + {error && !loading && ( + <div className="bg-destructive/10 text-destructive p-3 rounded-md"> + {error} + </div> + )} + + {!loading && !error && filteredLogs.length === 0 && ( + <p className="text-muted-foreground text-center py-8"> + {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."} + </p> + )} + + {!loading && !error && filteredLogs.length > 0 && ( + <> + <div className="text-xs text-muted-foreground mb-2"> + Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'} + {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`} + </div> + <div className="max-h-96 space-y-4 pr-4 overflow-y-auto"> + {filteredLogs.map((log) => ( + <div key={log.id} className="rounded-md border p-4 mb-3 hover:bg-muted/50 transition-colors"> + <div className="flex justify-between items-start mb-2"> + <Badge className="text-xs">{log.action}</Badge> + <div className="text-xs text-muted-foreground"> + {formatDateTime(log.createdAt)} + </div> + </div> + + {log.oldStatus && log.newStatus && ( + <div className="my-2"> + {renderStatusChange(log.oldStatus, log.newStatus)} + </div> + )} + + {log.comment && ( + <div className="my-2 text-sm bg-muted/50 p-2 rounded-md"> + <strong>Comment:</strong> {log.comment} + </div> + )} + + {(log.userName || log.userEmail) && ( + <div className="mt-3 pt-2 border-t flex items-center text-xs text-muted-foreground"> + <User className="h-3 w-3 mr-1" /> + {log.userName || "Unknown"} + {log.userEmail && <span className="ml-1">({log.userEmail})</span>} + </div> + )} + </div> + ))} + </div> + </> + )} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
