feat(pdf): brand kit foundation for @react-pdf/renderer

Phase 1 / commit 1 of 14 — installs deps and lays down the brand-kit
primitives used by every internal-only PDF. No callers wired yet.

Adds:
  @react-pdf/renderer 4.5.1   one engine for internal exports
  unpdf 1.6.2                 reserved for berth-PDF parser tier-2
  react-image-crop 11.0.10    admin logo crop UI (commit 2)
  svgo 4.0.1                  SVG sanitization on logo upload (commit 2)

brand-kit/
  tokens.ts          single source of truth for colors/fonts/spacing
  logo.ts            resolvePortLogo() — cached, soft-fallback
  DocumentShell      <Document><Page> + fixed Header + fixed Footer
  Header             dark band, logo slot (letterboxed) + text fallback
  Footer             page N of M + generated-at + confidential tag
  Section            heading + bottom border
  KeyValueGrid       2-col (default) or stacked label/value
  DataTable          zebra rows + sticky header + totals row + empty state
  Badge              5 tone pills
  charts/
    BarChart         pure SVG, 4-tick y-axis, optional value labels
    LineChart        pure SVG, line + markers + grid
    PieChart         pure SVG, donut-or-pie + side legend
    FunnelChart      pure SVG, slope-cut slices for pipeline stages

render.ts            renderToBuffer + renderToStream wrappers, typed

svg-primitives.tsx   <SvgLabel> wraps react-pdf SVG <Text> to bridge
                     missing TS declarations for fontSize/fontFamily

Smoke test renders a kitchen-sink Document including every primitive
and every chart, plus an empty-data path. 1293+4 vitest tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:45:28 +02:00
parent 81a98c6695
commit 73184c51e0
22 changed files with 1758 additions and 1 deletions

View File

@@ -57,6 +57,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^1.0.12",
"@react-pdf/renderer": "^4.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"@tanstack/query-broadcast-client-experimental": "^5.100.10",
"@tanstack/react-query": "^5.100.10",
@@ -97,15 +98,18 @@
"react-easy-crop": "^5.5.7",
"react-email": "^6.1.3",
"react-hook-form": "^7.75.0",
"react-image-crop": "^11.0.10",
"recharts": "^3.8.1",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"svgo": "^4.0.1",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"tesseract.js": "^7.0.0",
"ts-pattern": "^5.9.0",
"unpdf": "^1.6.2",
"vaul": "^1.1.2",
"web-vitals": "^5.2.0",
"zod": "^4.4.3",

345
pnpm-lock.yaml generated
View File

@@ -100,6 +100,9 @@ importers:
'@react-email/components':
specifier: ^1.0.12
version: 1.0.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf/renderer':
specifier: ^4.5.1
version: 4.5.1(react@19.2.6)
'@socket.io/redis-adapter':
specifier: ^8.3.0
version: 8.3.0(socket.io-adapter@2.5.6)
@@ -220,6 +223,9 @@ importers:
react-hook-form:
specifier: ^7.75.0
version: 7.75.0(react@19.2.6)
react-image-crop:
specifier: ^11.0.10
version: 11.0.10(react@19.2.6)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1)
@@ -235,6 +241,9 @@ importers:
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
svgo:
specifier: ^4.0.1
version: 4.0.1
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
@@ -247,6 +256,9 @@ importers:
ts-pattern:
specifier: ^5.9.0
version: 5.9.0
unpdf:
specifier: ^1.6.2
version: 1.6.2
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -2030,6 +2042,49 @@ packages:
resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==}
hasBin: true
'@react-pdf/fns@3.1.3':
resolution: {integrity: sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==}
'@react-pdf/font@4.0.8':
resolution: {integrity: sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==}
'@react-pdf/image@3.1.0':
resolution: {integrity: sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==}
'@react-pdf/layout@4.6.1':
resolution: {integrity: sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==}
'@react-pdf/pdfkit@5.1.1':
resolution: {integrity: sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==}
'@react-pdf/primitives@4.3.0':
resolution: {integrity: sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==}
'@react-pdf/reconciler@2.0.0':
resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-pdf/render@4.5.1':
resolution: {integrity: sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==}
'@react-pdf/renderer@4.5.1':
resolution: {integrity: sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-pdf/stylesheet@6.2.1':
resolution: {integrity: sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==}
'@react-pdf/svg@1.1.0':
resolution: {integrity: sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==}
'@react-pdf/textkit@6.3.0':
resolution: {integrity: sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==}
'@react-pdf/types@2.11.1':
resolution: {integrity: sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==}
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
@@ -2536,6 +2591,9 @@ packages:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
abs-svg-path@0.1.1:
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -2859,6 +2917,9 @@ packages:
bmp-js@0.1.0:
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@1.1.14:
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
@@ -3020,6 +3081,10 @@ packages:
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
commander@11.1.0:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
commander@13.1.0:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
@@ -3085,15 +3150,30 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
css-tree@2.2.1:
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
csso@5.0.5:
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -3398,6 +3478,9 @@ packages:
electron-to-chromium@1.5.352:
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
emoji-regex-xs@1.0.0:
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -3693,6 +3776,9 @@ packages:
picomatch:
optional: true
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -3862,6 +3948,12 @@ packages:
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
hsl-to-hex@1.0.0:
resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
hsl-to-rgb-for-reals@1.1.1:
resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -3884,6 +3976,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
hyphen@1.14.1:
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -4125,6 +4220,9 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jay-peg@1.1.1:
resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
jiti@1.21.7:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
@@ -4415,9 +4513,15 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
media-engine@1.0.3:
resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
@@ -4609,12 +4713,18 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
normalize-svg-path@1.1.0:
resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
normalize-wheel@1.0.1:
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
notepack.io@3.0.1:
resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==}
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
nypm@0.6.6:
resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==}
engines: {node: '>=18'}
@@ -4753,6 +4863,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse-svg-path@0.1.2:
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
@@ -4848,6 +4961,9 @@ packages:
png-js@1.1.0:
resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==}
png-js@2.0.0:
resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -4951,6 +5067,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
@@ -4994,6 +5113,11 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-image-crop@11.0.10:
resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==}
peerDependencies:
react: '>=16.13.1'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -5197,6 +5321,9 @@ packages:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.25.0-rc-603e6108-20241029:
resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -5494,6 +5621,14 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svg-arc-to-cubic-bezier@3.2.0:
resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
svgo@4.0.1:
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
engines: {node: '>=16'}
hasBin: true
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -5691,6 +5826,14 @@ packages:
unload@2.4.1:
resolution: {integrity: sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==}
unpdf@1.6.2:
resolution: {integrity: sha512-zQ80ySoPuPHOsvIoRp/nJyQt8TOUoTh1+WBCGcBvlddQNgKDLRwm0AY3x8Q35I7+kIiRSgqMx+Ma2pl9McIp7A==}
peerDependencies:
'@napi-rs/canvas': ^0.1.69
peerDependenciesMeta:
'@napi-rs/canvas':
optional: true
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
@@ -5755,6 +5898,10 @@ packages:
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-compatible-readable-stream@3.6.1:
resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
engines: {node: '>= 6'}
vite@8.0.5:
resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -6005,6 +6152,9 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
yoga-layout@3.2.1:
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
zip-stream@6.0.1:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
@@ -7557,6 +7707,110 @@ snapshots:
smol-toml: 1.6.1
tinyexec: 1.1.2
'@react-pdf/fns@3.1.3': {}
'@react-pdf/font@4.0.8':
dependencies:
'@react-pdf/pdfkit': 5.1.1
'@react-pdf/types': 2.11.1
fontkit: 2.0.4
is-url: 1.2.4
'@react-pdf/image@3.1.0':
dependencies:
'@react-pdf/svg': 1.1.0
jay-peg: 1.1.1
png-js: 2.0.0
'@react-pdf/layout@4.6.1':
dependencies:
'@react-pdf/fns': 3.1.3
'@react-pdf/image': 3.1.0
'@react-pdf/primitives': 4.3.0
'@react-pdf/stylesheet': 6.2.1
'@react-pdf/textkit': 6.3.0
'@react-pdf/types': 2.11.1
emoji-regex-xs: 1.0.0
queue: 6.0.2
yoga-layout: 3.2.1
'@react-pdf/pdfkit@5.1.1':
dependencies:
'@babel/runtime': 7.29.2
'@noble/ciphers': 1.3.0
'@noble/hashes': 1.8.0
browserify-zlib: 0.2.0
fontkit: 2.0.4
jay-peg: 1.1.1
js-md5: 0.8.3
linebreak: 1.1.0
png-js: 2.0.0
vite-compatible-readable-stream: 3.6.1
'@react-pdf/primitives@4.3.0': {}
'@react-pdf/reconciler@2.0.0(react@19.2.6)':
dependencies:
object-assign: 4.1.1
react: 19.2.6
scheduler: 0.25.0-rc-603e6108-20241029
'@react-pdf/render@4.5.1':
dependencies:
'@babel/runtime': 7.29.2
'@react-pdf/fns': 3.1.3
'@react-pdf/primitives': 4.3.0
'@react-pdf/textkit': 6.3.0
'@react-pdf/types': 2.11.1
abs-svg-path: 0.1.1
color-string: 2.1.4
normalize-svg-path: 1.1.0
parse-svg-path: 0.1.2
svg-arc-to-cubic-bezier: 3.2.0
'@react-pdf/renderer@4.5.1(react@19.2.6)':
dependencies:
'@babel/runtime': 7.29.2
'@react-pdf/fns': 3.1.3
'@react-pdf/font': 4.0.8
'@react-pdf/layout': 4.6.1
'@react-pdf/pdfkit': 5.1.1
'@react-pdf/primitives': 4.3.0
'@react-pdf/reconciler': 2.0.0(react@19.2.6)
'@react-pdf/render': 4.5.1
'@react-pdf/types': 2.11.1
events: 3.3.0
object-assign: 4.1.1
prop-types: 15.8.1
queue: 6.0.2
react: 19.2.6
'@react-pdf/stylesheet@6.2.1':
dependencies:
'@react-pdf/fns': 3.1.3
'@react-pdf/types': 2.11.1
color-string: 2.1.4
hsl-to-hex: 1.0.0
media-engine: 1.0.3
postcss-value-parser: 4.2.0
'@react-pdf/svg@1.1.0':
dependencies:
'@react-pdf/primitives': 4.3.0
'@react-pdf/textkit@6.3.0':
dependencies:
'@react-pdf/fns': 3.1.3
bidi-js: 1.0.3
hyphen: 1.14.1
unicode-properties: 1.4.1
'@react-pdf/types@2.11.1':
dependencies:
'@react-pdf/font': 4.0.8
'@react-pdf/primitives': 4.3.0
'@react-pdf/stylesheet': 6.2.1
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -8021,6 +8275,8 @@ snapshots:
dependencies:
event-target-shim: 5.0.1
abs-svg-path@0.1.1: {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -8324,6 +8580,8 @@ snapshots:
bmp-js@0.1.0: {}
boolbase@1.0.0: {}
brace-expansion@1.1.14:
dependencies:
balanced-match: 1.0.2
@@ -8499,6 +8757,8 @@ snapshots:
colorette@2.0.20: {}
commander@11.1.0: {}
commander@13.1.0: {}
commander@14.0.3: {}
@@ -8567,13 +8827,32 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
css-what: 6.2.2
domhandler: 5.0.3
domutils: 3.2.2
nth-check: 2.1.1
css-tree@2.2.1:
dependencies:
mdn-data: 2.0.28
source-map-js: 1.2.1
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
css-what@6.2.2: {}
cssesc@3.0.0: {}
csso@5.0.5:
dependencies:
css-tree: 2.2.1
csstype@3.2.3: {}
d3-array@3.2.4:
@@ -8765,6 +9044,8 @@ snapshots:
electron-to-chromium@1.5.352: {}
emoji-regex-xs@1.0.0: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -9230,6 +9511,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
fflate@0.8.2: {}
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -9412,6 +9695,12 @@ snapshots:
dependencies:
react-is: 16.13.1
hsl-to-hex@1.0.0:
dependencies:
hsl-to-rgb-for-reals: 1.1.1
hsl-to-rgb-for-reals@1.1.1: {}
html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0):
dependencies:
'@exodus/bytes': 1.15.0(@noble/hashes@2.2.0)
@@ -9439,6 +9728,8 @@ snapshots:
husky@9.1.7: {}
hyphen@1.14.1: {}
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -9689,6 +9980,10 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jay-peg@1.1.1:
dependencies:
restructure: 3.0.2
jiti@1.21.7: {}
jiti@2.4.2: {}
@@ -9962,8 +10257,12 @@ snapshots:
math-intrinsics@1.1.0: {}
mdn-data@2.0.28: {}
mdn-data@2.27.1: {}
media-engine@1.0.3: {}
memory-pager@1.5.0:
optional: true
@@ -10134,10 +10433,18 @@ snapshots:
normalize-path@3.0.0: {}
normalize-svg-path@1.1.0:
dependencies:
svg-arc-to-cubic-bezier: 3.2.0
normalize-wheel@1.0.1: {}
notepack.io@3.0.1: {}
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
nypm@0.6.6:
dependencies:
citty: 0.2.2
@@ -10281,6 +10588,8 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse-svg-path@0.1.2: {}
parse5@8.0.1:
dependencies:
entities: 8.0.0
@@ -10390,6 +10699,10 @@ snapshots:
dependencies:
browserify-zlib: 0.2.0
png-js@2.0.0:
dependencies:
fflate: 0.8.2
possible-typed-array-names@1.1.0: {}
postcss-import@15.1.0(postcss@8.5.14):
@@ -10474,6 +10787,10 @@ snapshots:
queue-microtask@1.2.3: {}
queue@6.0.2:
dependencies:
inherits: 2.0.4
quick-format-unescaped@4.0.4: {}
react-day-picker@10.0.0(react@19.2.6):
@@ -10536,6 +10853,10 @@ snapshots:
dependencies:
react: 19.2.6
react-image-crop@11.0.10(react@19.2.6):
dependencies:
react: 19.2.6
react-is@16.13.1: {}
react-is@18.3.1: {}
@@ -10774,6 +11095,8 @@ snapshots:
dependencies:
xmlchars: 2.2.0
scheduler@0.25.0-rc-603e6108-20241029: {}
scheduler@0.27.0: {}
secure-json-parse@4.1.0: {}
@@ -11143,6 +11466,18 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svg-arc-to-cubic-bezier@3.2.0: {}
svgo@4.0.1:
dependencies:
commander: 11.1.0
css-select: 5.2.2
css-tree: 3.2.1
css-what: 6.2.2
csso: 5.0.5
picocolors: 1.1.1
sax: 1.6.0
symbol-tree@3.2.4: {}
tagged-tag@1.0.0: {}
@@ -11387,6 +11722,8 @@ snapshots:
unload@2.4.1: {}
unpdf@1.6.2: {}
unrs-resolver@1.11.1:
dependencies:
napi-postinstall: 0.3.4
@@ -11478,6 +11815,12 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite-compatible-readable-stream@3.6.1:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4):
dependencies:
lightningcss: 1.32.0
@@ -11695,6 +12038,8 @@ snapshots:
yoctocolors@2.1.2: {}
yoga-layout@3.2.1: {}
zip-stream@6.0.1:
dependencies:
archiver-utils: 5.0.2

View File

@@ -0,0 +1,42 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import { PDF_TOKENS } from './tokens';
export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger';
const toneStyles: Record<BadgeTone, { background: string; foreground: string }> = {
neutral: { background: PDF_TOKENS.colors.border, foreground: PDF_TOKENS.colors.text },
accent: { background: PDF_TOKENS.colors.accentBlue, foreground: '#ffffff' },
success: { background: PDF_TOKENS.colors.success, foreground: '#ffffff' },
warning: { background: PDF_TOKENS.colors.warning, foreground: '#ffffff' },
danger: { background: PDF_TOKENS.colors.danger, foreground: '#ffffff' },
};
const styles = StyleSheet.create({
pill: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
alignSelf: 'flex-start',
},
label: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.caption,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
});
export interface BadgeProps {
text: string;
tone?: BadgeTone;
}
export function Badge({ text, tone = 'neutral' }: BadgeProps) {
const t = toneStyles[tone];
return (
<View style={[styles.pill, { backgroundColor: t.background }]}>
<Text style={[styles.label, { color: t.foreground }]}>{text}</Text>
</View>
);
}

View File

@@ -0,0 +1,165 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import type { ReactNode } from 'react';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
wrap: {
flexDirection: 'column',
borderTopWidth: 1,
borderTopColor: PDF_TOKENS.colors.border,
},
headerRow: {
flexDirection: 'row',
backgroundColor: PDF_TOKENS.colors.headerBand,
paddingVertical: 6,
paddingHorizontal: 8,
},
bodyRow: {
flexDirection: 'row',
paddingVertical: 6,
paddingHorizontal: 8,
borderBottomWidth: 1,
borderBottomColor: PDF_TOKENS.colors.border,
},
zebraRow: {
backgroundColor: PDF_TOKENS.colors.zebra,
},
totalsRow: {
flexDirection: 'row',
paddingVertical: 6,
paddingHorizontal: 8,
borderTopWidth: 1,
borderTopColor: PDF_TOKENS.colors.text,
borderBottomWidth: 1,
borderBottomColor: PDF_TOKENS.colors.text,
},
headerCell: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.small,
color: PDF_TOKENS.colors.headerText,
textTransform: 'uppercase',
letterSpacing: 0.4,
},
bodyCell: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.body,
color: PDF_TOKENS.colors.text,
},
bodyCellMuted: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.small,
color: PDF_TOKENS.colors.textMuted,
},
totalsCell: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.body,
color: PDF_TOKENS.colors.text,
},
});
export type Align = 'left' | 'right' | 'center';
export interface TableColumn<Row> {
header: string;
/** flex-grow weight (default 1) — controls column width proportions. */
flex?: number;
align?: Align;
render: (row: Row, rowIndex: number) => ReactNode;
}
export interface DataTableProps<Row> {
columns: TableColumn<Row>[];
rows: Row[];
/** Render a bold totals row beneath the body. Cell value-or-null per column. */
totals?: (string | null)[];
/** Apply zebra background to alternate rows (default true). */
zebra?: boolean;
/** Empty-state text shown when rows.length === 0. */
emptyMessage?: string;
}
function cellStyle(align: Align | undefined, base: Record<string, unknown>) {
return {
flex: 1,
paddingHorizontal: 4,
textAlign: align ?? 'left',
...base,
} as const;
}
export function DataTable<Row>({
columns,
rows,
totals,
zebra = true,
emptyMessage = 'No entries',
}: DataTableProps<Row>) {
return (
<View style={styles.wrap}>
<View style={styles.headerRow} fixed>
{columns.map((c, i) => (
<Text
key={i}
style={{
flex: c.flex ?? 1,
paddingHorizontal: 4,
textAlign: c.align ?? 'left',
...styles.headerCell,
}}
>
{c.header}
</Text>
))}
</View>
{rows.length === 0 ? (
<View style={styles.bodyRow}>
<Text style={{ ...styles.bodyCellMuted, flex: 1, textAlign: 'center' }}>
{emptyMessage}
</Text>
</View>
) : (
rows.map((row, i) => (
<View
key={i}
style={[styles.bodyRow, zebra && i % 2 === 1 ? styles.zebraRow : {}]}
wrap={false}
>
{columns.map((c, ci) => (
<View
key={ci}
style={{
flex: c.flex ?? 1,
paddingHorizontal: 4,
alignItems:
c.align === 'right'
? 'flex-end'
: c.align === 'center'
? 'center'
: 'flex-start',
}}
>
{(() => {
const node = c.render(row, i);
if (typeof node === 'string' || typeof node === 'number') {
return <Text style={styles.bodyCell}>{node}</Text>;
}
return node;
})()}
</View>
))}
</View>
))
)}
{totals ? (
<View style={styles.totalsRow}>
{columns.map((c, i) => (
<Text key={i} style={cellStyle(c.align, styles.totalsCell)}>
{totals[i] ?? ''}
</Text>
))}
</View>
) : null}
</View>
);
}

View File

@@ -0,0 +1,61 @@
import { Document, Page, StyleSheet, View } from '@react-pdf/renderer';
import type { ReactNode } from 'react';
import { Footer } from './Footer';
import { Header } from './Header';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
page: {
fontFamily: PDF_TOKENS.fonts.sans,
color: PDF_TOKENS.colors.text,
fontSize: PDF_TOKENS.sizes.body,
backgroundColor: PDF_TOKENS.colors.surface,
paddingBottom: PDF_TOKENS.spacing.pagePaddingBottom,
},
body: {
paddingHorizontal: PDF_TOKENS.spacing.pagePadding,
paddingTop: PDF_TOKENS.spacing.sectionGap,
flexDirection: 'column',
gap: PDF_TOKENS.spacing.sectionGap,
},
});
export interface DocumentShellProps {
portName: string;
docTitle: string;
docMeta?: string;
logoBuffer: Buffer | null;
/** ISO timestamp shown in footer. Defaults to render time. */
generatedAt?: Date;
/** Title shown in the PDF metadata (cmd+I in Preview). */
pdfTitle?: string;
/** Author shown in the PDF metadata. Defaults to port name. */
pdfAuthor?: string;
children: ReactNode;
}
export function DocumentShell({
portName,
docTitle,
docMeta,
logoBuffer,
generatedAt,
pdfTitle,
pdfAuthor,
children,
}: DocumentShellProps) {
return (
<Document
title={pdfTitle ?? docTitle}
author={pdfAuthor ?? portName}
producer="Port Nimara CRM"
>
<Page size="A4" style={styles.page}>
<Header portName={portName} docTitle={docTitle} meta={docMeta} logoBuffer={logoBuffer} />
<View style={styles.body}>{children}</View>
<Footer portName={portName} generatedAt={generatedAt} />
</Page>
</Document>
);
}

View File

@@ -0,0 +1,52 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
band: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: PDF_TOKENS.spacing.pagePadding,
paddingVertical: 12,
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: PDF_TOKENS.colors.border,
minHeight: PDF_TOKENS.spacing.footerHeight,
},
left: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.caption,
color: PDF_TOKENS.colors.textMuted,
},
right: {
marginLeft: 'auto',
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.caption,
color: PDF_TOKENS.colors.textMuted,
},
});
export interface FooterProps {
portName: string;
generatedAt?: Date;
confidential?: boolean;
}
export function Footer({ portName, generatedAt, confidential = true }: FooterProps) {
const tag = confidential ? `${portName} · Confidential` : portName;
const stamp = (generatedAt ?? new Date()).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
return (
<View style={styles.band} fixed>
<Text style={styles.left}>
{tag} · Generated {stamp}
</Text>
<Text
style={styles.right}
render={({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages}`}
/>
</View>
);
}

View File

@@ -0,0 +1,72 @@
import { Image, StyleSheet, Text, View } from '@react-pdf/renderer';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
band: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: PDF_TOKENS.colors.headerBand,
paddingHorizontal: PDF_TOKENS.spacing.pagePadding,
paddingVertical: 16,
minHeight: PDF_TOKENS.spacing.headerHeight,
},
logoSlot: {
width: PDF_TOKENS.spacing.logoMaxWidth,
height: PDF_TOKENS.spacing.logoMaxHeight,
justifyContent: 'center',
},
logoImage: {
maxWidth: PDF_TOKENS.spacing.logoMaxWidth,
maxHeight: PDF_TOKENS.spacing.logoMaxHeight,
objectFit: 'contain',
objectPositionX: 0,
},
portNameFallback: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.docTitle,
color: PDF_TOKENS.colors.headerText,
},
rightBlock: {
marginLeft: 'auto',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 4,
},
docTitle: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.docTitle,
color: PDF_TOKENS.colors.headerText,
},
meta: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.small,
color: PDF_TOKENS.colors.headerText,
opacity: 0.85,
},
});
export interface HeaderProps {
portName: string;
docTitle: string;
meta?: string;
logoBuffer: Buffer | null;
}
export function Header({ portName, docTitle, meta, logoBuffer }: HeaderProps) {
return (
<View style={styles.band} fixed>
<View style={styles.logoSlot}>
{logoBuffer ? (
<Image src={logoBuffer} style={styles.logoImage} />
) : (
<Text style={styles.portNameFallback}>{portName}</Text>
)}
</View>
<View style={styles.rightBlock}>
<Text style={styles.docTitle}>{docTitle}</Text>
{meta ? <Text style={styles.meta}>{meta}</Text> : null}
</View>
</View>
);
}

View File

@@ -0,0 +1,92 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
grid: {
flexDirection: 'column',
gap: PDF_TOKENS.spacing.rowGap,
},
twoCol: {
flexDirection: 'row',
gap: PDF_TOKENS.spacing.sectionGap,
},
cell: {
flexBasis: '50%',
flexDirection: 'column',
gap: 2,
},
fullRow: {
flexDirection: 'row',
gap: 8,
alignItems: 'flex-start',
},
label: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.small,
color: PDF_TOKENS.colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.4,
},
value: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.body,
color: PDF_TOKENS.colors.text,
},
});
export type KvRow = { label: string; value: string | number | null | undefined };
export interface KeyValueGridProps {
rows: KvRow[];
/** Render as two-column layout (default) or stacked. */
layout?: 'two-col' | 'stacked';
}
function fmt(v: string | number | null | undefined): string {
if (v === null || v === undefined || v === '') return '—';
return typeof v === 'number' ? String(v) : v;
}
export function KeyValueGrid({ rows, layout = 'two-col' }: KeyValueGridProps) {
if (layout === 'stacked') {
return (
<View style={styles.grid}>
{rows.map((r, i) => (
<View key={i} style={styles.cell}>
<Text style={styles.label}>{r.label}</Text>
<Text style={styles.value}>{fmt(r.value)}</Text>
</View>
))}
</View>
);
}
const pairs: KvRow[][] = [];
for (let i = 0; i < rows.length; i += 2) {
pairs.push([rows[i]!, rows[i + 1] ?? { label: '', value: '' }]);
}
return (
<View style={styles.grid}>
{pairs.map((pair, i) => (
<View key={i} style={styles.twoCol}>
<View style={styles.cell}>
{pair[0]?.label ? (
<>
<Text style={styles.label}>{pair[0].label}</Text>
<Text style={styles.value}>{fmt(pair[0].value)}</Text>
</>
) : null}
</View>
<View style={styles.cell}>
{pair[1]?.label ? (
<>
<Text style={styles.label}>{pair[1].label}</Text>
<Text style={styles.value}>{fmt(pair[1].value)}</Text>
</>
) : null}
</View>
</View>
))}
</View>
);
}

View File

@@ -0,0 +1,40 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import type { ReactNode } from 'react';
import { PDF_TOKENS } from './tokens';
const styles = StyleSheet.create({
wrap: {
flexDirection: 'column',
gap: PDF_TOKENS.spacing.rowGap,
},
heading: {
fontFamily: PDF_TOKENS.fonts.sansBold,
fontSize: PDF_TOKENS.sizes.sectionH,
color: PDF_TOKENS.colors.text,
borderBottomWidth: 1,
borderBottomColor: PDF_TOKENS.colors.border,
paddingBottom: 4,
},
subhead: {
fontFamily: PDF_TOKENS.fonts.sans,
fontSize: PDF_TOKENS.sizes.small,
color: PDF_TOKENS.colors.textMuted,
},
});
export interface SectionProps {
title: string;
subtitle?: string;
children: ReactNode;
}
export function Section({ title, subtitle, children }: SectionProps) {
return (
<View style={styles.wrap} wrap={false}>
<Text style={styles.heading}>{title}</Text>
{subtitle ? <Text style={styles.subhead}>{subtitle}</Text> : null}
{children}
</View>
);
}

View File

@@ -0,0 +1,155 @@
import { Line, Rect, Svg } from '@react-pdf/renderer';
import { SvgLabel } from '../svg-primitives';
import { PDF_TOKENS } from '../tokens';
export interface BarDatum {
label: string;
value: number;
/** Optional override color per bar. */
color?: string;
}
export interface BarChartProps {
data: BarDatum[];
width?: number;
height?: number;
/** Bar fill when not overridden per-datum. */
color?: string;
/** Render value labels on top of each bar. */
showValues?: boolean;
/** Optional y-axis label rendered vertically on the left. */
yLabel?: string;
}
const MARGIN_LEFT = 44;
const MARGIN_RIGHT = 12;
const MARGIN_TOP = 18;
const MARGIN_BOTTOM = 32;
export function BarChart({
data,
width = 480,
height = 200,
color = PDF_TOKENS.colors.accentBlue,
showValues = false,
yLabel,
}: BarChartProps) {
if (data.length === 0) {
return (
<Svg width={width} height={height}>
<SvgLabel
x={width / 2}
y={height / 2}
textAnchor="middle"
fontSize={9}
fill={PDF_TOKENS.colors.textMuted}
>
No data
</SvgLabel>
</Svg>
);
}
const max = Math.max(...data.map((d) => d.value));
const chartW = width - MARGIN_LEFT - MARGIN_RIGHT;
const chartH = height - MARGIN_TOP - MARGIN_BOTTOM;
const barW = chartW / data.length;
const yTicks = 4;
return (
<Svg width={width} height={height}>
{/* y-axis tick labels + horizontal grid */}
{Array.from({ length: yTicks + 1 }, (_, i) => {
const v = (max / yTicks) * (yTicks - i);
const y = MARGIN_TOP + (chartH / yTicks) * i;
return (
<Svg key={`t${i}`}>
<Line
x1={MARGIN_LEFT}
y1={y}
x2={width - MARGIN_RIGHT}
y2={y}
strokeWidth={0.5}
stroke={PDF_TOKENS.colors.border}
/>
<SvgLabel
x={MARGIN_LEFT - 6}
y={y + 3}
textAnchor="end"
fontSize={7}
fill={PDF_TOKENS.colors.textMuted}
>
{formatTick(v)}
</SvgLabel>
</Svg>
);
})}
{/* axes */}
<Line
x1={MARGIN_LEFT}
y1={MARGIN_TOP}
x2={MARGIN_LEFT}
y2={height - MARGIN_BOTTOM}
strokeWidth={1}
stroke={PDF_TOKENS.colors.text}
/>
<Line
x1={MARGIN_LEFT}
y1={height - MARGIN_BOTTOM}
x2={width - MARGIN_RIGHT}
y2={height - MARGIN_BOTTOM}
strokeWidth={1}
stroke={PDF_TOKENS.colors.text}
/>
{/* bars + labels */}
{data.map((d, i) => {
const h = max === 0 ? 0 : (d.value / max) * chartH;
const x = MARGIN_LEFT + i * barW + 4;
const y = height - MARGIN_BOTTOM - h;
const w = barW - 8;
return (
<Svg key={i}>
<Rect x={x} y={y} width={w} height={h} fill={d.color ?? color} />
<SvgLabel
x={x + w / 2}
y={height - MARGIN_BOTTOM + 14}
textAnchor="middle"
fontSize={7}
fill={PDF_TOKENS.colors.textMuted}
>
{d.label}
</SvgLabel>
{showValues ? (
<SvgLabel
x={x + w / 2}
y={y - 3}
textAnchor="middle"
fontSize={7}
fill={PDF_TOKENS.colors.text}
>
{formatTick(d.value)}
</SvgLabel>
) : null}
</Svg>
);
})}
{yLabel ? (
<SvgLabel
x={10}
y={MARGIN_TOP + chartH / 2}
textAnchor="middle"
fontSize={8}
fill={PDF_TOKENS.colors.textMuted}
transform={`rotate(-90 10 ${MARGIN_TOP + chartH / 2})`}
>
{yLabel}
</SvgLabel>
) : null}
</Svg>
);
}
function formatTick(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
return v.toLocaleString();
}

View File

@@ -0,0 +1,91 @@
import { Path, Svg } from '@react-pdf/renderer';
import { SvgLabel } from '../svg-primitives';
import { PDF_TOKENS } from '../tokens';
export interface FunnelDatum {
label: string;
value: number;
color?: string;
}
export interface FunnelChartProps {
data: FunnelDatum[];
width?: number;
height?: number;
}
const FUNNEL_PALETTE = [
'#1d4ed8',
'#2563eb',
'#3b82f6',
'#60a5fa',
'#93c5fd',
'#bfdbfe',
'#dbeafe',
];
export function FunnelChart({ data, width = 480, height = 240 }: FunnelChartProps) {
if (data.length === 0) {
return (
<Svg width={width} height={height}>
<SvgLabel
x={width / 2}
y={height / 2}
textAnchor="middle"
fontSize={9}
fill={PDF_TOKENS.colors.textMuted}
>
No data
</SvgLabel>
</Svg>
);
}
const max = Math.max(...data.map((d) => d.value), 1);
const sliceH = (height - 16) / data.length;
const centerX = width / 2;
return (
<Svg width={width} height={height}>
{data.map((d, i) => {
const topWidth = (data[i]!.value / max) * (width * 0.7);
const next = data[i + 1];
const bottomWidth = next ? (next.value / max) * (width * 0.7) : topWidth * 0.85;
const y0 = 8 + i * sliceH;
const y1 = y0 + sliceH;
const x0Top = centerX - topWidth / 2;
const x1Top = centerX + topWidth / 2;
const x0Bot = centerX - bottomWidth / 2;
const x1Bot = centerX + bottomWidth / 2;
const color =
d.color ?? FUNNEL_PALETTE[i % FUNNEL_PALETTE.length] ?? PDF_TOKENS.colors.accentBlue;
return (
<Svg key={i}>
<Path
d={`M ${x0Top} ${y0} L ${x1Top} ${y0} L ${x1Bot} ${y1} L ${x0Bot} ${y1} Z`}
fill={color}
/>
<SvgLabel
x={centerX}
y={y0 + sliceH / 2 + 2}
textAnchor="middle"
fontSize={8}
fill="#ffffff"
fontWeight="bold"
>
{d.label}
</SvgLabel>
<SvgLabel
x={centerX}
y={y0 + sliceH / 2 + 12}
textAnchor="middle"
fontSize={7}
fill="#ffffff"
>
{d.value.toLocaleString()}
</SvgLabel>
</Svg>
);
})}
</Svg>
);
}

View File

@@ -0,0 +1,146 @@
import { Circle, Line, Path, Svg } from '@react-pdf/renderer';
import { SvgLabel } from '../svg-primitives';
import { PDF_TOKENS } from '../tokens';
export interface LineDatum {
label: string;
value: number;
}
export interface LineChartProps {
data: LineDatum[];
width?: number;
height?: number;
color?: string;
yLabel?: string;
/** Render circle markers at each point (default true). */
markers?: boolean;
/** Optional fixed y-axis max — used when value-space should not auto-zoom. */
yMax?: number;
}
const MARGIN_LEFT = 44;
const MARGIN_RIGHT = 12;
const MARGIN_TOP = 18;
const MARGIN_BOTTOM = 32;
export function LineChart({
data,
width = 480,
height = 200,
color = PDF_TOKENS.colors.accentBlue,
yLabel,
markers = true,
yMax,
}: LineChartProps) {
if (data.length === 0) {
return (
<Svg width={width} height={height}>
<SvgLabel
x={width / 2}
y={height / 2}
textAnchor="middle"
fontSize={9}
fill={PDF_TOKENS.colors.textMuted}
>
No data
</SvgLabel>
</Svg>
);
}
const max = yMax ?? Math.max(...data.map((d) => d.value));
const chartW = width - MARGIN_LEFT - MARGIN_RIGHT;
const chartH = height - MARGIN_TOP - MARGIN_BOTTOM;
const stepX = data.length > 1 ? chartW / (data.length - 1) : 0;
const yTicks = 4;
const points = data.map((d, i) => {
const x = MARGIN_LEFT + i * stepX;
const y = MARGIN_TOP + chartH - (max === 0 ? 0 : (d.value / max) * chartH);
return { x, y, d };
});
const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
return (
<Svg width={width} height={height}>
{/* gridlines + y-axis ticks */}
{Array.from({ length: yTicks + 1 }, (_, i) => {
const v = (max / yTicks) * (yTicks - i);
const y = MARGIN_TOP + (chartH / yTicks) * i;
return (
<Svg key={`t${i}`}>
<Line
x1={MARGIN_LEFT}
y1={y}
x2={width - MARGIN_RIGHT}
y2={y}
strokeWidth={0.5}
stroke={PDF_TOKENS.colors.border}
/>
<SvgLabel
x={MARGIN_LEFT - 6}
y={y + 3}
textAnchor="end"
fontSize={7}
fill={PDF_TOKENS.colors.textMuted}
>
{formatTick(v)}
</SvgLabel>
</Svg>
);
})}
{/* axes */}
<Line
x1={MARGIN_LEFT}
y1={MARGIN_TOP}
x2={MARGIN_LEFT}
y2={height - MARGIN_BOTTOM}
strokeWidth={1}
stroke={PDF_TOKENS.colors.text}
/>
<Line
x1={MARGIN_LEFT}
y1={height - MARGIN_BOTTOM}
x2={width - MARGIN_RIGHT}
y2={height - MARGIN_BOTTOM}
strokeWidth={1}
stroke={PDF_TOKENS.colors.text}
/>
{/* line */}
<Path d={path} stroke={color} strokeWidth={1.5} fill="none" />
{/* markers + x-axis labels */}
{points.map((p, i) => (
<Svg key={i}>
{markers ? <Circle cx={p.x} cy={p.y} r={2.5} fill={color} /> : null}
<SvgLabel
x={p.x}
y={height - MARGIN_BOTTOM + 14}
textAnchor="middle"
fontSize={7}
fill={PDF_TOKENS.colors.textMuted}
>
{p.d.label}
</SvgLabel>
</Svg>
))}
{yLabel ? (
<SvgLabel
x={10}
y={MARGIN_TOP + chartH / 2}
textAnchor="middle"
fontSize={8}
fill={PDF_TOKENS.colors.textMuted}
transform={`rotate(-90 10 ${MARGIN_TOP + chartH / 2})`}
>
{yLabel}
</SvgLabel>
) : null}
</Svg>
);
}
function formatTick(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
if (v % 1 !== 0) return v.toFixed(1);
return v.toLocaleString();
}

View File

@@ -0,0 +1,140 @@
import { Path, Rect, Svg } from '@react-pdf/renderer';
import { SvgLabel } from '../svg-primitives';
import { PDF_TOKENS } from '../tokens';
export interface PieDatum {
label: string;
value: number;
color?: string;
}
export interface PieChartProps {
data: PieDatum[];
width?: number;
height?: number;
/** Inner radius as fraction of outer radius. 0 = solid pie, 0.6 = donut. */
innerRadiusRatio?: number;
}
const DEFAULT_PALETTE = [
PDF_TOKENS.colors.accentBlue,
PDF_TOKENS.colors.success,
PDF_TOKENS.colors.warning,
PDF_TOKENS.colors.danger,
PDF_TOKENS.colors.accentSlate,
'#7c3aed',
'#0891b2',
'#db2777',
];
export function PieChart({ data, width = 240, height = 200, innerRadiusRatio = 0 }: PieChartProps) {
if (data.length === 0) {
return (
<Svg width={width} height={height}>
<SvgLabel
x={width / 2}
y={height / 2}
textAnchor="middle"
fontSize={9}
fill={PDF_TOKENS.colors.textMuted}
>
No data
</SvgLabel>
</Svg>
);
}
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) {
return (
<Svg width={width} height={height}>
<SvgLabel
x={width / 2}
y={height / 2}
textAnchor="middle"
fontSize={9}
fill={PDF_TOKENS.colors.textMuted}
>
All zero
</SvgLabel>
</Svg>
);
}
const chartSize = Math.min(width * 0.55, height - 30);
const r = chartSize / 2;
const cx = r + 12;
const cy = height / 2;
const ir = r * innerRadiusRatio;
let angle = -Math.PI / 2;
const slices = data.map((d, i) => {
const slice = (d.value / total) * Math.PI * 2;
const startAngle = angle;
const endAngle = angle + slice;
angle = endAngle;
const color =
d.color ?? DEFAULT_PALETTE[i % DEFAULT_PALETTE.length] ?? PDF_TOKENS.colors.accentBlue;
return { d, color, startAngle, endAngle, slice };
});
const legendX = cx + r + 24;
const legendStartY = cy - (data.length * 12) / 2;
return (
<Svg width={width} height={height}>
{slices.map((s, i) => (
<Path key={i} d={arcPath(cx, cy, r, ir, s.startAngle, s.endAngle)} fill={s.color} />
))}
{slices.map((s, i) => (
<Svg key={`l${i}`}>
<Rect x={legendX} y={legendStartY + i * 12 - 5} width={8} height={8} fill={s.color} />
<SvgLabel
x={legendX + 12}
y={legendStartY + i * 12 + 1}
fontSize={8}
fill={PDF_TOKENS.colors.text}
>
{s.d.label}
</SvgLabel>
<SvgLabel
x={legendX + 12}
y={legendStartY + i * 12 + 10}
fontSize={7}
fill={PDF_TOKENS.colors.textMuted}
>
{Math.round((s.d.value / total) * 100)}% · {s.d.value.toLocaleString()}
</SvgLabel>
</Svg>
))}
</Svg>
);
}
function arcPath(
cx: number,
cy: number,
outerR: number,
innerR: number,
startAngle: number,
endAngle: number,
): string {
const x1 = cx + outerR * Math.cos(startAngle);
const y1 = cy + outerR * Math.sin(startAngle);
const x2 = cx + outerR * Math.cos(endAngle);
const y2 = cy + outerR * Math.sin(endAngle);
const large = endAngle - startAngle > Math.PI ? 1 : 0;
if (innerR === 0) {
return `M ${cx} ${cy} L ${x1} ${y1} A ${outerR} ${outerR} 0 ${large} 1 ${x2} ${y2} Z`;
}
const x3 = cx + innerR * Math.cos(endAngle);
const y3 = cy + innerR * Math.sin(endAngle);
const x4 = cx + innerR * Math.cos(startAngle);
const y4 = cy + innerR * Math.sin(startAngle);
return [
`M ${x1} ${y1}`,
`A ${outerR} ${outerR} 0 ${large} 1 ${x2} ${y2}`,
`L ${x3} ${y3}`,
`A ${innerR} ${innerR} 0 ${large} 0 ${x4} ${y4}`,
`Z`,
].join(' ');
}

View File

@@ -0,0 +1,4 @@
export { BarChart, type BarChartProps, type BarDatum } from './BarChart';
export { LineChart, type LineChartProps, type LineDatum } from './LineChart';
export { PieChart, type PieChartProps, type PieDatum } from './PieChart';
export { FunnelChart, type FunnelChartProps, type FunnelDatum } from './FunnelChart';

View File

@@ -0,0 +1,20 @@
export { PDF_TOKENS, type PdfTokens } from './tokens';
export { resolvePortLogo, PORT_LOGO_SETTING_KEY, type ResolvedLogo } from './logo';
export { DocumentShell, type DocumentShellProps } from './DocumentShell';
export { Header, type HeaderProps } from './Header';
export { Footer, type FooterProps } from './Footer';
export { Section, type SectionProps } from './Section';
export { KeyValueGrid, type KeyValueGridProps, type KvRow } from './KeyValueGrid';
export { DataTable, type DataTableProps, type TableColumn, type Align } from './DataTable';
export { Badge, type BadgeProps, type BadgeTone } from './Badge';
export { BarChart, LineChart, PieChart, FunnelChart } from './charts';
export type {
BarChartProps,
BarDatum,
LineChartProps,
LineDatum,
PieChartProps,
PieDatum,
FunnelChartProps,
FunnelDatum,
} from './charts';

View File

@@ -0,0 +1,57 @@
import { cache } from 'react';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { files } from '@/lib/db/schema/documents';
import { systemSettings } from '@/lib/db/schema/system';
import { getStorageBackend } from '@/lib/storage';
import { logger } from '@/lib/logger';
export const PORT_LOGO_SETTING_KEY = 'port_logo_file_id';
export type ResolvedLogo =
| { source: 'logo'; buffer: Buffer; mimeType: 'image/png' }
| { source: 'fallback'; buffer: null; mimeType: null };
async function readLogoSetting(portId: string): Promise<string | null> {
const [row] = await db
.select()
.from(systemSettings)
.where(and(eq(systemSettings.key, PORT_LOGO_SETTING_KEY), eq(systemSettings.portId, portId)));
if (!row) return null;
const value = row.value;
if (typeof value !== 'string') return null;
return value;
}
/**
* Resolves the port-level logo for PDF rendering. Returns `source: 'fallback'`
* when the setting is unset, the file row is missing, or the storage backend
* errors. Renderers should fall back to text-only headers in that case.
*
* Cached per request via React `cache()` so multi-page PDFs only fetch once.
*/
export const resolvePortLogo = cache(async (portId: string): Promise<ResolvedLogo> => {
const fileId = await readLogoSetting(portId);
if (!fileId) {
return { source: 'fallback', buffer: null, mimeType: null };
}
const file = await db.query.files.findFirst({ where: eq(files.id, fileId) });
if (!file) {
logger.warn({ portId, fileId }, 'port_logo_file_id points at missing file');
return { source: 'fallback', buffer: null, mimeType: null };
}
try {
const backend = await getStorageBackend();
const stream = await backend.get(file.storagePath);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const buffer = Buffer.concat(chunks);
return { source: 'logo', buffer, mimeType: 'image/png' };
} catch (err) {
logger.warn({ err, portId, fileId, storagePath: file.storagePath }, 'logo fetch failed');
return { source: 'fallback', buffer: null, mimeType: null };
}
});

View File

@@ -0,0 +1,50 @@
import { Text as PdfText } from '@react-pdf/renderer';
import type { ReactNode } from 'react';
import { PDF_TOKENS } from './tokens';
/**
* Brand-kit wrapper around @react-pdf/renderer's SVG `<Text>`. Exists because
* the renderer accepts `fontSize`/`fontFamily`/`fontWeight` as presentation
* attributes at runtime but the published types only declare the SVG-spec
* subset. Wrapping here keeps the casts isolated to one file.
*/
export interface SvgLabelProps {
x: number;
y: number;
fontSize?: number;
fontFamily?: string;
fontWeight?: string | number;
fill?: string;
textAnchor?: 'start' | 'middle' | 'end';
transform?: string;
opacity?: number | string;
children: ReactNode;
}
export function SvgLabel({
x,
y,
fontSize = PDF_TOKENS.sizes.caption,
fontFamily = PDF_TOKENS.fonts.sans,
fontWeight,
fill = PDF_TOKENS.colors.text,
textAnchor = 'start',
transform,
opacity,
children,
}: SvgLabelProps) {
// Runtime accepts these as presentation attrs; types omit them. Cast scoped here.
const props = {
x,
y,
fontSize,
fontFamily,
fontWeight,
fill,
textAnchor,
transform,
opacity,
} as unknown as { x: number; y: number; fill: string };
return <PdfText {...props}>{children}</PdfText>;
}

View File

@@ -0,0 +1,51 @@
/**
* Design tokens shared by every internally-generated PDF surface. Edit here
* to re-skin every report/export/expense PDF.
*
* Coordinates are in PDF points (1/72 inch). 36pt = 0.5in = standard margin.
*/
export const PDF_TOKENS = {
colors: {
text: '#111111',
textMuted: '#666666',
border: '#e5e7eb',
headerBand: '#0f172a',
headerText: '#ffffff',
accentBlue: '#1d4ed8',
accentSlate: '#334155',
zebra: '#f9fafb',
success: '#16a34a',
warning: '#d97706',
danger: '#dc2626',
surface: '#ffffff',
},
fonts: {
sans: 'Helvetica',
sansBold: 'Helvetica-Bold',
mono: 'Courier',
},
sizes: {
docTitle: 18,
sectionH: 13,
body: 10,
small: 8,
caption: 7,
},
spacing: {
pagePadding: 36,
pagePaddingTop: 72,
pagePaddingBottom: 54,
sectionGap: 18,
rowGap: 6,
headerHeight: 72,
footerHeight: 36,
logoMaxWidth: 200,
logoMaxHeight: 60,
},
page: {
width: 595,
height: 842,
},
} as const;
export type PdfTokens = typeof PDF_TOKENS;

32
src/lib/pdf/render.ts Normal file
View File

@@ -0,0 +1,32 @@
import { renderToBuffer, renderToStream, type DocumentProps } from '@react-pdf/renderer';
import type { ReactElement } from 'react';
import { logger } from '@/lib/logger';
type DocumentElement = ReactElement<DocumentProps>;
/**
* Render a react-pdf element tree to PDF bytes. Use for one-shot PDFs that
* fit in memory comfortably (reports, record exports, parent-company exports).
*
* For photo-heavy or hundreds-of-entries PDFs, see `renderPdfStream` or use
* `expense-pdf.service.ts` (pdfkit streaming).
*/
export async function renderPdf(element: DocumentElement): Promise<Buffer> {
try {
const buf = await renderToBuffer(element);
return buf;
} catch (err) {
logger.error({ err }, 'PDF render failed');
throw new Error('Failed to render PDF');
}
}
/**
* Stream-render a react-pdf element tree. Pages are emitted incrementally so
* memory peaks are bounded. Caller should pipe the stream to the response
* (or convert via `Readable.toWeb` for a Web Response).
*/
export async function renderPdfStream(element: DocumentElement): Promise<NodeJS.ReadableStream> {
return renderToStream(element);
}

8
src/types/react-pdf-augment.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/**
* Marker file kept to anchor the project's TS configuration. SVG font
* presentation attributes (`fontSize`, `fontFamily`, etc.) for @react-pdf's
* SVG <Text> are accepted at runtime but not declared in the renderer's
* namespace types. Brand-kit `<SvgLabel>` is the single place that bridges
* the gap with a typed wrapper, so we don't litter chart code with casts.
*/
export {};

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import { Page, Text } from '@react-pdf/renderer';
import { renderPdf } from '@/lib/pdf/render';
import {
Badge,
BarChart,
DataTable,
DocumentShell,
FunnelChart,
KeyValueGrid,
LineChart,
PDF_TOKENS,
PieChart,
Section,
} from '@/lib/pdf/brand-kit';
describe('pdf brand kit', () => {
it('exposes canonical design tokens', () => {
expect(PDF_TOKENS.colors.headerBand).toBe('#0f172a');
expect(PDF_TOKENS.sizes.body).toBe(10);
expect(PDF_TOKENS.spacing.logoMaxWidth).toBe(200);
});
it('renders a kitchen-sink PDF without throwing', async () => {
const tree = (
<DocumentShell
portName="Port Test"
docTitle="Smoke Report"
docMeta="2026-05-12"
logoBuffer={null}
>
<Section title="Summary">
<KeyValueGrid
rows={[
{ label: 'Total', value: 247 },
{ label: 'Active', value: 'Yes' },
]}
/>
</Section>
<Section title="Charts">
<BarChart
data={[
{ label: 'Mon', value: 10 },
{ label: 'Tue', value: 20 },
]}
/>
<LineChart
data={[
{ label: 'Jan', value: 5 },
{ label: 'Feb', value: 8 },
]}
/>
<PieChart
data={[
{ label: 'A', value: 30 },
{ label: 'B', value: 70 },
]}
/>
<FunnelChart
data={[
{ label: 'Lead', value: 100 },
{ label: 'Closed', value: 25 },
]}
/>
</Section>
<Section title="Table">
<Badge text="Active" tone="success" />
<DataTable
columns={[
{ header: 'Name', render: (r: { name: string }) => r.name },
{
header: 'Score',
align: 'right',
render: (r: { score: number }) => String(r.score),
},
]}
rows={[
{ name: 'Alpha', score: 1 },
{ name: 'Beta', score: 2 },
]}
totals={['Total', '3']}
/>
</Section>
</DocumentShell>
);
const bytes = await renderPdf(tree);
expect(bytes).toBeInstanceOf(Buffer);
expect(bytes.length).toBeGreaterThan(1000);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
it('falls back gracefully when no chart data is provided', async () => {
const tree = (
<DocumentShell portName="Port Empty" docTitle="Empty" logoBuffer={null}>
<BarChart data={[]} />
<LineChart data={[]} />
<PieChart data={[]} />
<FunnelChart data={[]} />
<DataTable columns={[{ header: 'X', render: () => '' }]} rows={[]} />
</DocumentShell>
);
const bytes = await renderPdf(tree);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
});
// Belt-and-suspenders: a minimal direct Page render to confirm @react-pdf/renderer
// is actually installed and produces a real PDF stream, not just our tree.
describe('react-pdf renderer wiring', () => {
it('renders a bare <Page> to bytes', async () => {
const { Document } = await import('@react-pdf/renderer');
const tree = (
<Document>
<Page size="A4">
<Text>hello</Text>
</Page>
</Document>
);
const bytes = await renderPdf(tree);
expect(bytes.length).toBeGreaterThan(500);
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
}, 30_000);
});

View File

@@ -14,7 +14,12 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.ts'],
include: [
'tests/unit/**/*.test.ts',
'tests/unit/**/*.test.tsx',
'tests/integration/**/*.test.ts',
'tests/integration/**/*.test.tsx',
],
exclude: ['tests/e2e/**', 'node_modules/**'],
pool: 'forks',
globalSetup: ['./tests/global-setup.ts'],