diff options
Diffstat (limited to 'lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx')
| -rw-r--r-- | lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx | 622 |
1 files changed, 622 insertions, 0 deletions
diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx new file mode 100644 index 00000000..3b5cdd21 --- /dev/null +++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx @@ -0,0 +1,622 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, FileDown, Mail, Scale, CheckCircle, AlertTriangle, Send, Gavel, Check, FileSignature } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { downloadFile } from "@/lib/file-download" +import { Button } from "@/components/ui/button" +import { BasicContractView } from "@/db/schema" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" +import { prepareFinalApprovalAction, quickFinalApprovalAction, requestLegalReviewAction } from "../service" +import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog" + +interface BasicContractDetailTableToolbarActionsProps { + table: Table<BasicContractView> +} + +export function BasicContractDetailTableToolbarActions({ table }: BasicContractDetailTableToolbarActionsProps) { + // 선택된 행들 가져오기 + const selectedRows = table.getSelectedRowModel().rows + const hasSelectedRows = selectedRows.length > 0 + + // 다이얼로그 상태 + const [resendDialog, setResendDialog] = React.useState(false) + const [legalReviewDialog, setLegalReviewDialog] = React.useState(false) + const [finalApproveDialog, setFinalApproveDialog] = React.useState(false) + const [loading, setLoading] = React.useState(false) + const [reviewNote, setReviewNote] = React.useState("") + const [buyerSignDialog, setBuyerSignDialog] = React.useState(false) + const [contractsToSign, setContractsToSign] = React.useState<any[]>([]) + + // 각 버튼별 활성화 조건 계산 + const canBulkDownload = hasSelectedRows && selectedRows.some(row => + row.original.signedFilePath && row.original.signedFileName && row.original.vendorSignedAt + ) + + const canBulkResend = hasSelectedRows + + const canRequestLegalReview = hasSelectedRows && selectedRows.some(row => + row.original.legalReviewRequired && !row.original.legalReviewRequestedAt + ) + + const canFinalApprove = hasSelectedRows && selectedRows.some(row => { + const contract = row.original; + if (contract.completedAt !== null || !contract.signedFilePath) { + return false; + } + if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { + return false; + } + return true; + }); + + // 필터링된 계약서들 계산 + const resendContracts = selectedRows.map(row => row.original) + + const legalReviewContracts = selectedRows + .map(row => row.original) + .filter(contract => contract.legalReviewRequired && !contract.legalReviewRequestedAt) + + const finalApproveContracts = selectedRows + .map(row => row.original) + .filter(contract => { + if (contract.completedAt !== null || !contract.signedFilePath) { + return false; + } + if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { + return false; + } + return true; + }); + + const contractsWithoutLegalReview = finalApproveContracts.filter(contract => + !contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt + ); + + // 대량 재발송 + const handleBulkResend = async () => { + if (!hasSelectedRows) { + toast.error("재발송할 계약서를 선택해주세요") + return + } + setResendDialog(true) + } + + // 선택된 계약서들 일괄 다운로드 + const handleBulkDownload = async () => { + if (!canBulkDownload) { + toast.error("다운로드할 파일이 있는 계약서를 선택해주세요") + return + } + + const selectedContracts = selectedRows + .map(row => row.original) + .filter(contract => contract.signedFilePath && contract.signedFileName) + + if (selectedContracts.length === 0) { + toast.error("다운로드할 파일이 없습니다") + return + } + + // 다운로드 시작 알림 + toast.success(`${selectedContracts.length}건의 파일 다운로드를 시작합니다`) + + let successCount = 0 + let failedCount = 0 + const failedFiles: string[] = [] + + // 순차적으로 다운로드 (병렬 다운로드는 브라우저 제한으로 인해 문제가 될 수 있음) + for (let i = 0; i < selectedContracts.length; i++) { + const contract = selectedContracts[i] + + try { + // 진행 상황 표시 + if (selectedContracts.length > 3) { + toast.loading(`다운로드 중... (${i + 1}/${selectedContracts.length})`, { + id: 'bulk-download-progress' + }) + } + + const result = await downloadFile( + contract.signedFilePath!, + contract.signedFileName!, + { + action: 'download', + showToast: false, // 개별 토스트는 비활성화 + onError: (error) => { + console.error(`다운로드 실패 - ${contract.signedFileName}:`, error) + failedFiles.push(`${contract.vendorName || '업체명 없음'} (${contract.signedFileName})`) + failedCount++ + }, + onSuccess: (fileName) => { + console.log(`다운로드 성공 - ${fileName}`) + successCount++ + } + } + ) + + if (result.success) { + successCount++ + } else { + failedCount++ + failedFiles.push(`${contract.vendorName || '업체명 없음'} (${contract.signedFileName})`) + } + + // 다운로드 간격 (브라우저 부하 방지) + if (i < selectedContracts.length - 1) { + await new Promise(resolve => setTimeout(resolve, 300)) + } + + } catch (error) { + console.error(`다운로드 에러 - ${contract.signedFileName}:`, error) + failedCount++ + failedFiles.push(`${contract.vendorName || '업체명 없음'} (${contract.signedFileName})`) + } + } + + // 진행 상황 토스트 제거 + toast.dismiss('bulk-download-progress') + + // 최종 결과 표시 + if (successCount === selectedContracts.length) { + toast.success(`모든 파일 다운로드 완료 (${successCount}건)`) + } else if (successCount > 0) { + toast.warning( + `일부 파일 다운로드 완료\n성공: ${successCount}건, 실패: ${failedCount}건`, + { + duration: 5000, + description: failedFiles.length > 0 + ? `실패한 파일: ${failedFiles.slice(0, 3).join(', ')}${failedFiles.length > 3 ? ` 외 ${failedFiles.length - 3}건` : ''}` + : undefined + } + ) + } else { + toast.error( + `모든 파일 다운로드 실패 (${failedCount}건)`, + { + duration: 5000, + description: failedFiles.length > 0 + ? `실패한 파일: ${failedFiles.slice(0, 3).join(', ')}${failedFiles.length > 3 ? ` 외 ${failedFiles.length - 3}건` : ''}` + : undefined + } + ) + } + + console.log("일괄 다운로드 완료:", { + total: selectedContracts.length, + success: successCount, + failed: failedCount, + failedFiles + }) + } + + // 법무검토 요청 + const handleLegalReviewRequest = async () => { + if (!canRequestLegalReview) { + toast.error("법무검토 요청 가능한 계약서를 선택해주세요") + return + } + setLegalReviewDialog(true) + } + + // 최종승인 + const handleFinalApprove = async () => { + if (!canFinalApprove) { + toast.error("최종승인 가능한 계약서를 선택해주세요") + return + } + setFinalApproveDialog(true) + } + + // 재발송 확인 + const confirmResend = async () => { + setLoading(true) + try { + // TODO: 서버액션 호출 + // await resendContractsAction(resendContracts.map(c => c.id)) + + console.log("대량 재발송:", resendContracts) + toast.success(`${resendContracts.length}건의 계약서 재발송을 완료했습니다`) + setResendDialog(false) + table.toggleAllPageRowsSelected(false) // 선택 해제 + } catch (error) { + toast.error("재발송 중 오류가 발생했습니다") + console.error(error) + } finally { + setLoading(false) + } + } + + // 법무검토 요청 확인 + const confirmLegalReview = async () => { + setLoading(true) + try { + // TODO: 서버액션 호출 + await requestLegalReviewAction(legalReviewContracts.map(c => c.id), reviewNote) + + console.log("법무검토 요청:", legalReviewContracts, "메모:", reviewNote) + toast.success(`${legalReviewContracts.length}건의 법무검토 요청을 완료했습니다`) + setLegalReviewDialog(false) + setReviewNote("") + table.toggleAllPageRowsSelected(false) // 선택 해제 + } catch (error) { + toast.error("법무검토 요청 중 오류가 발생했습니다") + console.error(error) + } finally { + setLoading(false) + } + } + + // 최종승인 확인 (수정됨) + const confirmFinalApprove = async () => { + setLoading(true) + try { + // 먼저 서명 가능한 계약서들을 준비 + const prepareResult = await prepareFinalApprovalAction( + finalApproveContracts.map(c => c.id) + ) + + if (prepareResult.success && prepareResult.contracts) { + // 서명이 필요한 경우 서명 다이얼로그 열기 + setContractsToSign(prepareResult.contracts) + setFinalApproveDialog(false) // 기존 다이얼로그는 닫기 + // buyerSignDialog는 더 이상 필요 없으므로 제거 + } else { + toast.error(prepareResult.message) + } + } catch (error) { + toast.error("최종승인 준비 중 오류가 발생했습니다") + console.error(error) + } finally { + setLoading(false) + } + } + + // 구매자 서명 완료 콜백 + const handleBuyerSignComplete = () => { + setContractsToSign([]) // 계약서 목록 초기화하여 BasicContractSignDialog 언마운트 + table.toggleAllPageRowsSelected(false) + toast.success("모든 계약서의 최종승인이 완료되었습니다!") + } + + // 빠른 승인 (서명 없이) + const confirmQuickApproval = async () => { + setLoading(true) + try { + const result = await quickFinalApprovalAction( + finalApproveContracts.map(c => c.id) + ) + + if (result.success) { + toast.success(result.message) + setFinalApproveDialog(false) + table.toggleAllPageRowsSelected(false) + } else { + toast.error(result.message) + } + } catch (error) { + toast.error("최종승인 중 오류가 발생했습니다") + console.error(error) + } finally { + setLoading(false) + } + } + + return ( + <> + <div className="flex items-center gap-2"> + {/* 일괄 다운로드 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleBulkDownload} + disabled={!canBulkDownload} + className="gap-2" + title={!hasSelectedRows + ? "계약서를 선택해주세요" + : !canBulkDownload + ? "다운로드할 파일이 있는 계약서를 선택해주세요" + : `${selectedRows.filter(row => row.original.signedFilePath && row.original.signedFileName).length}건 다운로드` + } + > + <FileDown className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 일괄 다운로드 {hasSelectedRows ? `(${selectedRows.length})` : ''} + </span> + </Button> + + {/* 재발송 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleBulkResend} + disabled={!canBulkResend} + className="gap-2" + title={!hasSelectedRows ? "계약서를 선택해주세요" : `${selectedRows.length}건 재발송`} + > + <Mail className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 재발송 {hasSelectedRows ? `(${selectedRows.length})` : ''} + </span> + </Button> + + {/* 법무검토 요청 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleLegalReviewRequest} + disabled={!canRequestLegalReview} + className="gap-2" + title={!hasSelectedRows + ? "계약서를 선택해주세요" + : !canRequestLegalReview + ? "법무검토 요청 가능한 계약서가 없습니다" + : `${legalReviewContracts.length}건 법무검토 요청` + } + > + <Scale className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 법무검토 {hasSelectedRows ? `(${selectedRows.length})` : ''} + </span> + </Button> + + {/* 최종승인 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleFinalApprove} + disabled={!canFinalApprove} + className="gap-2" + title={!hasSelectedRows + ? "계약서를 선택해주세요" + : !canFinalApprove + ? "최종승인 가능한 계약서가 없습니다" + : `${finalApproveContracts.length}건 최종승인` + } + > + <CheckCircle className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 최종승인 {hasSelectedRows ? `(${selectedRows.length})` : ''} + </span> + </Button> + + {/* 실제 구매자 서명을 위한 BasicContractSignDialog */} + {contractsToSign.length > 0 && ( + <BasicContractSignDialog + contracts={contractsToSign} + onSuccess={handleBuyerSignComplete} + hasSelectedRows={contractsToSign.length > 0} + mode="buyer" // 구매자 모드 prop + t={(key) => key} + /> + )} + + {/* Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "basic-contract-details", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + + {/* 재발송 다이얼로그 */} + <Dialog open={resendDialog} onOpenChange={setResendDialog}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Send className="size-5" /> + 계약서 재발송 확인 + </DialogTitle> + <DialogDescription> + 선택한 {resendContracts.length}건의 계약서를 재발송합니다. + </DialogDescription> + </DialogHeader> + + <div className="max-h-60 overflow-y-auto"> + <div className="space-y-3"> + {resendContracts.map((contract, index) => ( + <div key={contract.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"> + <div className="flex-1"> + <div className="font-medium">{contract.vendorName || '업체명 없음'}</div> + <div className="text-sm text-gray-500"> + {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'} + </div> + </div> + <Badge variant="secondary">{contract.status}</Badge> + </div> + ))} + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setResendDialog(false)} + disabled={loading} + > + 취소 + </Button> + <Button + onClick={confirmResend} + disabled={loading} + className="gap-2" + > + <Send className="size-4" /> + {loading ? "재발송 중..." : `${resendContracts.length}건 재발송`} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 법무검토 요청 다이얼로그 */} + <Dialog open={legalReviewDialog} onOpenChange={setLegalReviewDialog}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Gavel className="size-5" /> + 법무검토 요청 + </DialogTitle> + <DialogDescription> + 선택한 {legalReviewContracts.length}건의 계약서에 대한 법무검토를 요청합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="max-h-48 overflow-y-auto"> + <div className="space-y-3"> + {legalReviewContracts.map((contract, index) => ( + <div key={contract.id} className="flex items-center justify-between p-3 bg-blue-50 rounded-lg"> + <div className="flex-1"> + <div className="font-medium">{contract.vendorName || '업체명 없음'}</div> + <div className="text-sm text-gray-500"> + {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'} + </div> + </div> + <Badge variant="secondary">{contract.status}</Badge> + </div> + ))} + </div> + </div> + + <Separator /> + + <div className="space-y-2"> + <Label htmlFor="review-note">검토 요청 메모 (선택사항)</Label> + <Textarea + id="review-note" + placeholder="법무팀에게 전달할 특별한 요청사항이나 검토 포인트를 입력해주세요..." + value={reviewNote} + onChange={(e) => setReviewNote(e.target.value)} + rows={3} + /> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setLegalReviewDialog(false) + setReviewNote("") + }} + disabled={loading} + > + 취소 + </Button> + <Button + onClick={confirmLegalReview} + disabled={loading} + className="gap-2" + > + <Gavel className="size-4" /> + {loading ? "요청 중..." : `${legalReviewContracts.length}건 검토요청`} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* 최종승인 다이얼로그 */} + <Dialog open={finalApproveDialog} onOpenChange={setFinalApproveDialog}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Check className="size-5" /> + 최종승인 전 확인 + </DialogTitle> + <DialogDescription> + 선택한 {finalApproveContracts.length}건의 계약서를 최종승인을 위해 서명을 호출합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {contractsWithoutLegalReview.length > 0 && ( + <div className="flex items-start gap-3 p-4 bg-amber-50 border border-amber-200 rounded-lg"> + <AlertTriangle className="size-5 text-amber-600 flex-shrink-0 mt-0.5" /> + <div> + <div className="font-medium text-amber-800">법무검토 없이 승인되는 계약서</div> + <div className="text-sm text-amber-700 mt-1"> + {contractsWithoutLegalReview.length}건의 계약서가 법무검토 없이 승인됩니다. + 승인 후에는 되돌릴 수 없으니 신중히 검토해주세요. + </div> + </div> + </div> + )} + + <div className="max-h-60 overflow-y-auto"> + <div className="space-y-3"> + {finalApproveContracts.map((contract) => { + const hasLegalReview = contract.legalReviewRequestedAt && contract.legalReviewCompletedAt + const noLegalReview = !contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt + + return ( + <div + key={contract.id} + className={`flex items-center justify-between p-3 rounded-lg ${noLegalReview ? 'bg-amber-50 border border-amber-200' : 'bg-green-50 border border-green-200' + }`} + > + <div className="flex-1"> + <div className="font-medium">{contract.vendorName || '업체명 없음'}</div> + <div className="text-sm text-gray-500"> + {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'} + </div> + {noLegalReview && ( + <div className="text-xs text-amber-600 mt-1">법무검토 없음</div> + )} + {hasLegalReview && ( + <div className="text-xs text-green-600 mt-1">법무검토 완료</div> + )} + </div> + <Badge variant="secondary">{contract.status}</Badge> + </div> + ) + })} + </div> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setFinalApproveDialog(false)} + disabled={loading} + > + 취소 + </Button> + <Button + onClick={confirmFinalApprove} + disabled={loading} + className="gap-2" + variant={contractsWithoutLegalReview.length > 0 ? "destructive" : "default"} + > + <Check className="size-4" /> + {loading ? "호출 중..." : `${finalApproveContracts.length}건 서명호출`} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file |
