- Full grant strategy framework for renewable energy & green hydrogen - AI-powered grant studio, partner outreach, financial modeling - Umami analytics with data-performance tracking - Live Degelas metrics connected to solar.degelas.be - Trilingual (EN/FR/AR) with i18n support - Dockerized with Nginx frontend + Express API proxy
247 lines
16 KiB
TypeScript
247 lines
16 KiB
TypeScript
import React from "react";
|
|
import type { VaultDoc } from "../lib/vault";
|
|
import type {
|
|
GenerateStudioResponse, GenerateOutreachResponse, GenerateFinancialResponse,
|
|
WorkPackage, ConsortiumPartner, BudgetLine, ImpactKpi, RiskLine,
|
|
TimelineMilestone, ComplianceItem,
|
|
} from "../types/ai";
|
|
|
|
function SectionTitle({ children }: { children: string }) {
|
|
return <h4 className="mb-2 mt-5 text-[10px] font-bold uppercase tracking-widest text-emerald-600 border-b border-emerald-100 pb-1">{children}</h4>;
|
|
}
|
|
|
|
function Badge({ label, color }: { label: string; color: string }) {
|
|
return <span className={`inline-block rounded-full px-2.5 py-0.5 text-[10px] font-bold ${color}`}>{label}</span>;
|
|
}
|
|
|
|
function Field({ label, value }: { label: string; value: string }) {
|
|
if (!value) return null;
|
|
return <div className="mb-1.5"><span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">{label}</span><p className="text-sm leading-relaxed text-slate-700">{value}</p></div>;
|
|
}
|
|
|
|
function ListField({ label, items }: { label: string; items: string[] }) {
|
|
if (!items?.length) return null;
|
|
return <div className="mb-1.5"><span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">{label}</span><ul className="mt-1 space-y-1">{items.map((s, i) => <li key={i} className="flex items-start gap-2 text-sm text-slate-700"><span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-400" />{s}</li>)}</ul></div>;
|
|
}
|
|
|
|
function Table({ headers, rows }: { headers: string[]; rows: React.ReactNode[][] }) {
|
|
if (!rows?.length) return null;
|
|
return (
|
|
<div className="overflow-x-auto rounded-xl border border-slate-100">
|
|
<table className="w-full text-left text-xs">
|
|
<thead><tr className="bg-slate-50">{headers.map((h, i) => <th key={i} className="px-3 py-2 font-semibold text-slate-500">{h}</th>)}</tr></thead>
|
|
<tbody>{rows.filter(Boolean).map((row, ri) => <tr key={ri} className="border-t border-slate-50 even:bg-slate-50/50">{row.map((cell, ci) => <td key={ci} className="px-3 py-2 text-slate-700">{cell ?? "—"}</td>)}</tr>)}</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StudioPreview({ d }: { d: GenerateStudioResponse }) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="mb-4 rounded-2xl bg-gradient-to-r from-emerald-500 to-teal-600 p-5 text-white">
|
|
<h3 className="text-xl font-bold">{d.programName}</h3>
|
|
</div>
|
|
<Field label="Executive Summary" value={d.executiveSummary} />
|
|
<Field label="Problem Statement" value={d.problemStatement} />
|
|
<Field label="Solution Description" value={d.solutionDescription} />
|
|
<Field label="Innovation Statement" value={d.innovationStatement} />
|
|
<Field label="Technical Approach" value={d.technicalApproach} />
|
|
{d.workPackages?.length > 0 && <><SectionTitle>Work Packages</SectionTitle><Table headers={["Name", "Focus", "Months", "Deliverable"]} rows={d.workPackages.map((w: WorkPackage) => [w.name, w.focus, w.months, w.deliverable])} /></>}
|
|
{d.consortium?.length > 0 && <><SectionTitle>Consortium</SectionTitle><Table headers={["Partner", "Role", "Country"]} rows={d.consortium.map((c: ConsortiumPartner) => [c.partner, c.role, c.country])} /></>}
|
|
{d.budgetBreakdown?.length > 0 && <><SectionTitle>Budget Breakdown</SectionTitle><Table headers={["Category", "Share", "Justification"]} rows={d.budgetBreakdown.map((b: BudgetLine) => [b.category, b.share, b.justification])} /></>}
|
|
{d.impactKpis?.length > 0 && <><SectionTitle>Impact KPIs</SectionTitle><Table headers={["Metric", "Target", "Timeframe"]} rows={d.impactKpis.map((k: ImpactKpi) => [k.metric, k.target, k.timeframe])} /></>}
|
|
{d.risks?.length > 0 && <><SectionTitle>Risks & Mitigation</SectionTitle><Table headers={["Risk", "Mitigation"]} rows={d.risks.map((r: RiskLine) => [r.risk, r.mitigation])} /></>}
|
|
{d.timeline?.length > 0 && <><SectionTitle>Timeline</SectionTitle><Table headers={["Milestone", "Month"]} rows={d.timeline.map((t: TimelineMilestone) => [t.milestone, t.month])} /></>}
|
|
{d.complianceChecklist?.length > 0 && <><SectionTitle>Compliance Checklist</SectionTitle><Table headers={["Requirement", "Status"]} rows={d.complianceChecklist.map((c: ComplianceItem) => [c.requirement, <Badge key={c.requirement} label={c.met} color={c.met === "yes" ? "bg-emerald-100 text-emerald-700" : c.met === "partial" ? "bg-amber-100 text-amber-700" : "bg-rose-100 text-rose-700"} />])} /></>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OutreachPreview({ d }: { d: GenerateOutreachResponse }) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="mb-4 rounded-2xl bg-gradient-to-r from-blue-500 to-cyan-600 p-5 text-white">
|
|
<h3 className="text-xl font-bold">{d.subject}</h3>
|
|
</div>
|
|
<SectionTitle>Email</SectionTitle>
|
|
<div className="rounded-xl border border-slate-100 bg-white p-4 text-sm leading-relaxed text-slate-700 whitespace-pre-wrap">{d.emailBody}</div>
|
|
<SectionTitle>LinkedIn Message</SectionTitle>
|
|
<div className="rounded-xl border border-slate-100 bg-white p-4 text-sm leading-relaxed text-slate-700 whitespace-pre-wrap">{d.linkedinMessage}</div>
|
|
<SectionTitle>Follow-up Email</SectionTitle>
|
|
<div className="rounded-xl border border-slate-100 bg-white p-4 text-sm leading-relaxed text-slate-700 whitespace-pre-wrap">{d.followUpEmail}</div>
|
|
<ListField label="Meeting Agenda" items={d.meetingAgenda} />
|
|
<ListField label="Talking Points" items={d.talkingPoints} />
|
|
{d.mouOutline?.length > 0 && <><SectionTitle>MOU Outline</SectionTitle><Table headers={["Section", "Content"]} rows={d.mouOutline.map((m) => [m.section, m.content])} /></>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FinancialPreview({ d }: { d: GenerateFinancialResponse }) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="mb-4 rounded-2xl bg-gradient-to-r from-violet-500 to-purple-600 p-5 text-white">
|
|
<h3 className="text-xl font-bold">Financial Model</h3>
|
|
</div>
|
|
<Field label="Summary" value={d.summary} />
|
|
{d.assumptions?.length > 0 && <><SectionTitle>Assumptions</SectionTitle><Table headers={["Label", "Value"]} rows={d.assumptions.map((a) => [a.label, a.value])} /></>}
|
|
{d.revenueProjection?.length > 0 && <><SectionTitle>Revenue Projection</SectionTitle><Table headers={["Year", "Customers", "ARR", "Revenue"]} rows={d.revenueProjection.map((r) => [r.year, r.customers, r.arr, r.revenue])} /></>}
|
|
{d.costStructure?.length > 0 && <><SectionTitle>Cost Structure</SectionTitle><Table headers={["Category", "Y1", "Y2", "Y3"]} rows={d.costStructure.map((c) => [c.category, c.y1, c.y2, c.y3])} /></>}
|
|
{d.fundingStack?.length > 0 && <><SectionTitle>Funding Stack</SectionTitle><Table headers={["Source", "Amount", "Stage"]} rows={d.fundingStack.map((f) => [f.source, f.amount, f.stage])} /></>}
|
|
{d.keyMetrics?.length > 0 && <><SectionTitle>Key Metrics</SectionTitle><Table headers={["Metric", "Value"]} rows={d.keyMetrics.map((m) => [m.metric, m.value])} /></>}
|
|
{d.milestones?.length > 0 && <><SectionTitle>Milestones</SectionTitle><Table headers={["Month", "Milestone", "ARR Target"]} rows={d.milestones.map((m) => [m.month, m.milestone, m.arrTarget])} /></>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DocPreview({ doc }: { doc: VaultDoc }) {
|
|
if (doc.type === "grant_studio") return <StudioPreview d={doc.data as GenerateStudioResponse} />;
|
|
if (doc.type === "partner_outreach") return <OutreachPreview d={doc.data as GenerateOutreachResponse} />;
|
|
if (doc.type === "financial_model") return <FinancialPreview d={doc.data as GenerateFinancialResponse} />;
|
|
return <p className="text-sm text-slate-500">Unknown document type</p>;
|
|
}
|
|
|
|
const PRINT_CSS = `
|
|
@page { margin: 20mm; size: A4; }
|
|
@media print {
|
|
* { -webkit-print-color-adjust: exact; }
|
|
}
|
|
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1e293b; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }
|
|
h1 { font-size: 24px; font-weight: 700; margin-bottom: 4px; color: #065f46; }
|
|
h2 { font-size: 16px; font-weight: 700; margin-top: 28px; margin-bottom: 8px; color: #065f46; letter-spacing: 0.05em; text-transform: uppercase; border-bottom: 1px solid #d1fae5; padding-bottom: 4px; }
|
|
h3 { font-size: 14px; font-weight: 600; margin-top: 20px; margin-bottom: 4px; color: #334155; }
|
|
p { font-size: 13px; margin-bottom: 8px; color: #334155; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 12px; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
|
|
th { background: #f8fafc; text-align: left; padding: 6px 10px; font-weight: 600; color: #64748b; text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; border-bottom: 1px solid #e2e8f0; }
|
|
td { padding: 6px 10px; color: #334155; border-top: 1px solid #f1f5f9; }
|
|
tr:nth-child(even) td { background: #f8fafc; }
|
|
ul { list-style: none; padding: 0; margin: 4px 0; }
|
|
li { font-size: 13px; padding: 2px 0; color: #334155; }
|
|
li::before { content: "\\2022"; color: #10b981; font-weight: bold; display: inline-block; width: 16px; }
|
|
h1, h2, h3, p, table, ul, ol { page-break-inside: avoid; }
|
|
h1, h2, h3, table { page-break-after: avoid; }
|
|
h2, h3, p, td, th { page-break-before: avoid; }
|
|
thead { display: table-header-group; }
|
|
tfoot { display: table-footer-group; }
|
|
`;
|
|
|
|
function htmlEscape(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
export function printDocAsPdf(doc: VaultDoc): void {
|
|
const d = doc.data as any;
|
|
let bodyHtml = "";
|
|
|
|
if (doc.type === "grant_studio") {
|
|
bodyHtml = `<h1>${htmlEscape(d.programName || "")}</h1>`;
|
|
if (d.executiveSummary) bodyHtml += `<h2>Executive Summary</h2><p>${htmlEscape(d.executiveSummary)}</p>`;
|
|
if (d.problemStatement) bodyHtml += `<h2>Problem Statement</h2><p>${htmlEscape(d.problemStatement)}</p>`;
|
|
if (d.solutionDescription) bodyHtml += `<h2>Solution Description</h2><p>${htmlEscape(d.solutionDescription)}</p>`;
|
|
if (d.innovationStatement) bodyHtml += `<h2>Innovation Statement</h2><p>${htmlEscape(d.innovationStatement)}</p>`;
|
|
if (d.technicalApproach) bodyHtml += `<h2>Technical Approach</h2><p>${htmlEscape(d.technicalApproach)}</p>`;
|
|
if (d.workPackages?.length) {
|
|
bodyHtml += `<h2>Work Packages</h2><table><thead><tr><th>Name</th><th>Focus</th><th>Months</th><th>Deliverable</th></tr></thead><tbody>`;
|
|
d.workPackages.forEach((w: any) => { bodyHtml += `<tr><td>${htmlEscape(w.name)}</td><td>${htmlEscape(w.focus)}</td><td>${htmlEscape(w.months)}</td><td>${htmlEscape(w.deliverable)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.consortium?.length) {
|
|
bodyHtml += `<h2>Consortium</h2><table><thead><tr><th>Partner</th><th>Role</th><th>Country</th></tr></thead><tbody>`;
|
|
d.consortium.forEach((c: any) => { bodyHtml += `<tr><td>${htmlEscape(c.partner)}</td><td>${htmlEscape(c.role)}</td><td>${htmlEscape(c.country)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.budgetBreakdown?.length) {
|
|
bodyHtml += `<h2>Budget Breakdown</h2><table><thead><tr><th>Category</th><th>Share</th><th>Justification</th></tr></thead><tbody>`;
|
|
d.budgetBreakdown.forEach((b: any) => { bodyHtml += `<tr><td>${htmlEscape(b.category)}</td><td>${htmlEscape(b.share)}</td><td>${htmlEscape(b.justification)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.impactKpis?.length) {
|
|
bodyHtml += `<h2>Impact KPIs</h2><table><thead><tr><th>Metric</th><th>Target</th><th>Timeframe</th></tr></thead><tbody>`;
|
|
d.impactKpis.forEach((k: any) => { bodyHtml += `<tr><td>${htmlEscape(k.metric)}</td><td>${htmlEscape(k.target)}</td><td>${htmlEscape(k.timeframe)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.risks?.length) {
|
|
bodyHtml += `<h2>Risks & Mitigation</h2><table><thead><tr><th>Risk</th><th>Mitigation</th></tr></thead><tbody>`;
|
|
d.risks.forEach((r: any) => { bodyHtml += `<tr><td>${htmlEscape(r.risk)}</td><td>${htmlEscape(r.mitigation)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.timeline?.length) {
|
|
bodyHtml += `<h2>Timeline</h2><table><thead><tr><th>Milestone</th><th>Month</th></tr></thead><tbody>`;
|
|
d.timeline.forEach((t: any) => { bodyHtml += `<tr><td>${htmlEscape(t.milestone)}</td><td>${htmlEscape(t.month)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.complianceChecklist?.length) {
|
|
bodyHtml += `<h2>Compliance Checklist</h2><table><thead><tr><th>Requirement</th><th>Status</th></tr></thead><tbody>`;
|
|
d.complianceChecklist.forEach((c: any) => { bodyHtml += `<tr><td>${htmlEscape(c.requirement)}</td><td>${htmlEscape(c.met)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
} else if (doc.type === "partner_outreach") {
|
|
bodyHtml = `<h1>${htmlEscape(d.subject || "")}</h1>`;
|
|
if (d.emailBody) bodyHtml += `<h2>Email</h2><p>${htmlEscape(d.emailBody).replace(/\n/g, "<br>")}</p>`;
|
|
if (d.linkedinMessage) bodyHtml += `<h2>LinkedIn Message</h2><p>${htmlEscape(d.linkedinMessage)}</p>`;
|
|
if (d.followUpEmail) bodyHtml += `<h2>Follow-up Email</h2><p>${htmlEscape(d.followUpEmail).replace(/\n/g, "<br>")}</p>`;
|
|
if (d.meetingAgenda?.length) {
|
|
bodyHtml += `<h2>Meeting Agenda</h2><ul>${d.meetingAgenda.map((a: string) => `<li>${htmlEscape(a)}</li>`).join("")}</ul>`;
|
|
}
|
|
if (d.talkingPoints?.length) {
|
|
bodyHtml += `<h2>Talking Points</h2><ul>${d.talkingPoints.map((t: string) => `<li>${htmlEscape(t)}</li>`).join("")}</ul>`;
|
|
}
|
|
if (d.mouOutline?.length) {
|
|
bodyHtml += `<h2>MOU Outline</h2><table><thead><tr><th>Section</th><th>Content</th></tr></thead><tbody>`;
|
|
d.mouOutline.forEach((m: any) => { bodyHtml += `<tr><td>${htmlEscape(m.section)}</td><td>${htmlEscape(m.content)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
} else if (doc.type === "financial_model") {
|
|
bodyHtml = `<h1>Financial Model</h1>`;
|
|
if (d.summary) bodyHtml += `<h2>Summary</h2><p>${htmlEscape(d.summary)}</p>`;
|
|
if (d.assumptions?.length) {
|
|
bodyHtml += `<h2>Assumptions</h2><table><thead><tr><th>Label</th><th>Value</th></tr></thead><tbody>`;
|
|
d.assumptions.forEach((a: any) => { bodyHtml += `<tr><td>${htmlEscape(a.label)}</td><td>${htmlEscape(a.value)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.revenueProjection?.length) {
|
|
bodyHtml += `<h2>Revenue Projection</h2><table><thead><tr><th>Year</th><th>Customers</th><th>ARR</th><th>Revenue</th></tr></thead><tbody>`;
|
|
d.revenueProjection.forEach((r: any) => { bodyHtml += `<tr><td>${htmlEscape(r.year)}</td><td>${htmlEscape(r.customers)}</td><td>${htmlEscape(r.arr)}</td><td>${htmlEscape(r.revenue)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.costStructure?.length) {
|
|
bodyHtml += `<h2>Cost Structure</h2><table><thead><tr><th>Category</th><th>Y1</th><th>Y2</th><th>Y3</th></tr></thead><tbody>`;
|
|
d.costStructure.forEach((c: any) => { bodyHtml += `<tr><td>${htmlEscape(c.category)}</td><td>${htmlEscape(c.y1)}</td><td>${htmlEscape(c.y2)}</td><td>${htmlEscape(c.y3)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.fundingStack?.length) {
|
|
bodyHtml += `<h2>Funding Stack</h2><table><thead><tr><th>Source</th><th>Amount</th><th>Stage</th></tr></thead><tbody>`;
|
|
d.fundingStack.forEach((f: any) => { bodyHtml += `<tr><td>${htmlEscape(f.source)}</td><td>${htmlEscape(f.amount)}</td><td>${htmlEscape(f.stage)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.keyMetrics?.length) {
|
|
bodyHtml += `<h2>Key Metrics</h2><table><thead><tr><th>Metric</th><th>Value</th></tr></thead><tbody>`;
|
|
d.keyMetrics.forEach((m: any) => { bodyHtml += `<tr><td>${htmlEscape(m.metric)}</td><td>${htmlEscape(m.value)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
if (d.milestones?.length) {
|
|
bodyHtml += `<h2>Milestones</h2><table><thead><tr><th>Month</th><th>Milestone</th><th>ARR Target</th></tr></thead><tbody>`;
|
|
d.milestones.forEach((m: any) => { bodyHtml += `<tr><td>${htmlEscape(m.month)}</td><td>${htmlEscape(m.milestone)}</td><td>${htmlEscape(m.arrTarget)}</td></tr>`; });
|
|
bodyHtml += `</tbody></table>`;
|
|
}
|
|
}
|
|
|
|
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${htmlEscape(doc.title)}</title><style>${PRINT_CSS}</style></head><body>${bodyHtml}</body></html>`;
|
|
|
|
const iframe = document.createElement('iframe');
|
|
iframe.style.display = 'none';
|
|
document.body.appendChild(iframe);
|
|
|
|
iframe.contentWindow?.document.open();
|
|
iframe.contentWindow?.document.write(html);
|
|
iframe.contentWindow?.document.close();
|
|
|
|
iframe.onload = () => {
|
|
iframe.contentWindow?.focus();
|
|
iframe.contentWindow?.print();
|
|
setTimeout(() => {
|
|
document.body.removeChild(iframe);
|
|
}, 1000);
|
|
};
|
|
}
|