diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-13 11:05:09 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-13 11:05:09 +0000 |
| commit | 33be47506f0aa62b969d82521580a29e95080268 (patch) | |
| tree | 6b7e232f2d78ef8775944ea085a36b3ccbce7d95 /lib/bidding/list/bidding-detail-dialogs.tsx | |
| parent | 2ac95090157c355ea1bd0b8eb1e1e5e2bd56faf4 (diff) | |
(대표님) 입찰, 법무검토, EDP 변경사항 대응, dolce 개선, form-data 개선, 정규업체 등록관리 추가
(최겸) pq 미사용 컴포넌트 및 페이지 제거, 파일 라우트에 pq 적용
Diffstat (limited to 'lib/bidding/list/bidding-detail-dialogs.tsx')
| -rw-r--r-- | lib/bidding/list/bidding-detail-dialogs.tsx | 754 |
1 files changed, 754 insertions, 0 deletions
diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx new file mode 100644 index 00000000..2e58d676 --- /dev/null +++ b/lib/bidding/list/bidding-detail-dialogs.tsx @@ -0,0 +1,754 @@ +"use client" + +import * as React from "react" +import { useState, useEffect } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + CalendarIcon, + ClockIcon, + MapPinIcon, + FileTextIcon, + DownloadIcon, + EyeIcon, + PackageIcon, + HashIcon, + DollarSignIcon, + WeightIcon, + ExternalLinkIcon +} from "lucide-react" +import { toast } from "sonner" +import { BiddingListItem } from "@/db/schema" +import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download" +import { getPRDetailsAction, getSpecificationMeetingDetailsAction } from "../service" + +// 타입 정의 +interface SpecificationMeetingDetails { + id: number; + biddingId: number; + meetingDate: string; + meetingTime: string | null; + location: string | null; + address: string | null; + contactPerson: string | null; + contactPhone: string | null; + contactEmail: string | null; + agenda: string | null; + materials: string | null; + notes: string | null; + isRequired: boolean; + createdAt: string; + updatedAt: string; + documents: Array<{ + id: number; + fileName: string; + originalFileName: string; + fileSize: number; + filePath: string; + title: string | null; + uploadedAt: string; + uploadedBy: string | null; + }>; +} + +interface PRDetails { + documents: Array<{ + id: number; + documentName: string; + fileName: string; + originalFileName: string; + fileSize: number; + filePath: string; + registeredAt: string; + registeredBy: string | null; + version: string | null; + description: string | null; + createdAt: string; + updatedAt: string; + }>; + items: Array<{ + id: number; + itemNumber: string; + itemInfo: string | null; + quantity: number | null; + quantityUnit: string | null; + requestedDeliveryDate: string | null; + prNumber: string | null; + annualUnitPrice: number | null; + currency: string | null; + totalWeight: number | null; + weightUnit: string | null; + materialDescription: string | null; + hasSpecDocument: boolean; + createdAt: string; + updatedAt: string; + specDocuments: Array<{ + id: number; + fileName: string; + originalFileName: string; + fileSize: number; + filePath: string; + uploadedAt: string; + title: string | null; + }>; + }>; +} + +interface ActionResult<T> { + success: boolean; + data?: T; + error?: string; +} + +// 파일 다운로드 훅 +const useFileDownload = () => { + const [downloadingFiles, setDownloadingFiles] = useState<Set<string>>(new Set()); + + const handleDownload = async (filePath: string, fileName: string, options?: { + action?: 'download' | 'preview' + }) => { + const fileKey = `${filePath}_${fileName}`; + if (downloadingFiles.has(fileKey)) return; + + setDownloadingFiles(prev => new Set(prev).add(fileKey)); + + try { + await downloadFile(filePath, fileName, { + action: options?.action || 'download', + showToast: true, + showSuccessToast: true, + onError: (error) => { + console.error("파일 다운로드 실패:", error); + }, + onSuccess: (fileName, fileSize) => { + console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : ''); + } + }); + } catch (error) { + console.error("다운로드 처리 중 오류:", error); + } finally { + setDownloadingFiles(prev => { + const newSet = new Set(prev); + newSet.delete(fileKey); + return newSet; + }); + } + }; + + return { handleDownload, downloadingFiles }; +}; + +// 파일 링크 컴포넌트 +interface FileDownloadLinkProps { + filePath: string; + fileName: string; + fileSize?: number; + title?: string | null; + className?: string; +} + +const FileDownloadLink: React.FC<FileDownloadLinkProps> = ({ + filePath, + fileName, + fileSize, + title, + className = "" +}) => { + const { handleDownload, downloadingFiles } = useFileDownload(); + const fileInfo = getFileInfo(fileName); + const fileKey = `${filePath}_${fileName}`; + const isDownloading = downloadingFiles.has(fileKey); + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <button + onClick={() => handleDownload(filePath, fileName)} + disabled={isDownloading} + className={`inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 hover:underline disabled:opacity-50 disabled:cursor-not-allowed ${className}`} + > + <span className="text-xs">{fileInfo.icon}</span> + <span className="truncate max-w-[150px]"> + {isDownloading ? "다운로드 중..." : (title || fileName)} + </span> + <ExternalLinkIcon className="h-3 w-3 opacity-60" /> + </button> + </TooltipTrigger> + <TooltipContent> + <div className="text-xs"> + <div className="font-medium">{fileName}</div> + {fileSize && <div className="text-muted-foreground">{formatFileSize(fileSize)}</div>} + <div className="text-muted-foreground">클릭하여 다운로드</div> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); +}; + +// 파일 다운로드 버튼 컴포넌트 (간소화된 버전) +interface FileDownloadButtonProps { + filePath: string; + fileName: string; + fileSize?: number; + title?: string | null; + variant?: "download" | "preview"; + size?: "sm" | "default" | "lg"; +} + +const FileDownloadButton: React.FC<FileDownloadButtonProps> = ({ + filePath, + fileName, + fileSize, + title, + variant = "download", + size = "sm" +}) => { + const { handleDownload, downloadingFiles } = useFileDownload(); + const fileInfo = getFileInfo(fileName); + const fileKey = `${filePath}_${fileName}`; + const isDownloading = downloadingFiles.has(fileKey); + + const Icon = variant === "preview" && fileInfo.canPreview ? EyeIcon : DownloadIcon; + + return ( + <Button + onClick={() => handleDownload(filePath, fileName, { action: variant })} + disabled={isDownloading} + size={size} + variant="outline" + className="gap-2" + > + <Icon className="h-4 w-4" /> + {isDownloading ? "처리중..." : ( + variant === "preview" && fileInfo.canPreview ? "미리보기" : "다운로드" + )} + {fileSize && size !== "sm" && ( + <span className="text-xs text-muted-foreground"> + ({formatFileSize(fileSize)}) + </span> + )} + </Button> + ); +}; + +// 사양설명회 다이얼로그 +interface SpecificationMeetingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bidding: BiddingListItem | null; +} + +export function SpecificationMeetingDialog({ + open, + onOpenChange, + bidding +}: SpecificationMeetingDialogProps) { + const [data, setData] = useState<SpecificationMeetingDetails | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (open && bidding) { + fetchSpecificationMeetingData(); + } + }, [open, bidding]); + + const fetchSpecificationMeetingData = async () => { + if (!bidding) return; + + setLoading(true); + setError(null); + + try { + const result = await getSpecificationMeetingDetailsAction(bidding.id); + + if (result.success && result.data) { + setData(result.data); + } else { + setError(result.error || "사양설명회 정보를 불러올 수 없습니다."); + } + } catch (err) { + setError("데이터 로딩 중 오류가 발생했습니다."); + console.error("Failed to fetch specification meeting data:", err); + } finally { + setLoading(false); + } + }; + + const formatDate = (dateString: string) => { + try { + return new Date(dateString).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long' + }); + } catch { + return dateString; + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <CalendarIcon className="h-5 w-5" /> + 사양설명회 정보 + </DialogTitle> + <DialogDescription> + {bidding?.title}의 사양설명회 상세 정보입니다. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="max-h-[75vh]"> + {loading ? ( + <div className="flex items-center justify-center py-6"> + <div className="text-center"> + <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-2"></div> + <p className="text-sm text-muted-foreground">로딩 중...</p> + </div> + </div> + ) : error ? ( + <div className="flex items-center justify-center py-6"> + <div className="text-center"> + <p className="text-sm text-destructive mb-2">{error}</p> + <Button onClick={fetchSpecificationMeetingData} size="sm"> + 다시 시도 + </Button> + </div> + </div> + ) : data ? ( + <div className="space-y-4"> + {/* 기본 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">기본 정보</CardTitle> + </CardHeader> + <CardContent className="pt-0"> + <div className="text-sm space-y-1"> + <div> + <CalendarIcon className="inline h-3 w-3 text-muted-foreground mr-2" /> + <span className="font-medium">날짜:</span> {formatDate(data.meetingDate)} + {data.meetingTime && <span className="ml-4"><ClockIcon className="inline h-3 w-3 text-muted-foreground mr-1" />{data.meetingTime}</span>} + </div> + + {data.location && ( + <div> + <MapPinIcon className="inline h-3 w-3 text-muted-foreground mr-2" /> + <span className="font-medium">장소:</span> {data.location} + {data.address && <span className="text-muted-foreground ml-2">({data.address})</span>} + </div> + )} + + <div> + <span className="font-medium">참석 필수:</span> + <Badge variant={data.isRequired ? "destructive" : "secondary"} className="text-xs ml-2"> + {data.isRequired ? "필수" : "선택"} + </Badge> + </div> + </div> + </CardContent> + </Card> + + {/* 연락처 정보 */} + {(data.contactPerson || data.contactPhone || data.contactEmail) && ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">연락처 정보</CardTitle> + </CardHeader> + <CardContent className="pt-0"> + <div className="text-sm"> + {[ + data.contactPerson && `담당자: ${data.contactPerson}`, + data.contactPhone && `전화: ${data.contactPhone}`, + data.contactEmail && `이메일: ${data.contactEmail}` + ].filter(Boolean).join(' • ')} + </div> + </CardContent> + </Card> + )} + + {/* 안건 및 준비물 */} + {(data.agenda || data.materials) && ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">안건 및 준비물</CardTitle> + </CardHeader> + <CardContent className="pt-0 space-y-3"> + {data.agenda && ( + <div> + <span className="font-medium text-sm">안건:</span> + <div className="mt-1 p-2 bg-muted rounded text-sm whitespace-pre-wrap"> + {data.agenda} + </div> + </div> + )} + + {data.materials && ( + <div> + <span className="font-medium text-sm">준비물:</span> + <div className="mt-1 p-2 bg-muted rounded text-sm whitespace-pre-wrap"> + {data.materials} + </div> + </div> + )} + </CardContent> + </Card> + )} + + {/* 비고 */} + {data.notes && ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">비고</CardTitle> + </CardHeader> + <CardContent className="pt-0"> + <div className="p-2 bg-muted rounded text-sm whitespace-pre-wrap"> + {data.notes} + </div> + </CardContent> + </Card> + )} + + {/* 관련 문서 */} + {data.documents.length > 0 && ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base flex items-center gap-2"> + <FileTextIcon className="h-4 w-4" /> + 관련 문서 ({data.documents.length}개) + </CardTitle> + </CardHeader> + <CardContent className="pt-0"> + <div className="space-y-2"> + {data.documents.map((doc) => ( + <div key={doc.id} className="flex items-center gap-2"> + <FileDownloadLink + filePath={doc.filePath} + fileName={doc.originalFileName} + fileSize={doc.fileSize} + title={doc.title} + /> + </div> + ))} + </div> + </CardContent> + </Card> + )} + </div> + ) : null} + </ScrollArea> + </DialogContent> + </Dialog> + ); +} + +// PR 문서 다이얼로그 +interface PrDocumentsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bidding: BiddingListItem | null; +} + +export function PrDocumentsDialog({ + open, + onOpenChange, + bidding +}: PrDocumentsDialogProps) { + const [data, setData] = useState<PRDetails | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (open && bidding) { + fetchPRData(); + } + }, [open, bidding]); + + const fetchPRData = async () => { + if (!bidding) return; + + setLoading(true); + setError(null); + + try { + const result = await getPRDetailsAction(bidding.id); + + if (result.success && result.data) { + setData(result.data); + } else { + setError(result.error || "PR 문서 정보를 불러올 수 없습니다."); + } + } catch (err) { + setError("데이터 로딩 중 오류가 발생했습니다."); + console.error("Failed to fetch PR data:", err); + } finally { + setLoading(false); + } + }; + + const formatCurrency = (amount: number | null, currency: string | null) => { + if (amount === null) return "-"; + return `${amount.toLocaleString()} ${currency || ""}`; + }; + + const formatWeight = (weight: number | null, unit: string | null) => { + if (weight === null) return "-"; + return `${weight.toLocaleString()} ${unit || ""}`; + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-7xl max-h-[90vh]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <PackageIcon className="h-5 w-5" /> + PR 문서 + </DialogTitle> + <DialogDescription> + {bidding?.title}의 PR 문서 및 아이템 정보입니다. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="max-h-[75vh]"> + {loading ? ( + <div className="flex items-center justify-center py-8"> + <div className="text-center"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div> + <p className="text-sm text-muted-foreground">로딩 중...</p> + </div> + </div> + ) : error ? ( + <div className="flex items-center justify-center py-8"> + <div className="text-center"> + <p className="text-sm text-destructive mb-2">{error}</p> + <Button onClick={fetchPRData} size="sm"> + 다시 시도 + </Button> + </div> + </div> + ) : data ? ( + <div className="space-y-6"> + {/* PR 문서 목록 */} + {data.documents.length > 0 && ( + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <FileTextIcon className="h-5 w-5" /> + PR 문서 ({data.documents.length}개) + </CardTitle> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead>문서명</TableHead> + <TableHead>파일명</TableHead> + <TableHead>버전</TableHead> + <TableHead>크기</TableHead> + <TableHead>등록일</TableHead> + <TableHead>등록자</TableHead> + <TableHead className="text-right">다운로드</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {data.documents.map((doc) => ( + <TableRow key={doc.id}> + <TableCell className="font-medium"> + {doc.documentName} + {doc.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {doc.description} + </div> + )} + </TableCell> + <TableCell> + <FileDownloadLink + filePath={doc.filePath} + fileName={doc.originalFileName} + fileSize={doc.fileSize} + /> + </TableCell> + <TableCell> + {doc.version ? ( + <Badge variant="outline">{doc.version}</Badge> + ) : "-"} + </TableCell> + <TableCell>{formatFileSize(doc.fileSize)}</TableCell> + <TableCell> + {new Date(doc.registeredAt).toLocaleDateString('ko-KR')} + </TableCell> + <TableCell>{doc.registeredBy || "-"}</TableCell> + <TableCell className="text-right"> + <FileDownloadButton + filePath={doc.filePath} + fileName={doc.originalFileName} + fileSize={doc.fileSize} + variant="download" + size="sm" + /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + )} + + {/* PR 아이템 테이블 */} + {data.items.length > 0 && ( + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <HashIcon className="h-5 w-5" /> + PR 아이템 ({data.items.length}개) + </CardTitle> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[100px]">아이템 번호</TableHead> + <TableHead className="w-[150px]">PR 번호</TableHead> + <TableHead>아이템 정보</TableHead> + <TableHead className="w-[120px]">수량</TableHead> + <TableHead className="w-[120px]">단가</TableHead> + <TableHead className="w-[120px]">중량</TableHead> + <TableHead className="w-[120px]">요청 납기</TableHead> + <TableHead className="w-[200px]">스펙 문서</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {data.items.map((item) => ( + <TableRow key={item.id}> + <TableCell className="font-medium"> + {item.itemNumber} + </TableCell> + <TableCell> + {item.prNumber || "-"} + </TableCell> + <TableCell> + <div> + {item.itemInfo && ( + <div className="font-medium text-sm mb-1">{item.itemInfo}</div> + )} + {item.materialDescription && ( + <div className="text-xs text-muted-foreground"> + {item.materialDescription} + </div> + )} + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <PackageIcon className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {item.quantity ? `${item.quantity.toLocaleString()} ${item.quantityUnit || ""}` : "-"} + </span> + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <DollarSignIcon className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {formatCurrency(item.annualUnitPrice, item.currency)} + </span> + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <WeightIcon className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {formatWeight(item.totalWeight, item.weightUnit)} + </span> + </div> + </TableCell> + <TableCell> + {item.requestedDeliveryDate ? ( + <div className="flex items-center gap-1"> + <CalendarIcon className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {new Date(item.requestedDeliveryDate).toLocaleDateString('ko-KR')} + </span> + </div> + ) : "-"} + </TableCell> + <TableCell> + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <Badge variant={item.hasSpecDocument ? "default" : "secondary"} className="text-xs"> + {item.hasSpecDocument ? "있음" : "없음"} + </Badge> + {item.specDocuments.length > 0 && ( + <span className="text-xs text-muted-foreground"> + ({item.specDocuments.length}개) + </span> + )} + </div> + {item.specDocuments.length > 0 && ( + <div className="space-y-1"> + {item.specDocuments.map((doc, index) => ( + <div key={doc.id} className="text-xs"> + <FileDownloadLink + filePath={doc.filePath} + fileName={doc.originalFileName} + fileSize={doc.fileSize} + title={doc.title} + className="text-xs" + /> + </div> + ))} + </div> + )} + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + )} + + {/* 데이터가 없는 경우 */} + {data.documents.length === 0 && data.items.length === 0 && ( + <div className="text-center py-8"> + <FileTextIcon className="h-12 w-12 text-muted-foreground mx-auto mb-2" /> + <p className="text-muted-foreground">PR 문서가 없습니다.</p> + </div> + )} + </div> + ) : null} + </ScrollArea> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
