MSO Cloud · Documentation

Export pipeline — CSV / Excel / PDF

Source: docs/product/exports.md Updated 2026-09-21
On this page

Every report, dashboard, and operations directory ships a single Vietnamese-first Xuất menu that exposes CSV, Excel, and PDF. This document is the architectural overview and the place to start when adding export to a new surface.

What ships out of the box#

Surface CSV Excel PDF
/dashboard (home)
/briefing
/explore
/dashboards/[slug]
/reports/settlement ✓ (multi-sheet)
/reports/live-commerce ✓ (multi-sheet)
/reports/commerce-performance ✓ (multi-sheet)
/reports/cohort-rfm-kol ✓ (multi-sheet)
/workspaces/{owner,finance,creator,growth,ops}
/orders
/products
/inventory
/creators
/action-queue ✓ (3-sheet workbook)

PDF is omitted on operations / action-queue surfaces because the data is fundamentally tabular — CSV or Excel is the right artifact. PDF is included on every chart-driven surface.

Architecture#

@yng/exports               packages/exports/
  ├─ csv.ts                rowsToCsv + exportCsv (Excel-friendly UTF-8 BOM)
  ├─ xlsx.ts               exportXlsx — dynamic-imports `xlsx` (SheetJS)
  ├─ pdf.ts                exportPdf — dynamic-imports html2canvas + jspdf
  └─ filename.ts           safeFilename + triggerDownload helpers

@yng/ui                    packages/ui/src/components/export-menu.tsx
  └─ <ExportMenu>          single dropdown trigger; coloured chips per format

apps/web                   apps/web/src/lib/use-export-menu.ts
  ├─ useExportMenu()       hook that assembles items + handlers from a sheet spec
  ├─ sheet<T>()            type-preserving helper for extraSheets
  └─ <PdfExportPage>       wraps a server page in a client island with PDF trigger

Why dynamic imports

jspdf, html2canvas, and xlsx are heavy (combined ~470 KB gzipped). They're only invoked from a click handler. The exports package imports them with await import(...), so they live in a single isolated client chunk (.next/static/chunks/*.js) that only downloads when the user actually picks PDF or Excel — not on initial page load.

Why the xlsx package is sourced from the SheetJS CDN tarball

xlsx@<0.20.2 on npm has a high-severity CVE (CVE-2023-30533). The maintainer publishes patched versions only at https://cdn.sheetjs.com/xlsx-<ver>/xlsx-<ver>.tgz. Our packages/exports/package.json references that tarball directly:

"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"

pnpm audit --audit-level=high passes; bump the URL when SheetJS releases a new patched version.

Building an export for a new page#

1. CSV/Excel from typed tRPC rows

import { ExportMenu } from '@yng/ui';
import { sheet, useExportMenu } from '@/lib/use-export-menu';

function MyDirectory({ rows }: { rows: MyRow[] }) {
  const { items } = useExportMenu<MyRow>({
    locale: 'vi',
    filename: 'my-page',
    excludePdf: true,                       // operations pages — tabular only
    table: {
      sheet: {
        name: 'Sheet 1',
        columns: [
          { header: 'ID',    value: (r) => r.id },
          { header: 'Tên',   value: (r) => r.name },
          { header: 'VND',   value: (r) => r.amountVnd },  // VND stays integer
          { header: 'Ngày',  value: (r) => r.placedAt },   // Date → ISO string
        ],
        rows,
      },
      extraSheets: [
        // multi-sheet xlsx — each extra sheet uses sheet<T>() to keep types
        sheet<SummaryRow>({ name: 'Summary', columns: [...], rows: summaries }),
      ],
    },
  });

  return <ExportMenu label="Xuất" items={items} />;
}

The shared <DirectoryTable> accepts an optional toolbar slot which is the canonical place to render the menu in directory pages.

2. PDF capture of a server page

For server-rendered surfaces (dashboard home, workspaces, briefing), wrap the page body in <PdfExportPage>:

// app/(dashboard)/dashboard/page.tsx — server component
import { PdfExportPage } from '@/lib/page-export-wrapper';

export default async function DashboardHomePage() {
  return (
    <PdfExportPage filename="dashboard-home" pdfTitle="Trang chủ">
      <PageHeader … />
      <FilterBar />
      … all the content …
    </PdfExportPage>
  );
}

The wrapper is a thin client island that owns the capture ref. The trigger renders as a small right-aligned Xuất ▾ button above the page content.

3. PDF + tabular on the same page

When you want a PDF of the visible report plus CSV/Excel of its underlying tabular data (typical reports pattern):

const captureRef = useRef<HTMLDivElement>(null);
const { items } = useExportMenu({
  locale: 'vi',
  filename: `report-${fromLabel}-${toLabel}`,
  pdfRef: captureRef,
  pdfTitle: `Báo cáo ${fromLabel} → ${toLabel}`,
  table: { sheet: { name: 'Rows', columns: [...], rows } },
});

return (
  <div ref={captureRef} className="flex flex-col gap-6">
    <div className="flex items-center justify-between">
      <p className="text-xs text-[var(--text-muted)]">Kỳ {fromLabel} → {toLabel}</p>
      <ExportMenu label="Xuất" items={items} />
    </div>
    {/* report body */}
  </div>
);

File naming#

All exports go through safeFilename():

  • Vietnamese diacritics stripped (NFD + combining-mark strip).
  • đd, lowercase, non-alphanumeric → -.
  • Suffixed with the current ISO date (-YYYY-MM-DD).
  • Capped at 64 chars.

Result: bao-cao-doanh-thu-2026-05-11.xlsx. Customer-friendly and shell-safe.

CSV format details#

  • RFC 4180 compliant.
  • \r\n line endings (Excel for Windows expects this).
  • UTF-8 BOM () prepended so Excel auto-detects encoding for Vietnamese.
  • Date values serialised as ISO-8601.
  • null / undefined / non-finite numbers emit empty cells.

PDF capture details#

  • html2canvas rasterises the captured element at scale: 2 for sharper text.
  • The canvas is sliced into A4-sized pages and stamped into a multi-page jsPDF.
  • Optional title rendered on the first page (12 pt).
  • Background colour inferred from the body's computed style — handles dark / light theme automatically.

For pixel-perfect PDFs (typography, complex layouts), the architectural escape hatch is a server-side Puppeteer rendering path. We have not deployed that — open a ticket if it becomes a real requirement.

Tests#

  • Unit: packages/exports/src/csv.test.ts — covers CSV escaping, Date serialisation, NaN handling, and safeFilename Vietnamese cleanup.
  • E2E: apps/web/e2e/exports.spec.ts — verifies the menu opens with the correct items on every surface.

Run with:

pnpm --filter @yng/exports test
pnpm --filter @yng/web test:e2e