summaryrefslogtreecommitdiff
path: root/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
diff options
context:
space:
mode:
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.tsx622
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