task-h
This commit is contained in:
135
docs/implementation/task-h-pdf-generation-persistence.md
Normal file
135
docs/implementation/task-h-pdf-generation-persistence.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Task H - PDF Generation and Approved Document Persistence
|
||||||
|
|
||||||
|
## Files Added
|
||||||
|
|
||||||
|
- `src/features/foundation/pdf-generator/server/service.ts`
|
||||||
|
- `src/app/api/crm/quotations/[id]/pdf-preview/route.ts`
|
||||||
|
- `src/app/api/crm/quotations/[id]/pdf-download/route.ts`
|
||||||
|
- `src/app/api/crm/quotations/[id]/approved-pdf/route.ts`
|
||||||
|
- `drizzle/0007_luxuriant_malice.sql`
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `package.json`
|
||||||
|
- `package-lock.json`
|
||||||
|
- `src/components/icons.tsx`
|
||||||
|
- `src/db/schema.ts`
|
||||||
|
- `src/lib/auth/rbac.ts`
|
||||||
|
- `src/features/crm/quotations/api/types.ts`
|
||||||
|
- `src/features/crm/quotations/server/service.ts`
|
||||||
|
- `src/features/crm/quotations/document/types.ts`
|
||||||
|
- `src/features/crm/quotations/document/server/service.ts`
|
||||||
|
- `src/features/foundation/approval/server/service.ts`
|
||||||
|
- `src/app/dashboard/crm/quotations/[id]/page.tsx`
|
||||||
|
- `src/features/crm/quotations/components/quotation-detail.tsx`
|
||||||
|
- `src/features/crm/enquiries/components/enquiry-form-sheet.tsx`
|
||||||
|
- `src/features/crm/enquiries/schemas/enquiry.schema.ts`
|
||||||
|
- `src/features/crm/quotations/schemas/quotation.schema.ts`
|
||||||
|
- `docs/implementation/technical-debt.md`
|
||||||
|
|
||||||
|
## PDF Generator Added
|
||||||
|
|
||||||
|
- Added production server-side `pdfme` generation in `src/features/foundation/pdf-generator/server/service.ts`.
|
||||||
|
- Installed:
|
||||||
|
- `@pdfme/common`
|
||||||
|
- `@pdfme/generator`
|
||||||
|
- `@pdfme/schemas`
|
||||||
|
- Implemented:
|
||||||
|
- `generatePdfFromTemplate()`
|
||||||
|
- `generateQuotationPdf()`
|
||||||
|
- `generateQuotationPreviewPdf()`
|
||||||
|
- `generateApprovedQuotationPdf()`
|
||||||
|
- `getStoredApprovedQuotationPdf()`
|
||||||
|
|
||||||
|
## API Routes Added
|
||||||
|
|
||||||
|
- `GET /api/crm/quotations/[id]/pdf-preview`
|
||||||
|
- `GET /api/crm/quotations/[id]/pdf-download`
|
||||||
|
- `GET /api/crm/quotations/[id]/approved-pdf`
|
||||||
|
- `POST /api/crm/quotations/[id]/approved-pdf`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- preview returns inline PDF from current quotation state
|
||||||
|
- download returns attachment PDF from current quotation state
|
||||||
|
- approved-pdf POST generates, persists, audits, and returns the approved PDF
|
||||||
|
- approved-pdf GET streams the already stored approved artifact
|
||||||
|
|
||||||
|
## UI Actions Added
|
||||||
|
|
||||||
|
Quotation detail now supports:
|
||||||
|
|
||||||
|
- `Preview PDF`
|
||||||
|
- `Download PDF`
|
||||||
|
- `Generate Approved PDF`
|
||||||
|
- `View Approved PDF`
|
||||||
|
|
||||||
|
The detail page also now displays:
|
||||||
|
|
||||||
|
- `Approved At`
|
||||||
|
- `Approved Template Version`
|
||||||
|
|
||||||
|
## Approved Snapshot Persistence
|
||||||
|
|
||||||
|
Added quotation persistence fields:
|
||||||
|
|
||||||
|
- `approvedAt`
|
||||||
|
- `approvedPdfUrl`
|
||||||
|
- `approvedSnapshot`
|
||||||
|
- `approvedTemplateVersionId`
|
||||||
|
|
||||||
|
Approved snapshot now stores:
|
||||||
|
|
||||||
|
- quotation id
|
||||||
|
- approved timestamp
|
||||||
|
- normalized document data
|
||||||
|
- template version id
|
||||||
|
- mapped template input
|
||||||
|
- generated timestamp
|
||||||
|
- generated by user id
|
||||||
|
|
||||||
|
## Attachment Metadata Integration
|
||||||
|
|
||||||
|
After generating an approved PDF:
|
||||||
|
|
||||||
|
- a local file is written under `public/generated/quotations/<organizationId>/`
|
||||||
|
- quotation attachment metadata is created or updated with:
|
||||||
|
- `fileName`
|
||||||
|
- `originalFileName`
|
||||||
|
- `filePath`
|
||||||
|
- `fileSize`
|
||||||
|
- `fileType = application/pdf`
|
||||||
|
- `description = Approved quotation PDF`
|
||||||
|
- `uploadedBy`
|
||||||
|
- `uploadedAt`
|
||||||
|
|
||||||
|
## Permission Added
|
||||||
|
|
||||||
|
- `crm.quotation.pdf.preview`
|
||||||
|
- `crm.quotation.pdf.download`
|
||||||
|
- `crm.quotation.pdf.generate_approved`
|
||||||
|
|
||||||
|
## Audit Integration
|
||||||
|
|
||||||
|
Required audit was added for approved PDF generation with:
|
||||||
|
|
||||||
|
- `entityType = crm_quotation_pdf_generate_approved`
|
||||||
|
- `action = generate_approved`
|
||||||
|
|
||||||
|
Preview and download remain unaudited by design.
|
||||||
|
|
||||||
|
## Remaining Risks
|
||||||
|
|
||||||
|
- Current `pdfme` generation uses a font fallback normalization step because the provided templates reference `cordia/cordiaBold`, while direct TTC-based rendering did not behave reliably with table layout generation.
|
||||||
|
- Approved PDF storage uses local filesystem placeholder output under `public/generated/...`, not external object storage.
|
||||||
|
- The environment used during implementation had no quotation rows available in the database, so full quotation-specific end-to-end smoke testing through a live record could not be completed here.
|
||||||
|
|
||||||
|
## Task I Readiness
|
||||||
|
|
||||||
|
Task H leaves the repo ready for:
|
||||||
|
|
||||||
|
- auto-generate approved PDF after final approval
|
||||||
|
- object-storage abstraction
|
||||||
|
- signed download / retention policy
|
||||||
|
- immutable approved artifact lifecycle rules
|
||||||
|
- richer template version publish controls
|
||||||
@@ -191,6 +191,31 @@ Future:
|
|||||||
- storage abstraction
|
- storage abstraction
|
||||||
- immutable approved artifact URL on quotation
|
- immutable approved artifact URL on quotation
|
||||||
|
|
||||||
|
## After Task H
|
||||||
|
|
||||||
|
### Font Render Parity
|
||||||
|
`pdfme` generation currently falls back from `cordia/cordiaBold` to standard fonts when TTC-backed runtime rendering is not reliable.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
- package or vendor the exact Cordia font assets in a generator-friendly format
|
||||||
|
- remove fallback normalization
|
||||||
|
- verify Thai text metrics against the original designer output
|
||||||
|
|
||||||
|
### Local PDF Storage
|
||||||
|
Approved PDFs currently persist to local `public/generated/...` paths.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
- object storage provider abstraction
|
||||||
|
- signed/private delivery strategy
|
||||||
|
- retention and regeneration policy
|
||||||
|
|
||||||
|
### Post-Approval Automation
|
||||||
|
Approved PDF generation is still manual by design.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
- optional auto-generate hook after final approval
|
||||||
|
- retry-safe job queue for PDF generation
|
||||||
|
|
||||||
## After Task G
|
## After Task G
|
||||||
|
|
||||||
### Template Mapping Editor
|
### Template Mapping Editor
|
||||||
@@ -207,4 +232,4 @@ Future: define rule สำหรับ crane/dockdoor/solarcell
|
|||||||
|
|
||||||
### PDF Binary Generation
|
### PDF Binary Generation
|
||||||
ยังไม่มี generate/download/store PDF
|
ยังไม่มี generate/download/store PDF
|
||||||
Future: Task H
|
Future: Task H
|
||||||
|
|||||||
4
drizzle/0007_luxuriant_malice.sql
Normal file
4
drizzle/0007_luxuriant_malice.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "crm_quotations" ADD COLUMN "approved_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "crm_quotations" ADD COLUMN "approved_pdf_url" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "crm_quotations" ADD COLUMN "approved_snapshot" jsonb;--> statement-breakpoint
|
||||||
|
ALTER TABLE "crm_quotations" ADD COLUMN "approved_template_version_id" text;
|
||||||
2995
drizzle/meta/0007_snapshot.json
Normal file
2995
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
|||||||
"when": 1781579746622,
|
"when": 1781579746622,
|
||||||
"tag": "0006_worried_mordo",
|
"tag": "0006_worried_mordo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781589458455,
|
||||||
|
"tag": "0007_luxuriant_malice",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
360
package-lock.json
generated
360
package-lock.json
generated
@@ -12,6 +12,9 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@pdfme/common": "^6.1.5",
|
||||||
|
"@pdfme/generator": "^6.1.5",
|
||||||
|
"@pdfme/schemas": "^6.1.5",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
@@ -2791,6 +2794,140 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts/node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng/node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
|
"node_modules/@pdfme/common": {
|
||||||
|
"version": "6.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdfme/common/-/common-6.1.5.tgz",
|
||||||
|
"integrity": "sha512-Ofp0L+96h1MJnK5W3/NRdV1hQlsq07Kko5bXraenHF+KFTq7mBe90FYny6jgwjRqPCnpt1ULlrB6UAadobZ+9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdfme/pdf-lib": "*",
|
||||||
|
"acorn": "^8.16.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdfme/generator": {
|
||||||
|
"version": "6.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdfme/generator/-/generator-6.1.5.tgz",
|
||||||
|
"integrity": "sha512-rt6idKJ20qiHqNA6FE9lBeZs/n8aBeRFcmisdcGnN63gKGZqej+ByO/TMyWxnF8Umul2bP+xpu16P/8KncnlLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdfme/pdf-lib": "*",
|
||||||
|
"fontkit": "^2.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@pdfme/common": "latest",
|
||||||
|
"@pdfme/schemas": "latest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdfme/pdf-lib": {
|
||||||
|
"version": "6.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdfme/pdf-lib/-/pdf-lib-6.1.5.tgz",
|
||||||
|
"integrity": "sha512-VdSIEpG2EOaco0F/3UShTfguxo0cFgRY0Kj/wi9B6gNcOeoyqnQoUQycsZrGMnUa54/VTV0VRjf/ly8eByAYZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||||
|
"@pdf-lib/upng": "^1.0.1",
|
||||||
|
"color": "^5.0.3",
|
||||||
|
"node-html-better-parser": "^1.5.8",
|
||||||
|
"pako": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdfme/pdf-lib/node_modules/color": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^3.1.3",
|
||||||
|
"color-string": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdfme/pdf-lib/node_modules/color-convert": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdfme/pdf-lib/node_modules/color-name": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdfme/pdf-lib/node_modules/color-string": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdfme/schemas": {
|
||||||
|
"version": "6.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdfme/schemas/-/schemas-6.1.5.tgz",
|
||||||
|
"integrity": "sha512-pbg24+7qa+Uj6DVT4/UnPf4MWkT09Te9dtnH9DjcgqPUAA9ucck7uwFcHgdkQv4VeFpTy/Rr2mHdhqvD2qyiQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdfme/pdf-lib": "*",
|
||||||
|
"air-datepicker": "^3.6.0",
|
||||||
|
"bwip-js": "^4.11.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"dompurify": "^3.4.7",
|
||||||
|
"fontkit": "^2.0.2",
|
||||||
|
"lucide": "^1.17.0",
|
||||||
|
"signature_pad": "^5.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@pdfme/common": "latest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/number": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
@@ -6036,6 +6173,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/uuid": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
@@ -6076,6 +6220,12 @@
|
|||||||
"node": ">= 6.0.0"
|
"node": ">= 6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/air-datepicker": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/air-datepicker/-/air-datepicker-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-+txUkqa949rXBJDmkQAIb/GehZECJYF4rm9XJxVYtEX22C9WvBpE/XwCUQZBopKIkpg4ycAySJ9lH3JOg9qQTw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ansi-escapes": {
|
"node_modules/ansi-escapes": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||||
@@ -6148,6 +6298,26 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.33",
|
"version": "2.10.33",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
|
||||||
@@ -6194,6 +6364,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.2",
|
"version": "4.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||||
@@ -6227,6 +6406,30 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -6234,6 +6437,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bwip-js": {
|
||||||
|
"version": "4.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.11.1.tgz",
|
||||||
|
"integrity": "sha512-9KCjsJF/VSETOS6jPgNxd6X9yCHTbqcm2zccDceX7bOmxDDjQ92V9S+iFf+UD5+hijUZDw7SFsYT7vL2rW7qcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"bwip-js": "bin/bwip-js.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001793",
|
"version": "1.0.30001793",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||||
@@ -6324,6 +6536,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -6621,6 +6842,12 @@
|
|||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-helpers": {
|
"node_modules/dom-helpers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
@@ -6631,6 +6858,15 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
|
||||||
|
"integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -6911,6 +7147,12 @@
|
|||||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-equals": {
|
"node_modules/fast-equals": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz",
|
||||||
@@ -6975,6 +7217,23 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/framer-motion": {
|
"node_modules/framer-motion": {
|
||||||
"version": "11.18.2",
|
"version": "11.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||||
@@ -7105,6 +7364,22 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-entities": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/mdevils"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://patreon.com/mdevils"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
@@ -7150,6 +7425,26 @@
|
|||||||
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/import-in-the-middle": {
|
"node_modules/import-in-the-middle": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz",
|
||||||
@@ -7709,6 +8004,12 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide": {
|
||||||
|
"version": "1.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide/-/lucide-1.18.0.tgz",
|
||||||
|
"integrity": "sha512-xzUa3LbA/Uvn3DCs7B7YfDcOZeqV8aZ4r4OFyCgJSfZGUdMglJiK3PJSbd26SXLLQvGGsYX9PdETCRXvfK4rnA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -8439,6 +8740,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-html-better-parser": {
|
||||||
|
"version": "1.5.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
|
||||||
|
"integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"html-entities": "^2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.47",
|
"version": "2.0.47",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
|
||||||
@@ -8682,6 +8992,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -9383,6 +9699,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rfdc": {
|
"node_modules/rfdc": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
@@ -9542,6 +9864,12 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/signature_pad": {
|
||||||
|
"version": "5.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.1.3.tgz",
|
||||||
|
"integrity": "sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/simple-swizzle": {
|
"node_modules/simple-swizzle": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||||
@@ -9747,6 +10075,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
@@ -10330,6 +10664,32 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie/node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@pdfme/common": "^6.1.5",
|
||||||
|
"@pdfme/generator": "^6.1.5",
|
||||||
|
"@pdfme/schemas": "^6.1.5",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
|
|||||||
BIN
public/fonts/Cordia_New_Bold.ttf
Normal file
BIN
public/fonts/Cordia_New_Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/cordia.ttc
Normal file
BIN
public/fonts/cordia.ttc
Normal file
Binary file not shown.
BIN
public/fonts/cordia_new_r.ttf
Normal file
BIN
public/fonts/cordia_new_r.ttf
Normal file
Binary file not shown.
@@ -19,8 +19,6 @@ const enquiryRequestSchema = enquirySchema.extend({
|
|||||||
contactId: z.string().nullable().optional(),
|
contactId: z.string().nullable().optional(),
|
||||||
branchId: z.string().nullable().optional(),
|
branchId: z.string().nullable().optional(),
|
||||||
leadChannel: z.string().nullable().optional(),
|
leadChannel: z.string().nullable().optional(),
|
||||||
estimatedValue: z.number().nullable().optional(),
|
|
||||||
chancePercent: z.number().nullable().optional(),
|
|
||||||
expectedCloseDate: z.string().nullable().optional()
|
expectedCloseDate: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const enquiryRequestSchema = enquirySchema.extend({
|
|||||||
contactId: z.string().nullable().optional(),
|
contactId: z.string().nullable().optional(),
|
||||||
branchId: z.string().nullable().optional(),
|
branchId: z.string().nullable().optional(),
|
||||||
leadChannel: z.string().nullable().optional(),
|
leadChannel: z.string().nullable().optional(),
|
||||||
estimatedValue: z.number().nullable().optional(),
|
|
||||||
chancePercent: z.number().nullable().optional(),
|
|
||||||
expectedCloseDate: z.string().nullable().optional()
|
expectedCloseDate: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
78
src/app/api/crm/quotations/[id]/approved-pdf/route.ts
Normal file
78
src/app/api/crm/quotations/[id]/approved-pdf/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import {
|
||||||
|
generateApprovedQuotationPdf,
|
||||||
|
getStoredApprovedQuotationPdf
|
||||||
|
} from '@/features/foundation/pdf-generator/server/service';
|
||||||
|
import { getQuotationDetail } from '@/features/crm/quotations/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmQuotationPdfDownload
|
||||||
|
});
|
||||||
|
const pdf = await getStoredApprovedQuotationPdf(id, organization.id);
|
||||||
|
|
||||||
|
return new NextResponse(pdf.buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `inline; filename="${pdf.fileName}"`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to load approved PDF' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmQuotationPdfGenerateApproved
|
||||||
|
});
|
||||||
|
const before = await getQuotationDetail(id, organization.id);
|
||||||
|
const pdf = await generateApprovedQuotationPdf(id, organization.id, session.user.id);
|
||||||
|
const after = await getQuotationDetail(id, organization.id);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_quotation_pdf_generate_approved',
|
||||||
|
entityId: id,
|
||||||
|
action: 'generate_approved',
|
||||||
|
beforeData: before,
|
||||||
|
afterData: {
|
||||||
|
approvedPdfUrl: after.approvedPdfUrl,
|
||||||
|
approvedTemplateVersionId: after.approvedTemplateVersionId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(pdf.buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${pdf.fileName}"`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to generate approved PDF' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/crm/quotations/[id]/pdf-download/route.ts
Normal file
33
src/app/api/crm/quotations/[id]/pdf-download/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmQuotationPdfDownload
|
||||||
|
});
|
||||||
|
const pdf = await generateQuotationPdf(id, organization.id);
|
||||||
|
|
||||||
|
return new NextResponse(pdf.buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${pdf.fileName}"`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to generate quotation PDF' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/crm/quotations/[id]/pdf-preview/route.ts
Normal file
33
src/app/api/crm/quotations/[id]/pdf-preview/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { generateQuotationPreviewPdf } from '@/features/foundation/pdf-generator/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmQuotationPdfPreview
|
||||||
|
});
|
||||||
|
const pdf = await generateQuotationPreviewPdf(id, organization.id);
|
||||||
|
|
||||||
|
return new NextResponse(pdf.buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `inline; filename="${pdf.fileName}"`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to generate preview PDF' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,21 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
|||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationDocumentPreview)));
|
session.user.activePermissions.includes(PERMISSIONS.crmQuotationDocumentPreview)));
|
||||||
|
const canPreviewPdf =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfPreview)));
|
||||||
|
const canDownloadPdf =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfDownload)));
|
||||||
|
const canGenerateApprovedPdf =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfGenerateApproved)));
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
if (canRead) {
|
if (canRead) {
|
||||||
@@ -149,6 +164,9 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
|||||||
isOrgAdmin={isOrgAdmin}
|
isOrgAdmin={isOrgAdmin}
|
||||||
currentUserId={session?.user?.id ?? ''}
|
currentUserId={session?.user?.id ?? ''}
|
||||||
canPreviewDocument={canPreviewDocument}
|
canPreviewDocument={canPreviewDocument}
|
||||||
|
canPreviewPdf={canPreviewPdf}
|
||||||
|
canDownloadPdf={canDownloadPdf}
|
||||||
|
canGenerateApprovedPdf={canGenerateApprovedPdf}
|
||||||
/>
|
/>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
IconDeviceLaptop,
|
IconDeviceLaptop,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
|
IconDownload,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconExternalLink,
|
IconExternalLink,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
@@ -164,6 +165,7 @@ export const Icons = {
|
|||||||
add: IconPlus,
|
add: IconPlus,
|
||||||
edit: IconEdit,
|
edit: IconEdit,
|
||||||
upload: IconUpload,
|
upload: IconUpload,
|
||||||
|
download: IconDownload,
|
||||||
share: IconShare,
|
share: IconShare,
|
||||||
login: IconLogin,
|
login: IconLogin,
|
||||||
logout: IconLogout,
|
logout: IconLogout,
|
||||||
|
|||||||
@@ -284,6 +284,10 @@ export const crmQuotations = pgTable(
|
|||||||
isSent: boolean('is_sent').default(false).notNull(),
|
isSent: boolean('is_sent').default(false).notNull(),
|
||||||
sentAt: timestamp('sent_at', { withTimezone: true }),
|
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||||||
sentVia: text('sent_via'),
|
sentVia: text('sent_via'),
|
||||||
|
approvedAt: timestamp('approved_at', { withTimezone: true }),
|
||||||
|
approvedPdfUrl: text('approved_pdf_url'),
|
||||||
|
approvedSnapshot: jsonb('approved_snapshot'),
|
||||||
|
approvedTemplateVersionId: text('approved_template_version_id'),
|
||||||
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
||||||
rejectedAt: timestamp('rejected_at', { withTimezone: true }),
|
rejectedAt: timestamp('rejected_at', { withTimezone: true }),
|
||||||
rejectionReason: text('rejection_reason'),
|
rejectionReason: text('rejection_reason'),
|
||||||
|
|||||||
@@ -44,11 +44,8 @@ function toDefaultValues(
|
|||||||
priority:
|
priority:
|
||||||
enquiry?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '',
|
enquiry?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '',
|
||||||
leadChannel: enquiry?.leadChannel ?? undefined,
|
leadChannel: enquiry?.leadChannel ?? undefined,
|
||||||
estimatedValue: enquiry?.estimatedValue ? String(enquiry.estimatedValue) : '',
|
estimatedValue: enquiry?.estimatedValue ?? undefined,
|
||||||
chancePercent:
|
chancePercent: enquiry?.chancePercent ?? undefined,
|
||||||
enquiry?.chancePercent !== null && enquiry?.chancePercent !== undefined
|
|
||||||
? String(enquiry.chancePercent)
|
|
||||||
: '',
|
|
||||||
expectedCloseDate: enquiry?.expectedCloseDate ? enquiry.expectedCloseDate.slice(0, 10) : '',
|
expectedCloseDate: enquiry?.expectedCloseDate ? enquiry.expectedCloseDate.slice(0, 10) : '',
|
||||||
competitor: enquiry?.competitor ?? '',
|
competitor: enquiry?.competitor ?? '',
|
||||||
source: enquiry?.source ?? '',
|
source: enquiry?.source ?? '',
|
||||||
@@ -121,8 +118,10 @@ export function EnquiryFormSheet({
|
|||||||
status: value.status,
|
status: value.status,
|
||||||
priority: value.priority,
|
priority: value.priority,
|
||||||
leadChannel: value.leadChannel || null,
|
leadChannel: value.leadChannel || null,
|
||||||
estimatedValue: value.estimatedValue ? Number(value.estimatedValue) : null,
|
estimatedValue:
|
||||||
chancePercent: value.chancePercent ? Number(value.chancePercent) : null,
|
typeof value.estimatedValue === 'number' ? value.estimatedValue : null,
|
||||||
|
chancePercent:
|
||||||
|
typeof value.chancePercent === 'number' ? value.chancePercent : null,
|
||||||
expectedCloseDate: value.expectedCloseDate || null,
|
expectedCloseDate: value.expectedCloseDate || null,
|
||||||
competitor: value.competitor,
|
competitor: value.competitor,
|
||||||
source: value.source,
|
source: value.source,
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
function coerceOptionalNumber(message: string) {
|
||||||
|
return z.preprocess((value) => {
|
||||||
|
if (value === '' || value === null || value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}, z.number().refine((value) => !Number.isNaN(value), message).optional());
|
||||||
|
}
|
||||||
|
|
||||||
export const enquirySchema = z.object({
|
export const enquirySchema = z.object({
|
||||||
customerId: z.string().min(1, 'Please select a customer'),
|
customerId: z.string().min(1, 'Please select a customer'),
|
||||||
contactId: z.string().optional().nullable(),
|
contactId: z.string().optional().nullable(),
|
||||||
@@ -13,18 +37,14 @@ export const enquirySchema = z.object({
|
|||||||
status: z.string().min(1, 'Please select a status'),
|
status: z.string().min(1, 'Please select a status'),
|
||||||
priority: z.string().min(1, 'Please select a priority'),
|
priority: z.string().min(1, 'Please select a priority'),
|
||||||
leadChannel: z.string().optional().nullable(),
|
leadChannel: z.string().optional().nullable(),
|
||||||
estimatedValue: z
|
estimatedValue: coerceOptionalNumber('Estimated value must be a number').refine(
|
||||||
.string()
|
(value) => value === undefined || value >= 0,
|
||||||
.optional()
|
'Estimated value must be 0 or greater'
|
||||||
.refine((value) => !value || !Number.isNaN(Number(value)), 'Estimated value must be a number'),
|
),
|
||||||
chancePercent: z
|
chancePercent: coerceOptionalNumber('Chance percent must be a number').refine(
|
||||||
.string()
|
(value) => value === undefined || (value >= 0 && value <= 100),
|
||||||
.optional()
|
'Chance percent must be between 0 and 100'
|
||||||
.refine((value) => !value || !Number.isNaN(Number(value)), 'Chance percent must be a number')
|
),
|
||||||
.refine(
|
|
||||||
(value) => !value || (Number(value) >= 0 && Number(value) <= 100),
|
|
||||||
'Chance percent must be between 0 and 100'
|
|
||||||
),
|
|
||||||
expectedCloseDate: z.string().optional().nullable(),
|
expectedCloseDate: z.string().optional().nullable(),
|
||||||
competitor: z.string().optional(),
|
competitor: z.string().optional(),
|
||||||
source: z.string().optional(),
|
source: z.string().optional(),
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export interface QuotationRecord {
|
|||||||
isSent: boolean;
|
isSent: boolean;
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
sentVia: string | null;
|
sentVia: string | null;
|
||||||
|
approvedAt: string | null;
|
||||||
|
approvedPdfUrl: string | null;
|
||||||
|
approvedSnapshot: unknown | null;
|
||||||
|
approvedTemplateVersionId: string | null;
|
||||||
acceptedAt: string | null;
|
acceptedAt: string | null;
|
||||||
rejectedAt: string | null;
|
rejectedAt: string | null;
|
||||||
rejectionReason: string | null;
|
rejectionReason: string | null;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
quotationCustomersOptions,
|
quotationCustomersOptions,
|
||||||
quotationFollowupsOptions,
|
quotationFollowupsOptions,
|
||||||
quotationItemsOptions,
|
quotationItemsOptions,
|
||||||
|
quotationKeys,
|
||||||
quotationRevisionsOptions,
|
quotationRevisionsOptions,
|
||||||
quotationTopicsOptions
|
quotationTopicsOptions
|
||||||
} from '../api/queries';
|
} from '../api/queries';
|
||||||
@@ -69,10 +70,12 @@ import type {
|
|||||||
QuotationTopicRecord
|
QuotationTopicRecord
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
||||||
|
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
||||||
import { QuotationDocumentPreview } from './quotation-document-preview';
|
import { QuotationDocumentPreview } from './quotation-document-preview';
|
||||||
import { QuotationFormSheet } from './quotation-form-sheet';
|
import { QuotationFormSheet } from './quotation-form-sheet';
|
||||||
import { QuotationApprovalTab } from './quotation-approval-tab';
|
import { QuotationApprovalTab } from './quotation-approval-tab';
|
||||||
import { QuotationStatusBadge } from './quotation-status-badge';
|
import { QuotationStatusBadge } from './quotation-status-badge';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
return (
|
return (
|
||||||
@@ -483,7 +486,10 @@ export function QuotationDetail({
|
|||||||
activeBusinessRole,
|
activeBusinessRole,
|
||||||
isOrgAdmin,
|
isOrgAdmin,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
canPreviewDocument
|
canPreviewDocument,
|
||||||
|
canPreviewPdf,
|
||||||
|
canDownloadPdf,
|
||||||
|
canGenerateApprovedPdf
|
||||||
}: {
|
}: {
|
||||||
quotationId: string;
|
quotationId: string;
|
||||||
referenceData: QuotationReferenceData;
|
referenceData: QuotationReferenceData;
|
||||||
@@ -502,6 +508,9 @@ export function QuotationDetail({
|
|||||||
isOrgAdmin: boolean;
|
isOrgAdmin: boolean;
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
canPreviewDocument: boolean;
|
canPreviewDocument: boolean;
|
||||||
|
canPreviewPdf: boolean;
|
||||||
|
canDownloadPdf: boolean;
|
||||||
|
canGenerateApprovedPdf: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||||
const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId));
|
const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId));
|
||||||
@@ -634,6 +643,41 @@ export function QuotationDetail({
|
|||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
|
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
|
||||||
});
|
});
|
||||||
|
const generateApprovedPdf = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||||
|
throw new Error(payload?.message ?? 'Failed to generate approved PDF');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
},
|
||||||
|
onSuccess: async (blob) => {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `${quotation.code}-approved.pdf`;
|
||||||
|
document.body.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: quotationKeys.attachments(quotationId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) })
|
||||||
|
]);
|
||||||
|
toast.success('Approved PDF generated successfully');
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to generate approved PDF')
|
||||||
|
});
|
||||||
|
|
||||||
const branchMap = useMemo(
|
const branchMap = useMemo(
|
||||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||||
@@ -675,6 +719,8 @@ export function QuotationDetail({
|
|||||||
);
|
);
|
||||||
const canSubmitCurrentQuotation =
|
const canSubmitCurrentQuotation =
|
||||||
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
||||||
|
const canGenerateApprovedNow =
|
||||||
|
canGenerateApprovedPdf && (status?.code ?? '') === 'approved';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
@@ -856,6 +902,36 @@ export function QuotationDetail({
|
|||||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit Quotation
|
<Icons.edit className='mr-2 h-4 w-4' /> Edit Quotation
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canPreviewPdf ? (
|
||||||
|
<Button asChild variant='outline'>
|
||||||
|
<Link href={`/api/crm/quotations/${quotationId}/pdf-preview`} target='_blank'>
|
||||||
|
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview PDF
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canDownloadPdf ? (
|
||||||
|
<Button asChild variant='outline'>
|
||||||
|
<Link href={`/api/crm/quotations/${quotationId}/pdf-download`} target='_blank'>
|
||||||
|
<Icons.download className='mr-2 h-4 w-4' /> Download PDF
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canGenerateApprovedNow ? (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
isLoading={generateApprovedPdf.isPending}
|
||||||
|
onClick={() => generateApprovedPdf.mutate()}
|
||||||
|
>
|
||||||
|
<Icons.badgeCheck className='mr-2 h-4 w-4' /> Generate Approved PDF
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{quotation.approvedPdfUrl ? (
|
||||||
|
<Button asChild variant='outline'>
|
||||||
|
<Link href={`/api/crm/quotations/${quotationId}/approved-pdf`} target='_blank'>
|
||||||
|
<Icons.externalLink className='mr-2 h-4 w-4' /> View Approved PDF
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1246,6 +1322,8 @@ export function QuotationDetail({
|
|||||||
<FieldItem label='Created At' value={new Date(quotation.createdAt).toLocaleString()} />
|
<FieldItem label='Created At' value={new Date(quotation.createdAt).toLocaleString()} />
|
||||||
<FieldItem label='Updated At' value={new Date(quotation.updatedAt).toLocaleString()} />
|
<FieldItem label='Updated At' value={new Date(quotation.updatedAt).toLocaleString()} />
|
||||||
<FieldItem label='Revision' value={`R${String(quotation.revision).padStart(2, '0')}`} />
|
<FieldItem label='Revision' value={`R${String(quotation.revision).padStart(2, '0')}`} />
|
||||||
|
<FieldItem label='Approved At' value={quotation.approvedAt ? new Date(quotation.approvedAt).toLocaleString() : null} />
|
||||||
|
<FieldItem label='Approved Template Version' value={quotation.approvedTemplateVersionId} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -345,15 +345,19 @@ export async function getQuotationDocumentPreviewData(
|
|||||||
|
|
||||||
export async function prepareApprovedQuotationSnapshot(
|
export async function prepareApprovedQuotationSnapshot(
|
||||||
quotationId: string,
|
quotationId: string,
|
||||||
organizationId: string
|
organizationId: string,
|
||||||
|
userId: string
|
||||||
): Promise<ApprovedQuotationSnapshot> {
|
): Promise<ApprovedQuotationSnapshot> {
|
||||||
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
|
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
|
||||||
|
const generatedAt = new Date().toISOString();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quotationId,
|
quotationId,
|
||||||
approvedAt: preview.documentData.approval.approvedAt,
|
approvedAt: preview.documentData.approval.approvedAt,
|
||||||
documentData: preview.documentData,
|
documentData: preview.documentData,
|
||||||
templateVersionId: preview.template.version.id,
|
templateVersionId: preview.template.version.id,
|
||||||
templateInput: preview.templateInput
|
templateInput: preview.templateInput,
|
||||||
|
generatedAt,
|
||||||
|
generatedBy: userId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,4 +150,6 @@ export interface ApprovedQuotationSnapshot {
|
|||||||
documentData: QuotationDocumentData;
|
documentData: QuotationDocumentData;
|
||||||
templateVersionId: string;
|
templateVersionId: string;
|
||||||
templateInput: Record<string, unknown>;
|
templateInput: Record<string, unknown>;
|
||||||
|
generatedAt: string;
|
||||||
|
generatedBy: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
function coerceOptionalNumber(message: string) {
|
||||||
|
return z.preprocess((value) => {
|
||||||
|
if (value === '' || value === null || value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}, z.number().refine((value) => !Number.isNaN(value), message).optional());
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceRequiredNumber(message: string) {
|
||||||
|
return z.preprocess((value) => {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return Number(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}, z.number().refine((value) => !Number.isNaN(value), message));
|
||||||
|
}
|
||||||
|
|
||||||
export const quotationSchema = z.object({
|
export const quotationSchema = z.object({
|
||||||
enquiryId: z.string().optional().nullable(),
|
enquiryId: z.string().optional().nullable(),
|
||||||
customerId: z.string().min(1, 'Please select a customer'),
|
customerId: z.string().min(1, 'Please select a customer'),
|
||||||
@@ -12,14 +50,11 @@ export const quotationSchema = z.object({
|
|||||||
attention: z.string().optional(),
|
attention: z.string().optional(),
|
||||||
branchId: z.string().optional().nullable(),
|
branchId: z.string().optional().nullable(),
|
||||||
currency: z.string().min(1, 'Please select a currency'),
|
currency: z.string().min(1, 'Please select a currency'),
|
||||||
exchangeRate: z.number().nullable().optional(),
|
exchangeRate: coerceOptionalNumber('Exchange rate must be a number'),
|
||||||
status: z.string().min(1, 'Please select a status'),
|
status: z.string().min(1, 'Please select a status'),
|
||||||
chancePercent: z
|
chancePercent: coerceOptionalNumber('Chance percent must be a number')
|
||||||
.number()
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.refine(
|
.refine(
|
||||||
(value) => value === null || value === undefined || (value >= 0 && value <= 100),
|
(value) => value === undefined || (value >= 0 && value <= 100),
|
||||||
'Chance percent must be between 0 and 100'
|
'Chance percent must be between 0 and 100'
|
||||||
),
|
),
|
||||||
isHotProject: z.boolean().default(false),
|
isHotProject: z.boolean().default(false),
|
||||||
@@ -27,9 +62,9 @@ export const quotationSchema = z.object({
|
|||||||
reference: z.string().optional(),
|
reference: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
salesmanId: z.string().optional().nullable(),
|
salesmanId: z.string().optional().nullable(),
|
||||||
discount: z.number().nullable().optional(),
|
discount: coerceOptionalNumber('Discount must be a number'),
|
||||||
discountType: z.string().optional().nullable(),
|
discountType: z.string().optional().nullable(),
|
||||||
taxRate: z.number().nullable().optional(),
|
taxRate: coerceOptionalNumber('Tax rate must be a number'),
|
||||||
isActive: z.boolean().default(true),
|
isActive: z.boolean().default(true),
|
||||||
sentVia: z.string().optional().nullable(),
|
sentVia: z.string().optional().nullable(),
|
||||||
revisionRemark: z.string().optional().nullable()
|
revisionRemark: z.string().optional().nullable()
|
||||||
@@ -38,14 +73,26 @@ export const quotationSchema = z.object({
|
|||||||
export const quotationItemSchema = z.object({
|
export const quotationItemSchema = z.object({
|
||||||
productType: z.string().min(1, 'Please select a product type'),
|
productType: z.string().min(1, 'Please select a product type'),
|
||||||
description: z.string().min(2, 'Description must be at least 2 characters'),
|
description: z.string().min(2, 'Description must be at least 2 characters'),
|
||||||
quantity: z.number().positive('Quantity must be greater than 0'),
|
quantity: coerceRequiredNumber('Quantity must be a number').refine(
|
||||||
|
(value) => value > 0,
|
||||||
|
'Quantity must be greater than 0'
|
||||||
|
),
|
||||||
unit: z.string().optional(),
|
unit: z.string().optional(),
|
||||||
unitPrice: z.number().min(0, 'Unit price must be 0 or greater'),
|
unitPrice: coerceRequiredNumber('Unit price must be a number').refine(
|
||||||
discount: z.number().min(0).nullable().optional(),
|
(value) => value >= 0,
|
||||||
|
'Unit price must be 0 or greater'
|
||||||
|
),
|
||||||
|
discount: coerceOptionalNumber('Discount must be a number').refine(
|
||||||
|
(value) => value === undefined || value >= 0,
|
||||||
|
'Discount must be 0 or greater'
|
||||||
|
),
|
||||||
discountType: z.string().nullable().optional(),
|
discountType: z.string().nullable().optional(),
|
||||||
taxRate: z.number().min(0).nullable().optional(),
|
taxRate: coerceOptionalNumber('Tax rate must be a number').refine(
|
||||||
|
(value) => value === undefined || value >= 0,
|
||||||
|
'Tax rate must be 0 or greater'
|
||||||
|
),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
sortOrder: z.number().nullable().optional()
|
sortOrder: coerceOptionalNumber('Sort order must be a number')
|
||||||
});
|
});
|
||||||
|
|
||||||
export const quotationCustomerSchema = z.object({
|
export const quotationCustomerSchema = z.object({
|
||||||
@@ -58,13 +105,13 @@ export const quotationTopicSchema = z.object({
|
|||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
topicType: z.string().min(1, 'Please select a topic type'),
|
topicType: z.string().min(1, 'Please select a topic type'),
|
||||||
title: z.string().min(2, 'Title must be at least 2 characters'),
|
title: z.string().min(2, 'Title must be at least 2 characters'),
|
||||||
sortOrder: z.number().nullable().optional(),
|
sortOrder: coerceOptionalNumber('Sort order must be a number'),
|
||||||
items: z
|
items: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
content: z.string().min(1, 'Topic item content is required'),
|
content: z.string().min(1, 'Topic item content is required'),
|
||||||
sortOrder: z.number().nullable().optional()
|
sortOrder: coerceOptionalNumber('Sort order must be a number')
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.min(1, 'At least one topic item is required')
|
.min(1, 'At least one topic item is required')
|
||||||
@@ -86,7 +133,7 @@ export const quotationAttachmentSchema = z.object({
|
|||||||
fileName: z.string().min(1, 'Stored file name is required'),
|
fileName: z.string().min(1, 'Stored file name is required'),
|
||||||
originalFileName: z.string().min(1, 'Original file name is required'),
|
originalFileName: z.string().min(1, 'Original file name is required'),
|
||||||
filePath: z.string().min(1, 'File path is required'),
|
filePath: z.string().min(1, 'File path is required'),
|
||||||
fileSize: z.number().nullable().optional(),
|
fileSize: coerceOptionalNumber('File size must be a number'),
|
||||||
fileType: z.string().nullable().optional(),
|
fileType: z.string().nullable().optional(),
|
||||||
description: z.string().optional()
|
description: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ function mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRe
|
|||||||
isSent: row.isSent,
|
isSent: row.isSent,
|
||||||
sentAt: row.sentAt?.toISOString() ?? null,
|
sentAt: row.sentAt?.toISOString() ?? null,
|
||||||
sentVia: row.sentVia,
|
sentVia: row.sentVia,
|
||||||
|
approvedAt: row.approvedAt?.toISOString() ?? null,
|
||||||
|
approvedPdfUrl: row.approvedPdfUrl,
|
||||||
|
approvedSnapshot: row.approvedSnapshot ?? null,
|
||||||
|
approvedTemplateVersionId: row.approvedTemplateVersionId,
|
||||||
acceptedAt: row.acceptedAt?.toISOString() ?? null,
|
acceptedAt: row.acceptedAt?.toISOString() ?? null,
|
||||||
rejectedAt: row.rejectedAt?.toISOString() ?? null,
|
rejectedAt: row.rejectedAt?.toISOString() ?? null,
|
||||||
rejectionReason: row.rejectionReason,
|
rejectionReason: row.rejectionReason,
|
||||||
@@ -1024,6 +1028,10 @@ export async function createQuotation(
|
|||||||
salesmanId: payload.salesmanId ?? null,
|
salesmanId: payload.salesmanId ?? null,
|
||||||
isSent: false,
|
isSent: false,
|
||||||
sentVia: payload.sentVia ?? null,
|
sentVia: payload.sentVia ?? null,
|
||||||
|
approvedAt: null,
|
||||||
|
approvedPdfUrl: null,
|
||||||
|
approvedSnapshot: null,
|
||||||
|
approvedTemplateVersionId: null,
|
||||||
isActive: payload.isActive ?? true,
|
isActive: payload.isActive ?? true,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
updatedBy: userId
|
updatedBy: userId
|
||||||
@@ -1765,6 +1773,10 @@ export async function createQuotationRevision(
|
|||||||
salesmanId: parent.salesmanId,
|
salesmanId: parent.salesmanId,
|
||||||
isSent: false,
|
isSent: false,
|
||||||
sentVia: parent.sentVia,
|
sentVia: parent.sentVia,
|
||||||
|
approvedAt: null,
|
||||||
|
approvedPdfUrl: null,
|
||||||
|
approvedSnapshot: null,
|
||||||
|
approvedTemplateVersionId: null,
|
||||||
isActive: parent.isActive,
|
isActive: parent.isActive,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
updatedBy: userId
|
updatedBy: userId
|
||||||
|
|||||||
@@ -314,13 +314,22 @@ async function syncQuotationStatusFromApproval(
|
|||||||
throw new AuthError(`Quotation status ${nextStatusCode} is not configured`, 400);
|
throw new AuthError(`Quotation status ${nextStatusCode} is not configured`, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextValues: Partial<typeof crmQuotations.$inferInsert> = {
|
||||||
|
status: statusId,
|
||||||
|
approvedAt: nextStatusCode === 'approved' ? new Date() : null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (nextStatusCode !== 'approved') {
|
||||||
|
nextValues.approvedPdfUrl = null;
|
||||||
|
nextValues.approvedSnapshot = null;
|
||||||
|
nextValues.approvedTemplateVersionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(crmQuotations)
|
.update(crmQuotations)
|
||||||
.set({
|
.set(nextValues)
|
||||||
status: statusId,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
updatedBy: userId
|
|
||||||
})
|
|
||||||
.where(eq(crmQuotations.id, quotationId));
|
.where(eq(crmQuotations.id, quotationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
313
src/features/foundation/pdf-generator/server/service.ts
Normal file
313
src/features/foundation/pdf-generator/server/service.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { Font, Template } from '@pdfme/common';
|
||||||
|
import { generate } from '@pdfme/generator';
|
||||||
|
import { image, line, table, text } from '@pdfme/schemas';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
crmQuotationAttachments,
|
||||||
|
crmQuotations,
|
||||||
|
organizations
|
||||||
|
} from '@/db/schema';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import {
|
||||||
|
getQuotationDocumentPreviewData,
|
||||||
|
prepareApprovedQuotationSnapshot
|
||||||
|
} from '@/features/crm/quotations/document/server/service';
|
||||||
|
import { getQuotationDetail } from '@/features/crm/quotations/server/service';
|
||||||
|
|
||||||
|
type GeneratePdfFromTemplateInput = {
|
||||||
|
template: Template;
|
||||||
|
inputs: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GeneratedQuotationPdf = {
|
||||||
|
buffer: Buffer;
|
||||||
|
fileName: string;
|
||||||
|
templateVersionId: string;
|
||||||
|
approvedPdfUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEMPLATE_FONT_FALLBACKS = new Set(['cordia', 'cordiaBold']);
|
||||||
|
|
||||||
|
function getPdfPlugins() {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
image,
|
||||||
|
line,
|
||||||
|
table
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePdfFonts(): Promise<Font> {
|
||||||
|
const regularPath = path.join(process.cwd(), 'public', 'fonts', 'cordia_new_r.ttf');
|
||||||
|
const boldPath = path.join(process.cwd(), 'public', 'fonts', 'Cordia_New_Bold.ttf');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [regularFontData, boldFontData] = await Promise.all([
|
||||||
|
readFile(regularPath),
|
||||||
|
readFile(boldPath)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cordia: {
|
||||||
|
data: new Uint8Array(regularFontData),
|
||||||
|
fallback: true
|
||||||
|
},
|
||||||
|
cordiaBold: {
|
||||||
|
data: new Uint8Array(boldFontData)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateFonts<T>(value: T): T {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => normalizeTemplateFonts(item)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const nextObject = Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([key, child]) => {
|
||||||
|
if (
|
||||||
|
key === 'fontName' &&
|
||||||
|
typeof child === 'string' &&
|
||||||
|
TEMPLATE_FONT_FALLBACKS.has(child)
|
||||||
|
) {
|
||||||
|
return [key, 'Roboto'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [key, normalizeTemplateFonts(child)];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return nextObject as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePdfFromTemplate(input: GeneratePdfFromTemplateInput) {
|
||||||
|
const font = await resolvePdfFonts();
|
||||||
|
const generatorInput = {
|
||||||
|
template: input.template,
|
||||||
|
inputs: input.inputs,
|
||||||
|
plugins: getPdfPlugins(),
|
||||||
|
options: Object.keys(font).length > 0 ? { font } : undefined
|
||||||
|
};
|
||||||
|
let pdf: Uint8Array;
|
||||||
|
|
||||||
|
try {
|
||||||
|
pdf = await generate(generatorInput);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.includes('Font "') || error.message.includes('fontKitFont.layout'))
|
||||||
|
) {
|
||||||
|
pdf = await generate({
|
||||||
|
...generatorInput,
|
||||||
|
template: normalizeTemplateFonts(input.template),
|
||||||
|
options: undefined
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSafeFileName(value: string) {
|
||||||
|
return value.replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGeneratedDirectory(organizationId: string) {
|
||||||
|
const directory = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'public',
|
||||||
|
'generated',
|
||||||
|
'quotations',
|
||||||
|
organizationId
|
||||||
|
);
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistApprovedQuotationArtifact(args: {
|
||||||
|
quotationId: string;
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
fileName: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
templateVersionId: string;
|
||||||
|
}) {
|
||||||
|
const quotation = await getQuotationDetail(args.quotationId, args.organizationId);
|
||||||
|
const snapshot = await prepareApprovedQuotationSnapshot(
|
||||||
|
args.quotationId,
|
||||||
|
args.organizationId,
|
||||||
|
args.userId
|
||||||
|
);
|
||||||
|
const directory = await ensureGeneratedDirectory(args.organizationId);
|
||||||
|
const safeFileName = toSafeFileName(args.fileName);
|
||||||
|
const absolutePath = path.join(directory, safeFileName);
|
||||||
|
const publicPath = `/generated/quotations/${args.organizationId}/${safeFileName}`;
|
||||||
|
|
||||||
|
await writeFile(absolutePath, args.buffer);
|
||||||
|
const fileStats = await stat(absolutePath);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmQuotations)
|
||||||
|
.set({
|
||||||
|
approvedAt: quotation.approvedAt ? new Date(quotation.approvedAt) : new Date(),
|
||||||
|
approvedPdfUrl: publicPath,
|
||||||
|
approvedSnapshot: snapshot,
|
||||||
|
approvedTemplateVersionId: args.templateVersionId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: args.userId
|
||||||
|
})
|
||||||
|
.where(eq(crmQuotations.id, args.quotationId));
|
||||||
|
|
||||||
|
const existingAttachment = await db
|
||||||
|
.select()
|
||||||
|
.from(crmQuotationAttachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmQuotationAttachments.quotationId, args.quotationId),
|
||||||
|
eq(crmQuotationAttachments.organizationId, args.organizationId),
|
||||||
|
eq(crmQuotationAttachments.filePath, publicPath),
|
||||||
|
isNull(crmQuotationAttachments.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
|
if (existingAttachment) {
|
||||||
|
await db
|
||||||
|
.update(crmQuotationAttachments)
|
||||||
|
.set({
|
||||||
|
fileName: safeFileName,
|
||||||
|
originalFileName: safeFileName,
|
||||||
|
filePath: publicPath,
|
||||||
|
fileSize: fileStats.size,
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
description: 'Approved quotation PDF',
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
uploadedBy: args.userId,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmQuotationAttachments.id, existingAttachment.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(crmQuotationAttachments).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId: args.organizationId,
|
||||||
|
quotationId: args.quotationId,
|
||||||
|
fileName: safeFileName,
|
||||||
|
originalFileName: safeFileName,
|
||||||
|
filePath: publicPath,
|
||||||
|
fileSize: fileStats.size,
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
description: 'Approved quotation PDF',
|
||||||
|
uploadedBy: args.userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicPath,
|
||||||
|
fileSize: fileStats.size,
|
||||||
|
snapshot
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateQuotationPdf(
|
||||||
|
quotationId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<GeneratedQuotationPdf> {
|
||||||
|
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
|
||||||
|
const buffer = await generatePdfFromTemplate({
|
||||||
|
template: preview.template.version.schemaJson as Template,
|
||||||
|
inputs: [preview.templateInput]
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
fileName: `${preview.documentData.quotation.code}.pdf`,
|
||||||
|
templateVersionId: preview.template.version.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateQuotationPreviewPdf(
|
||||||
|
quotationId: string,
|
||||||
|
organizationId: string
|
||||||
|
) {
|
||||||
|
return generateQuotationPdf(quotationId, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateApprovedQuotationPdf(
|
||||||
|
quotationId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<GeneratedQuotationPdf> {
|
||||||
|
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
|
||||||
|
|
||||||
|
if (preview.documentData.quotation.statusCode !== 'approved') {
|
||||||
|
throw new AuthError('Only approved quotations can generate approved PDF', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await generatePdfFromTemplate({
|
||||||
|
template: preview.template.version.schemaJson as Template,
|
||||||
|
inputs: [preview.templateInput]
|
||||||
|
});
|
||||||
|
const fileName = `${preview.documentData.quotation.code}-approved.pdf`;
|
||||||
|
const artifact = await persistApprovedQuotationArtifact({
|
||||||
|
quotationId,
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
fileName,
|
||||||
|
buffer,
|
||||||
|
templateVersionId: preview.template.version.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
fileName,
|
||||||
|
templateVersionId: preview.template.version.id,
|
||||||
|
approvedPdfUrl: artifact.publicPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStoredApprovedQuotationPdf(
|
||||||
|
quotationId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<GeneratedQuotationPdf> {
|
||||||
|
const quotation = await getQuotationDetail(quotationId, organizationId);
|
||||||
|
|
||||||
|
if (!quotation.approvedPdfUrl) {
|
||||||
|
throw new AuthError('Approved PDF has not been generated yet', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = quotation.approvedPdfUrl.replace(/^\//, '');
|
||||||
|
const absolutePath = path.join(process.cwd(), 'public', relativePath);
|
||||||
|
const buffer = await readFile(absolutePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
fileName: `${quotation.code}-approved.pdf`,
|
||||||
|
templateVersionId: quotation.approvedTemplateVersionId ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationGeneratedPdfDirectory(organizationId: string) {
|
||||||
|
const [organization] = await db
|
||||||
|
.select()
|
||||||
|
.from(organizations)
|
||||||
|
.where(eq(organizations.id, organizationId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw new AuthError('Organization not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureGeneratedDirectory(organizationId);
|
||||||
|
}
|
||||||
@@ -57,7 +57,10 @@ export const PERMISSIONS = {
|
|||||||
crmDocumentTemplateCreate: 'crm.document_template.create',
|
crmDocumentTemplateCreate: 'crm.document_template.create',
|
||||||
crmDocumentTemplateUpdate: 'crm.document_template.update',
|
crmDocumentTemplateUpdate: 'crm.document_template.update',
|
||||||
crmDocumentTemplateDelete: 'crm.document_template.delete',
|
crmDocumentTemplateDelete: 'crm.document_template.delete',
|
||||||
crmQuotationDocumentPreview: 'crm.quotation.document.preview'
|
crmQuotationDocumentPreview: 'crm.quotation.document.preview',
|
||||||
|
crmQuotationPdfPreview: 'crm.quotation.pdf.preview',
|
||||||
|
crmQuotationPdfDownload: 'crm.quotation.pdf.download',
|
||||||
|
crmQuotationPdfGenerateApproved: 'crm.quotation.pdf.generate_approved'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||||
@@ -104,7 +107,10 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.crmDocumentTemplateCreate,
|
PERMISSIONS.crmDocumentTemplateCreate,
|
||||||
PERMISSIONS.crmDocumentTemplateUpdate,
|
PERMISSIONS.crmDocumentTemplateUpdate,
|
||||||
PERMISSIONS.crmDocumentTemplateDelete,
|
PERMISSIONS.crmDocumentTemplateDelete,
|
||||||
PERMISSIONS.crmQuotationDocumentPreview
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
|
PERMISSIONS.crmQuotationPdfPreview,
|
||||||
|
PERMISSIONS.crmQuotationPdfDownload,
|
||||||
|
PERMISSIONS.crmQuotationPdfGenerateApproved
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +123,9 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.crmQuotationRead,
|
PERMISSIONS.crmQuotationRead,
|
||||||
PERMISSIONS.crmApprovalRead,
|
PERMISSIONS.crmApprovalRead,
|
||||||
PERMISSIONS.crmDocumentTemplateRead,
|
PERMISSIONS.crmDocumentTemplateRead,
|
||||||
PERMISSIONS.crmQuotationDocumentPreview
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
|
PERMISSIONS.crmQuotationPdfPreview,
|
||||||
|
PERMISSIONS.crmQuotationPdfDownload
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user