diff options
Diffstat (limited to 'lib/basic-contract/status-detail')
3 files changed, 1130 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 diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx new file mode 100644 index 00000000..80c39d1e --- /dev/null +++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx @@ -0,0 +1,418 @@ +// basic-contracts-detail-columns.tsx +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" + +import { formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { Button } from "@/components/ui/button" +import { MoreHorizontal, Download, Eye, Mail, FileText, Clock } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { BasicContractView } from "@/db/schema" +import { downloadFile, quickPreview } from "@/lib/file-download" +import { toast } from "sonner" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>> +} + +const CONTRACT_STATUS_CONFIG = { + PENDING: { label: "발송완료", color: "gray" }, + VENDOR_SIGNED: { label: "협력업체 서명완료", color: "blue" }, + BUYER_SIGNED: { label: "구매팀 서명완료", color: "green" }, + LEGAL_REVIEW_REQUESTED: { label: "법무검토 요청", color: "purple" }, + LEGAL_REVIEW_COMPLETED: { label: "법무검토 완료", color: "indigo" }, + COMPLETED: { label: "계약완료", color: "emerald" }, + REJECTED: { label: "거절됨", color: "red" }, +} as const + +export function getDetailColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractView>[] { + + const selectColumn: ColumnDef<BasicContractView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + } + + const actionsColumn: ColumnDef<BasicContractView> = { + id: "actions", + header: "작업", + cell: ({ row }) => { + const contract = row.original + const hasSignedFile = contract.signedFilePath && contract.signedFileName + + const handleDownload = async () => { + if (!hasSignedFile) { + toast.error("다운로드할 파일이 없습니다") + return + } + + await downloadFile( + contract.signedFilePath!, + contract.signedFileName!, + { + action: 'download', + showToast: true, + onError: (error) => { + console.error("Download failed:", error) + }, + onSuccess: (fileName, fileSize) => { + console.log(`Downloaded: ${fileName} (${fileSize} bytes)`) + } + } + ) + } + + const handlePreview = async () => { + if (!hasSignedFile) { + toast.error("미리볼 파일이 없습니다") + return + } + + await quickPreview(contract.signedFilePath!, contract.signedFileName!) + } + + const handleResend = () => { + setRowAction({ type: "resend", row }) + } + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">Open menu</span> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {hasSignedFile && ( + <> + <DropdownMenuItem onClick={handlePreview}> + <Eye className="mr-2 h-4 w-4" /> + 파일 미리보기 + </DropdownMenuItem> + <DropdownMenuItem onClick={handleDownload}> + <Download className="mr-2 h-4 w-4" /> + 파일 다운로드 + </DropdownMenuItem> + </> + )} + <DropdownMenuItem onClick={handleResend}> + <Mail className="mr-2 h-4 w-4" /> + 재발송 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setRowAction({ type: "view", row })}> + <FileText className="mr-2 h-4 w-4" /> + 상세 정보 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + enableSorting: false, + enableHiding: false, + maxSize: 80, + } + + return [ + selectColumn, + + // 업체 코드 + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="업체코드" /> + ), + cell: ({ row }) => { + const code = row.getValue("vendorCode") as string | null + return code ? ( + <span className="font-mono text-sm bg-gray-100 px-2 py-1 rounded"> + {code} + </span> + ) : "-" + }, + minSize: 120, + }, + + // 업체명 + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="업체명" /> + ), + cell: ({ row }) => { + const name = row.getValue("vendorName") as string | null + return ( + <div className="font-medium">{name || "-"}</div> + ) + }, + minSize: 180, + }, + + // 진행상태 + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="진행상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("status") as keyof typeof CONTRACT_STATUS_CONFIG + const config = CONTRACT_STATUS_CONFIG[status] || { label: status, color: "gray" } + + const variantMap = { + gray: "secondary", + blue: "default", + green: "default", + purple: "secondary", + indigo: "secondary", + emerald: "default", + red: "destructive", + } as const + + return ( + <Badge variant={variantMap[config.color as keyof typeof variantMap]}> + {config.label} + </Badge> + ) + }, + minSize: 140, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + }, + + // 요청일 + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="요청일" /> + ), + cell: ({ row }) => { + const date = row.getValue("createdAt") as Date + return ( + <div className="text-sm"> + <div>{formatDateTime(date, "KR")}</div> + </div> + ) + }, + minSize: 130, + }, + + // 마감일 + { + accessorKey: "deadline", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="마감일" /> + ), + cell: ({ row }) => { + const deadline = row.getValue("deadline") as string | null + const status = row.getValue("status") as string + + if (!deadline) return "-" + + const deadlineDate = new Date(deadline) + const today = new Date() + const isOverdue = deadlineDate < today && !["COMPLETED", "REJECTED"].includes(status) + const isNearDeadline = !isOverdue && deadlineDate.getTime() - today.getTime() < 2 * 24 * 60 * 60 * 1000 // 2일 이내 + + return ( + <div className={`text-sm flex items-center gap-1 ${ + isOverdue ? 'text-red-600 font-medium' : + isNearDeadline ? 'text-orange-600' : + 'text-gray-900' + }`}> + <Clock className="h-3 w-3" /> + <div> + <div>{deadlineDate.toLocaleDateString('ko-KR')}</div> + {isOverdue && <div className="text-xs">(지연)</div>} + {isNearDeadline && !isOverdue && <div className="text-xs">(임박)</div>} + </div> + </div> + ) + }, + minSize: 120, + }, + + // 협력업체 서명일 + { + accessorKey: "vendorSignedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체 서명" /> + ), + cell: ({ row }) => { + const date = row.getValue("vendorSignedAt") as Date | null + return date ? ( + <div className="text-sm text-blue-600"> + <div className="font-medium">완료</div> + <div className="text-xs">{formatDateTime(date, "KR")}</div> + </div> + ) : ( + <div className="text-sm text-gray-400">미완료</div> + ) + }, + minSize: 130, + }, + + // 법무검토 요청일 + { + accessorKey: "legalReviewRequestedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="법무검토 요청" /> + ), + cell: ({ row }) => { + const date = row.getValue("legalReviewRequestedAt") as Date | null + return date ? ( + <div className="text-sm text-purple-600"> + <div className="font-medium">요청됨</div> + <div className="text-xs">{formatDateTime(date, "KR")}</div> + </div> + ) : ( + <div className="text-sm text-gray-400">-</div> + ) + }, + minSize: 140, + }, + + // 법무검토 완료일 + { + accessorKey: "legalReviewCompletedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="법무검토 완료" /> + ), + cell: ({ row }) => { + const date = row.getValue("legalReviewCompletedAt") as Date | null + const requestedDate = row.getValue("legalReviewRequestedAt") as Date | null + + return date ? ( + <div className="text-sm text-indigo-600"> + <div className="font-medium">완료</div> + <div className="text-xs">{formatDateTime(date, "KR")}</div> + </div> + ) : requestedDate ? ( + <div className="text-sm text-orange-500"> + <div className="font-medium">진행중</div> + <div className="text-xs">검토 대기</div> + </div> + ) : ( + <div className="text-sm text-gray-400">-</div> + ) + }, + minSize: 140, + }, + + // 계약완료일 + { + accessorKey: "completedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="계약완료" /> + ), + cell: ({ row }) => { + const date = row.getValue("completedAt") as Date | null + return date ? ( + <div className="text-sm text-emerald-600"> + <div className="font-medium">완료</div> + <div className="text-xs">{formatDateTime(date, "KR")}</div> + </div> + ) : ( + <div className="text-sm text-gray-400">미완료</div> + ) + }, + minSize: 120, + }, + + + // 서명된 파일 + { + accessorKey: "signedFileName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="서명파일" /> + ), + cell: ({ row }) => { + const fileName = row.getValue("signedFileName") as string | null + const filePath = row.original.signedFilePath + const vendorSignedAt = row.original.vendorSignedAt + + if (!fileName || !filePath|| !vendorSignedAt) { + return <div className="text-sm text-gray-400">파일 없음</div> + } + + const handleQuickDownload = async (e: React.MouseEvent) => { + e.stopPropagation() + await downloadFile(filePath, fileName, { + action: 'download', + showToast: true + }) + } + + const handleQuickPreview = async (e: React.MouseEvent) => { + e.stopPropagation() + await quickPreview(filePath, fileName) + } + + return ( + <div className="flex items-center gap-2"> + <div className="text-sm"> + <div className="font-medium text-blue-600 truncate max-w-[150px]" title={fileName}> + 서명파일 + </div> + <div className="text-xs text-gray-500">클릭하여 다운로드</div> + </div> + <div className="flex gap-1"> + <Button + variant="ghost" + size="sm" + onClick={handleQuickPreview} + className="h-6 w-6 p-0" + title="미리보기" + > + <Eye className="h-3 w-3" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={handleQuickDownload} + className="h-6 w-6 p-0" + title="다운로드" + > + <Download className="h-3 w-3" /> + </Button> + </div> + </div> + ) + }, + minSize: 200, + enableSorting: false, + }, + + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx new file mode 100644 index 00000000..2698842e --- /dev/null +++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { DataTable } from "@/components/data-table/data-table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table" +import { getDetailColumns } from "./basic-contracts-detail-columns" +import { getBasicContractsByTemplateId } from "@/lib/basic-contract/service" +import { BasicContractView } from "@/db/schema" +import { BasicContractDetailTableToolbarActions } from "./basic-contract-detail-table-toolbar-actions" + +interface BasicContractsDetailTableProps { + templateId: number + promises: Promise< + [ + Awaited<ReturnType<typeof getBasicContractsByTemplateId>>, + ] + > +} + +export function BasicContractsDetailTable({ templateId, promises }: BasicContractsDetailTableProps) { + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<BasicContractView> | null>(null) + + const [{ data, pageCount }] = React.use(promises) + + console.log(data,"data") + + const columns = React.useMemo( + () => getDetailColumns({ setRowAction }), + [setRowAction] + ) + + const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [ + { id: "vendorName", label: "업체명", type: "text" }, + { id: "vendorCode", label: "업체코드", type: "text" }, + { + id: "status", + label: "진행상태", + type: "select", + options: [ + { label: "발송완료", value: "PENDING" }, + { label: "협력업체 서명완료", value: "VENDOR_SIGNED" }, + { label: "구매팀 서명완료", value: "BUYER_SIGNED" }, + { label: "법무검토 요청", value: "LEGAL_REVIEW_REQUESTED" }, + { label: "법무검토 완료", value: "LEGAL_REVIEW_COMPLETED" }, + { label: "계약완료", value: "COMPLETED" }, + { label: "거절됨", value: "REJECTED" }, + ] + }, + { id: "requestedByName", label: "요청자", type: "text" }, + { id: "createdAt", label: "요청일", type: "date" }, + { id: "deadline", label: "마감일", type: "date" }, + { id: "vendorSignedAt", label: "협력업체 서명일", type: "date" }, + { id: "buyerSignedAt", label: "구매팀 서명일", type: "date" }, + { id: "completedAt", label: "계약완료일", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <BasicContractDetailTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ No newline at end of file |
