diff --git a/.DS_Store b/.DS_Store index 2cbe81c..e08d61c 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/components/.DS_Store b/components/.DS_Store index a9e49eb..cde0503 100644 Binary files a/components/.DS_Store and b/components/.DS_Store differ diff --git a/components/global-file-viewer/.DS_Store b/components/global-file-viewer/.DS_Store new file mode 100644 index 0000000..ae6b063 Binary files /dev/null and b/components/global-file-viewer/.DS_Store differ diff --git a/components/smart-pdf-engine/.DS_Store b/components/smart-pdf-engine/.DS_Store new file mode 100644 index 0000000..fc7fdcd Binary files /dev/null and b/components/smart-pdf-engine/.DS_Store differ diff --git a/components/smart-pdf-engine/README.md b/components/smart-pdf-engine/README.md new file mode 100644 index 0000000..238c671 --- /dev/null +++ b/components/smart-pdf-engine/README.md @@ -0,0 +1,55 @@ +## Username + +widlestudiollp + +## Project Name + +Smart Auto PDF Engine + +## About + +Smart Auto PDF Engine is an intelligent, auto-detecting document generator for Retool that analyzes any input data and transforms it into a structured, printable document with live preview and PDF export functionality. It supports multiple document types such as invoices, reports, payslips, and generic data layouts without requiring predefined schemas. + +The component automatically interprets raw JSON, detects structure, and renders a clean UI along with a high-quality downloadable PDF. This allows developers to quickly generate professional documents from any backend response with minimal configuration. + +## Preview + +![Smart Auto PDF Engine Preview](cover.png) + +## How it works + +The component receives data via Retool state (`schema`) and processes it using a smart normalization engine. It evaluates the structure of the data and converts it into a unified internal model that can be rendered both in UI and PDF format. + +### Data interpretation logic + +* Structured data (`sections`) → Render directly (grid, table, text, summary) +* Flat objects → Converted into key-value sections +* Nested objects → Recursively expanded into multiple sections +* Arrays of objects → Converted into tables +* Arrays of primitives → Rendered as text +* Mixed / irregular data → Safely normalized and displayed without breaking + +### Document detection logic + +* Invoice-like data → Invoice layout +* Payslip-like data → Salary/payslip layout +* Analytical data → Report layout +* Unknown structure → Generic document layout + +### Example input + +```json +{ + "employee": { + "name": "John Doe", + "department": "Engineering" + }, + "earnings": { + "basic": 50000, + "hra": 20000 + }, + "deductions": { + "tax": 8000 + }, + "netPay": 62000 +} \ No newline at end of file diff --git a/components/smart-pdf-engine/cover.png b/components/smart-pdf-engine/cover.png new file mode 100644 index 0000000..ec47949 Binary files /dev/null and b/components/smart-pdf-engine/cover.png differ diff --git a/components/smart-pdf-engine/metadata.json b/components/smart-pdf-engine/metadata.json new file mode 100644 index 0000000..83f3c01 --- /dev/null +++ b/components/smart-pdf-engine/metadata.json @@ -0,0 +1,16 @@ +{ + "id": "smart-auto-pdf-engine", + "title": "Smart Auto PDF Engine", + "author": "@widlestudiollp", + "shortDescription": "A fully automated PDF generation engine that intelligently interprets any JSON data and transforms it into a structured, responsive document with preview and download capabilities.", + "tags": [ + "PDF Generator", + "Auto Layout", + "Smart Rendering", + "Invoices", + "Reports", + "Data Processing", + "No-Code", + "Retool" + ] +} \ No newline at end of file diff --git a/components/smart-pdf-engine/package.json b/components/smart-pdf-engine/package.json new file mode 100644 index 0000000..3c94888 --- /dev/null +++ b/components/smart-pdf-engine/package.json @@ -0,0 +1,52 @@ +{ + "name": "my-react-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tryretool/custom-component-support": "latest", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", + "jspdf-autotable": "^5.0.7", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "uuid": "^13.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy", + "test": "vitest" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-scroll-to-bottom": "^4.2.5", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "postcss-modules": "^6.0.0", + "prettier": "^3.0.3", + "vitest": "^4.0.17" + }, + "retoolCustomComponentLibraryConfig": { + "name": "SmartPdf", + "label": "Smart Pdf", + "description": "Smart Auto detect Pdf engine.", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} diff --git a/components/smart-pdf-engine/src/component/smartPdfComponent.css b/components/smart-pdf-engine/src/component/smartPdfComponent.css new file mode 100644 index 0000000..92e0a83 --- /dev/null +++ b/components/smart-pdf-engine/src/component/smartPdfComponent.css @@ -0,0 +1,813 @@ +@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap'); + +:root { + --ink: #0d1117; + --ink-80: #1c2333; + --ink-60: #374151; + --ink-40: #6b7280; + --ink-20: #9ca3af; + --ink-10: #e5e7eb; + --ink-05: #f9fafb; + + --paper: #ffffff; + --canvas: #f0f2f7; + + --blue: #1d4ed8; + --blue-lt: #eff6ff; + --blue-mid: #3b82f6; + --teal: #0d9488; + --teal-lt: #f0fdfa; + --amber: #d97706; + --amber-lt: #fffbeb; + --rose: #e11d48; + --rose-lt: #fff1f2; + --violet: #7c3aed; + --violet-lt: #f5f3ff; + --green: #059669; + --green-lt: #ecfdf5; + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, .08), 0 1px 2px rgba(0, 0, 0, .04); + --shadow-md: 0 4px 16px rgba(0, 0, 0, .08), 0 1px 4px rgba(0, 0, 0, .05); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, .10), 0 2px 8px rgba(0, 0, 0, .06); + + --font-head: 'Syne', system-ui, sans-serif; + --font-body: 'DM Sans', system-ui, sans-serif; + + --r: 6px; + --r-lg: 12px; + --r-xl: 18px; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +.doc-root { + font-family: var(--font-body); + background: transparent; + min-height: auto; + padding: 12px; + color: var(--ink); +} + +.doc-toolbar { + display: flex; + margin-bottom: 8px; + align-items: center; + max-width: 900px; + margin: 0 auto 12px; + gap: 12px; + justify-content: space-between; +} + +.doc-toolbar-left { + display: flex; + align-items: center; + gap: 10px; +} + +.doc-toolbar-right { + display: flex; + width: 100%; +} + +.align-left { + justify-content: flex-start; +} + +.align-center { + justify-content: center; +} + +.align-right { + justify-content: flex-end; +} + +.align-full { + justify-content: stretch; +} + +.full-width { + width: 100%; + justify-content: center; +} + +.doc-type-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 99px; + font-family: var(--font-head); + font-size: 11px; + font-weight: 700; + letter-spacing: .8px; + text-transform: uppercase; +} + +.doc-type-pill.invoice { + background: var(--blue-lt); + color: var(--blue); + border: 1px solid #bfdbfe; +} + +.doc-type-pill.payslip { + background: var(--green-lt); + color: var(--green); + border: 1px solid #a7f3d0; +} + +.doc-type-pill.table { + background: var(--violet-lt); + color: var(--violet); + border: 1px solid #ddd6fe; +} + +.doc-type-pill.report { + background: var(--teal-lt); + color: var(--teal); + border: 1px solid #99f6e4; +} + +.doc-type-pill.generic { + background: var(--amber-lt); + color: var(--amber); + border: 1px solid #fde68a; +} + +.doc-type-pill-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + animation: pulse-dot 2s infinite; +} + +@keyframes pulse-dot { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: .4; + } +} + +.doc-toolbar-right { + display: flex; + gap: 8px; + align-items: center; +} + +.doc-download-btn { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--ink); + color: white; + padding: 9px 18px; + border-radius: var(--r); + border: none; + cursor: pointer; + font-family: var(--font-head); + font-size: 13px; + font-weight: 600; + letter-spacing: .3px; + transition: background .15s, transform .1s, box-shadow .15s; + box-shadow: var(--shadow-sm); +} + +.doc-download-btn:hover { + background: var(--ink-80); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.doc-download-btn:active { + transform: translateY(0); +} + +.doc-download-btn svg { + flex-shrink: 0; +} + +.doc-root:has(.doc-toolbar:only-child) { + padding: 8px; +} + +.doc-paper { + background: var(--paper); + max-width: 900px; + margin: 0 auto 12px; + border-radius: var(--r-xl); + box-shadow: var(--shadow-lg); + overflow: hidden; + border: 1px solid var(--ink-10); +} + +.doc-header { + padding: 32px 36px 28px; + position: relative; + overflow: hidden; +} + +.doc-header.invoice { + background: linear-gradient(135deg, #0f172a 0%, #1e3a8a 55%, #1d4ed8 100%); +} + +.doc-header.payslip { + background: linear-gradient(135deg, #064e3b 0%, #065f46 55%, #059669 100%); +} + +.doc-header.table { + background: linear-gradient(135deg, #2e1065 0%, #4c1d95 55%, #7c3aed 100%); +} + +.doc-header.report { + background: linear-gradient(135deg, #134e4a 0%, #0f766e 55%, #0d9488 100%); +} + +.doc-header.generic { + background: linear-gradient(135deg, #1c1917 0%, #292524 55%, #44403c 100%); +} + +.doc-header::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, .04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, .04) 1px, transparent 1px); + background-size: 28px 28px; + pointer-events: none; +} + +.doc-header::after { + content: ''; + position: absolute; + right: -60px; + top: -60px; + width: 240px; + height: 240px; + background: radial-gradient(circle, rgba(255, 255, 255, .08) 0%, transparent 70%); + pointer-events: none; +} + +.doc-header-inner { + position: relative; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.doc-header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.doc-logo-wrap { + width: 56px; + height: 56px; + background: rgba(255, 255, 255, .12); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, .2); + border-radius: var(--r-lg); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; +} + +.doc-logo-wrap img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 6px; +} + +.doc-logo-icon { + font-size: 26px; +} + +.doc-header-titles {} + +.doc-header-title { + font-family: var(--font-head); + font-size: 26px; + font-weight: 800; + color: #ffffff; + letter-spacing: .5px; + line-height: 1.1; + text-transform: uppercase; +} + +.doc-header-subtitle { + margin-top: 5px; + font-size: 13px; + color: rgba(255, 255, 255, .65); + font-weight: 400; + letter-spacing: .2px; +} + +.doc-header-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + text-align: right; + gap: 6px; +} + +.doc-header-meta-row { + display: flex; + align-items: center; + gap: 8px; + background: rgba(255, 255, 255, .1); + backdrop-filter: blur(6px); + border: 1px solid rgba(255, 255, 255, .15); + border-radius: var(--r); + padding: 5px 12px; +} + +.doc-header-meta-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: rgba(255, 255, 255, .55); +} + +.doc-header-meta-value { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, .9); + font-family: var(--font-head); +} + +.doc-body { + padding: 0 36px 32px; +} + +.doc-section { + margin-top: 28px; + animation: fadeUp .3s ease both; +} + +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.doc-section-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.doc-section-accent { + width: 3px; + height: 18px; + border-radius: 99px; + flex-shrink: 0; +} + +.accent-blue { + background: var(--blue); +} + +.accent-teal { + background: var(--teal); +} + +.accent-violet { + background: var(--violet); +} + +.accent-amber { + background: var(--amber); +} + +.accent-green { + background: var(--green); +} + +.accent-rose { + background: var(--rose); +} + +.doc-section-title { + font-family: var(--font-head); + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--ink-60); +} + +.doc-divider { + height: 1px; + background: var(--ink-10); + margin: 0 0 14px; +} + +.doc-kv-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1px; + background: var(--ink-10); + border: 1px solid var(--ink-10); + border-radius: var(--r-lg); + overflow: hidden; +} + +.doc-kv-item { + background: var(--paper); + padding: 13px 16px; + display: flex; + flex-direction: column; + gap: 4px; + justify-content: center; + transition: background .12s; +} + +.doc-kv-item:hover { + background: var(--ink-05); +} + +.doc-kv-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .9px; + color: var(--ink-20); +} + +.doc-kv-value { + font-size: 14px; + display: block; + font-weight: 500; + color: var(--ink); + word-break: break-word; + line-height: 1.4; +} + +.doc-kv-value.is-number { + font-family: var(--font-head); + font-weight: 700; + font-size: 15px; + color: var(--blue); +} + +.doc-kv-value.is-currency { + font-family: var(--font-head); + font-weight: 700; + font-size: 15px; + color: var(--teal); +} + +.doc-kv-value.is-status { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.doc-metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; +} + +.doc-metric-card { + background: var(--ink-05); + border: 1px solid var(--ink-10); + border-radius: var(--r-lg); + padding: 16px; + position: relative; + overflow: hidden; + transition: box-shadow .15s, transform .12s; +} + +.doc-metric-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.doc-metric-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; +} + +.metric-stripe-blue .doc-metric-card::before, +.doc-metric-card.stripe-blue::before { + background: var(--blue); +} + +.doc-metric-card.stripe-teal::before { + background: var(--teal); +} + +.doc-metric-card.stripe-violet::before { + background: var(--violet); +} + +.doc-metric-card.stripe-amber::before { + background: var(--amber); +} + +.doc-metric-card.stripe-green::before { + background: var(--green); +} + +.doc-metric-card.stripe-rose::before { + background: var(--rose); +} + +.doc-metric-value { + font-family: var(--font-head); + font-size: 22px; + font-weight: 800; + color: var(--ink); + line-height: 1.1; + margin-bottom: 6px; + word-break: break-word; +} + +.doc-metric-label { + font-size: 11px; + font-weight: 500; + color: var(--ink-40); + text-transform: uppercase; + letter-spacing: .7px; +} + +.doc-table-wrapper { + overflow-x: auto; + border-radius: var(--r-lg); + border: 1px solid var(--ink-10); +} + +.doc-table-wrapper table { + width: 100%; + border-collapse: collapse; + margin-top: 0; + font-size: 13px; +} + +.doc-table-wrapper th { + background: var(--ink); + color: #fff; + padding: 11px 14px; + text-align: left; + font-family: var(--font-head); + font-size: 11px; + font-weight: 700; + letter-spacing: .8px; + text-transform: uppercase; + white-space: nowrap; + border: none; +} + +.doc-table-wrapper th:first-child { + border-radius: 0; +} + +.doc-table-wrapper td { + padding: 10px 14px; + border: none; + border-bottom: 1px solid var(--ink-10); + color: var(--ink-60); + word-break: break-word; + vertical-align: top; + line-height: 1.45; +} + +.doc-table-wrapper tbody tr:last-child td { + border-bottom: none; +} + +.doc-table-wrapper tbody tr:nth-child(even) { + background: var(--ink-05); +} + +.doc-table-wrapper tbody tr:hover { + background: var(--blue-lt); +} + +.doc-table-count { + font-size: 11px; + color: var(--ink-40); + margin-top: 6px; + text-align: right; +} + +.doc-text-block { + background: var(--ink-05); + border: 1px solid var(--ink-10); + border-left: 3px solid var(--blue); + border-radius: var(--r); + padding: 14px 18px; + font-size: 13.5px; + line-height: 1.75; + color: var(--ink-60); + white-space: pre-wrap; + word-break: break-word; +} + +.doc-netpay { + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(135deg, var(--green) 0%, #10b981 100%); + border-radius: var(--r-lg); + padding: 20px 28px; + flex-wrap: wrap; + gap: 12px; +} + +.doc-netpay-label { + font-family: var(--font-head); + font-size: 13px; + font-weight: 700; + color: rgba(255, 255, 255, .75); + text-transform: uppercase; + letter-spacing: 1px; +} + +.doc-netpay-value { + font-family: var(--font-head); + font-size: 30px; + font-weight: 800; + color: #fff; + letter-spacing: -.5px; +} + +.doc-totals { + margin-left: auto; + width: 100%; + max-width: 380px; + border: 1px solid var(--ink-10); + border-radius: var(--r-lg); + overflow: hidden; +} + +.doc-total-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--ink-10); + font-size: 13px; +} + +.doc-total-row:last-child { + border-bottom: none; +} + +.doc-total-label { + color: var(--ink-40); + font-size: 12.5px; +} + +.doc-total-amount { + font-family: var(--font-head); + font-weight: 600; + color: var(--ink-60); + font-size: 13px; +} + +.doc-total-row.is-grand { + background: var(--blue-lt); + border-top: 2px solid var(--blue); +} + +.doc-total-row.is-grand .doc-total-label { + color: var(--blue); + font-weight: 700; + font-size: 13.5px; +} + +.doc-total-row.is-grand .doc-total-amount { + color: var(--blue); + font-size: 16px; +} + +.doc-empty { + text-align: center; + padding: 64px 24px; + color: var(--ink-20); +} + +.doc-empty-icon { + font-size: 48px; + margin-bottom: 12px; +} + +.doc-empty-text { + font-size: 14px; + font-weight: 500; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--ink-10); + border-radius: 99px; +} + +@media (max-width: 700px) { + .doc-root { + padding: 12px 8px 32px; + } + + .doc-body { + padding: 0 16px 24px; + } + + .doc-header { + padding: 22px 20px 20px; + } + + .doc-header-title { + font-size: 20px; + } + + .doc-kv-grid { + grid-template-columns: 1fr 1fr; + } + + .doc-metrics-grid { + grid-template-columns: 1fr 1fr; + } + + .doc-netpay { + padding: 16px 18px; + } + + .doc-netpay-value { + font-size: 24px; + } + + .doc-totals { + max-width: 100%; + } + + .doc-toolbar { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .doc-toolbar-left { + flex-wrap: wrap; + } + + .doc-toolbar-right { + width: auto; + } + + .doc-download-btn { + width: auto; + } +} + +@media (max-width: 420px) { + .doc-kv-grid { + grid-template-columns: 1fr; + } + + .doc-metrics-grid { + grid-template-columns: 1fr; + } + + .doc-header-meta { + display: none; + } +} \ No newline at end of file diff --git a/components/smart-pdf-engine/src/component/smartPdfComponent.tsx b/components/smart-pdf-engine/src/component/smartPdfComponent.tsx new file mode 100644 index 0000000..2cf665e --- /dev/null +++ b/components/smart-pdf-engine/src/component/smartPdfComponent.tsx @@ -0,0 +1,1407 @@ +import React, { useMemo } from "react"; +import { Retool } from "@tryretool/custom-component-support"; +import jsPDF from "jspdf"; +import autoTable from "jspdf-autotable"; +import "./smartPdfComponent.css"; + +type DocType = "invoice" | "payslip" | "table" | "report" | "generic"; + +type KVSection = { + type: "kv"; + title: string; + data: { label: string; value: any }[]; +}; +type TableSection = { + type: "table"; + title: string; + columns: string[]; + columnKeys: string[]; + rows: any[][]; +}; +type TextSection = { + type: "text"; + title: string; + content: string; +}; +type MetricsSection = { + type: "metrics"; + title: string; + data: { label: string; value: any }[]; +}; +type NetPaySection = { + type: "netpay"; + label: string; + value: string | number; +}; +type TotalSection = { + type: "total"; + rows: { label: string; value: string | number; isGrand?: boolean }[]; +}; + +type Section = + | KVSection + | TableSection + | TextSection + | MetricsSection + | NetPaySection + | TotalSection; + +type HeaderMeta = { label: string; value: any }; + +type DocumentModel = { + title: string; + subtitle?: string; + logo?: string | null; + docType: DocType; + headerMeta: HeaderMeta[]; + sections: Section[]; +}; + +const humanLabel = (k: string): string => + k + .replace(/([A-Z])/g, " $1") + .replace(/[_-]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); + +const toStr = (v: any): string => { + if (v === null || v === undefined || v === "") return "—"; + + if (typeof v === "boolean") return v ? "Yes" : "No"; + + if (typeof v === "object") { + try { + return Object.entries(v) + .map(([k, val]) => `${k}: ${val ?? "—"}`) + .join(", "); + } catch { + return JSON.stringify(v); + } + } + + return String(v); +}; + +const fmtNum = (v: any): string => { + const n = parseFloat(String(v).replace(/[^0-9.-]/g, "")); + if (isNaN(n)) return String(v); + return n.toLocaleString("en-IN", { maximumFractionDigits: 2 }); +}; + +const fmtCurrency = (v: any): string => { + const n = parseFloat(String(v).replace(/[^0-9.-]/g, "")); + if (isNaN(n)) return String(v); + return n.toLocaleString("en-IN", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +}; + +const isImageSrc = (v: any): v is string => + typeof v === "string" && + (v.startsWith("data:image") || + /\.(png|jpg|jpeg|gif|svg|webp)(\?.*)?$/i.test(v) || + (v.startsWith("http") && v.length < 600)); + +const isMetricArray = (arr: any[]): boolean => + Array.isArray(arr) && + arr.length > 0 && + arr.every( + (item) => + item && + typeof item === "object" && + !Array.isArray(item) && + Object.keys(item).length <= 3 && + ("label" in item || "key" in item || "name" in item || "component" in item) && + ("value" in item || "amount" in item || "total" in item) + ); + +const isObjectArray = (arr: any[]): boolean => + Array.isArray(arr) && + arr.length > 0 && + typeof arr[0] === "object" && + arr[0] !== null && + !Array.isArray(arr[0]); + +const flattenToKV = (obj: Record): { label: string; value: any }[] => + Object.entries(obj) + .filter(([, v]) => !isImageSrc(v) && (typeof v !== "object" || v === null)) + .map(([k, v]) => ({ label: humanLabel(k), value: v })); + +const extractLogo = (data: any): string | null => { + if (!data || typeof data !== "object") return null; + const candidates = [ + data.logo, + data.image, + data.companyLogo, + data.company?.logo, + data.header?.logo, + data.brand?.logo, + data.vendor?.logo, + ]; + return candidates.find(isImageSrc) ?? null; +}; + +const detectType = (data: any): DocType => { + if (!data || typeof data !== "object") return "generic"; + + const flat = Array.isArray(data) ? data[0] ?? {} : data; + const str = JSON.stringify(flat).toLowerCase(); + + const scores: Record = { + invoice: 0, + payslip: 0, + table: 0, + report: 0, + generic: 0, + }; + + [ + ["invoice", 4], ["invoicenumber", 5], ["invoiceno", 5], ["customer", 3], + ["client", 2], ["billto", 4], ["items", 2], ["lineitem", 5], ["subtotal", 5], + ["tax", 2], ["gst", 4], ["vat", 4], ["total", 2], ["grandtotal", 5], + ["duedate", 4], ["paymentterms", 5], ["vendor", 3], ["seller", 3], + ].forEach(([k, w]) => { if (str.includes(k as string)) scores.invoice += w as number; }); + + [ + ["payslip", 6], ["payroll", 5], ["salary", 4], ["salaryslip", 6], + ["employee", 4], ["employeeid", 5], ["earnings", 5], ["deductions", 5], + ["allowance", 4], ["basic", 3], ["hra", 5], ["pf", 3], ["epf", 5], + ["esi", 4], ["tds", 4], ["netpay", 6], ["grosspay", 6], ["net_pay", 6], + ["gross_pay", 6], ["department", 3], ["designation", 4], ["payperiod", 5], + ].forEach(([k, w]) => { if (str.includes(k as string)) scores.payslip += w as number; }); + + if (Array.isArray(data) && data.length > 0 && isObjectArray(data)) { + scores.table += 12; + } + + [ + ["report", 4], ["summary", 3], ["analysis", 3], ["dashboard", 3], + ["metric", 3], ["performance", 3], ["revenue", 3], ["kpi", 5], + ].forEach(([k, w]) => { if (str.includes(k as string)) scores.report += w as number; }); + + const winner = (Object.keys(scores) as DocType[]).reduce((a, b) => + scores[a] >= scores[b] ? a : b + ); + return scores[winner] >= 4 ? winner : "generic"; +}; + +const buildInvoice = (data: any, logo: string | null): DocumentModel => { + const sections: Section[] = []; + + const headerMeta: HeaderMeta[] = [ + data.invoiceNumber && { label: "Invoice #", value: data.invoiceNumber }, + data.invoiceNo && { label: "Invoice #", value: data.invoiceNo }, + data.invoice_number && { label: "Invoice #", value: data.invoice_number }, + data.date && { label: "Date", value: data.date }, + data.invoiceDate && { label: "Date", value: data.invoiceDate }, + data.dueDate && { label: "Due", value: data.dueDate }, + data.due_date && { label: "Due", value: data.due_date }, + ].filter(Boolean) as HeaderMeta[]; + + const detailKeys = ["invoiceNumber", "invoiceNo", "invoice_number", "invoice_no", "date", + "invoiceDate", "invoice_date", "dueDate", "due_date", "paymentTerms", "payment_terms", "currency", "reference"]; + const detailData = detailKeys + .filter((k) => data[k] !== undefined) + .map((k) => ({ label: humanLabel(k), value: data[k] })); + if (detailData.length > 0) + sections.push({ type: "kv", title: "Invoice Details", data: detailData }); + + const customerSrc = data.customer || data.client || data.billTo || data.bill_to; + if (customerSrc) { + if (typeof customerSrc === "string") { + sections.push({ type: "kv", title: "Bill To", data: [{ label: "Name", value: customerSrc }] }); + } else if (typeof customerSrc === "object") { + const kv = flattenToKV(customerSrc); + if (kv.length) sections.push({ type: "kv", title: "Bill To", data: kv }); + } + } + + const vendorSrc = data.vendor || data.seller || data.from || data.company; + if (vendorSrc && typeof vendorSrc === "object") { + const kv = flattenToKV(vendorSrc); + if (kv.length) sections.push({ type: "kv", title: "From", data: kv }); + } + + const items: any[] = data.items || data.lineItems || data.line_items || []; + if (items.length > 0 && typeof items[0] === "object") { + const keys = Object.keys(items[0]); + sections.push({ + type: "table", + title: "Line Items", + columns: keys.map(humanLabel), + columnKeys: keys, + rows: items.map((item) => keys.map((k) => item[k])), + }); + } + + const totalKeys = ["subtotal", "sub_total", "discount", "shipping", "tax", "gst", "vat", + "total", "grandTotal", "grand_total", "amountDue", "amount_due", "balanceDue", "balance_due"]; + const totalRows = totalKeys + .filter((k) => data[k] !== undefined) + .map((k) => ({ + label: humanLabel(k), + value: fmtCurrency(data[k]), + isGrand: ["total", "grandTotal", "grand_total", "amountDue", "amount_due", "balanceDue", "balance_due"] + .includes(k), + })); + if (totalRows.length) sections.push({ type: "total", rows: totalRows }); + + const notes = data.notes || data.terms || data.remarks || data.note; + if (notes && typeof notes === "string") + sections.push({ type: "text", title: "Notes & Terms", content: notes }); + + const used = new Set(["logo", "image", "title", "subtitle", "invoiceNumber", "invoiceNo", + "invoice_number", "invoice_no", "date", "invoiceDate", "invoice_date", "dueDate", "due_date", + "paymentTerms", "payment_terms", "currency", "reference", "customer", "client", "billTo", "bill_to", + "vendor", "seller", "from", "company", "items", "lineItems", "line_items", ...totalKeys, + "notes", "terms", "remarks", "note"]); + const remaining = Object.entries(data) + .filter(([k, v]) => !used.has(k) && typeof v !== "object" && !isImageSrc(v)) + .map(([k, v]) => ({ label: humanLabel(k), value: v })); + if (remaining.length) + sections.push({ type: "kv", title: "Additional Info", data: remaining }); + + return { + title: data.title || "INVOICE", + subtitle: data.invoiceNumber + ? `Invoice #${data.invoiceNumber}` + : data.invoiceNo + ? `Invoice #${data.invoiceNo}` + : undefined, + logo, + docType: "invoice", + headerMeta, + sections, + }; +}; + +const buildPayslip = (data: any, logo: string | null): DocumentModel => { + const sections: Section[] = []; + + const headerMeta: HeaderMeta[] = [ + (data.month || data.payPeriod) && { label: "Period", value: data.month || data.payPeriod }, + data.payDate && { label: "Pay Date", value: data.payDate }, + (data.employeeId || data.employee?.employeeId || data.employee?.id) && { + label: "Employee ID", + value: data.employeeId || data.employee?.employeeId || data.employee?.id, + }, + ].filter(Boolean) as HeaderMeta[]; + + const companySrc = data.company || data.employer || data.organization; + if (companySrc && typeof companySrc === "object") { + const kv = flattenToKV(companySrc); + if (kv.length) sections.push({ type: "kv", title: "Company", data: kv }); + } else if (data.companyName || data.company_name) { + sections.push({ type: "kv", title: "Company", data: [{ label: "Name", value: data.companyName || data.company_name }] }); + } + + const empSrc = data.employee || data.employeeDetails || data.emp; + if (empSrc && typeof empSrc === "object") { + const kv = flattenToKV(empSrc); + if (kv.length) sections.push({ type: "kv", title: "Employee Details", data: kv }); + } else { + const empKeys = ["employeeId", "employee_id", "empId", "name", "employeeName", "employee_name", + "department", "designation", "grade", "location", "pan", "uan", "bankAccount", "bank_account"]; + const empData = empKeys.filter((k) => data[k]).map((k) => ({ label: humanLabel(k), value: data[k] })); + if (empData.length) sections.push({ type: "kv", title: "Employee Details", data: empData }); + } + + const metaKeys = ["payPeriod", "pay_period", "month", "year", "payDate", "pay_date", "paymentMode", "payment_mode", "pfNumber", "uanNumber"]; + const metaData = metaKeys.filter((k) => data[k]).map((k) => ({ label: humanLabel(k), value: data[k] })); + if (metaData.length) sections.push({ type: "kv", title: "Pay Period", data: metaData }); + + const earningsSrc: any[] = data.earnings || data.allowances || []; + if (Array.isArray(earningsSrc) && earningsSrc.length > 0) { + if (isMetricArray(earningsSrc)) { + sections.push({ + type: "kv", + title: "Earnings", + data: earningsSrc.map((e) => ({ + label: e.label || e.key || e.name || e.component || "Item", + value: fmtCurrency(e.value ?? e.amount ?? e.total ?? 0), + })), + }); + } else if (isObjectArray(earningsSrc)) { + const keys = Object.keys(earningsSrc[0]); + sections.push({ + type: "table", + title: "Earnings", + columns: keys.map(humanLabel), + columnKeys: keys, + rows: earningsSrc.map((r) => keys.map((k) => r[k])), + }); + } + } + + const deductionsSrc: any[] = data.deductions || []; + if (Array.isArray(deductionsSrc) && deductionsSrc.length > 0) { + if (isMetricArray(deductionsSrc)) { + sections.push({ + type: "kv", + title: "Deductions", + data: deductionsSrc.map((d) => ({ + label: d.label || d.key || d.name || d.component || "Item", + value: fmtCurrency(d.value ?? d.amount ?? d.total ?? 0), + })), + }); + } else if (isObjectArray(deductionsSrc)) { + const keys = Object.keys(deductionsSrc[0]); + sections.push({ + type: "table", + title: "Deductions", + columns: keys.map(humanLabel), + columnKeys: keys, + rows: deductionsSrc.map((r) => keys.map((k) => r[k])), + }); + } + } + + const netPay = + data.netPay ?? data.net_pay ?? data.netSalary ?? data.net_salary ?? + data.takeHome ?? data.take_home; + if (netPay !== undefined) + sections.push({ type: "netpay", label: "Net Pay", value: fmtCurrency(netPay) }); + + return { + title: data.title || "SALARY SLIP", + subtitle: data.month + ? `Pay Period: ${data.month}${data.year ? " " + data.year : ""}` + : data.payPeriod || undefined, + logo, + docType: "payslip", + headerMeta, + sections, + }; +}; + +const buildTable = (data: any[]): DocumentModel => { + const keys = Object.keys(data[0] || {}); + return { + title: "Data Table", + docType: "table", + logo: null, + headerMeta: [{ label: "Rows", value: data.length }], + sections: [ + { + type: "table", + title: "Records", + columns: keys.map(humanLabel), + columnKeys: keys, + rows: data.map((row) => keys.map((k) => row[k])), + }, + ], + }; +}; + +const buildGeneric = (data: any, logo: string | null): DocumentModel => { + const sections: Section[] = []; + + const processNode = (node: any, title: string, depth = 0): void => { + if (node === null || node === undefined) return; + if (depth > 5) return; + + if (typeof node === "string") { + if (node.length > 100) { + sections.push({ type: "text", title, content: node }); + } else { + sections.push({ type: "kv", title, data: [{ label: title, value: node }] }); + } + return; + } + + if (typeof node !== "object") { + sections.push({ type: "kv", title, data: [{ label: title, value: node }] }); + return; + } + + if (Array.isArray(node)) { + if (node.length === 0) return; + if (isMetricArray(node)) { + sections.push({ + type: "metrics", + title, + data: node.map((item) => ({ + label: item.label || item.key || item.name || item.component || "—", + value: item.value ?? item.amount ?? item.total ?? "—", + })), + }); + return; + } + if (isObjectArray(node)) { + const keys = Object.keys(node[0]); + sections.push({ + type: "table", + title, + columns: keys.map(humanLabel), + columnKeys: keys, + rows: node.map((row) => keys.map((k) => row[k])), + }); + return; + } + sections.push({ type: "text", title, content: node.map(toStr).join(", ") }); + return; + } + + const scalars: { label: string; value: any }[] = []; + const nested: [string, any][] = []; + + Object.entries(node).forEach(([k, v]) => { + if (isImageSrc(v)) return; + if (v === null || typeof v !== "object") { + scalars.push({ label: humanLabel(k), value: v }); + } else { + nested.push([humanLabel(k), v]); + } + }); + + if (scalars.length) sections.push({ type: "kv", title, data: scalars }); + nested.forEach(([k, v]) => processNode(v, k, depth + 1)); + }; + + if (Array.isArray(data)) { + processNode(data, "Data"); + } else { + processNode(data, "Details"); + } + + return { + title: data?.title || data?.name || "Document", + subtitle: data?.subtitle || data?.description || undefined, + logo, + docType: "generic", + headerMeta: [], + sections, + }; +}; + +const generatePDF = async (model: DocumentModel, fileName?: string, pdfQuality: "high" | "mid" | "low" = "high"): void => { + const qualityMap = { high: { compress: false, precision: 16 }, mid: { compress: true, precision: 12 }, low: { compress: true, precision: 8 }, }; const q = qualityMap[pdfQuality] || qualityMap.high; const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4", compress: q.compress, precision: q.precision, }); const W = 210; const margin = 14; const colW = W - margin * 2; let y = margin; + + const C = { + primary: [15, 23, 42] as [number, number, number], + accent: [29, 78, 216] as [number, number, number], + green: [5, 150, 105] as [number, number, number], + white: [255, 255, 255] as [number, number, number], + text: [30, 30, 30] as [number, number, number], + muted: [107, 114, 128] as [number, number, number], + border: [229, 231, 235] as [number, number, number], + rowAlt: [249, 250, 251] as [number, number, number], + highlight: [239, 246, 255] as [number, number, number], + }; + + const docAccent: Record = { + invoice: [29, 78, 216], + payslip: [5, 150, 105], + table: [124, 58, 237], + report: [13, 148, 136], + generic: [68, 64, 60], + }; + const accent = docAccent[model.docType] ?? C.accent; + + const checkPage = (need = 20): void => { + if (y + need > 280) { pdf.addPage(); y = margin; } + }; + + const drawSectionBanner = (title: string): void => { + checkPage(12); + pdf.setFillColor(...accent); + pdf.rect(margin, y, colW, 8, "F"); + pdf.setTextColor(...C.white); + pdf.setFontSize(8.5); + pdf.setFont("helvetica", "bold"); + pdf.text(title.toUpperCase(), margin + 4, y + 5.5); + y += 11; + pdf.setTextColor(...C.text); + }; + + pdf.setFillColor(...C.primary); + pdf.rect(0, 0, W, 42, "F"); + + pdf.setFillColor(...accent); + pdf.rect(0, 40, W, 2, "F"); + + let logoOffset = 0; + const addSafeImage = async (src: string) => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "Anonymous"; + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + }; + + if (model.logo) { + try { + const img = await addSafeImage(model.logo); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + canvas.width = 200; + canvas.height = 200; + + ctx?.drawImage(img, 0, 0, 200, 200); + + const dataUrl = canvas.toDataURL("image/jpeg", 1); + + pdf.addImage( + dataUrl, + "JPEG", + margin, + 9, + 22, + 22, + undefined, + pdfQuality === "high" ? "NONE" : "FAST" + ); + + logoOffset = 27; + } catch (e) { + console.warn("Logo failed, fallback to icon"); + } + } + + pdf.setTextColor(...C.white); + pdf.setFont("helvetica", "bold"); + pdf.setFontSize(18); + pdf.text(model.title, margin + logoOffset, 21, { + baseline: "middle", + }); + + if (model.subtitle) { + pdf.setFontSize(9); + pdf.setFont("helvetica", "normal"); + pdf.setTextColor(200, 210, 230); + pdf.text(model.subtitle, margin + logoOffset, 30); + } + + pdf.setFillColor(...accent); + pdf.roundedRect(W - margin - 26, 11, 24, 8, 2, 2, "F"); + pdf.setTextColor(...C.white); + pdf.setFontSize(7); + pdf.setFont("helvetica", "bold"); + pdf.text(model.docType.toUpperCase(), W - margin - 14, 16.5, { align: "center" }); + + if (model.headerMeta.length > 0) { + let mx = W - margin; + let my = 27; + model.headerMeta.slice(0, 3).forEach((m) => { + const str = `${m.label}: ${toStr(m.value)}`; + pdf.setFontSize(7.5); + pdf.setFont("helvetica", "normal"); + pdf.setTextColor(180, 200, 220); + pdf.text(str, mx, my, { align: "right" }); + my += 5; + }); + } + + y = 50; + pdf.setTextColor(...C.text); + + for (const section of model.sections) { + checkPage(18); + if (section.type === "kv") { + drawSectionBanner(section.title); + + const half = (colW - 4) / 2; + + for (let i = 0; i < section.data.length; i += 2) { + const left = section.data[i]; + const right = section.data[i + 1]; + + const leftLines = pdf.splitTextToSize(toStr(left?.value), half - 6); + const rightLines = right + ? pdf.splitTextToSize(toStr(right?.value), half - 6) + : []; + + const leftHeight = 6 + leftLines.length * 5; + const rightHeight = 6 + rightLines.length * 5; + + const rowHeight = Math.max(12, leftHeight, rightHeight); + + checkPage(rowHeight + 4); + + pdf.setFillColor(...C.rowAlt); + pdf.rect(margin, y, half, rowHeight, "F"); + pdf.setFont("helvetica", "bold"); + pdf.setFontSize(7); + pdf.setTextColor(...C.muted); + pdf.text(left.label, margin + 3, y + 4); + pdf.setFont("helvetica", "normal"); + pdf.setFontSize(8.5); + pdf.setTextColor(...C.text); + pdf.text(leftLines, margin + 3, y + 9); + + if (right) { + const xRight = margin + half + 4; + + pdf.setFillColor(...C.white); + pdf.rect(xRight, y, half, rowHeight, "F"); + pdf.setFont("helvetica", "bold"); + pdf.setFontSize(7); + pdf.setTextColor(...C.muted); + pdf.text(right.label, xRight + 3, y + 4); + pdf.setFont("helvetica", "normal"); + pdf.setFontSize(8.5); + pdf.setTextColor(...C.text); + pdf.text(rightLines, xRight + 3, y + 9); + } + y += rowHeight + 3; + } + + y += 5; + } + + if (section.type === "table") { + drawSectionBanner(section.title); + if (!Array.isArray(section.rows)) return; + autoTable(pdf, { + startY: y, + head: [section.columns], + body: section.rows.map((row) => { + if (Array.isArray(row)) { + return row.map((c) => toStr(c)); + } + + return section.columnKeys.map((key) => + toStr(row?.[key]) + ); + }), + margin: { left: margin, right: margin }, + styles: { + fontSize: 8, + cellPadding: 3, + lineColor: C.border, + lineWidth: 0.2, + textColor: C.text, + font: "helvetica", + valign: "middle", + overflow: "linebreak", + }, + headStyles: { + fillColor: accent, + textColor: C.white, + fontStyle: "bold", + fontSize: 8.5, + }, + alternateRowStyles: { fillColor: C.rowAlt }, + tableWidth: colW, + didDrawPage: (d: any) => { y = d.cursor.y + 4; }, + }); + y = (pdf as any).lastAutoTable?.finalY + 8 ?? y; + } + + if (section.type === "text") { + drawSectionBanner(section.title); + pdf.setFont("helvetica", "normal"); + pdf.setFontSize(8.5); + pdf.setTextColor(...C.muted); + const lines = pdf.splitTextToSize(section.content, colW - 6); + lines.forEach((line: string) => { + checkPage(6); + pdf.text(line, margin + 3, y + 4); + y += 5.5; + }); + y += 5; + } + + if (section.type === "metrics") { + drawSectionBanner(section.title); + const cardW = (colW - 8) / 3; + let col = 0; + let rowY = y; + + section.data.forEach((m) => { + const xOff = margin + col * (cardW + 4); + if (col === 0) rowY = y; + checkPage(22); + + pdf.setFillColor(...C.highlight); + pdf.roundedRect(xOff, rowY, cardW, 18, 2, 2, "F"); + pdf.setFillColor(...accent); + pdf.rect(xOff, rowY, 2.5, 18, "F"); + + pdf.setFont("helvetica", "bold"); + pdf.setFontSize(10); + pdf.setTextColor(...accent); + pdf.text(toStr(m.value), xOff + cardW / 2, rowY + 9, { align: "center" }); + + pdf.setFont("helvetica", "normal"); + pdf.setFontSize(7); + pdf.setTextColor(...C.muted); + pdf.text(m.label, xOff + cardW / 2, rowY + 15, { align: "center" }); + + col++; + if (col === 3) { col = 0; y += 22; } + }); + if (col !== 0) y += 22; + y += 5; + } + + if (section.type === "netpay") { + checkPage(26); + pdf.setFillColor(...C.green); + pdf.rect(margin, y, colW, 22, "F"); + + pdf.setFillColor(0, 120, 80); + pdf.rect(margin, y, 4, 22, "F"); + + pdf.setTextColor(...C.white); + pdf.setFont("helvetica", "bold"); + pdf.setFontSize(10); + pdf.text(section.label.toUpperCase(), margin + 9, y + 9); + + pdf.setFont("helvetica", "normal"); + pdf.setFontSize(8); + pdf.setTextColor(180, 240, 220); + pdf.text("Amount (INR)", margin + 9, y + 17); + + pdf.setFont("helvetica", "bold"); + pdf.setFontSize(16); + pdf.setTextColor(...C.white); + pdf.text(toStr(section.value), W - margin - 6, y + 14, { + align: "right", + baseline: "middle", + }); + + y += 28; + } + + if (section.type === "total") { + checkPage(section.rows.length * 10 + 10); + const totW = Math.min(colW, 160); + const totX = W - margin - totW; + + section.rows.forEach((row) => { + if (row.isGrand) { + pdf.setFillColor(...C.highlight); + pdf.rect(totX, y, totW, 11, "F"); + pdf.setDrawColor(...accent); + pdf.setLineWidth(0.5); + pdf.line(totX, y, totX + totW, y); + } + pdf.setFont("helvetica", row.isGrand ? "bold" : "normal"); + pdf.setFontSize(row.isGrand ? 9.5 : 8.5); + pdf.setTextColor(...(row.isGrand ? accent : C.muted)); + pdf.text(row.label, totX + 4, y + 7); + pdf.text(String(row.value), W - margin - 4, y + 7, { align: "right" }); + y += row.isGrand ? 13 : 10; + }); + y += 5; + } + } + + const totalPages = pdf.getNumberOfPages(); + for (let p = 1; p <= totalPages; p++) { + pdf.setPage(p); + pdf.setFillColor(...C.primary); + pdf.rect(0, 287, W, 10, "F"); + pdf.setTextColor(...C.white); + pdf.setFont("helvetica", "normal"); + pdf.setFontSize(7); + pdf.text( + `${model.title} | Page ${p} of ${totalPages} | Generated by Smart PDF Engine`, + W / 2, 293, + { align: "center" } + ); + } + + pdf.save(fileName || `${model.title.replace(/\s+/g, "_")}.pdf`); +}; + +const ACCENT_CLASSES = ["accent-blue", "accent-teal", "accent-violet", "accent-amber", "accent-green", "accent-rose"]; +const STRIPE_CLASSES = ["stripe-blue", "stripe-teal", "stripe-violet", "stripe-amber", "stripe-green", "stripe-rose"]; + +const getAccentClass = (i: number): string => ACCENT_CLASSES[i % ACCENT_CLASSES.length]; +const getStripeClass = (i: number): string => STRIPE_CLASSES[i % STRIPE_CLASSES.length]; + +const DOC_ICONS: Record = { + invoice: "🧾", + payslip: "💰", + table: "📊", + report: "📈", + generic: "📄", +}; + +const isNumericLike = (v: any): boolean => { + if (typeof v === "number") return true; + if (typeof v === "string") return !isNaN(parseFloat(v)) && /^[\d,.% ]+$/.test(v.trim()); + return false; +}; + +const isCurrencyLike = (label: string, v: any): boolean => { + const l = label.toLowerCase(); + return ( + (l.includes("amount") || l.includes("salary") || l.includes("pay") || + l.includes("total") || l.includes("cost") || l.includes("price") || + l.includes("fee") || l.includes("tax") || l.includes("gst")) && + isNumericLike(v) + ); +}; + +const RenderKV: React.FC<{ section: KVSection; idx: number }> = ({ section, idx }) => ( +
+
+
+
{section.title}
+
+
+
+ {section.data.map((item, j) => { + const currency = isCurrencyLike(item.label, item.value); + const numeric = !currency && isNumericLike(item.value); + return ( +
+
{item.label}
+
+ {currency ? fmtCurrency(item.value) : toStr(item.value)} +
+
+ ); + })} +
+
+); + +const RenderTable: React.FC<{ section: TableSection; idx: number }> = ({ section, idx }) => ( +
+
+
+
{section.title}
+
+
+
+ + + + {section.columns.map((col, c) => )} + + + + {section.rows.map((row: any, i: number) => ( + + + {Array.isArray(row) && + row.map((cell: any, j: number) => ( + + ))} + + {!Array.isArray(row) && + section.columnKeys?.map((key: string, j: number) => ( + + ))} + + + ))} + +
{col}
+ {toStr(cell)} + + {toStr(row[key])} +
+
+
{section.rows.length} row{section.rows.length !== 1 ? "s" : ""}
+
+); + +const RenderText: React.FC<{ section: TextSection; idx: number }> = ({ section, idx }) => ( +
+
+
+
{section.title}
+
+
+
{section.content}
+
+); + +const RenderMetrics: React.FC<{ section: MetricsSection; idx: number }> = ({ section, idx }) => ( +
+
+
+
{section.title}
+
+
+
+ {section.data.map((m, j) => ( +
+
{toStr(m.value)}
+
{m.label}
+
+ ))} +
+
+); + +const RenderNetPay: React.FC<{ section: NetPaySection }> = ({ section }) => ( +
+
+
+
{section.label}
+
+
{toStr(section.value)}
+
+
+); + +const RenderTotal: React.FC<{ section: TotalSection }> = ({ section }) => ( +
+
+ {section.rows.map((row, i) => ( +
+ {row.label} + {toStr(row.value)} +
+ ))} +
+
+); + +type Props = { + data?: any; + fileName?: string; + pdfQuality?: "high" | "mid" | "low"; + showPreview?: boolean; + showDownload?: boolean; +}; + +export const ReportComponent: React.FC = ({ + data, + fileName: propFileName, + pdfQuality: propPdfQuality, + showPreview: propShowPreview, + showDownload: propShowDownload +}) => { + const [schema] = Retool.useStateObject({ + name: "schema", + label: "Input Data", + description: "Provide the JSON data to generate and render the document.", + }); + + const [fileName] = Retool.useStateString({ + name: "fileName", + label: "File Name", + description: "Optional name for the downloaded PDF file. If empty, a default name will be used.", + }); + + const [pdfQuality] = Retool.useStateEnumeration({ + name: "pdfQuality", + enumDefinition: ["high", "mid", "low"], + initialValue: "high", + enumLabels: { high: "High", mid: "Medium", low: "Low", }, + inspector: "select", + label: "PDF Quality", + description: "Controls PDF size (not visual quality)", + }); + + const [showPreviewRaw] = Retool.useStateBoolean({ + name: "showPreview", + initialValue: true, + label: "Show Preview", + inspector: "checkbox", + description: "Display a live preview of the document inside the component.", + }); + + const [showDownloadRaw] = Retool.useStateBoolean({ + name: "showDownload", + initialValue: true, + label: "Show Download Button", + inspector: "checkbox", + description: "Show the download button to export the document as a PDF.", + }); + + const [buttonAlign] = Retool.useStateEnumeration({ + name: "buttonAlign", + label: "Button Alignment", + inspector: "select", + enumDefinition: ["left", "center", "right", "full"], + enumLabels: { + left: "Left Align", + center: "Center Align", + right: "Right Align", + full: "Full Width" + }, + description: "Controls position of download button", + initialValue: "right" + }); + + const [buttonVariant] = Retool.useStateEnumeration({ + name: "buttonVariant", + label: "Button Style", + inspector: "select", + enumDefinition: ["text", "icon", "both"], + enumLabels: { + text: "Text Only", + icon: "Icon Only", + both: "Icon + Text" + }, + description: "Choose how button appears", + initialValue: "both" + }); + + const [buttonIcon] = Retool.useStateEnumeration({ + name: "buttonIcon", + label: "Button Icon", + inspector: "select", + enumDefinition: ["download", "file", "arrow"], + enumLabels: { + download: "Download Icon", + file: "File Icon", + arrow: "Arrow Icon" + }, + initialValue: "download", + description: "Select icon for the download button" + }); + + const [buttonText] = Retool.useStateString({ + name: "buttonText", + label: "Button Text", + initialValue: "Download PDF", + description: "Customize the label displayed on the download button. Leave empty to use the default text.", + }); + + const finalData = data ?? schema; + const finalFileName = propFileName ?? fileName; + const finalPdfQuality = propPdfQuality ?? pdfQuality; + const showPreview = propShowPreview ?? showPreviewRaw; + const showDownload = propShowDownload ?? showDownloadRaw; + + const normalizeAnyData = (input: any): DocumentModel => { + if (!input) { + return { + title: "Empty", + docType: "generic", + logo: null, + headerMeta: [], + sections: [], + }; + } + + if (input.sections) { + const detectedType = input.docType || detectType(input); + return { + title: input.title || "Document", + subtitle: input.subtitle || "", + docType: detectedType, + logo: input.logo || null, + headerMeta: input.headerMeta || [], + sections: input.sections.map((sec: any) => { + if (sec.type === "grid") { + return { type: "kv", title: sec.title, data: sec.data || [] }; + } + + if (sec.type === "summary") { + return { + type: "total", + rows: (sec.data || []).map((d: any) => ({ + label: d.label, + value: d.value, + isGrand: d.label?.toLowerCase().includes("total"), + })), + }; + } + + if (sec.type === "table") { + const cols = sec.columns || []; + const keys = cols.map((c: any) => + typeof c === "string" ? c : c.key + ); + + return { + type: "table", + title: sec.title, + columns: cols.map((c: any) => + typeof c === "string" ? c : c.label + ), + columnKeys: keys, + rows: (sec.rows || []).map((row: any) => + Array.isArray(row) ? row : keys.map((k) => row?.[k]) + ), + }; + } + + if (sec.type === "text") { + return { + type: "text", + title: sec.title, + content: sec.value || sec.content || "", + }; + } + + return sec; + }), + }; + } + + const sections: any[] = []; + + Object.entries(input).forEach(([key, value]) => { + if (["title", "subtitle", "docType", "logo", "headerMeta"].includes(key)) + return; + + if ( + typeof value === "object" && + !Array.isArray(value) && + Object.values(value).every(v => typeof v !== "object") + ) { + sections.push({ + type: "kv", + title: humanLabel(key), + data: Object.entries(value).map(([k, v]) => ({ + label: humanLabel(k), + value: v, + })), + }); + } + + else if (Array.isArray(value)) { + if (value.length === 0) return; + + const first = value[0]; + + if (typeof first === "object") { + const keys = Object.keys(first); + + sections.push({ + type: "table", + title: humanLabel(key), + columns: keys.map(humanLabel), + columnKeys: keys, + rows: value.map((row: any) => + keys.map((k) => row?.[k]) + ), + }); + } else { + sections.push({ + type: "text", + title: humanLabel(key), + content: value.join(", "), + }); + } + } + + else { + sections.push({ + type: "kv", + title: humanLabel(key), + data: [{ label: humanLabel(key), value }], + }); + } + }); + + const detectedType = input.docType || detectType(input); + + return { + title: input.title || "Document", + subtitle: input.subtitle || input.month || "", + docType: detectedType, + logo: input.logo || null, + headerMeta: input.headerMeta || [], + sections, + }; + }; + + const model = useMemo(() => { + try { + const safe = JSON.parse(JSON.stringify(finalData ?? {})); + return normalizeAnyData(safe); + } catch { + return { + title: "Error", + docType: "generic", + logo: null, + headerMeta: [], + sections: [ + { type: "text", title: "Error", content: "Invalid data." } + ], + }; + } + }, [finalData]); + + const handleDownload = async (): Promise => { + await generatePDF(model, finalFileName, finalPdfQuality); + }; + + + return ( +
+ + {!showPreview && !showDownload && ( +
+ Nothing to display +
+ )} + + {(showPreview || showDownload) && ( + <> + +
+ + {showPreview && ( +
+
+ + {DOC_ICONS[model.docType]} {model.docType} +
+ + {model.sections.length} section + {model.sections.length !== 1 ? "s" : ""} + +
+ )} + {showDownload && ( +
+ +
+ )} +
+ + {showPreview && ( +
+
+
+ +
+
+ {model.logo ? ( + Logo { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + {DOC_ICONS[model.docType]} + + )} +
+ +
+
{model.title}
+ {model.subtitle && ( +
+ {model.subtitle} +
+ )} +
+
+ + {model.headerMeta.length > 0 && ( +
+ {model.headerMeta.slice(0, 3).map((m, i) => ( +
+ + {m.label} + + + {toStr(m.value)} + +
+ ))} +
+ )} +
+
+ +
+ {model.sections.length === 0 ? ( +
+
📋
+
No content to display
+
+ ) : ( + model.sections.map((section, i) => { + if (section.type === "kv") { + return ; + } + + if (section.type === "table") { + return ; + } + + if (section.type === "text") { + return ; + } + + if (section.type === "metrics") { + return ; + } + + if (section.type === "netpay") { + return ; + } + + if (section.type === "total") { + return ; + } + + return null; + }) + )} +
+
+ )} + + )} +
+ ); +}; \ No newline at end of file diff --git a/components/smart-pdf-engine/src/component/smartPdfWrapper.tsx b/components/smart-pdf-engine/src/component/smartPdfWrapper.tsx new file mode 100644 index 0000000..504d18b --- /dev/null +++ b/components/smart-pdf-engine/src/component/smartPdfWrapper.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { Retool } from "@tryretool/custom-component-support"; +import { ReportComponent } from "./smartPdfComponent"; + +export const ReportWrapper: React.FC = () => { + const [schema] = Retool.useStateObject({ + name: "schema", + label: "Input Data", + description: "Provide the JSON data to generate and render the document.", + }); + + const [fileName] = Retool.useStateString({ + name: "fileName", + label: "File Name", + description: "Optional name for the downloaded PDF file. If empty, a default name will be used.", + }); + + const [pdfQuality] = Retool.useStateEnumeration({ + name: "pdfQuality", + enumDefinition: ["high", "mid", "low"], + initialValue: "high", + enumLabels: { high: "High", mid: "Medium", low: "Low", }, + inspector: "select", label: "PDF Quality", + description: "Controls PDF size (not visual quality)", + }); + + const [showPreviewRaw] = Retool.useStateBoolean({ + name: "showPreview", + initialValue: true, + label: "Show Preview", + inspector: "checkbox", + description: "Display a live preview of the document inside the component.", + }); + + const [showDownloadRaw] = Retool.useStateBoolean({ + name: "showDownload", + initialValue: true, + label: "Show Download", + inspector: "checkbox", + description: "Show the download button to export the document as a PDF.", + }); + + const showPreview = !!showPreviewRaw; + const showDownload = !!showDownloadRaw; + + return ( +
+ +
+ ); +}; + +export default ReportWrapper; diff --git a/components/smart-pdf-engine/src/index.tsx b/components/smart-pdf-engine/src/index.tsx new file mode 100644 index 0000000..2a9b062 --- /dev/null +++ b/components/smart-pdf-engine/src/index.tsx @@ -0,0 +1 @@ +export { ReportWrapper } from "./component/smartPdfWrapper";