summaryrefslogtreecommitdiff
path: root/lib/vendors/table/request-basicContract-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendors/table/request-basicContract-dialog.tsx')
-rw-r--r--lib/vendors/table/request-basicContract-dialog.tsx548
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