diff options
Diffstat (limited to 'lib/bidding/list/bidding-detail-dialogs.tsx')
| -rw-r--r-- | lib/bidding/list/bidding-detail-dialogs.tsx | 554 |
1 files changed, 149 insertions, 405 deletions
diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx index 4fbca616..065000ce 100644 --- a/lib/bidding/list/bidding-detail-dialogs.tsx +++ b/lib/bidding/list/bidding-detail-dialogs.tsx @@ -9,58 +9,49 @@ import { 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 { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" -import { - CalendarIcon, - ClockIcon, +import { + CalendarIcon, + ClockIcon, MapPinIcon, FileTextIcon, - DownloadIcon, - EyeIcon, - PackageIcon, - HashIcon, - DollarSignIcon, - WeightIcon, - ExternalLinkIcon + ExternalLinkIcon, + FileXIcon, + UploadIcon } 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" +import { getSpecificationMeetingDetailsAction } from "../service" +import { bidClosureAction } from "../actions" import { formatDate } from "@/lib/utils" +import { toast } from "sonner" // 타입 정의 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; + 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; @@ -70,60 +61,13 @@ interface SpecificationMeetingDetails { originalFileName: string; fileSize: number; filePath: string; - title: string | null; + 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; - }>; + uploadedBy?: string | null; }>; } -interface ActionResult<T> { - success: boolean; - data?: T; - error?: string; -} +// PR 관련 타입과 컴포넌트는 bidding-pr-documents-dialog.tsx로 이동됨 // 파일 다운로드 훅 const useFileDownload = () => { @@ -212,52 +156,6 @@ const FileDownloadLink: React.FC<FileDownloadLinkProps> = ({ ); }; -// 파일 다운로드 버튼 컴포넌트 (간소화된 버전) -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; @@ -458,285 +356,131 @@ export function SpecificationMeetingDialog({ ); } -// 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 || ""}`; - }; +// PR 문서 다이얼로그는 bidding-pr-documents-dialog.tsx로 이동됨 +// import { PrDocumentsDialog } from './bidding-pr-documents-dialog'로 사용하세요 - 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> - )} +// 폐찰하기 다이얼로그 +interface BidClosureDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bidding: BiddingListItem | null; + userId: string; +} - {/* 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> - )} +export function BidClosureDialog({ + open, + onOpenChange, + bidding, + userId +}: BidClosureDialogProps) { + const [description, setDescription] = useState('') + const [files, setFiles] = useState<File[]>([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!bidding || !description.trim()) { + toast.error('폐찰 사유를 입력해주세요.') + return + } + + setIsSubmitting(true) + + try { + const result = await bidClosureAction(bidding.id, { + description: description.trim(), + files + }, userId) + + if (result.success) { + toast.success(result.message) + onOpenChange(false) + // 페이지 새로고침 또는 상태 업데이트 + window.location.reload() + } else { + toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.') + } + } catch (error) { + toast.error('폐찰 처리 중 오류가 발생했습니다.') + } finally { + setIsSubmitting(false) + } + } + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + if (e.target.files) { + setFiles(Array.from(e.target.files)) + } + } + + if (!bidding) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileXIcon className="h-5 w-5 text-destructive" /> + 폐찰하기 + </DialogTitle> + <DialogDescription> + {bidding.title} ({bidding.biddingNumber})를 폐찰합니다. + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label> + <Textarea + id="description" + placeholder="폐찰 사유를 입력해주세요..." + value={description} + onChange={(e) => setDescription(e.target.value)} + className="min-h-[100px]" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="files">첨부파일</Label> + <Input + id="files" + type="file" + multiple + onChange={handleFileChange} + className="cursor-pointer" + accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png" + /> + {files.length > 0 && ( + <div className="text-sm text-muted-foreground"> + 선택된 파일: {files.map(f => f.name).join(', ')} + </div> + )} + </div> + + <div className="flex justify-end gap-2 pt-4"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + variant="destructive" + disabled={isSubmitting || !description.trim()} + > + {isSubmitting ? '처리 중...' : '폐찰하기'} + </Button> + </div> + </form> + </DialogContent> + </Dialog> + ) +} - {/* 데이터가 없는 경우 */} - {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 +// Re-export for backward compatibility +export { PrDocumentsDialog } from './bidding-pr-documents-dialog'
\ No newline at end of file |
