diff --git a/docs/implementation/task-h-pdf-generation-persistence.md b/docs/implementation/task-h-pdf-generation-persistence.md new file mode 100644 index 0000000..98890a3 --- /dev/null +++ b/docs/implementation/task-h-pdf-generation-persistence.md @@ -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//` +- 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 diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index fc24bfe..a80299c 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -191,6 +191,31 @@ Future: - storage abstraction - 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 ### Template Mapping Editor @@ -207,4 +232,4 @@ Future: define rule สำหรับ crane/dockdoor/solarcell ### PDF Binary Generation ยังไม่มี generate/download/store PDF -Future: Task H \ No newline at end of file +Future: Task H diff --git a/drizzle/0007_luxuriant_malice.sql b/drizzle/0007_luxuriant_malice.sql new file mode 100644 index 0000000..9af75d3 --- /dev/null +++ b/drizzle/0007_luxuriant_malice.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..9b48bca --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2995 @@ +{ + "id": "dae63ce5-8077-4920-abd6-a232df70e20a", + "prevId": "ad73f2e2-1046-414f-bcd9-32482c623e57", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.crm_approval_actions": { + "name": "crm_approval_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_request_id": { + "name": "approval_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acted_by": { + "name": "acted_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_requests": { + "name": "crm_approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_steps": { + "name": "crm_approval_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_code": { + "name": "role_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_steps_workflow_step_idx": { + "name": "crm_approval_steps_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_workflows": { + "name": "crm_approval_workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_workflows_org_code_idx": { + "name": "crm_approval_workflows_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_contacts": { + "name": "crm_customer_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customers": { + "name": "crm_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbr": { + "name": "abbr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_id": { + "name": "tax_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_status": { + "name": "customer_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub_district": { + "name": "sub_district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fax": { + "name": "fax", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_group": { + "name": "customer_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_customers_org_code_idx": { + "name": "crm_customers_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_mappings": { + "name": "crm_document_template_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "placeholder_key": { + "name": "placeholder_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_type": { + "name": "data_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheet_name": { + "name": "sheet_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_value": { + "name": "default_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format_mask": { + "name": "format_mask", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_document_template_mappings_version_placeholder_idx": { + "name": "crm_document_template_mappings_version_placeholder_idx", + "columns": [ + { + "expression": "template_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "placeholder_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_table_columns": { + "name": "crm_document_template_table_columns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mapping_id": { + "name": "mapping_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_name": { + "name": "column_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_field": { + "name": "source_field", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_letter": { + "name": "column_letter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "format_mask": { + "name": "format_mask", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_document_template_table_columns_mapping_name_idx": { + "name": "crm_document_template_table_columns_mapping_name_idx", + "columns": [ + { + "expression": "mapping_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "column_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_versions": { + "name": "crm_document_template_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_json": { + "name": "schema_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview_image_url": { + "name": "preview_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_template_versions_template_version_idx": { + "name": "crm_document_template_versions_template_version_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_templates": { + "name": "crm_document_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_templates_org_doc_product_file_name_idx": { + "name": "crm_document_templates_org_doc_product_file_name_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_enquiries": { + "name": "crm_enquiries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requirement": { + "name": "requirement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_close_date": { + "name": "expected_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_enquiries_org_code_idx": { + "name": "crm_enquiries_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_enquiry_followups": { + "name": "crm_enquiry_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_attachments": { + "name": "crm_quotation_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_customers": { + "name": "crm_quotation_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_followups": { + "name": "crm_quotation_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_items": { + "name": "crm_quotation_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_number": { + "name": "item_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_price": { + "name": "total_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topic_items": { + "name": "crm_quotation_topic_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topics": { + "name": "crm_quotation_topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_type": { + "name": "topic_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotations": { + "name": "crm_quotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotation_date": { + "name": "quotation_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quotation_type": { + "name": "quotation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attention": { + "name": "attention", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_quotation_id": { + "name": "parent_quotation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision_remark": { + "name": "revision_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exchange_rate": { + "name": "exchange_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "subtotal": { + "name": "subtotal", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tax_amount": { + "name": "tax_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_amount": { + "name": "total_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "salesman_id": { + "name": "salesman_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_sent": { + "name": "is_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_via": { + "name": "sent_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_pdf_url": { + "name": "approved_pdf_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_snapshot": { + "name": "approved_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_template_version_id": { + "name": "approved_template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_quotations_org_code_idx": { + "name": "crm_quotations_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_sequences": { + "name": "document_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_number": { + "name": "current_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "padding_length": { + "name": "padding_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_sequences_org_doc_period_branch_idx": { + "name": "document_sequences_org_doc_period_branch_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "business_role": { + "name": "business_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ms_options": { + "name": "ms_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ms_options_org_category_code_idx": { + "name": "ms_options_org_category_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "products_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tr_audit_logs": { + "name": "tr_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_data": { + "name": "before_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_data": { + "name": "after_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b43e326..a825cde 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1781579746622, "tag": "0006_worried_mordo", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1781589458455, + "tag": "0007_luxuriant_malice", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0da9c35..d917dc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@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-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", @@ -2791,6 +2794,140 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -6036,6 +6173,13 @@ "dev": true, "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": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -6076,6 +6220,12 @@ "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": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -6148,6 +6298,26 @@ "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": { "version": "2.10.33", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", @@ -6194,6 +6364,15 @@ "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": { "version": "4.28.2", "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_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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -6234,6 +6437,15 @@ "dev": true, "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": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -6324,6 +6536,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -6621,6 +6842,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "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": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -6631,6 +6858,15 @@ "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": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -6911,6 +7147,12 @@ "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": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz", @@ -6975,6 +7217,23 @@ "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": { "version": "11.18.2", "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==", "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": { "version": "5.0.1", "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==", "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": { "version": "3.0.1", "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" } }, + "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": { "version": "0.30.21", "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": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -8682,6 +8992,12 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9383,6 +9699,12 @@ "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": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -9542,6 +9864,12 @@ "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": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -9747,6 +10075,12 @@ "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": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -10330,6 +10664,32 @@ "dev": true, "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index 35a4349..5246445 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@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-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", diff --git a/public/fonts/Cordia_New_Bold.ttf b/public/fonts/Cordia_New_Bold.ttf new file mode 100644 index 0000000..5b44e57 Binary files /dev/null and b/public/fonts/Cordia_New_Bold.ttf differ diff --git a/public/fonts/cordia.ttc b/public/fonts/cordia.ttc new file mode 100644 index 0000000..7201efe Binary files /dev/null and b/public/fonts/cordia.ttc differ diff --git a/public/fonts/cordia_new_r.ttf b/public/fonts/cordia_new_r.ttf new file mode 100644 index 0000000..6cb3a3b Binary files /dev/null and b/public/fonts/cordia_new_r.ttf differ diff --git a/src/app/api/crm/enquiries/[id]/route.ts b/src/app/api/crm/enquiries/[id]/route.ts index 510465e..462564f 100644 --- a/src/app/api/crm/enquiries/[id]/route.ts +++ b/src/app/api/crm/enquiries/[id]/route.ts @@ -19,8 +19,6 @@ const enquiryRequestSchema = enquirySchema.extend({ contactId: z.string().nullable().optional(), branchId: z.string().nullable().optional(), leadChannel: z.string().nullable().optional(), - estimatedValue: z.number().nullable().optional(), - chancePercent: z.number().nullable().optional(), expectedCloseDate: z.string().nullable().optional() }); diff --git a/src/app/api/crm/enquiries/route.ts b/src/app/api/crm/enquiries/route.ts index 08f5999..6e54356 100644 --- a/src/app/api/crm/enquiries/route.ts +++ b/src/app/api/crm/enquiries/route.ts @@ -10,8 +10,6 @@ const enquiryRequestSchema = enquirySchema.extend({ contactId: z.string().nullable().optional(), branchId: z.string().nullable().optional(), leadChannel: z.string().nullable().optional(), - estimatedValue: z.number().nullable().optional(), - chancePercent: z.number().nullable().optional(), expectedCloseDate: z.string().nullable().optional() }); diff --git a/src/app/api/crm/quotations/[id]/approved-pdf/route.ts b/src/app/api/crm/quotations/[id]/approved-pdf/route.ts new file mode 100644 index 0000000..e493df9 --- /dev/null +++ b/src/app/api/crm/quotations/[id]/approved-pdf/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/quotations/[id]/pdf-download/route.ts b/src/app/api/crm/quotations/[id]/pdf-download/route.ts new file mode 100644 index 0000000..c697e13 --- /dev/null +++ b/src/app/api/crm/quotations/[id]/pdf-download/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/quotations/[id]/pdf-preview/route.ts b/src/app/api/crm/quotations/[id]/pdf-preview/route.ts new file mode 100644 index 0000000..2292e78 --- /dev/null +++ b/src/app/api/crm/quotations/[id]/pdf-preview/route.ts @@ -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 }); + } +} diff --git a/src/app/dashboard/crm/quotations/[id]/page.tsx b/src/app/dashboard/crm/quotations/[id]/page.tsx index 73d2d1e..260c95d 100644 --- a/src/app/dashboard/crm/quotations/[id]/page.tsx +++ b/src/app/dashboard/crm/quotations/[id]/page.tsx @@ -91,6 +91,21 @@ export default async function QuotationDetailRoute({ params }: PageProps) { (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || 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(); if (canRead) { @@ -149,6 +164,9 @@ export default async function QuotationDetailRoute({ params }: PageProps) { isOrgAdmin={isOrgAdmin} currentUserId={session?.user?.id ?? ''} canPreviewDocument={canPreviewDocument} + canPreviewPdf={canPreviewPdf} + canDownloadPdf={canDownloadPdf} + canGenerateApprovedPdf={canGenerateApprovedPdf} /> ) : null} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 8271bcc..ad8d9eb 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -31,6 +31,7 @@ import { IconDeviceLaptop, IconDots, IconDotsVertical, + IconDownload, IconEdit, IconExternalLink, IconEyeOff, @@ -164,6 +165,7 @@ export const Icons = { add: IconPlus, edit: IconEdit, upload: IconUpload, + download: IconDownload, share: IconShare, login: IconLogin, logout: IconLogout, diff --git a/src/db/schema.ts b/src/db/schema.ts index eda70cc..e489bb8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -284,6 +284,10 @@ export const crmQuotations = pgTable( isSent: boolean('is_sent').default(false).notNull(), sentAt: timestamp('sent_at', { withTimezone: true }), 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 }), rejectedAt: timestamp('rejected_at', { withTimezone: true }), rejectionReason: text('rejection_reason'), diff --git a/src/features/crm/enquiries/components/enquiry-form-sheet.tsx b/src/features/crm/enquiries/components/enquiry-form-sheet.tsx index 8c776e8..ed0a1b0 100644 --- a/src/features/crm/enquiries/components/enquiry-form-sheet.tsx +++ b/src/features/crm/enquiries/components/enquiry-form-sheet.tsx @@ -44,11 +44,8 @@ function toDefaultValues( priority: enquiry?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '', leadChannel: enquiry?.leadChannel ?? undefined, - estimatedValue: enquiry?.estimatedValue ? String(enquiry.estimatedValue) : '', - chancePercent: - enquiry?.chancePercent !== null && enquiry?.chancePercent !== undefined - ? String(enquiry.chancePercent) - : '', + estimatedValue: enquiry?.estimatedValue ?? undefined, + chancePercent: enquiry?.chancePercent ?? undefined, expectedCloseDate: enquiry?.expectedCloseDate ? enquiry.expectedCloseDate.slice(0, 10) : '', competitor: enquiry?.competitor ?? '', source: enquiry?.source ?? '', @@ -121,8 +118,10 @@ export function EnquiryFormSheet({ status: value.status, priority: value.priority, leadChannel: value.leadChannel || null, - estimatedValue: value.estimatedValue ? Number(value.estimatedValue) : null, - chancePercent: value.chancePercent ? Number(value.chancePercent) : null, + estimatedValue: + typeof value.estimatedValue === 'number' ? value.estimatedValue : null, + chancePercent: + typeof value.chancePercent === 'number' ? value.chancePercent : null, expectedCloseDate: value.expectedCloseDate || null, competitor: value.competitor, source: value.source, diff --git a/src/features/crm/enquiries/schemas/enquiry.schema.ts b/src/features/crm/enquiries/schemas/enquiry.schema.ts index 05c7419..72e2723 100644 --- a/src/features/crm/enquiries/schemas/enquiry.schema.ts +++ b/src/features/crm/enquiries/schemas/enquiry.schema.ts @@ -1,5 +1,29 @@ 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({ customerId: z.string().min(1, 'Please select a customer'), contactId: z.string().optional().nullable(), @@ -13,18 +37,14 @@ export const enquirySchema = z.object({ status: z.string().min(1, 'Please select a status'), priority: z.string().min(1, 'Please select a priority'), leadChannel: z.string().optional().nullable(), - estimatedValue: z - .string() - .optional() - .refine((value) => !value || !Number.isNaN(Number(value)), 'Estimated value must be a number'), - chancePercent: z - .string() - .optional() - .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' - ), + estimatedValue: coerceOptionalNumber('Estimated value must be a number').refine( + (value) => value === undefined || value >= 0, + 'Estimated value must be 0 or greater' + ), + chancePercent: coerceOptionalNumber('Chance percent must be a number').refine( + (value) => value === undefined || (value >= 0 && value <= 100), + 'Chance percent must be between 0 and 100' + ), expectedCloseDate: z.string().optional().nullable(), competitor: z.string().optional(), source: z.string().optional(), diff --git a/src/features/crm/quotations/api/types.ts b/src/features/crm/quotations/api/types.ts index 03a1abf..5b0f4b5 100644 --- a/src/features/crm/quotations/api/types.ts +++ b/src/features/crm/quotations/api/types.ts @@ -79,6 +79,10 @@ export interface QuotationRecord { isSent: boolean; sentAt: string | null; sentVia: string | null; + approvedAt: string | null; + approvedPdfUrl: string | null; + approvedSnapshot: unknown | null; + approvedTemplateVersionId: string | null; acceptedAt: string | null; rejectedAt: string | null; rejectionReason: string | null; diff --git a/src/features/crm/quotations/components/quotation-detail.tsx b/src/features/crm/quotations/components/quotation-detail.tsx index a5c25b7..d2f8038 100644 --- a/src/features/crm/quotations/components/quotation-detail.tsx +++ b/src/features/crm/quotations/components/quotation-detail.tsx @@ -52,6 +52,7 @@ import { quotationCustomersOptions, quotationFollowupsOptions, quotationItemsOptions, + quotationKeys, quotationRevisionsOptions, quotationTopicsOptions } from '../api/queries'; @@ -69,10 +70,12 @@ import type { QuotationTopicRecord } from '../api/types'; import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations'; +import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries'; import { QuotationDocumentPreview } from './quotation-document-preview'; import { QuotationFormSheet } from './quotation-form-sheet'; import { QuotationApprovalTab } from './quotation-approval-tab'; import { QuotationStatusBadge } from './quotation-status-badge'; +import { getQueryClient } from '@/lib/query-client'; function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { return ( @@ -483,7 +486,10 @@ export function QuotationDetail({ activeBusinessRole, isOrgAdmin, currentUserId, - canPreviewDocument + canPreviewDocument, + canPreviewPdf, + canDownloadPdf, + canGenerateApprovedPdf }: { quotationId: string; referenceData: QuotationReferenceData; @@ -502,6 +508,9 @@ export function QuotationDetail({ isOrgAdmin: boolean; currentUserId: string; canPreviewDocument: boolean; + canPreviewPdf: boolean; + canDownloadPdf: boolean; + canGenerateApprovedPdf: boolean; }) { const { data } = useSuspenseQuery(quotationByIdOptions(quotationId)); const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId)); @@ -634,6 +643,41 @@ export function QuotationDetail({ onError: (error) => 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( () => new Map(referenceData.branches.map((item) => [item.id, item.name])), @@ -675,6 +719,8 @@ export function QuotationDetail({ ); const canSubmitCurrentQuotation = canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? ''); + const canGenerateApprovedNow = + canGenerateApprovedPdf && (status?.code ?? '') === 'approved'; return (
@@ -856,6 +902,36 @@ export function QuotationDetail({ Edit Quotation ) : null} + {canPreviewPdf ? ( + + ) : null} + {canDownloadPdf ? ( + + ) : null} + {canGenerateApprovedNow ? ( + + ) : null} + {quotation.approvedPdfUrl ? ( + + ) : null}
@@ -1246,6 +1322,8 @@ export function QuotationDetail({ + + diff --git a/src/features/crm/quotations/document/server/service.ts b/src/features/crm/quotations/document/server/service.ts index 7c5b408..23e9a58 100644 --- a/src/features/crm/quotations/document/server/service.ts +++ b/src/features/crm/quotations/document/server/service.ts @@ -345,15 +345,19 @@ export async function getQuotationDocumentPreviewData( export async function prepareApprovedQuotationSnapshot( quotationId: string, - organizationId: string + organizationId: string, + userId: string ): Promise { const preview = await getQuotationDocumentPreviewData(quotationId, organizationId); + const generatedAt = new Date().toISOString(); return { quotationId, approvedAt: preview.documentData.approval.approvedAt, documentData: preview.documentData, templateVersionId: preview.template.version.id, - templateInput: preview.templateInput + templateInput: preview.templateInput, + generatedAt, + generatedBy: userId }; } diff --git a/src/features/crm/quotations/document/types.ts b/src/features/crm/quotations/document/types.ts index d13ede6..9089c0d 100644 --- a/src/features/crm/quotations/document/types.ts +++ b/src/features/crm/quotations/document/types.ts @@ -150,4 +150,6 @@ export interface ApprovedQuotationSnapshot { documentData: QuotationDocumentData; templateVersionId: string; templateInput: Record; + generatedAt: string; + generatedBy: string; } diff --git a/src/features/crm/quotations/schemas/quotation.schema.ts b/src/features/crm/quotations/schemas/quotation.schema.ts index 42504a1..fcd890c 100644 --- a/src/features/crm/quotations/schemas/quotation.schema.ts +++ b/src/features/crm/quotations/schemas/quotation.schema.ts @@ -1,5 +1,43 @@ 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({ enquiryId: z.string().optional().nullable(), customerId: z.string().min(1, 'Please select a customer'), @@ -12,14 +50,11 @@ export const quotationSchema = z.object({ attention: z.string().optional(), branchId: z.string().optional().nullable(), 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'), - chancePercent: z - .number() - .nullable() - .optional() + chancePercent: coerceOptionalNumber('Chance percent must be a number') .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' ), isHotProject: z.boolean().default(false), @@ -27,9 +62,9 @@ export const quotationSchema = z.object({ reference: z.string().optional(), notes: z.string().optional(), salesmanId: z.string().optional().nullable(), - discount: z.number().nullable().optional(), + discount: coerceOptionalNumber('Discount must be a number'), discountType: z.string().optional().nullable(), - taxRate: z.number().nullable().optional(), + taxRate: coerceOptionalNumber('Tax rate must be a number'), isActive: z.boolean().default(true), sentVia: z.string().optional().nullable(), revisionRemark: z.string().optional().nullable() @@ -38,14 +73,26 @@ export const quotationSchema = z.object({ export const quotationItemSchema = z.object({ productType: z.string().min(1, 'Please select a product type'), 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(), - unitPrice: z.number().min(0, 'Unit price must be 0 or greater'), - discount: z.number().min(0).nullable().optional(), + unitPrice: coerceRequiredNumber('Unit price must be a number').refine( + (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(), - 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(), - sortOrder: z.number().nullable().optional() + sortOrder: coerceOptionalNumber('Sort order must be a number') }); export const quotationCustomerSchema = z.object({ @@ -58,13 +105,13 @@ export const quotationTopicSchema = z.object({ id: z.string().optional(), topicType: z.string().min(1, 'Please select a topic type'), 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 .array( z.object({ id: z.string().optional(), 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') @@ -86,7 +133,7 @@ export const quotationAttachmentSchema = z.object({ fileName: z.string().min(1, 'Stored file name is required'), originalFileName: z.string().min(1, 'Original file name 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(), description: z.string().optional() }); diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts index af309f5..91f167f 100644 --- a/src/features/crm/quotations/server/service.ts +++ b/src/features/crm/quotations/server/service.ts @@ -131,6 +131,10 @@ function mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRe isSent: row.isSent, sentAt: row.sentAt?.toISOString() ?? null, sentVia: row.sentVia, + approvedAt: row.approvedAt?.toISOString() ?? null, + approvedPdfUrl: row.approvedPdfUrl, + approvedSnapshot: row.approvedSnapshot ?? null, + approvedTemplateVersionId: row.approvedTemplateVersionId, acceptedAt: row.acceptedAt?.toISOString() ?? null, rejectedAt: row.rejectedAt?.toISOString() ?? null, rejectionReason: row.rejectionReason, @@ -1024,6 +1028,10 @@ export async function createQuotation( salesmanId: payload.salesmanId ?? null, isSent: false, sentVia: payload.sentVia ?? null, + approvedAt: null, + approvedPdfUrl: null, + approvedSnapshot: null, + approvedTemplateVersionId: null, isActive: payload.isActive ?? true, createdBy: userId, updatedBy: userId @@ -1765,6 +1773,10 @@ export async function createQuotationRevision( salesmanId: parent.salesmanId, isSent: false, sentVia: parent.sentVia, + approvedAt: null, + approvedPdfUrl: null, + approvedSnapshot: null, + approvedTemplateVersionId: null, isActive: parent.isActive, createdBy: userId, updatedBy: userId diff --git a/src/features/foundation/approval/server/service.ts b/src/features/foundation/approval/server/service.ts index 5b65156..067e648 100644 --- a/src/features/foundation/approval/server/service.ts +++ b/src/features/foundation/approval/server/service.ts @@ -314,13 +314,22 @@ async function syncQuotationStatusFromApproval( throw new AuthError(`Quotation status ${nextStatusCode} is not configured`, 400); } + const nextValues: Partial = { + 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 .update(crmQuotations) - .set({ - status: statusId, - updatedAt: new Date(), - updatedBy: userId - }) + .set(nextValues) .where(eq(crmQuotations.id, quotationId)); } diff --git a/src/features/foundation/pdf-generator/server/service.ts b/src/features/foundation/pdf-generator/server/service.ts new file mode 100644 index 0000000..5c95d01 --- /dev/null +++ b/src/features/foundation/pdf-generator/server/service.ts @@ -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>; +}; + +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 { + 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(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).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 { + 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 { + 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 { + 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); +} diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index a904196..ede3989 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -57,7 +57,10 @@ export const PERMISSIONS = { crmDocumentTemplateCreate: 'crm.document_template.create', crmDocumentTemplateUpdate: 'crm.document_template.update', 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; export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; @@ -104,7 +107,10 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] { PERMISSIONS.crmDocumentTemplateCreate, PERMISSIONS.crmDocumentTemplateUpdate, PERMISSIONS.crmDocumentTemplateDelete, - PERMISSIONS.crmQuotationDocumentPreview + PERMISSIONS.crmQuotationDocumentPreview, + PERMISSIONS.crmQuotationPdfPreview, + PERMISSIONS.crmQuotationPdfDownload, + PERMISSIONS.crmQuotationPdfGenerateApproved ]; } @@ -117,7 +123,9 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] { PERMISSIONS.crmQuotationRead, PERMISSIONS.crmApprovalRead, PERMISSIONS.crmDocumentTemplateRead, - PERMISSIONS.crmQuotationDocumentPreview + PERMISSIONS.crmQuotationDocumentPreview, + PERMISSIONS.crmQuotationPdfPreview, + PERMISSIONS.crmQuotationPdfDownload ]; }