summaryrefslogtreecommitdiff
path: root/lib/basic-contract/status-detail
diff options
context:
space:
mode:
Diffstat (limited to 'lib/basic-contract/status-detail')
-rw-r--r--lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx622
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx418
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-table.tsx90
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