@pagamio/frontend-commons-lib 0.8.350 → 0.8.351

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
  import { Dropdown, DropdownItem } from 'flowbite-react';
4
4
  import { jsPDF } from 'jspdf';
5
- import autoTable from 'jspdf-autotable';
5
+ import { autoTable } from 'jspdf-autotable';
6
6
  import { FiDownload } from 'react-icons/fi';
7
7
  import * as XLSX from 'xlsx';
8
8
  const TableDownload = ({ columns, data }) => {
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Dropdown, DropdownItem } from 'flowbite-react';
3
3
  import { jsPDF } from 'jspdf';
4
- import autoTable from 'jspdf-autotable';
4
+ import { autoTable } from 'jspdf-autotable';
5
5
  import { FiDownload } from 'react-icons/fi';
6
6
  import * as XLSX from 'xlsx';
7
7
  const TableDownload = ({ columns, data }) => {
@@ -1,4 +1,3 @@
1
- import 'jspdf-autotable';
2
1
  import { type MRT_ColumnDef } from 'mantine-react-table';
3
2
  interface ThemeColors {
4
3
  primary: {
@@ -36,9 +35,9 @@ interface CsvExportOptions {
36
35
  delimiter?: string;
37
36
  filename?: string;
38
37
  }
39
- export declare const exportToCsv: <T extends Record<string, any>>(data: T[], columns: MRT_ColumnDef<T>[], options?: CsvExportOptions) => void;
40
- export declare const exportToXlsx: <T extends Record<string, any>>(data: T[], columns: MRT_ColumnDef<T>[], options?: XlsxExportOptions) => void;
41
- export declare const exportToPdf: <T extends Record<string, any>>(data: T[], columns: MRT_ColumnDef<T>[], options?: PdfExportOptions) => Promise<void>;
38
+ export declare const exportToCsv: <T extends Record<string, any>>(data: T[], allColumns: MRT_ColumnDef<T>[], options?: CsvExportOptions) => void;
39
+ export declare const exportToXlsx: <T extends Record<string, any>>(data: T[], allColumns: MRT_ColumnDef<T>[], options?: XlsxExportOptions) => void;
40
+ export declare const exportToPdf: <T extends Record<string, any>>(data: T[], allColumns: MRT_ColumnDef<T>[], options?: PdfExportOptions) => Promise<void>;
42
41
  /**
43
42
  * Sends an export file to the user via email using multipart/form-data
44
43
  * Automatically includes Authorization header from cookies if available
@@ -1,7 +1,10 @@
1
- import jsPDF from 'jspdf';
2
- import 'jspdf-autotable';
1
+ import { jsPDF } from 'jspdf';
2
+ import { applyPlugin } from 'jspdf-autotable';
3
3
  import Papa from 'papaparse';
4
4
  import * as XLSX from 'xlsx';
5
+ // jspdf-autotable v5 no longer auto-attaches `autoTable` to jsPDF instances.
6
+ // applyPlugin restores the `doc.autoTable(...)` call style used below.
7
+ applyPlugin(jsPDF);
5
8
  // Helper function to convert RGB string to RGB values
6
9
  const parseRgbString = (rgbString) => {
7
10
  // Handle rgb(r, g, b) format
@@ -42,6 +45,50 @@ const loadImageAsBase64 = async (url) => {
42
45
  return null;
43
46
  }
44
47
  };
48
+ /**
49
+ * jsPDF's addImage only embeds raster formats (PNG/JPEG/WEBP), not SVG. Many
50
+ * logos are SVGs, so rasterise any SVG data URL onto a canvas and return a PNG
51
+ * data URL. Raster inputs pass through unchanged.
52
+ */
53
+ const ensureRasterImage = async (dataUrl,
54
+ // High render width so SVG logos stay crisp once scaled down into the PDF
55
+ // (the logo prints at ~30mm; rendering at 600px gives ample DPI headroom).
56
+ targetWidthPx = 600) => {
57
+ const isSvg = dataUrl.startsWith('data:image/svg');
58
+ if (!isSvg) {
59
+ const match = /^data:image\/(png|jpeg|jpg|webp)/i.exec(dataUrl);
60
+ if (!match)
61
+ return null;
62
+ const fmt = match[1].toUpperCase();
63
+ return {
64
+ dataUrl,
65
+ format: fmt === 'JPG' ? 'JPEG' : fmt,
66
+ };
67
+ }
68
+ return new Promise((resolve) => {
69
+ const img = new Image();
70
+ img.onload = () => {
71
+ // SVGs may report 0 intrinsic size; fall back to a 2:1 logo ratio so the
72
+ // canvas is never degenerate (which would yield a blank/blurry raster).
73
+ const ratio = img.naturalWidth > 0 ? img.naturalHeight / img.naturalWidth : 0.5;
74
+ const canvas = document.createElement('canvas');
75
+ canvas.width = targetWidthPx;
76
+ canvas.height = Math.max(1, Math.round(targetWidthPx * ratio));
77
+ const ctx = canvas.getContext('2d');
78
+ if (!ctx)
79
+ return resolve(null);
80
+ ctx.imageSmoothingEnabled = true;
81
+ ctx.imageSmoothingQuality = 'high';
82
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
83
+ resolve({ dataUrl: canvas.toDataURL('image/png'), format: 'PNG' });
84
+ };
85
+ img.onerror = () => resolve(null);
86
+ // Force the SVG to rasterise at the target size rather than its (often
87
+ // missing) intrinsic size.
88
+ img.width = targetWidthPx;
89
+ img.src = dataUrl;
90
+ });
91
+ };
45
92
  // Helper functions for formatting different data types
46
93
  const formatStatusObject = (value) => {
47
94
  if ('label' in value && typeof value.label === 'string') {
@@ -224,8 +271,10 @@ const formatCellContentForExcel = (value, columnKey) => {
224
271
  return String(value);
225
272
  };
226
273
  // CSV Export
227
- export const exportToCsv = (data, columns, options = {}) => {
274
+ export const exportToCsv = (data, allColumns, options = {}) => {
228
275
  const { title = 'Data Export', includeTimestamp = true, includeHeaders = true, delimiter = ',', filename } = options;
276
+ // Drop display-only columns (no accessorKey) so keys/headers stay aligned.
277
+ const columns = allColumns.filter((col) => Boolean(col.accessorKey));
229
278
  const columnKeys = columns.map((col) => col.accessorKey);
230
279
  const headers = columns.map((col) => formatHeaderText(col.header || ''));
231
280
  // Prepare data with proper formatting
@@ -471,7 +520,9 @@ const addMergedCells = (worksheet, title, hasSubtitle, headers) => {
471
520
  }
472
521
  };
473
522
  // XLSX Export
474
- export const exportToXlsx = (data, columns, options = {}) => {
523
+ export const exportToXlsx = (data, allColumns, options = {}) => {
524
+ // Drop display-only columns (no accessorKey) so keys/headers stay aligned.
525
+ const columns = allColumns.filter((col) => Boolean(col.accessorKey));
475
526
  const { title = 'Data Export', subtitle, colors, sheetName = 'Data', includeTimestamp = true, autoFitColumns = true, } = options;
476
527
  const columnKeys = columns.map((col) => col.accessorKey);
477
528
  const headers = columns.map((col) => formatHeaderText(col.header || ''));
@@ -509,6 +560,11 @@ export const exportToXlsx = (data, columns, options = {}) => {
509
560
  };
510
561
  // Helper function to check if a column likely contains images
511
562
  const isImageColumn = (columnKey, data) => {
563
+ // Display-only columns (e.g. an "actions" column) have no accessorKey, so
564
+ // columnKey can be undefined here. They hold no exportable data.
565
+ if (!columnKey) {
566
+ return false;
567
+ }
512
568
  const imageKeywords = ['image', 'photo', 'picture', 'avatar', 'logo'];
513
569
  const keyLower = columnKey.toLowerCase();
514
570
  // Check if column key suggests images
@@ -579,7 +635,11 @@ const prepareTableDataForPdf = async (data, columns, doc) => {
579
635
  return { tableData, imageInfo };
580
636
  };
581
637
  // PDF Export
582
- export const exportToPdf = async (data, columns, options = {}) => {
638
+ export const exportToPdf = async (data, allColumns, options = {}) => {
639
+ // Only data-bound columns are exportable; display-only columns (e.g. an
640
+ // "actions" column) have no accessorKey and would otherwise produce
641
+ // undefined keys/headers that crash downstream formatting.
642
+ const columns = allColumns.filter((col) => Boolean(col.accessorKey));
583
643
  const { title = 'Data Export', subtitle = `Generated on ${new Date().toLocaleDateString('en-US', {
584
644
  year: 'numeric',
585
645
  month: 'long',
@@ -606,10 +666,11 @@ export const exportToPdf = async (data, columns, options = {}) => {
606
666
  if (logo?.url) {
607
667
  try {
608
668
  const logoBase64 = await loadImageAsBase64(logo.url);
609
- if (logoBase64) {
669
+ const raster = logoBase64 ? await ensureRasterImage(logoBase64) : null;
670
+ if (raster) {
610
671
  const logoWidth = logo.width || 30;
611
672
  const logoHeight = logo.height || 15;
612
- doc.addImage(logoBase64, 'PNG', margin, currentY, logoWidth, logoHeight);
673
+ doc.addImage(raster.dataUrl, raster.format, margin, currentY, logoWidth, logoHeight);
613
674
  currentY += logoHeight + 5;
614
675
  }
615
676
  }
@@ -638,7 +699,6 @@ export const exportToPdf = async (data, columns, options = {}) => {
638
699
  const columnHeaders = columns.map((col) => formatHeaderText(col.header || String(col.accessorKey)));
639
700
  // Format table data with enhanced handling for images, status, etc.
640
701
  const { tableData, imageInfo } = await prepareTableDataForPdf(data, columns, doc);
641
- console.log(`PDF export: Processing ${tableData.length} rows with ${imageInfo.size} images`);
642
702
  // Calculate optimal column widths
643
703
  const availableWidth = pageWidth - 2 * margin;
644
704
  const numColumns = columnHeaders.length;
@@ -20,11 +20,23 @@ export const createPdfExportOptions = (config, title) => {
20
20
  primary: config.theme.colors.primary,
21
21
  core: config.theme.colors.core,
22
22
  },
23
- logo: {
24
- url: config.branding.navbarLogo?.path || config.branding.logo.path,
25
- width: config.branding.navbarLogo?.width || config.branding.logo.width,
26
- height: config.branding.navbarLogo?.height || config.branding.logo.height,
27
- },
23
+ logo: scalePdfLogo(config.branding.navbarLogo?.path || config.branding.logo.path, config.branding.navbarLogo?.width || config.branding.logo.width, config.branding.navbarLogo?.height || config.branding.logo.height),
24
+ };
25
+ };
26
+ /**
27
+ * Branding logo dimensions in `config` are CSS pixels (sized for the navbar).
28
+ * `exportToPdf` interprets logo width/height as millimetres, so passing the raw
29
+ * pixel values renders a giant logo that fills the page. Scale to a fixed print
30
+ * width (mm) while preserving the source aspect ratio.
31
+ */
32
+ const PDF_LOGO_WIDTH_MM = 32;
33
+ const PDF_LOGO_FALLBACK_HEIGHT_MM = 12;
34
+ const scalePdfLogo = (url, pxWidth, pxHeight) => {
35
+ const aspect = pxWidth && pxHeight && pxWidth > 0 ? pxHeight / pxWidth : undefined;
36
+ return {
37
+ url,
38
+ width: PDF_LOGO_WIDTH_MM,
39
+ height: aspect ? Math.round(PDF_LOGO_WIDTH_MM * aspect) : PDF_LOGO_FALLBACK_HEIGHT_MM,
28
40
  };
29
41
  };
30
42
  /**
@@ -3,3 +3,4 @@ export * from './useMediaQueries';
3
3
  export * from './useSessionTimer';
4
4
  export * from './usePagamioTable';
5
5
  export * from './usePagamioCombobox';
6
+ export * from './useInfiniteCursor';
@@ -3,3 +3,4 @@ export * from './useMediaQueries';
3
3
  export * from './useSessionTimer';
4
4
  export * from './usePagamioTable';
5
5
  export * from './usePagamioCombobox';
6
+ export * from './useInfiniteCursor';
@@ -0,0 +1,104 @@
1
+ /**
2
+ * useInfiniteCursor — TanStack `useInfiniteQuery` wrapper for cursor-paginated
3
+ * Pagamio endpoints.
4
+ *
5
+ * Matches the backend cursor contract from `pagamio-nestjs-api-commons`:
6
+ * `GET /<resource>/feed` → CursorPaginatedResponseDto<T> =
7
+ * { data: T[], meta: { nextCursor, previousCursor, hasNextPage,
8
+ * hasPreviousPage, count, total? } }
9
+ *
10
+ * The hook treats `nextCursor` as an opaque base64 string — never parse or
11
+ * construct cursors on the client. If the backend changes its cursor encoding,
12
+ * the frontend doesn't need to know.
13
+ *
14
+ * For tables and any page-numbered UI, use `usePagamioTable` (offset paging)
15
+ * instead. Cursor pagination is for infinite-scroll surfaces (storefront,
16
+ * mobile feeds) where "Page X of Y" UX isn't shown.
17
+ *
18
+ * SRP: Wires `useInfiniteQuery` to the cursor contract. Callers own:
19
+ * - flattening `data.pages` into a single list
20
+ * - intersection-observer / "load more" wiring
21
+ * - reading `data.pages[0]?.meta.total` for header counts (only present
22
+ * when the caller passed `withTotal=true` on the first page).
23
+ */
24
+ import { type QueryKey, type UseInfiniteQueryResult } from '@tanstack/react-query';
25
+ /**
26
+ * Wire shape for one page of a cursor-paginated Pagamio response.
27
+ *
28
+ * Mirrors `CursorPaginatedResponseDto<T>` from `@pagamio/nestjs-api-commons`.
29
+ * `total` is optional and only set when the caller passes `withTotal=true`
30
+ * (conventionally first page only — see backend `.agent/conventions/pagination.md`).
31
+ */
32
+ export type CursorResponse<T> = {
33
+ data: T[];
34
+ meta: {
35
+ nextCursor: string | null;
36
+ previousCursor: string | null;
37
+ hasNextPage: boolean;
38
+ hasPreviousPage: boolean;
39
+ count: number;
40
+ total?: number;
41
+ };
42
+ };
43
+ /**
44
+ * Documentation-as-code marker for the cursor meta shape. Mirrors the role
45
+ * of `pagamioMapping` for offset pagination — gives consumers a single named
46
+ * export to reference. Not consumed by `useInfiniteCursor` itself (TanStack
47
+ * handles the wiring); useful for grep / IDE jump-to-definition.
48
+ */
49
+ export declare const cursorMapping: {
50
+ responseType: "cursor";
51
+ data: string;
52
+ nextCursor: string;
53
+ hasNextPage: string;
54
+ total: string;
55
+ };
56
+ export interface UseInfiniteCursorConfig<T> {
57
+ /** TanStack query key. Include scope params (orgId, BU id, filters) so
58
+ * changes invalidate correctly. */
59
+ queryKey: QueryKey;
60
+ /** Fetches one page. Receives the cursor for the next page (undefined on
61
+ * first page) and an AbortSignal forwarded from TanStack. */
62
+ queryFn: (args: {
63
+ cursor: string | undefined;
64
+ signal: AbortSignal;
65
+ }) => Promise<CursorResponse<T>>;
66
+ enabled?: boolean;
67
+ staleTime?: number;
68
+ gcTime?: number;
69
+ /**
70
+ * When true, the previous page set stays visible while the query refetches
71
+ * after a key change (filter swap, sort change). Prevents the consumer's
72
+ * grid from flashing to a skeleton between filter changes. Maps to
73
+ * TanStack's `placeholderData: keepPreviousData`.
74
+ */
75
+ keepPreviousResults?: boolean;
76
+ }
77
+ /**
78
+ * Cursor-paginated infinite query for Pagamio `/feed` endpoints.
79
+ *
80
+ * Returns the raw TanStack `useInfiniteQueryResult` — no custom shape — so
81
+ * callers keep full access to `fetchNextPage`, `hasNextPage`, `isFetchingNextPage`,
82
+ * `data.pages`, etc.
83
+ *
84
+ * Example:
85
+ * ```ts
86
+ * const query = useInfiniteCursor<Product>({
87
+ * queryKey: ['catalog', 'products', { businessUnitId, search }],
88
+ * queryFn: ({ cursor, signal }) =>
89
+ * apiClient
90
+ * .get('/ecommerce/catalog/products/feed', {
91
+ * params: { businessUnitId, cursor, limit: 30, withTotal: cursor === undefined },
92
+ * signal,
93
+ * })
94
+ * .then((r) => r.data),
95
+ * });
96
+ *
97
+ * const items = query.data?.pages.flatMap((p) => p.data) ?? [];
98
+ * const total = query.data?.pages[0]?.meta.total;
99
+ * ```
100
+ */
101
+ export declare function useInfiniteCursor<T>({ queryKey, queryFn, enabled, staleTime, gcTime, keepPreviousResults, }: UseInfiniteCursorConfig<T>): UseInfiniteQueryResult<{
102
+ pages: Array<CursorResponse<T>>;
103
+ pageParams: Array<string | undefined>;
104
+ }, Error>;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * useInfiniteCursor — TanStack `useInfiniteQuery` wrapper for cursor-paginated
3
+ * Pagamio endpoints.
4
+ *
5
+ * Matches the backend cursor contract from `pagamio-nestjs-api-commons`:
6
+ * `GET /<resource>/feed` → CursorPaginatedResponseDto<T> =
7
+ * { data: T[], meta: { nextCursor, previousCursor, hasNextPage,
8
+ * hasPreviousPage, count, total? } }
9
+ *
10
+ * The hook treats `nextCursor` as an opaque base64 string — never parse or
11
+ * construct cursors on the client. If the backend changes its cursor encoding,
12
+ * the frontend doesn't need to know.
13
+ *
14
+ * For tables and any page-numbered UI, use `usePagamioTable` (offset paging)
15
+ * instead. Cursor pagination is for infinite-scroll surfaces (storefront,
16
+ * mobile feeds) where "Page X of Y" UX isn't shown.
17
+ *
18
+ * SRP: Wires `useInfiniteQuery` to the cursor contract. Callers own:
19
+ * - flattening `data.pages` into a single list
20
+ * - intersection-observer / "load more" wiring
21
+ * - reading `data.pages[0]?.meta.total` for header counts (only present
22
+ * when the caller passed `withTotal=true` on the first page).
23
+ */
24
+ import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query';
25
+ /**
26
+ * Documentation-as-code marker for the cursor meta shape. Mirrors the role
27
+ * of `pagamioMapping` for offset pagination — gives consumers a single named
28
+ * export to reference. Not consumed by `useInfiniteCursor` itself (TanStack
29
+ * handles the wiring); useful for grep / IDE jump-to-definition.
30
+ */
31
+ export const cursorMapping = {
32
+ responseType: 'cursor',
33
+ data: 'data',
34
+ nextCursor: 'meta.nextCursor',
35
+ hasNextPage: 'meta.hasNextPage',
36
+ total: 'meta.total',
37
+ };
38
+ /**
39
+ * Cursor-paginated infinite query for Pagamio `/feed` endpoints.
40
+ *
41
+ * Returns the raw TanStack `useInfiniteQueryResult` — no custom shape — so
42
+ * callers keep full access to `fetchNextPage`, `hasNextPage`, `isFetchingNextPage`,
43
+ * `data.pages`, etc.
44
+ *
45
+ * Example:
46
+ * ```ts
47
+ * const query = useInfiniteCursor<Product>({
48
+ * queryKey: ['catalog', 'products', { businessUnitId, search }],
49
+ * queryFn: ({ cursor, signal }) =>
50
+ * apiClient
51
+ * .get('/ecommerce/catalog/products/feed', {
52
+ * params: { businessUnitId, cursor, limit: 30, withTotal: cursor === undefined },
53
+ * signal,
54
+ * })
55
+ * .then((r) => r.data),
56
+ * });
57
+ *
58
+ * const items = query.data?.pages.flatMap((p) => p.data) ?? [];
59
+ * const total = query.data?.pages[0]?.meta.total;
60
+ * ```
61
+ */
62
+ export function useInfiniteCursor({ queryKey, queryFn, enabled, staleTime, gcTime, keepPreviousResults, }) {
63
+ return useInfiniteQuery({
64
+ queryKey,
65
+ queryFn: ({ pageParam, signal }) => queryFn({ cursor: pageParam, signal }),
66
+ initialPageParam: undefined,
67
+ getNextPageParam: (lastPage) => lastPage.meta.hasNextPage && lastPage.meta.nextCursor ? lastPage.meta.nextCursor : undefined,
68
+ enabled,
69
+ staleTime,
70
+ gcTime,
71
+ ...(keepPreviousResults ? { placeholderData: keepPreviousData } : {}),
72
+ });
73
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pagamio/frontend-commons-lib",
3
3
  "description": "Pagamio library for Frontend reusable components like the form engine and table container",
4
- "version": "0.8.350",
4
+ "version": "0.8.351",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "provenance": false
@@ -100,8 +100,8 @@
100
100
  "dayjs": "^1.11.13",
101
101
  "file-saver": "^2.0.5",
102
102
  "js-cookie": "^3.0.5",
103
- "jspdf": "^2.5.2",
104
- "jspdf-autotable": "^3.8.4",
103
+ "jspdf": "^4.2.1",
104
+ "jspdf-autotable": "^5.0.8",
105
105
  "jwt-decode": "^4.0.0",
106
106
  "lodash": "^4.17.21",
107
107
  "papaparse": "^5.4.1",