From ef4c533ebacc2cdc97e518f30e9a9350004fcdfb Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
Date: Mon, 28 Apr 2025 02:13:30 +0000
Subject: ~20250428 작업사항
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 lib/vendors/table/request-basicContract-dialog.tsx | 548 +++++++++++++++++++++
 1 file changed, 548 insertions(+)
 create mode 100644 lib/vendors/table/request-basicContract-dialog.tsx

(limited to 'lib/vendors/table/request-basicContract-dialog.tsx')

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
-- 
cgit v1.2.3

