diff options
Diffstat (limited to 'lib/itb/table/view-purchase-request-sheet.tsx')
| -rw-r--r-- | lib/itb/table/view-purchase-request-sheet.tsx | 809 |
1 files changed, 809 insertions, 0 deletions
diff --git a/lib/itb/table/view-purchase-request-sheet.tsx b/lib/itb/table/view-purchase-request-sheet.tsx new file mode 100644 index 00000000..c4ff9416 --- /dev/null +++ b/lib/itb/table/view-purchase-request-sheet.tsx @@ -0,0 +1,809 @@ +// components/purchase-requests/view-purchase-request-sheet.tsx +"use client"; + +import * as React from "react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetFooter, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TableFooter, +} from "@/components/ui/table"; +import { Separator } from "@/components/ui/separator"; +import { + FileList, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; +import { + FileText, + Package, + Edit, + Download, + Calendar, + User, + Building, + MapPin, + Hash, + DollarSign, + Clock, + CheckCircle, + XCircle, + AlertCircle, + Layers, + Tag, + Paperclip, + FileIcon, + Eye +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import type { PurchaseRequestView } from "@/db/schema"; +import { useRouter } from "next/navigation"; +import { getPurchaseRequestAttachments } from "../service"; +import { downloadFile, quickPreview, formatFileSize, getFileInfo } from "@/lib/file-download"; + +interface ViewPurchaseRequestSheetProps { + request: PurchaseRequestView; + open: boolean; + onOpenChange: (open: boolean) => void; + onEditClick?: () => void; +} + +const statusConfig = { + "작성중": { + variant: "secondary" as const, + color: "text-gray-500", + icon: Edit, + bgColor: "bg-gray-100" + }, + "요청완료": { + variant: "default" as const, + color: "text-blue-500", + icon: CheckCircle, + bgColor: "bg-blue-50" + }, + "검토중": { + variant: "warning" as const, + color: "text-yellow-500", + icon: Clock, + bgColor: "bg-yellow-50" + }, + "승인": { + variant: "success" as const, + color: "text-green-500", + icon: CheckCircle, + bgColor: "bg-green-50" + }, + "반려": { + variant: "destructive" as const, + color: "text-red-500", + icon: XCircle, + bgColor: "bg-red-50" + }, + "RFQ생성완료": { + variant: "outline" as const, + color: "text-purple-500", + icon: Package, + bgColor: "bg-purple-50" + }, +}; + +export function ViewPurchaseRequestSheet({ + request, + open, + onOpenChange, + onEditClick, +}: ViewPurchaseRequestSheetProps) { + const router = useRouter(); + const [activeTab, setActiveTab] = React.useState("overview"); + const [attachments, setAttachments] = React.useState<any[]>([]); + const [isLoadingFiles, setIsLoadingFiles] = React.useState(false); + + // 첨부파일 로드 + React.useEffect(() => { + async function loadAttachments() { + if (open && request.id) { + setIsLoadingFiles(true); + try { + const result = await getPurchaseRequestAttachments(request.id); + if (result.success && result.data) { + setAttachments(result.data); + } else { + console.error("Failed to load attachments:", result.error); + setAttachments([]); + } + } catch (error) { + console.error("Error loading attachments:", error); + setAttachments([]); + } finally { + setIsLoadingFiles(false); + } + } + } + + loadAttachments(); + }, [open, request.id]); + + // 파일 다운로드 핸들러 + const handleFileDownload = async (file: any) => { + const result = await downloadFile(file.filePath, file.originalFileName, { + action: 'download', + showToast: true, + onError: (error) => { + console.error("Download failed:", error); + }, + onSuccess: (fileName, fileSize) => { + console.log(`Successfully downloaded: ${fileName} (${fileSize} bytes)`); + } + }); + + return result; + }; + + // 파일 미리보기 핸들러 + const handleFilePreview = async (file: any) => { + const fileInfo = getFileInfo(file.originalFileName); + + if (!fileInfo.canPreview) { + // 미리보기가 지원되지 않는 파일은 다운로드 + return handleFileDownload(file); + } + + const result = await quickPreview(file.filePath, file.originalFileName); + return result; + }; + + // 전체 다운로드 핸들러 + const handleDownloadAll = async () => { + for (const file of attachments) { + await handleFileDownload(file); + // 여러 파일 다운로드 시 간격 두기 + await new Promise(resolve => setTimeout(resolve, 500)); + } + }; + + // 아이템 총액 계산 + const totalAmount = React.useMemo(() => { + if (!request.items || !Array.isArray(request.items)) return 0; + return request.items.reduce((sum, item) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return sum + subtotal; + }, 0); + }, [request.items]); + + const handleEdit = () => { + if (onEditClick) { + onEditClick(); + } else { + onOpenChange(false); + } + }; + + const handleExport = () => { + // Export to Excel 기능 + console.log("Export to Excel"); + }; + + const StatusIcon = statusConfig[request.status]?.icon || AlertCircle; + const statusBgColor = statusConfig[request.status]?.bgColor || "bg-gray-50"; + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] max-w-[900px] overflow-hidden flex flex-col min-h-0" style={{width:900 , maxWidth:900}}> + <SheetHeader className="flex-shrink-0"> + <div className="flex items-center justify-between"> + <SheetTitle>구매요청 상세</SheetTitle> + <div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${statusBgColor}`}> + <StatusIcon className={`h-4 w-4 ${statusConfig[request.status]?.color}`} /> + <span className={`text-sm font-medium ${statusConfig[request.status]?.color}`}> + {request.status} + </span> + </div> + </div> + <SheetDescription> + 요청번호: <span className="font-mono font-medium">{request.requestCode}</span> | + 작성일: {request.createdAt && format(new Date(request.createdAt), "yyyy-MM-dd")} + </SheetDescription> + </SheetHeader> + + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0"> + <TabsList className="grid w-full grid-cols-4 flex-shrink-0"> + <TabsTrigger value="overview"> + <FileText className="mr-2 h-4 w-4" /> + 개요 + </TabsTrigger> + <TabsTrigger value="items"> + <Package className="mr-2 h-4 w-4" /> + 품목 정보 + {request.itemCount > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {request.itemCount} + </span> + )} + </TabsTrigger> + <TabsTrigger value="files"> + <Paperclip className="mr-2 h-4 w-4" /> + 첨부파일 + {attachments.length > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {attachments.length} + </span> + )} + </TabsTrigger> + <TabsTrigger value="history"> + <Clock className="mr-2 h-4 w-4" /> + 처리 이력 + </TabsTrigger> + </TabsList> + + <div className="flex-1 overflow-y-auto px-1 min-h-0"> + <TabsContent value="overview" className="space-y-6 mt-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <p className="text-sm text-muted-foreground mb-1">요청 제목</p> + <p className="text-lg font-semibold">{request.requestTitle}</p> + </div> + + {request.requestDescription && ( + <div> + <p className="text-sm text-muted-foreground mb-1">요청 설명</p> + <p className="text-sm bg-muted/30 p-3 rounded-lg">{request.requestDescription}</p> + </div> + )} + + <Separator /> + + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-center gap-3"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm text-muted-foreground">희망 납기일</p> + <p className="font-medium"> + {request.requestedDeliveryDate + ? format(new Date(request.requestedDeliveryDate), "yyyy-MM-dd") + : "-"} + </p> + </div> + </div> + + <div className="flex items-center gap-3"> + <DollarSign className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm text-muted-foreground">예상 예산</p> + <p className="font-medium">{request.estimatedBudget || "-"}</p> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 프로젝트 & 패키지 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">프로젝트 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <Hash className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">프로젝트 코드</p> + <p className="font-medium">{request.projectCode || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <FileText className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">프로젝트명</p> + <p className="font-medium">{request.projectName || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <Building className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">발주처</p> + <p className="font-medium">{request.projectCompany || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <MapPin className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">현장</p> + <p className="font-medium">{request.projectSite || "-"}</p> + </div> + </div> + </div> + + <Separator /> + + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <Package className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">패키지</p> + <p className="font-medium">{request.packageNo} - {request.packageName || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <Tag className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">SM 코드</p> + <p className="font-medium">{request.smCode || "-"}</p> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 자재 정보 */} + {(request.majorItemMaterialCategory || request.majorItemMaterialDescription) && ( + <Card> + <CardHeader> + <CardTitle className="text-base">자재 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <Layers className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">자재 그룹</p> + <p className="font-medium">{request.majorItemMaterialCategory || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <FileText className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">자재 설명</p> + <p className="font-medium">{request.majorItemMaterialDescription || "-"}</p> + </div> + </div> + </div> + </CardContent> + </Card> + )} + + {/* 담당자 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">담당자 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <User className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">설계 담당자</p> + <p className="font-medium">{request.engPicName || "-"}</p> + {request.engPicEmail && ( + <p className="text-sm text-muted-foreground">{request.engPicEmail}</p> + )} + </div> + </div> + + <div className="flex items-start gap-3"> + <User className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">구매 담당자</p> + <p className="font-medium">{request.purchasePicName || "미배정"}</p> + {request.purchasePicEmail && ( + <p className="text-sm text-muted-foreground">{request.purchasePicEmail}</p> + )} + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 반려 사유 */} + {request.status === "반려" && request.rejectReason && ( + <Card className="border-destructive"> + <CardHeader className="bg-destructive/5"> + <CardTitle className="text-base text-destructive flex items-center gap-2"> + <XCircle className="h-4 w-4" /> + 반려 사유 + </CardTitle> + </CardHeader> + <CardContent className="pt-4"> + <p className="text-sm">{request.rejectReason}</p> + </CardContent> + </Card> + )} + </TabsContent> + + <TabsContent value="items" className="mt-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="text-base">품목 목록</CardTitle> + <CardDescription className="mt-1"> + 총 {request.itemCount || 0}개 품목 | 총 수량 {request.totalQuantity || 0}개 + </CardDescription> + </div> + <Button variant="outline" size="sm" onClick={handleExport}> + <Download className="mr-2 h-4 w-4" /> + Excel 다운로드 + </Button> + </div> + </CardHeader> + <CardContent> + {(!request.items || request.items.length === 0) ? ( + <div className="flex flex-col items-center justify-center h-32 text-muted-foreground"> + <Package className="h-8 w-8 mb-2" /> + <p>등록된 품목이 없습니다</p> + </div> + ) : ( + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px] text-center">번호</TableHead> + <TableHead>아이템 코드</TableHead> + <TableHead>아이템명</TableHead> + <TableHead>사양</TableHead> + <TableHead className="text-right">수량</TableHead> + <TableHead className="text-center">단위</TableHead> + <TableHead className="text-right">예상 단가</TableHead> + <TableHead className="text-right">예상 금액</TableHead> + {request.items[0]?.remarks && <TableHead>비고</TableHead>} + </TableRow> + </TableHeader> + <TableBody> + {request.items.map((item: any, index: number) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return ( + <TableRow key={item.id || index}> + <TableCell className="text-center">{index + 1}</TableCell> + <TableCell className="font-mono text-sm">{item.itemCode || "-"}</TableCell> + <TableCell className="font-medium">{item.itemName}</TableCell> + <TableCell className="text-sm">{item.specification || "-"}</TableCell> + <TableCell className="text-right font-medium"> + {item.quantity?.toLocaleString('ko-KR')} + </TableCell> + <TableCell className="text-center">{item.unit}</TableCell> + <TableCell className="text-right"> + {item.estimatedUnitPrice + ? item.estimatedUnitPrice.toLocaleString('ko-KR') + "원" + : "-"} + </TableCell> + <TableCell className="text-right font-medium"> + {subtotal > 0 + ? subtotal.toLocaleString('ko-KR') + "원" + : "-"} + </TableCell> + {request.items[0]?.remarks && ( + <TableCell className="text-sm">{item.remarks || "-"}</TableCell> + )} + </TableRow> + ); + })} + </TableBody> + <TableFooter> + <TableRow> + <TableCell colSpan={6} className="text-right font-medium"> + 총 합계 + </TableCell> + <TableCell colSpan={2} className="text-right"> + <div className="flex flex-col"> + <span className="text-2xl font-bold text-primary"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(totalAmount)} + </span> + </div> + </TableCell> + {request.items[0]?.remarks && <TableCell />} + </TableRow> + </TableFooter> + </Table> + </div> + )} + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="files" className="mt-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="text-base">첨부파일</CardTitle> + <CardDescription className="mt-1"> + 구매 요청 관련 문서 및 파일 + </CardDescription> + </div> + </div> + </CardHeader> + <CardContent> + {isLoadingFiles ? ( + <div className="flex items-center justify-center h-32"> + <p className="text-muted-foreground">파일 목록을 불러오는 중...</p> + </div> + ) : attachments.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-32 text-muted-foreground"> + <Paperclip className="h-8 w-8 mb-2" /> + <p>첨부된 파일이 없습니다</p> + </div> + ) : ( + <div className="space-y-4"> + <div className="flex items-center justify-between mb-4"> + <p className="text-sm text-muted-foreground"> + 총 {attachments.length}개의 파일이 첨부되어 있습니다 + </p> + <Button + variant="outline" + size="sm" + onClick={handleDownloadAll} + disabled={attachments.length === 0} + > + <Download className="mr-2 h-4 w-4" /> + 전체 다운로드 + </Button> + </div> + + <FileList> + {attachments.map((file, index) => { + const fileInfo = getFileInfo(file.originalFileName || file.fileName); + return ( + <FileListItem key={file.id || file.fileName || index}> + <FileListIcon> + <FileIcon className="h-4 w-4" /> + </FileListIcon> + <FileListInfo> + <FileListHeader> + <FileListName> + {file.originalFileName || file.fileName} + </FileListName> + <FileListSize> + {file.fileSize} + </FileListSize> + </FileListHeader> + <FileListDescription className="flex items-center gap-4"> + {file.createdAt && ( + <span className="text-xs"> + {format(new Date(file.createdAt), "yyyy-MM-dd HH:mm")} + </span> + )} + {file.category && ( + <Badge variant="secondary" className="text-xs"> + {file.category} + </Badge> + )} + </FileListDescription> + </FileListInfo> + <div className="flex items-center gap-1"> + {fileInfo.canPreview && ( + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleFilePreview(file)} + title="미리보기" + > + <Eye className="h-4 w-4" /> + </Button> + )} + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleFileDownload(file)} + title="다운로드" + > + <Download className="h-4 w-4" /> + </Button> + </div> + </FileListItem> + ); + })} + </FileList> + + {/* 파일 종류별 요약 */} + {attachments.length > 0 && ( + <div className="mt-6 p-4 bg-muted/30 rounded-lg"> + <h4 className="text-sm font-medium mb-3">파일 요약</h4> + <div className="grid grid-cols-3 gap-4 text-sm"> + <div> + <p className="text-muted-foreground">총 파일 수</p> + <p className="font-medium">{attachments.length}개</p> + </div> + <div> + <p className="text-muted-foreground">총 용량</p> + <p className="font-medium"> + {formatFileSize( + attachments.reduce((sum, file) => sum + (file.fileSize || 0), 0) + )} + </p> + </div> + <div> + <p className="text-muted-foreground">최근 업로드</p> + <p className="font-medium"> + {attachments[0]?.createdAt + ? format(new Date(attachments[0].createdAt), "yyyy-MM-dd") + : "-"} + </p> + </div> + </div> + </div> + )} + </div> + )} + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="history" className="mt-6"> + <Card> + <CardHeader> + <CardTitle className="text-base">처리 이력</CardTitle> + <CardDescription>요청서의 처리 현황을 시간순으로 확인할 수 있습니다</CardDescription> + </CardHeader> + <CardContent> + <div className="relative"> + <div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" /> + + <div className="space-y-6"> + {/* 생성 */} + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center"> + <div className="h-2 w-2 rounded-full bg-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">요청서 작성</p> + <p className="text-sm text-muted-foreground"> + {request.createdByName} ({request.createdByEmail}) + </p> + <p className="text-xs text-muted-foreground mt-1"> + {request.createdAt && format(new Date(request.createdAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + + {/* 확정 */} + {request.confirmedAt && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-blue-500 flex items-center justify-center"> + <CheckCircle className="h-3 w-3 text-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">요청 확정</p> + <p className="text-sm text-muted-foreground"> + {request.confirmedByName} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {format(new Date(request.confirmedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + + {/* 반려 */} + {request.status === "반려" && request.rejectReason && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-destructive flex items-center justify-center"> + <XCircle className="h-3 w-3 text-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium text-destructive">반려됨</p> + <p className="text-sm text-muted-foreground"> + {request.rejectReason} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {request.updatedAt && format(new Date(request.updatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + + {/* RFQ 생성 */} + {request.rfqCreatedAt && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-green-500 flex items-center justify-center"> + <Package className="h-3 w-3 text-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">RFQ 생성 완료</p> + <p className="text-sm text-muted-foreground"> + RFQ 번호: <span className="font-mono font-medium">{request.rfqCode}</span> + </p> + <p className="text-xs text-muted-foreground mt-1"> + {format(new Date(request.rfqCreatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + + {/* 최종 수정 */} + {request.updatedAt && request.updatedAt !== request.createdAt && !request.rfqCreatedAt && request.status !== "반려" && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center"> + <Edit className="h-3 w-3 text-muted-foreground" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">최종 수정</p> + <p className="text-sm text-muted-foreground"> + {request.updatedByName} ({request.updatedByEmail}) + </p> + <p className="text-xs text-muted-foreground mt-1"> + {format(new Date(request.updatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + </div> + </div> + </CardContent> + </Card> + </TabsContent> + </div> + </Tabs> + + <SheetFooter className="mt-6 flex-shrink-0"> + <div className="flex w-full justify-between"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + {request.status === "작성중" && ( + <Button onClick={handleEdit}> + <Edit className="mr-2 h-4 w-4" /> + 수정하기 + </Button> + )} + </div> + </SheetFooter> + </SheetContent> + </Sheet> + ); +}
\ No newline at end of file |
