| // src/components/grant/KnowledgeBasePanel.tsx | |
| import { useState } from "react"; | |
| import type { SuccessfulProposal } from "@/types/grant-assistant"; | |
| import { BookOpen, Award, ExternalLink } from "lucide-react"; | |
| interface KnowledgeBasePanelProps { | |
| proposals: SuccessfulProposal[]; | |
| } | |
| export function KnowledgeBasePanel({ proposals }: KnowledgeBasePanelProps) { | |
| return ( | |
| <div className="p-4"> | |
| <h4 className="text-sm font-semibold flex items-center gap-2 mb-3"> | |
| <BookOpen className="w-4 h-4 text-primary" /> Similar Successful Proposals | |
| </h4> | |
| {proposals.length === 0 ? ( | |
| <p className="text-xs text-gray-400 py-8 text-center"> | |
| No similar proposals found. Upload an RFP to search the knowledge base. | |
| </p> | |
| ) : ( | |
| <div className="space-y-3"> | |
| {proposals.map((p) => ( | |
| <div key={p.id} className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-xl border"> | |
| <p className="text-sm font-semibold">{p.title}</p> | |
| <p className="text-xs text-gray-500 mt-1">{p.agency} · {p.year}</p> | |
| <p className="text-xs text-gray-600 dark:text-gray-400 mt-1.5 line-clamp-3">{p.abstract}</p> | |
| <div className="flex items-center gap-2 mt-2"> | |
| <Award className="w-3 h-3 text-amber-500" /> | |
| <span className="text-xs font-semibold">${p.amount.toLocaleString()}</span> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // src/components/grant/BudgetCalculator.tsx | |
| import type { BudgetEstimate } from "@/types/grant-assistant"; | |
| import { DollarSign } from "lucide-react"; | |
| interface BudgetCalculatorProps { | |
| estimate: BudgetEstimate; | |
| } | |
| export function BudgetCalculator({ estimate }: BudgetCalculatorProps) { | |
| const items = [ | |
| { label: "Personnel", value: estimate.personnel, color: "bg-blue-500" }, | |
| { label: "Equipment", value: estimate.equipment, color: "bg-purple-500" }, | |
| { label: "Supplies", value: estimate.supplies, color: "bg-green-500" }, | |
| { label: "Travel", value: estimate.travel, color: "bg-amber-500" }, | |
| { label: "Overhead", value: estimate.overhead, color: "bg-rose-500" }, | |
| ]; | |
| return ( | |
| <div className="p-4"> | |
| <h4 className="text-sm font-semibold flex items-center gap-2 mb-3"> | |
| <DollarSign className="w-4 h-4 text-primary" /> Budget Estimate | |
| </h4> | |
| <div className="text-center mb-4"> | |
| <p className="text-3xl font-extrabold text-primary"> | |
| ${estimate.total.toLocaleString()} | |
| </p> | |
| <p className="text-xs text-gray-500">{estimate.currency}</p> | |
| </div> | |
| <div className="space-y-3"> | |
| {items.map((item) => ( | |
| <div key={item.label} className="space-y-1"> | |
| <div className="flex justify-between text-xs"> | |
| <span>{item.label}</span> | |
| <span className="font-semibold">${item.value.toLocaleString()}</span> | |
| </div> | |
| <div className="h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full"> | |
| <div | |
| className={`h-full rounded-full ${item.color}`} | |
| style={{ width: `${estimate.total > 0 ? (item.value / estimate.total) * 100 : 0}%` }} | |
| /> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {estimate.notes && ( | |
| <p className="text-xs text-gray-500 mt-4 p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">{estimate.notes}</p> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // src/components/grant/TimelineBuilder.tsx | |
| import type { TimelineItem } from "@/types/grant-assistant"; | |
| import { Clock, Calendar } from "lucide-react"; | |
| interface TimelineBuilderProps { items: TimelineItem[]; } | |
| export function TimelineBuilder({ items }: TimelineBuilderProps) { | |
| if (items.length === 0) { | |
| return ( | |
| <div className="p-4 text-center text-gray-400"> | |
| <Clock className="w-8 h-8 mx-auto mb-2 opacity-30" /> | |
| <p className="text-xs">No timeline items yet. Generate a proposal first.</p> | |
| </div> | |
| ); | |
| } | |
| const totalMonths = Math.max(...items.map((i) => i.startMonth + i.durationMonths), 1); | |
| return ( | |
| <div className="p-4"> | |
| <h4 className="text-sm font-semibold flex items-center gap-2 mb-4"> | |
| <Calendar className="w-4 h-4 text-primary" /> Project Timeline | |
| </h4> | |
| <div className="relative"> | |
| {/* Month scale */} | |
| <div className="flex justify-between text-[9px] text-gray-400 mb-2"> | |
| {Array.from({ length: Math.ceil(totalMonths / 3) + 1 }, (_, i) => ( | |
| <span key={i}>M{i * 3}</span> | |
| ))} | |
| </div> | |
| {/* Bars */} | |
| <div className="space-y-2"> | |
| {items.map((item) => ( | |
| <div key={item.id} className="space-y-0.5"> | |
| <p className="text-[10px] font-medium text-gray-700 dark:text-gray-300 truncate">{item.task}</p> | |
| <div className="relative h-6 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden"> | |
| <div | |
| className="absolute h-full bg-primary/70 rounded-full flex items-center px-2" | |
| style={{ | |
| left: `${(item.startMonth / totalMonths) * 100}%`, | |
| width: `${(item.durationMonths / totalMonths) * 100}%`, | |
| }} | |
| > | |
| <span className="text-[9px] text-white font-medium truncate"> | |
| {item.durationMonths} mo | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // src/components/grant/ComplianceChecker.tsx | |
| import type { RfpDocument, Proposal } from "@/types/grant-assistant"; | |
| import { CheckCircle2, XCircle, AlertTriangle, CheckCheck } from "lucide-react"; | |
| import { useMemo } from "react"; | |
| interface ComplianceCheckerProps { rfp: RfpDocument; proposal: Proposal; } | |
| export function ComplianceChecker({ rfp, proposal }: ComplianceCheckerProps) { | |
| const checks = useMemo(() => { | |
| const results: { label: string; passed: boolean; note?: string }[] = []; | |
| // Check all required sections are present | |
| rfp.sections.filter((s) => s.required).forEach((section) => { | |
| const found = proposal.sections.find((ps) => ps.name === section.name); | |
| results.push({ | |
| label: `Section "${section.name}" present`, | |
| passed: !!found && found.wordCount > 0, | |
| note: found ? `${found.wordCount} words` : "Missing", | |
| }); | |
| }); | |
| // Page limit checks (assuming ~500 words/page) | |
| rfp.sections.forEach((section) => { | |
| if (section.pageLimit) { | |
| const found = proposal.sections.find((ps) => ps.name === section.name); | |
| const pages = (found?.wordCount ?? 0) / 500; | |
| results.push({ | |
| label: `"${section.name}" under ${section.pageLimit} pages`, | |
| passed: pages <= section.pageLimit, | |
| note: `~${pages.toFixed(1)} pages`, | |
| }); | |
| } | |
| }); | |
| // Deadline not passed | |
| const deadline = new Date(rfp.deadline); | |
| results.push({ | |
| label: "Deadline not passed", | |
| passed: deadline > new Date(), | |
| note: deadline.toLocaleDateString(), | |
| }); | |
| return results; | |
| }, [rfp, proposal]); | |
| const passedCount = checks.filter((c) => c.passed).length; | |
| return ( | |
| <div className="p-4"> | |
| <h4 className="text-sm font-semibold flex items-center gap-2 mb-3"> | |
| <CheckCheck className="w-4 h-4 text-primary" /> Compliance Check | |
| <span className="text-xs font-normal text-gray-400"> | |
| ({passedCount}/{checks.length}) | |
| </span> | |
| </h4> | |
| <div className="space-y-2"> | |
| {checks.map((check, i) => ( | |
| <div | |
| key={i} | |
| className={`flex items-start gap-2 p-2.5 rounded-lg text-xs ${ | |
| check.passed | |
| ? "bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800" | |
| : "bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800" | |
| }`} | |
| > | |
| {check.passed ? ( | |
| <CheckCircle2 className="w-4 h-4 text-green-500 mt-0.5 shrink-0" /> | |
| ) : ( | |
| <XCircle className="w-4 h-4 text-red-500 mt-0.5 shrink-0" /> | |
| )} | |
| <div> | |
| <p className={check.passed ? "text-green-800 dark:text-green-300" : "text-red-800 dark:text-red-300"}> | |
| {check.label} | |
| </p> | |
| {check.note && ( | |
| <p className="text-gray-500 mt-0.5">{check.note}</p> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // src/components/grant/ExportPanel.tsx — simple export button | |
| import { Download, FileText, Loader2 } from "lucide-react"; | |
| import { toast } from "react-toastify"; | |
| import { saveAs } from "file-saver"; | |
| interface ExportPanelProps { | |
| onExport: (format: "docx" | "pdf") => Promise<Blob | null>; | |
| } | |
| export function ExportPanel({ onExport }: ExportPanelProps) { | |
| const [exporting, setExporting] = useState<string | null>(null); | |
| const handleExport = async (format: "docx" | "pdf") => { | |
| setExporting(format); | |
| const blob = await onExport(format); | |
| if (blob) { | |
| saveAs(blob, `grant-proposal.${format === "docx" ? "docx" : "pdf"}`); | |
| toast.success(`Exported as ${format.toUpperCase()}`); | |
| } | |
| setExporting(null); | |
| }; | |
| return ( | |
| <div className="p-4 flex gap-2"> | |
| <button onClick={() => handleExport("docx")} disabled={!!exporting} className="btn btn-primary btn-sm flex-1 gap-1"> | |
| {exporting === "docx" ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />} | |
| DOCX | |
| </button> | |
| <button onClick={() => handleExport("pdf")} disabled={!!exporting} className="btn btn-secondary btn-sm flex-1 gap-1"> | |
| {exporting === "pdf" ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />} | |
| </button> | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 9.91 kB
- Xet hash:
- c51fc27ae3ccaa8bae78496ab15fbd17b3425106ade07aee4339b0143ac57c39
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.