csv-export

This skill implements data export functionality for RFPs, evaluations, and pursuit pipelines in multiple formats.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "csv-export" with this command: npx skills add atemndobs/nebula-rfp/atemndobs-nebula-rfp-csv-export

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 &#x26;&#x26; !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 &#x26;&#x26; e.evaluatedAt &#x3C; args.startDate) return false;
    if (args.endDate &#x26;&#x26; 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&#x3C;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 */}
  &#x3C;div>
    &#x3C;label className="block text-sm text-muted-foreground mb-2">
      Export Type
    &#x3C;/label>
    &#x3C;select
      value={exportType}
      onChange={(e) => setExportType(e.target.value as any)}
      className="w-full p-2 bg-background border rounded"
    >
      &#x3C;option value="rfps">RFP List&#x3C;/option>
      &#x3C;option value="evaluations">Evaluation Details&#x3C;/option>
      &#x3C;option value="pursuits">Pursuit Pipeline&#x3C;/option>
    &#x3C;/select>
  &#x3C;/div>

  {/* Filters */}
  {exportType === "rfps" &#x26;&#x26; (
    &#x3C;div className="space-y-2">
      &#x3C;select
        value={filters.source}
        onChange={(e) => setFilters({ ...filters, source: e.target.value })}
        className="w-full p-2 bg-background border rounded"
      >
        &#x3C;option value="">All Sources&#x3C;/option>
        &#x3C;option value="sam.gov">SAM.gov&#x3C;/option>
        &#x3C;option value="emma">Maryland eMMA&#x3C;/option>
        &#x3C;option value="rfpmart">RFPMart&#x3C;/option>
      &#x3C;/select>

      &#x3C;label className="flex items-center gap-2">
        &#x3C;input
          type="checkbox"
          checked={filters.showOnlyFit}
          onChange={(e) =>
            setFilters({ ...filters, showOnlyFit: e.target.checked })
          }
        />
        &#x3C;span className="text-sm">Show only fit opportunities&#x3C;/span>
      &#x3C;/label>
    &#x3C;/div>
  )}

  &#x3C;ExportButton exportType={exportType} filters={filters} />

  &#x3C;p className="text-xs text-muted-foreground">
    Exports include all visible columns. Dates are in ISO 8601 format.
  &#x3C;/p>
&#x3C;/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> ); }

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

rfp-evaluate

No summary provided by upstream source.

Repository SourceNeeds Review
General

proposal-builder

No summary provided by upstream source.

Repository SourceNeeds Review
General

pursuit-brief

No summary provided by upstream source.

Repository SourceNeeds Review