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 | |
|---|---|---|---|
/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\nline endings (Excel for Windows expects this).- UTF-8 BOM (
) prepended so Excel auto-detects encoding for Vietnamese. Datevalues serialised as ISO-8601.null/undefined/ non-finite numbers emit empty cells.
PDF capture details#
html2canvasrasterises the captured element atscale: 2for 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, andsafeFilenameVietnamese 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