CSV Export Skill
Overview
This skill implements data export functionality for RFPs, evaluations, and pursuit pipelines in multiple formats.
Export Types
RFP List Export
interface RfpExportRow { id: string; externalId: string; source: string; title: string; description: string; location: string; category: string; postedDate: string; expiryDate: string; daysRemaining: number; url: string; // Evaluation score: number | null; isFit: boolean | null; eligibilityStatus: string | null; // Pursuit pursuitStatus: string | null; decision: string | null; }
Evaluation Export
interface EvaluationExportRow { rfpId: string; rfpTitle: string; evaluatedAt: string; overallScore: number; isFit: boolean; eligibilityStatus: string; // Per-criterion (dynamic columns) [criterionName_score: string]: number; [criterionName_met: string]: boolean; [criterionName_keywords: string]: string; reasoning: string; }
Pursuit Pipeline Export
interface PursuitExportRow { rfpId: string; rfpTitle: string; source: string; deadline: string; daysRemaining: number; status: string; decision: string; decisionBy: string; decisionDate: string; score: number; teamMembers: string; notes: string; }
CSV Generation
// services/csvExport.ts
type ExportableValue = string | number | boolean | null | undefined; type ExportRow = Record<string, ExportableValue>;
export function generateCsv( data: ExportRow[], options?: { headers?: string[]; delimiter?: string; includeHeaders?: boolean; } ): string { if (data.length === 0) return "";
const delimiter = options?.delimiter ?? ","; const includeHeaders = options?.includeHeaders ?? true; const headers = options?.headers ?? Object.keys(data[0]);
const rows: string[] = [];
// Header row if (includeHeaders) { rows.push(headers.map(escapeForCsv).join(delimiter)); }
// Data rows for (const row of data) { const values = headers.map((header) => escapeForCsv(formatValue(row[header])) ); rows.push(values.join(delimiter)); }
return rows.join("\n"); }
function escapeForCsv(value: string): string {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return "${value.replace(/"/g, '""')}";
}
return value;
}
function formatValue(value: ExportableValue): string { if (value === null || value === undefined) return ""; if (typeof value === "boolean") return value ? "Yes" : "No"; if (typeof value === "number") return value.toString(); return String(value); }
export function downloadCsv(csv: string, filename: string): void { const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }
Convex Export Queries
// convex/exports.ts import { query } from "./_generated/server"; import { v } from "convex/values";
export const exportRfps = query({ args: { source: v.optional(v.string()), showOnlyFit: v.optional(v.boolean()), limit: v.optional(v.number()), }, handler: async (ctx, args) => { let q = ctx.db.query("rfps");
if (args.source) {
q = q.withIndex("by_source", (q) => q.eq("source", args.source));
}
const rfps = await q.take(args.limit ?? 500);
// Join with evaluations and pursuits
const exportData = await Promise.all(
rfps.map(async (rfp) => {
const evaluation = await ctx.db
.query("evaluations")
.withIndex("by_rfp", (q) => q.eq("rfpId", rfp._id))
.order("desc")
.first();
const pursuit = await ctx.db
.query("pursuits")
.withIndex("by_rfp", (q) => q.eq("rfpId", rfp._id))
.first();
// Filter by fit if requested
if (args.showOnlyFit && !evaluation?.isFit) {
return null;
}
return {
id: rfp._id,
externalId: rfp.externalId,
source: rfp.source,
title: rfp.title,
description: truncate(rfp.description, 500),
location: rfp.location,
category: rfp.category,
postedDate: formatDate(rfp.postedDate),
expiryDate: formatDate(rfp.expiryDate),
daysRemaining: calculateDaysRemaining(rfp.expiryDate),
url: rfp.url,
score: evaluation?.score ?? null,
isFit: evaluation?.isFit ?? null,
eligibilityStatus: evaluation?.eligibility?.status ?? null,
pursuitStatus: pursuit?.status ?? null,
decision: pursuit?.decision ?? null,
};
})
);
return exportData.filter(Boolean);
}, });
export const exportEvaluations = query({ args: { startDate: v.optional(v.number()), endDate: v.optional(v.number()), }, handler: async (ctx, args) => { let evaluations = await ctx.db.query("evaluations").collect();
// Date filter
if (args.startDate || args.endDate) {
evaluations = evaluations.filter((e) => {
if (args.startDate && e.evaluatedAt < args.startDate) return false;
if (args.endDate && e.evaluatedAt > args.endDate) return false;
return true;
});
}
return Promise.all(
evaluations.map(async (eval_) => {
const rfp = await ctx.db.get(eval_.rfpId);
// Flatten criteria results
const criteriaData: Record<string, any> = {};
for (const result of eval_.criteriaResults) {
const key = result.criterionName.toLowerCase().replace(/\s+/g, "_");
criteriaData[`${key}_score`] = result.score;
criteriaData[`${key}_met`] = result.met;
criteriaData[`${key}_keywords`] = result.matchedKeywords.join("; ");
}
return {
rfpId: eval_.rfpId,
rfpTitle: rfp?.title ?? "Unknown",
evaluatedAt: formatDateTime(eval_.evaluatedAt),
overallScore: eval_.score,
isFit: eval_.isFit,
eligibilityStatus: eval_.eligibility.status,
...criteriaData,
reasoning: eval_.reasoning ?? "",
};
})
);
}, });
export const exportPursuits = query({ args: { status: v.optional(v.string()), }, handler: async (ctx, args) => { let q = ctx.db.query("pursuits");
if (args.status) {
q = q.filter((q) => q.eq(q.field("status"), args.status));
}
const pursuits = await q.collect();
return Promise.all(
pursuits.map(async (pursuit) => {
const rfp = await ctx.db.get(pursuit.rfpId);
const evaluation = await ctx.db
.query("evaluations")
.withIndex("by_rfp", (q) => q.eq("rfpId", pursuit.rfpId))
.first();
return {
rfpId: pursuit.rfpId,
rfpTitle: rfp?.title ?? "Unknown",
source: rfp?.source ?? "Unknown",
deadline: rfp ? formatDate(rfp.expiryDate) : "",
daysRemaining: rfp ? calculateDaysRemaining(rfp.expiryDate) : null,
status: pursuit.status,
decision: pursuit.decision ?? "",
decisionBy: pursuit.decisionBy ?? "",
decisionDate: pursuit.decisionAt ? formatDate(pursuit.decisionAt) : "",
score: evaluation?.score ?? null,
teamMembers: pursuit.teamMembers?.join("; ") ?? "",
notes: pursuit.notes ?? "",
};
})
);
}, });
// Helpers function formatDate(timestamp: number): string { return new Date(timestamp).toISOString().split("T")[0]; }
function formatDateTime(timestamp: number): string { return new Date(timestamp).toISOString(); }
function calculateDaysRemaining(expiryDate: number): number { return Math.ceil((expiryDate - Date.now()) / (1000 * 60 * 60 * 24)); }
function truncate(text: string, maxLength: number): string { if (text.length <= maxLength) return text; return text.substring(0, maxLength - 3) + "..."; }
React Components
// components/ExportButton.tsx import { useQuery } from "convex/react"; import { api } from "../convex/_generated/api"; import { generateCsv, downloadCsv } from "../services/csvExport";
interface ExportButtonProps { exportType: "rfps" | "evaluations" | "pursuits"; filters?: Record<string, any>; filename?: string; }
export function ExportButton({ exportType, filters, filename }: ExportButtonProps) { const [isExporting, setIsExporting] = useState(false);
// Get query based on type const queryFn = exportType === "rfps" ? api.exports.exportRfps : exportType === "evaluations" ? api.exports.exportEvaluations : api.exports.exportPursuits;
const data = useQuery(queryFn, filters ?? {});
const handleExport = () => { if (!data) return;
setIsExporting(true);
try {
const csv = generateCsv(data);
const defaultFilename = `${exportType}-${formatDateForFilename(new Date())}.csv`;
downloadCsv(csv, filename ?? defaultFilename);
} finally {
setIsExporting(false);
}
};
return ( <button onClick={handleExport} disabled={isExporting || !data} className="flex items-center gap-2 px-4 py-2 bg-secondary text-secondary-foreground rounded hover:bg-secondary/80 disabled:opacity-50" > <DownloadIcon className="w-4 h-4" /> {isExporting ? "Exporting..." : "Export CSV"} </button> ); }
Export Panel
// components/ExportPanel.tsx export function ExportPanel() { const [exportType, setExportType] = useState<"rfps" | "evaluations" | "pursuits">("rfps"); const [filters, setFilters] = useState({ source: "", showOnlyFit: false, status: "", });
return ( <div className="p-6 bg-card rounded-lg space-y-4"> <h2 className="text-xl font-semibold">Export Data</h2>
{/* Export Type */}
<div>
<label className="block text-sm text-muted-foreground mb-2">
Export Type
</label>
<select
value={exportType}
onChange={(e) => setExportType(e.target.value as any)}
className="w-full p-2 bg-background border rounded"
>
<option value="rfps">RFP List</option>
<option value="evaluations">Evaluation Details</option>
<option value="pursuits">Pursuit Pipeline</option>
</select>
</div>
{/* Filters */}
{exportType === "rfps" && (
<div className="space-y-2">
<select
value={filters.source}
onChange={(e) => setFilters({ ...filters, source: e.target.value })}
className="w-full p-2 bg-background border rounded"
>
<option value="">All Sources</option>
<option value="sam.gov">SAM.gov</option>
<option value="emma">Maryland eMMA</option>
<option value="rfpmart">RFPMart</option>
</select>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={filters.showOnlyFit}
onChange={(e) =>
setFilters({ ...filters, showOnlyFit: e.target.checked })
}
/>
<span className="text-sm">Show only fit opportunities</span>
</label>
</div>
)}
<ExportButton exportType={exportType} filters={filters} />
<p className="text-xs text-muted-foreground">
Exports include all visible columns. Dates are in ISO 8601 format.
</p>
</div>
); }
JSON Export Alternative
export function downloadJson(data: any, filename: string): void { const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); }
Selected Items Export
// components/SelectionExport.tsx
export function SelectionExport({
selectedIds,
}: {
selectedIds: Id<"rfps">[];
}) {
const exportSelected = async () => {
const data = await convex.query(api.exports.exportRfps, {
rfpIds: selectedIds,
});
const csv = generateCsv(data);
downloadCsv(csv, selected-rfps-${formatDate(new Date())}.csv);
};
return ( <button onClick={exportSelected} disabled={selectedIds.length === 0} className="px-4 py-2 bg-primary text-primary-foreground rounded disabled:opacity-50" > Export {selectedIds.length} Selected </button> ); }