diff options
Diffstat (limited to 'lib/vendors/table/request-basicContract-dialog.tsx')
| -rw-r--r-- | lib/vendors/table/request-basicContract-dialog.tsx | 548 |
1 files changed, 548 insertions, 0 deletions
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 |
