task-g
This commit is contained in:
125
docs/implementation/task-g-document-preview-foundation.md
Normal file
125
docs/implementation/task-g-document-preview-foundation.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Task G - Quotation Document Preview Foundation
|
||||||
|
|
||||||
|
## Files Added
|
||||||
|
|
||||||
|
- `src/features/foundation/document-template/types.ts`
|
||||||
|
- `src/features/foundation/document-template/service.ts`
|
||||||
|
- `src/features/foundation/document-template/queries.ts`
|
||||||
|
- `src/features/foundation/document-template/mutations.ts`
|
||||||
|
- `src/features/foundation/document-template/server/service.ts`
|
||||||
|
- `src/features/foundation/document-template/components/template-settings.tsx`
|
||||||
|
- `src/features/crm/quotations/document/types.ts`
|
||||||
|
- `src/features/crm/quotations/document/service.ts`
|
||||||
|
- `src/features/crm/quotations/document/queries.ts`
|
||||||
|
- `src/features/crm/quotations/document/server/service.ts`
|
||||||
|
- `src/features/crm/quotations/components/quotation-document-preview.tsx`
|
||||||
|
- `src/app/api/crm/quotations/[id]/document-data/route.ts`
|
||||||
|
- `src/app/api/crm/quotations/[id]/document-preview/route.ts`
|
||||||
|
- `src/app/api/crm/document-templates/route.ts`
|
||||||
|
- `src/app/api/crm/document-templates/[id]/route.ts`
|
||||||
|
- `src/app/api/crm/document-templates/[id]/versions/route.ts`
|
||||||
|
- `drizzle/0006_worried_mordo.sql`
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `src/db/schema.ts`
|
||||||
|
- `src/db/seeds/foundation.seed.ts`
|
||||||
|
- `src/lib/auth/rbac.ts`
|
||||||
|
- `src/config/nav-config.ts`
|
||||||
|
- `src/app/dashboard/crm/settings/templates/page.tsx`
|
||||||
|
- `src/app/dashboard/crm/quotations/[id]/page.tsx`
|
||||||
|
- `src/features/crm/quotations/components/quotation-detail.tsx`
|
||||||
|
- `src/features/crm/quotations/api/mutations.ts`
|
||||||
|
- `src/features/foundation/approval/mutations.ts`
|
||||||
|
- `docs/implementation/technical-debt.md`
|
||||||
|
|
||||||
|
## Schema Added
|
||||||
|
|
||||||
|
- `crm_document_templates`
|
||||||
|
- `crm_document_template_versions`
|
||||||
|
- `crm_document_template_mappings`
|
||||||
|
- `crm_document_template_table_columns`
|
||||||
|
|
||||||
|
## API Routes Added
|
||||||
|
|
||||||
|
- `GET /api/crm/quotations/[id]/document-data`
|
||||||
|
- `GET /api/crm/quotations/[id]/document-preview`
|
||||||
|
- `GET/POST /api/crm/document-templates`
|
||||||
|
- `GET/PATCH/DELETE /api/crm/document-templates/[id]`
|
||||||
|
- `GET/POST /api/crm/document-templates/[id]/versions`
|
||||||
|
|
||||||
|
## Preview UI Added
|
||||||
|
|
||||||
|
- Replaced quotation `Document Preview` placeholder with a production preview tab backed by real quotation data, template resolution, and mapped preview payloads.
|
||||||
|
- Added watermark/status behavior for non-approved quotations through normalized `watermarkStatus`.
|
||||||
|
- Preview now shows:
|
||||||
|
- quotation header
|
||||||
|
- customer block
|
||||||
|
- project info
|
||||||
|
- items table
|
||||||
|
- price summary
|
||||||
|
- scope / exclusion / payment topics
|
||||||
|
- approval block
|
||||||
|
- signature placeholders
|
||||||
|
|
||||||
|
## Template Settings UI Added
|
||||||
|
|
||||||
|
- Replaced `/dashboard/crm/settings/templates` placeholder with a production listing page.
|
||||||
|
- Current UI is intentionally lightweight and read-focused:
|
||||||
|
- template name and description
|
||||||
|
- default badge
|
||||||
|
- active/inactive badge
|
||||||
|
- file type
|
||||||
|
- latest version
|
||||||
|
- version count
|
||||||
|
- mapping count
|
||||||
|
|
||||||
|
## Template Seed Added
|
||||||
|
|
||||||
|
- Foundation seed now upserts a default quotation `pdfme` template per organization.
|
||||||
|
- Seed picks template JSON by organization name:
|
||||||
|
- `ALLA_template_pdfme_fainal3.json`
|
||||||
|
- `ONVALLA_template_pdfme_fainal3.json`
|
||||||
|
- If the organization name does not match either branch, the seed falls back to a minimal schema payload.
|
||||||
|
- Seed also creates baseline placeholder mappings and a table mapping for quotation line items.
|
||||||
|
|
||||||
|
## Mapping Resolver Added
|
||||||
|
|
||||||
|
- `resolveTemplateForDocument()`
|
||||||
|
- `resolveTemplateMappings()`
|
||||||
|
- `mapDocumentDataToTemplateInput()`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- resolves by `documentType + productType + fileType`
|
||||||
|
- falls back to `default` product type when no specific product template exists
|
||||||
|
- maps dot-path source fields into scalar, multiline, or table-ready template input
|
||||||
|
|
||||||
|
## Approved Snapshot Prep Added
|
||||||
|
|
||||||
|
- `prepareApprovedQuotationSnapshot(quotationId, organizationId)`
|
||||||
|
|
||||||
|
Output contains:
|
||||||
|
|
||||||
|
- `quotationId`
|
||||||
|
- `approvedAt`
|
||||||
|
- `documentData`
|
||||||
|
- `templateVersionId`
|
||||||
|
- `templateInput`
|
||||||
|
|
||||||
|
## Remaining Risks
|
||||||
|
|
||||||
|
- Mapping CRUD UI is not implemented yet, so template mappings remain seed-driven and read-only.
|
||||||
|
- `pdfme` preview is not canvas-accurate rendering; current preview is an application-native document view using the normalized document snapshot.
|
||||||
|
- Template resolution currently uses `productType: default` for quotation preview. Product-specific template selection can be added later once the business rule is finalized.
|
||||||
|
- No binary PDF generation, storage, or approved PDF URL persistence yet.
|
||||||
|
|
||||||
|
## Task H Readiness
|
||||||
|
|
||||||
|
Task G leaves the repo ready for:
|
||||||
|
|
||||||
|
- actual `pdfme` generation service integration
|
||||||
|
- approved PDF snapshot persistence
|
||||||
|
- template version publishing workflow
|
||||||
|
- mapping editor or admin maintenance UI
|
||||||
|
- product-specific template resolution rules
|
||||||
@@ -140,3 +140,53 @@ Future:
|
|||||||
Priority:
|
Priority:
|
||||||
|
|
||||||
* High
|
* High
|
||||||
|
|
||||||
|
## After Task F
|
||||||
|
|
||||||
|
### Approval Permission Sync
|
||||||
|
Existing memberships may not automatically receive new approval permissions.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
- add permission sync script
|
||||||
|
- admin UI for role/permission management
|
||||||
|
|
||||||
|
### Approval Workflow Config UI
|
||||||
|
Approval workflow is seeded but not configurable from UI.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
- workflow setup screen
|
||||||
|
- step editor
|
||||||
|
- approver role mapping
|
||||||
|
|
||||||
|
### Approval Notification
|
||||||
|
Approval workflow has no email/line notification yet.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
- notification queue
|
||||||
|
- approval reminder
|
||||||
|
|
||||||
|
## After Task G
|
||||||
|
|
||||||
|
### Document Mapping Editor
|
||||||
|
Template mappings are seeded and readable, but not yet manageable from UI.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
- mapping CRUD UI
|
||||||
|
- table column editor
|
||||||
|
- publish/draft template workflow
|
||||||
|
|
||||||
|
### pdfme Render Parity
|
||||||
|
Document preview is application-native, not pixel-perfect `pdfme` rendering.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
- server-side pdfme render pipeline
|
||||||
|
- preview image generation
|
||||||
|
- schema validation against live template versions
|
||||||
|
|
||||||
|
### Approved PDF Persistence
|
||||||
|
Approved snapshot preparation exists, but no binary file generation or storage write-back yet.
|
||||||
|
|
||||||
|
Future:
|
||||||
|
- approved PDF generator
|
||||||
|
- storage abstraction
|
||||||
|
- immutable approved artifact URL on quotation
|
||||||
|
|||||||
66
drizzle/0006_worried_mordo.sql
Normal file
66
drizzle/0006_worried_mordo.sql
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
CREATE TABLE "crm_document_template_mappings" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"template_version_id" text NOT NULL,
|
||||||
|
"placeholder_key" text NOT NULL,
|
||||||
|
"source_path" text NOT NULL,
|
||||||
|
"data_type" text NOT NULL,
|
||||||
|
"sheet_name" text,
|
||||||
|
"default_value" text,
|
||||||
|
"format_mask" text,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "crm_document_template_table_columns" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"mapping_id" text NOT NULL,
|
||||||
|
"column_name" text NOT NULL,
|
||||||
|
"source_field" text NOT NULL,
|
||||||
|
"column_letter" text,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"format_mask" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "crm_document_template_versions" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"template_id" text NOT NULL,
|
||||||
|
"version" text NOT NULL,
|
||||||
|
"file_path" text,
|
||||||
|
"schema_json" jsonb NOT NULL,
|
||||||
|
"preview_image_url" text,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
"created_by" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "crm_document_templates" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"document_type" text NOT NULL,
|
||||||
|
"product_type" text NOT NULL,
|
||||||
|
"file_type" text NOT NULL,
|
||||||
|
"template_name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"is_default" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"updated_by" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "crm_document_template_mappings_version_placeholder_idx" ON "crm_document_template_mappings" USING btree ("template_version_id","placeholder_key");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "crm_document_template_table_columns_mapping_name_idx" ON "crm_document_template_table_columns" USING btree ("mapping_id","column_name");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "crm_document_template_versions_template_version_idx" ON "crm_document_template_versions" USING btree ("template_id","version");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "crm_document_templates_org_doc_product_file_name_idx" ON "crm_document_templates" USING btree ("organization_id","document_type","product_type","file_type","template_name");
|
||||||
2971
drizzle/meta/0006_snapshot.json
Normal file
2971
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1781575976324,
|
"when": 1781575976324,
|
||||||
"tag": "0005_numerous_blob",
|
"tag": "0005_numerous_blob",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781579746622,
|
||||||
|
"tag": "0006_worried_mordo",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
509
plans/task-g.md
Normal file
509
plans/task-g.md
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
# Task G: Quotation Document Preview / PDF Template Foundation
|
||||||
|
|
||||||
|
## ALLA OS CRM vNext
|
||||||
|
|
||||||
|
คุณคือ Senior Full-stack Engineer
|
||||||
|
|
||||||
|
ให้ implement Quotation Document Preview และ PDF Template Foundation โดยต่อยอดจาก:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Task A
|
||||||
|
Task B
|
||||||
|
Task B.1
|
||||||
|
Task C
|
||||||
|
Task D
|
||||||
|
Task E
|
||||||
|
Task E.1
|
||||||
|
Task F
|
||||||
|
```
|
||||||
|
|
||||||
|
## ต้องอ่านก่อน
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/task-a-template-audit.md
|
||||||
|
docs/implementation/task-b-template-audit.md
|
||||||
|
docs/implementation/task-b1-foundation-stabilization.md
|
||||||
|
docs/implementation/technical-debt.md
|
||||||
|
|
||||||
|
docs/adr/0007-quotation-revision-strategy.md
|
||||||
|
docs/adr/0008-attachment-storage-strategy.md
|
||||||
|
|
||||||
|
Layout.md
|
||||||
|
AGENTS.md
|
||||||
|
.agents/skills/kiranism-shadcn-dashboard
|
||||||
|
|
||||||
|
src/features/foundation/**
|
||||||
|
src/features/crm/customers/**
|
||||||
|
src/features/crm/enquiries/**
|
||||||
|
src/features/crm/quotations/**
|
||||||
|
src/db/schema.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Goal
|
||||||
|
|
||||||
|
สร้าง foundation สำหรับเอกสารใบเสนอราคา
|
||||||
|
|
||||||
|
โฟกัสที่:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Quotation document preview
|
||||||
|
PDF template metadata
|
||||||
|
Template versioning
|
||||||
|
Template placeholder mapping
|
||||||
|
Approved snapshot preparation
|
||||||
|
```
|
||||||
|
|
||||||
|
ยังไม่ทำ PDF generation จริงแบบ production เต็ม
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope G1: Template Schema
|
||||||
|
|
||||||
|
เพิ่ม tables ถ้ายังไม่มี
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_document_templates
|
||||||
|
crm_document_template_versions
|
||||||
|
crm_document_template_mappings
|
||||||
|
crm_document_template_table_columns
|
||||||
|
```
|
||||||
|
|
||||||
|
## crm_document_templates
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
|
||||||
|
documentType
|
||||||
|
productType
|
||||||
|
fileType
|
||||||
|
|
||||||
|
templateName
|
||||||
|
description
|
||||||
|
|
||||||
|
isDefault
|
||||||
|
isActive
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
deletedAt
|
||||||
|
createdBy
|
||||||
|
updatedBy
|
||||||
|
```
|
||||||
|
|
||||||
|
documentType example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotation
|
||||||
|
```
|
||||||
|
|
||||||
|
fileType example:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
pdfme
|
||||||
|
excel
|
||||||
|
html
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## crm_document_template_versions
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
templateId
|
||||||
|
|
||||||
|
version
|
||||||
|
filePath
|
||||||
|
schemaJson
|
||||||
|
previewImageUrl
|
||||||
|
|
||||||
|
isActive
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
deletedAt
|
||||||
|
createdBy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## crm_document_template_mappings
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
templateVersionId
|
||||||
|
|
||||||
|
placeholderKey
|
||||||
|
sourcePath
|
||||||
|
dataType
|
||||||
|
|
||||||
|
sheetName
|
||||||
|
defaultValue
|
||||||
|
formatMask
|
||||||
|
sortOrder
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
deletedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
dataType:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
scalar
|
||||||
|
multiline
|
||||||
|
table
|
||||||
|
image
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## crm_document_template_table_columns
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
mappingId
|
||||||
|
|
||||||
|
columnName
|
||||||
|
sourceField
|
||||||
|
columnLetter
|
||||||
|
sortOrder
|
||||||
|
formatMask
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
deletedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope G2: Quotation Snapshot Preparation
|
||||||
|
|
||||||
|
เพิ่มใน quotation document service
|
||||||
|
|
||||||
|
สร้าง function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
buildQuotationDocumentData(quotationId)
|
||||||
|
```
|
||||||
|
|
||||||
|
ให้ output เป็น normalized object สำหรับ preview/document generation
|
||||||
|
|
||||||
|
ต้องรวม:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
company
|
||||||
|
branch
|
||||||
|
customer
|
||||||
|
contact
|
||||||
|
quotation
|
||||||
|
items
|
||||||
|
quotationCustomers
|
||||||
|
topics
|
||||||
|
approval
|
||||||
|
signatures placeholder
|
||||||
|
```
|
||||||
|
|
||||||
|
ตัวอย่าง output:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
company: {},
|
||||||
|
branch: {},
|
||||||
|
customer: {},
|
||||||
|
contact: {},
|
||||||
|
quotation: {},
|
||||||
|
items: [],
|
||||||
|
topics: {
|
||||||
|
scope: [],
|
||||||
|
exclusion: [],
|
||||||
|
payment: []
|
||||||
|
},
|
||||||
|
approval: {
|
||||||
|
status,
|
||||||
|
approvedAt,
|
||||||
|
approvers: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* validate organizationId
|
||||||
|
* ห้าม query ข้าม organization
|
||||||
|
* ใช้ approved quotation ได้
|
||||||
|
* draft/pending preview ได้ แต่ต้องแสดง watermark/status badge ใน UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope G3: Template Mapping Resolver
|
||||||
|
|
||||||
|
สร้าง service:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/foundation/document-template/**
|
||||||
|
```
|
||||||
|
|
||||||
|
หรือถ้าเหมาะสม:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/quotations/document/**
|
||||||
|
```
|
||||||
|
|
||||||
|
Functions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resolveTemplateForDocument(params)
|
||||||
|
resolveTemplateMappings(templateVersionId)
|
||||||
|
mapDocumentDataToTemplateInput(documentData, mappings)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* template default ตาม documentType + productType + fileType
|
||||||
|
* fallback default template ของ documentType
|
||||||
|
* mapping ต้องไม่ hardcode เฉพาะ quotation มากเกินไป
|
||||||
|
* sourcePath ใช้ dot path เช่น customer.name, quotation.code, items
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope G4: API Routes
|
||||||
|
|
||||||
|
เพิ่ม route handlers:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/app/api/crm/quotations/[id]/document-data/route.ts
|
||||||
|
src/app/api/crm/quotations/[id]/document-preview/route.ts
|
||||||
|
src/app/api/crm/document-templates/route.ts
|
||||||
|
src/app/api/crm/document-templates/[id]/route.ts
|
||||||
|
src/app/api/crm/document-templates/[id]/versions/route.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
รองรับ:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET quotation document data
|
||||||
|
GET quotation document preview data
|
||||||
|
GET/POST document templates
|
||||||
|
GET/PATCH/DELETE document template
|
||||||
|
GET/POST template versions
|
||||||
|
```
|
||||||
|
|
||||||
|
ยังไม่ต้องทำ binary PDF download
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope G5: Preview UI
|
||||||
|
|
||||||
|
ใน:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/dashboard/crm/quotations/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
แทน Document Preview Placeholder ด้วย real preview tab
|
||||||
|
|
||||||
|
Preview ต้องแสดง:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotation header
|
||||||
|
customer block
|
||||||
|
project info
|
||||||
|
items table
|
||||||
|
price summary
|
||||||
|
scope/exclusion/payment topics
|
||||||
|
approval block
|
||||||
|
signature placeholders
|
||||||
|
status watermark/badge
|
||||||
|
```
|
||||||
|
|
||||||
|
ใช้ Layout.md และ shadcn dashboard style
|
||||||
|
|
||||||
|
ห้ามสร้าง design system ใหม่
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope G6: Template Settings UI
|
||||||
|
|
||||||
|
เพิ่ม route ถ้ายังเป็น placeholder:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/dashboard/crm/settings/templates
|
||||||
|
```
|
||||||
|
|
||||||
|
UI ขั้นต่ำ:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Template list
|
||||||
|
Template versions
|
||||||
|
Default template badge
|
||||||
|
Active/inactive badge
|
||||||
|
Mapping count
|
||||||
|
```
|
||||||
|
|
||||||
|
ยังไม่ต้องทำ template designer แบบ drag/drop
|
||||||
|
|
||||||
|
ทำได้แค่ metadata CRUD หรือ read-only listing ก่อน
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope G7: pdfme Template Seed
|
||||||
|
|
||||||
|
ถ้ามี template json อยู่แล้ว ให้ seed เป็น default quotation pdfme template
|
||||||
|
|
||||||
|
Seed:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
documentType = quotation
|
||||||
|
fileType = pdfme
|
||||||
|
productType = default
|
||||||
|
templateName = ALLA Quotation Standard
|
||||||
|
version = 1.0
|
||||||
|
schemaJson = existing pdfme template json
|
||||||
|
isDefault = true
|
||||||
|
isActive = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Mapping placeholders:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
customer_name -> customer.name
|
||||||
|
customer_addr -> customer.address
|
||||||
|
customer_tel -> customer.phone
|
||||||
|
customer_email -> customer.email
|
||||||
|
customer_att -> contact.name
|
||||||
|
project_name -> quotation.projectName
|
||||||
|
site_location -> quotation.projectLocation
|
||||||
|
quotation_date -> quotation.quotationDate
|
||||||
|
quotation_code -> quotation.code
|
||||||
|
quotation_price -> quotation.totalAmount
|
||||||
|
currency -> quotation.currency
|
||||||
|
exclusion_data -> topics.exclusion
|
||||||
|
item_topic -> topics.scope
|
||||||
|
```
|
||||||
|
|
||||||
|
ถ้าไม่มี template json ใน repo ให้สร้าง seed placeholder template schemaJson แบบ minimal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope G8: Approved Snapshot Preparation
|
||||||
|
|
||||||
|
ยังไม่ต้อง generate PDF จริง
|
||||||
|
|
||||||
|
แต่ให้เตรียม service function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
prepareApprovedQuotationSnapshot(quotationId)
|
||||||
|
```
|
||||||
|
|
||||||
|
ใช้สำหรับอนาคตเมื่อ quotation approved แล้ว
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
quotationId,
|
||||||
|
approvedAt,
|
||||||
|
documentData,
|
||||||
|
templateVersionId,
|
||||||
|
templateInput
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ยังไม่ต้องเขียนลง approvedPdfUrl ถ้ายังไม่มี PDF generator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
|
||||||
|
เพิ่มถ้ายังไม่มี:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm.document_template.read
|
||||||
|
crm.document_template.create
|
||||||
|
crm.document_template.update
|
||||||
|
crm.document_template.delete
|
||||||
|
crm.quotation.document.preview
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
|
||||||
|
Audit mutation:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_document_template
|
||||||
|
crm_document_template_version
|
||||||
|
crm_document_template_mapping
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview GET ไม่ต้อง audit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ห้ามทำ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
PDF binary generation
|
||||||
|
Excel binary generation
|
||||||
|
File upload storage
|
||||||
|
Drag/drop template designer
|
||||||
|
Report
|
||||||
|
Dashboard KPI
|
||||||
|
Notification
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Output
|
||||||
|
|
||||||
|
หลังทำเสร็จ สรุป:
|
||||||
|
|
||||||
|
1. Files Added
|
||||||
|
2. Files Modified
|
||||||
|
3. Schema Added
|
||||||
|
4. API Routes Added
|
||||||
|
5. Preview UI Added
|
||||||
|
6. Template Settings UI Added
|
||||||
|
7. Template Seed Added
|
||||||
|
8. Mapping Resolver Added
|
||||||
|
9. Approved Snapshot Prep Added
|
||||||
|
10. Remaining Risks
|
||||||
|
11. Task H Readiness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
Task G ผ่านเมื่อ:
|
||||||
|
|
||||||
|
✔ document template schema พร้อม
|
||||||
|
|
||||||
|
✔ template version schema พร้อม
|
||||||
|
|
||||||
|
✔ mapping schema พร้อม
|
||||||
|
|
||||||
|
✔ quotation document data service พร้อม
|
||||||
|
|
||||||
|
✔ template resolver พร้อม
|
||||||
|
|
||||||
|
✔ quotation preview tab แสดงข้อมูลจริง
|
||||||
|
|
||||||
|
✔ template settings route ใช้งานได้ขั้นต่ำ
|
||||||
|
|
||||||
|
✔ pdfme default template seed พร้อม หรือ fallback minimal seed พร้อม
|
||||||
|
|
||||||
|
✔ approved snapshot preparation พร้อม
|
||||||
|
|
||||||
|
✔ ยังไม่ generate PDF binary จริง
|
||||||
|
|
||||||
|
✔ ยังไม่ทำ template designer
|
||||||
|
|
||||||
|
✔ ยังไม่ทำ report/dashboard
|
||||||
119
src/app/api/crm/document-templates/[id]/route.ts
Normal file
119
src/app/api/crm/document-templates/[id]/route.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||||
|
import {
|
||||||
|
getDocumentTemplate,
|
||||||
|
softDeleteDocumentTemplate,
|
||||||
|
updateDocumentTemplate
|
||||||
|
} from '@/features/foundation/document-template/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const documentTemplateSchema = z.object({
|
||||||
|
documentType: z.string().min(1).optional(),
|
||||||
|
productType: z.string().min(1).optional(),
|
||||||
|
fileType: z.string().min(1).optional(),
|
||||||
|
templateName: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmDocumentTemplateRead
|
||||||
|
});
|
||||||
|
const template = await getDocumentTemplate(id, organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Document template loaded successfully',
|
||||||
|
template
|
||||||
|
});
|
||||||
|
} 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 document template' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
||||||
|
});
|
||||||
|
const payload = documentTemplateSchema.parse(await request.json());
|
||||||
|
const before = await getDocumentTemplate(id, organization.id);
|
||||||
|
const updated = await updateDocumentTemplate(id, organization.id, session.user.id, payload);
|
||||||
|
|
||||||
|
await auditUpdate({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_document_template',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: updated
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Document template updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to update document template' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmDocumentTemplateDelete
|
||||||
|
});
|
||||||
|
const before = await getDocumentTemplate(id, organization.id);
|
||||||
|
const updated = await softDeleteDocumentTemplate(id, organization.id, session.user.id);
|
||||||
|
|
||||||
|
await auditDelete({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_document_template',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: updated
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Document template deleted successfully'
|
||||||
|
});
|
||||||
|
} 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 delete document template' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/app/api/crm/document-templates/[id]/versions/route.ts
Normal file
81
src/app/api/crm/document-templates/[id]/versions/route.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||||
|
import {
|
||||||
|
createDocumentTemplateVersion,
|
||||||
|
listDocumentTemplateVersions
|
||||||
|
} from '@/features/foundation/document-template/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const versionSchema = z.object({
|
||||||
|
version: z.string().min(1),
|
||||||
|
filePath: z.string().optional().nullable(),
|
||||||
|
schemaJson: z.unknown(),
|
||||||
|
previewImageUrl: z.string().optional().nullable(),
|
||||||
|
isActive: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmDocumentTemplateRead
|
||||||
|
});
|
||||||
|
const items = await listDocumentTemplateVersions(id, organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Document template versions loaded successfully',
|
||||||
|
items
|
||||||
|
});
|
||||||
|
} 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 document template versions' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmDocumentTemplateUpdate
|
||||||
|
});
|
||||||
|
const payload = versionSchema.parse(await request.json());
|
||||||
|
const created = await createDocumentTemplateVersion(id, organization.id, session.user.id, payload);
|
||||||
|
|
||||||
|
await auditCreate({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_document_template_version',
|
||||||
|
entityId: created.id,
|
||||||
|
afterData: created
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Document template version created successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to create document template version' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/app/api/crm/document-templates/route.ts
Normal file
82
src/app/api/crm/document-templates/route.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||||
|
import {
|
||||||
|
createDocumentTemplate,
|
||||||
|
listDocumentTemplates
|
||||||
|
} from '@/features/foundation/document-template/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
const documentTemplateSchema = z.object({
|
||||||
|
documentType: z.string().min(1),
|
||||||
|
productType: z.string().min(1),
|
||||||
|
fileType: z.string().min(1),
|
||||||
|
templateName: z.string().min(1),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
isDefault: z.boolean().optional(),
|
||||||
|
isActive: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmDocumentTemplateRead
|
||||||
|
});
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const items = await listDocumentTemplates(organization.id, {
|
||||||
|
documentType: searchParams.get('documentType') ?? undefined,
|
||||||
|
fileType: searchParams.get('fileType') ?? undefined,
|
||||||
|
isActive: searchParams.get('isActive') ?? undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Document templates loaded successfully',
|
||||||
|
items
|
||||||
|
});
|
||||||
|
} 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 document templates' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmDocumentTemplateCreate
|
||||||
|
});
|
||||||
|
const payload = documentTemplateSchema.parse(await request.json());
|
||||||
|
const created = await createDocumentTemplate(organization.id, session.user.id, payload);
|
||||||
|
|
||||||
|
await auditCreate({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_document_template',
|
||||||
|
entityId: created.id,
|
||||||
|
afterData: created
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Document template created successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ message: error.issues[0]?.message ?? 'Invalid payload' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to create document template' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/crm/quotations/[id]/document-data/route.ts
Normal file
33
src/app/api/crm/quotations/[id]/document-data/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { buildQuotationDocumentData } from '@/features/crm/quotations/document/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.crmQuotationDocumentPreview
|
||||||
|
});
|
||||||
|
const documentData = await buildQuotationDocumentData(id, organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Quotation document data loaded successfully',
|
||||||
|
documentData
|
||||||
|
});
|
||||||
|
} 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 quotation document data' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/crm/quotations/[id]/document-preview/route.ts
Normal file
33
src/app/api/crm/quotations/[id]/document-preview/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getQuotationDocumentPreviewData } from '@/features/crm/quotations/document/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.crmQuotationDocumentPreview
|
||||||
|
});
|
||||||
|
const preview = await getQuotationDocumentPreviewData(id, organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Quotation document preview loaded successfully',
|
||||||
|
preview
|
||||||
|
});
|
||||||
|
} 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 quotation document preview' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth';
|
||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
|
import { quotationDocumentPreviewOptions } from '@/features/crm/quotations/document/queries';
|
||||||
import {
|
import {
|
||||||
quotationAttachmentsOptions,
|
quotationAttachmentsOptions,
|
||||||
quotationByIdOptions,
|
quotationByIdOptions,
|
||||||
@@ -85,6 +86,11 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
|||||||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn)));
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn)));
|
||||||
const isOrgAdmin =
|
const isOrgAdmin =
|
||||||
session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin';
|
session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin';
|
||||||
|
const canPreviewDocument =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmQuotationDocumentPreview)));
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
if (canRead) {
|
if (canRead) {
|
||||||
@@ -102,6 +108,9 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
|||||||
limit: 20
|
limit: 20
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
if (canPreviewDocument) {
|
||||||
|
void queryClient.prefetchQuery(quotationDocumentPreviewOptions(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const referenceData =
|
const referenceData =
|
||||||
@@ -139,6 +148,7 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
|||||||
activeBusinessRole={session?.user?.activeBusinessRole ?? null}
|
activeBusinessRole={session?.user?.activeBusinessRole ?? null}
|
||||||
isOrgAdmin={isOrgAdmin}
|
isOrgAdmin={isOrgAdmin}
|
||||||
currentUserId={session?.user?.id ?? ''}
|
currentUserId={session?.user?.id ?? ''}
|
||||||
|
canPreviewDocument={canPreviewDocument}
|
||||||
/>
|
/>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
import { TemplateSettings } from '@/features/foundation/document-template/components/template-settings';
|
||||||
|
import { documentTemplatesQueryOptions } from '@/features/foundation/document-template/queries';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
|
export default async function TemplatesRoute() {
|
||||||
|
const session = await auth();
|
||||||
|
const canRead =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmDocumentTemplateRead)));
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
|
if (canRead) {
|
||||||
|
void queryClient.prefetchQuery(
|
||||||
|
documentTemplatesQueryOptions({ documentType: 'quotation', fileType: 'pdfme' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TemplatesRoute() {
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Quotation Templates'
|
pageTitle='Quotation Templates'
|
||||||
pageDescription='Template management is not part of the production path yet and no longer depends on legacy demo services.'
|
pageDescription='Template metadata, versions, and mapping counts for production quotation documents.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to CRM document templates.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CrmProductionPlaceholder
|
{canRead ? (
|
||||||
title='Template management pending'
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
summary='The placeholder quotation template demo has been isolated away from the production CRM route tree.'
|
<TemplateSettings />
|
||||||
foundationItems={['Master options foundation', 'Sequence foundation', 'Audit foundation']}
|
</HydrationBoundary>
|
||||||
nextStep='Introduce template persistence only when the production quotation/PDF flow is designed.'
|
) : null}
|
||||||
demoHref='/dashboard/crm-demo/settings/templates'
|
|
||||||
/>
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
url: '/dashboard/crm/settings/master-options',
|
url: '/dashboard/crm/settings/master-options',
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
access: { requireOrg: true, permission: 'organization:manage' },
|
access: { requireOrg: true, permission: 'crm.document_template.read' },
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Master Options',
|
title: 'Master Options',
|
||||||
@@ -135,7 +135,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
title: 'Templates',
|
title: 'Templates',
|
||||||
url: '/dashboard/crm/settings/templates',
|
url: '/dashboard/crm/settings/templates',
|
||||||
access: { requireOrg: true, permission: 'organization:manage' }
|
access: { requireOrg: true, permission: 'crm.document_template.read' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/db/schema.ts
101
src/db/schema.ts
@@ -465,3 +465,104 @@ export const crmApprovalActions = pgTable('crm_approval_actions', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const crmDocumentTemplates = pgTable(
|
||||||
|
'crm_document_templates',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
documentType: text('document_type').notNull(),
|
||||||
|
productType: text('product_type').notNull(),
|
||||||
|
fileType: text('file_type').notNull(),
|
||||||
|
templateName: text('template_name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
isDefault: boolean('is_default').default(false).notNull(),
|
||||||
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
|
createdBy: text('created_by').notNull(),
|
||||||
|
updatedBy: text('updated_by').notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
organizationTemplateIdx: uniqueIndex('crm_document_templates_org_doc_product_file_name_idx').on(
|
||||||
|
table.organizationId,
|
||||||
|
table.documentType,
|
||||||
|
table.productType,
|
||||||
|
table.fileType,
|
||||||
|
table.templateName
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const crmDocumentTemplateVersions = pgTable(
|
||||||
|
'crm_document_template_versions',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
templateId: text('template_id').notNull(),
|
||||||
|
version: text('version').notNull(),
|
||||||
|
filePath: text('file_path'),
|
||||||
|
schemaJson: jsonb('schema_json').notNull(),
|
||||||
|
previewImageUrl: text('preview_image_url'),
|
||||||
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
|
createdBy: text('created_by').notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
templateVersionIdx: uniqueIndex('crm_document_template_versions_template_version_idx').on(
|
||||||
|
table.templateId,
|
||||||
|
table.version
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const crmDocumentTemplateMappings = pgTable(
|
||||||
|
'crm_document_template_mappings',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
templateVersionId: text('template_version_id').notNull(),
|
||||||
|
placeholderKey: text('placeholder_key').notNull(),
|
||||||
|
sourcePath: text('source_path').notNull(),
|
||||||
|
dataType: text('data_type').notNull(),
|
||||||
|
sheetName: text('sheet_name'),
|
||||||
|
defaultValue: text('default_value'),
|
||||||
|
formatMask: text('format_mask'),
|
||||||
|
sortOrder: integer('sort_order').default(0).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
templatePlaceholderIdx: uniqueIndex('crm_document_template_mappings_version_placeholder_idx').on(
|
||||||
|
table.templateVersionId,
|
||||||
|
table.placeholderKey
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const crmDocumentTemplateTableColumns = pgTable(
|
||||||
|
'crm_document_template_table_columns',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
mappingId: text('mapping_id').notNull(),
|
||||||
|
columnName: text('column_name').notNull(),
|
||||||
|
sourceField: text('source_field').notNull(),
|
||||||
|
columnLetter: text('column_letter'),
|
||||||
|
sortOrder: integer('sort_order').default(0).notNull(),
|
||||||
|
formatMask: text('format_mask'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
mappingColumnIdx: uniqueIndex('crm_document_template_table_columns_mapping_name_idx').on(
|
||||||
|
table.mappingId,
|
||||||
|
table.columnName
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ type BranchSeedRow = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TemplateColumnSeed = {
|
||||||
|
columnName: string;
|
||||||
|
sourceField: string;
|
||||||
|
columnLetter?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
formatMask?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TemplateMappingSeed = {
|
||||||
|
placeholderKey: string;
|
||||||
|
sourcePath: string;
|
||||||
|
dataType: 'scalar' | 'multiline' | 'table' | 'image';
|
||||||
|
defaultValue?: string | null;
|
||||||
|
formatMask?: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
columns?: TemplateColumnSeed[];
|
||||||
|
};
|
||||||
|
|
||||||
function loadEnvFile(filePath: string) {
|
function loadEnvFile(filePath: string) {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return;
|
return;
|
||||||
@@ -213,6 +231,145 @@ const APPROVAL_WORKFLOWS = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
|
||||||
|
{
|
||||||
|
placeholderKey: 'customer_name',
|
||||||
|
sourcePath: 'customer.name',
|
||||||
|
dataType: 'scalar',
|
||||||
|
sortOrder: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'customer_addr',
|
||||||
|
sourcePath: 'customer.address',
|
||||||
|
dataType: 'multiline',
|
||||||
|
sortOrder: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'customer_tel',
|
||||||
|
sourcePath: 'customer.phone',
|
||||||
|
dataType: 'scalar',
|
||||||
|
sortOrder: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'customer_email',
|
||||||
|
sourcePath: 'customer.email',
|
||||||
|
dataType: 'scalar',
|
||||||
|
sortOrder: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'customer_att',
|
||||||
|
sourcePath: 'contact.name',
|
||||||
|
dataType: 'scalar',
|
||||||
|
sortOrder: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'project_name',
|
||||||
|
sourcePath: 'quotation.projectName',
|
||||||
|
dataType: 'scalar',
|
||||||
|
sortOrder: 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'site_location',
|
||||||
|
sourcePath: 'quotation.projectLocation',
|
||||||
|
dataType: 'multiline',
|
||||||
|
sortOrder: 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'quotation_date',
|
||||||
|
sourcePath: 'quotation.quotationDate',
|
||||||
|
dataType: 'scalar',
|
||||||
|
formatMask: 'date',
|
||||||
|
sortOrder: 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'quotation_code',
|
||||||
|
sourcePath: 'quotation.code',
|
||||||
|
dataType: 'scalar',
|
||||||
|
sortOrder: 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'quotation_price',
|
||||||
|
sourcePath: 'quotation.totalAmount',
|
||||||
|
dataType: 'scalar',
|
||||||
|
formatMask: 'currency',
|
||||||
|
sortOrder: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'currency',
|
||||||
|
sourcePath: 'quotation.currencyCode',
|
||||||
|
dataType: 'scalar',
|
||||||
|
sortOrder: 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'exclusion_data',
|
||||||
|
sourcePath: 'topics.exclusion',
|
||||||
|
dataType: 'multiline',
|
||||||
|
sortOrder: 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'item_topic',
|
||||||
|
sourcePath: 'topics.scope',
|
||||||
|
dataType: 'multiline',
|
||||||
|
sortOrder: 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'items_table',
|
||||||
|
sourcePath: 'items',
|
||||||
|
dataType: 'table',
|
||||||
|
sortOrder: 14,
|
||||||
|
columns: [
|
||||||
|
{ columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
|
||||||
|
{ columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
|
||||||
|
{ columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 },
|
||||||
|
{ columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 },
|
||||||
|
{
|
||||||
|
columnName: 'Unit Price',
|
||||||
|
sourceField: 'unitPrice',
|
||||||
|
columnLetter: 'E',
|
||||||
|
sortOrder: 5,
|
||||||
|
formatMask: 'currency'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnName: 'Total',
|
||||||
|
sourceField: 'totalPrice',
|
||||||
|
columnLetter: 'F',
|
||||||
|
sortOrder: 6,
|
||||||
|
formatMask: 'currency'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function loadTemplateSchemaByOrganizationName(organizationName: string) {
|
||||||
|
const normalized = organizationName.toUpperCase();
|
||||||
|
const fileName = normalized.includes('ONVALLA')
|
||||||
|
? 'ONVALLA_template_pdfme_fainal3.json'
|
||||||
|
: normalized.includes('ALLA')
|
||||||
|
? 'ALLA_template_pdfme_fainal3.json'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!fileName) {
|
||||||
|
return {
|
||||||
|
filePath: null,
|
||||||
|
templateName: `${organizationName} Quotation Standard`,
|
||||||
|
schemaJson: {
|
||||||
|
basePdf: '',
|
||||||
|
schemas: [],
|
||||||
|
pdfmeVersion: 'fallback'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.resolve(process.cwd(), 'src', 'pdfme_template', fileName);
|
||||||
|
const raw = fs.readFileSync(absolutePath, 'utf8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: `src/pdfme_template/${fileName}`,
|
||||||
|
templateName: `${organizationName} Quotation Standard`,
|
||||||
|
schemaJson: JSON.parse(raw)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertMasterOptionsForOrganization(
|
async function upsertMasterOptionsForOrganization(
|
||||||
sql: SqlClient,
|
sql: SqlClient,
|
||||||
organizationId: string
|
organizationId: string
|
||||||
@@ -382,6 +539,188 @@ async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upsertDocumentTemplatesForOrganization(
|
||||||
|
sql: SqlClient,
|
||||||
|
organization: { id: string; name: string; createdBy: string }
|
||||||
|
) {
|
||||||
|
const templateSeed = loadTemplateSchemaByOrganizationName(organization.name);
|
||||||
|
const [templateRow] = await sql`
|
||||||
|
insert into crm_document_templates (
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
document_type,
|
||||||
|
product_type,
|
||||||
|
file_type,
|
||||||
|
template_name,
|
||||||
|
description,
|
||||||
|
is_default,
|
||||||
|
is_active,
|
||||||
|
deleted_at,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
|
) values (
|
||||||
|
${crypto.randomUUID()},
|
||||||
|
${organization.id},
|
||||||
|
${'quotation'},
|
||||||
|
${'default'},
|
||||||
|
${'pdfme'},
|
||||||
|
${templateSeed.templateName},
|
||||||
|
${'Seeded quotation template metadata for production preview foundation.'},
|
||||||
|
${true},
|
||||||
|
${true},
|
||||||
|
${null},
|
||||||
|
${organization.createdBy},
|
||||||
|
${organization.createdBy}
|
||||||
|
)
|
||||||
|
on conflict do nothing
|
||||||
|
returning id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolvedTemplateId =
|
||||||
|
templateRow?.id ??
|
||||||
|
(
|
||||||
|
await sql`
|
||||||
|
select id
|
||||||
|
from crm_document_templates
|
||||||
|
where organization_id = ${organization.id}
|
||||||
|
and document_type = ${'quotation'}
|
||||||
|
and product_type = ${'default'}
|
||||||
|
and file_type = ${'pdfme'}
|
||||||
|
and deleted_at is null
|
||||||
|
order by is_default desc, created_at asc
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
)[0]?.id;
|
||||||
|
|
||||||
|
if (!resolvedTemplateId) {
|
||||||
|
throw new Error(`Unable to resolve template id for organization ${organization.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [versionRow] = await sql`
|
||||||
|
insert into crm_document_template_versions (
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
template_id,
|
||||||
|
version,
|
||||||
|
file_path,
|
||||||
|
schema_json,
|
||||||
|
preview_image_url,
|
||||||
|
is_active,
|
||||||
|
deleted_at,
|
||||||
|
created_by
|
||||||
|
) values (
|
||||||
|
${crypto.randomUUID()},
|
||||||
|
${organization.id},
|
||||||
|
${resolvedTemplateId},
|
||||||
|
${'1.0'},
|
||||||
|
${templateSeed.filePath},
|
||||||
|
${JSON.stringify(templateSeed.schemaJson)},
|
||||||
|
${null},
|
||||||
|
${true},
|
||||||
|
${null},
|
||||||
|
${organization.createdBy}
|
||||||
|
)
|
||||||
|
on conflict do nothing
|
||||||
|
returning id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolvedVersionId =
|
||||||
|
versionRow?.id ??
|
||||||
|
(
|
||||||
|
await sql`
|
||||||
|
select id
|
||||||
|
from crm_document_template_versions
|
||||||
|
where organization_id = ${organization.id}
|
||||||
|
and template_id = ${resolvedTemplateId}
|
||||||
|
and version = ${'1.0'}
|
||||||
|
and deleted_at is null
|
||||||
|
order by created_at asc
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
)[0]?.id;
|
||||||
|
|
||||||
|
if (!resolvedVersionId) {
|
||||||
|
throw new Error(`Unable to resolve template version id for organization ${organization.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mapping of DOCUMENT_TEMPLATE_MAPPINGS) {
|
||||||
|
const [mappingRow] = await sql`
|
||||||
|
insert into crm_document_template_mappings (
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
template_version_id,
|
||||||
|
placeholder_key,
|
||||||
|
source_path,
|
||||||
|
data_type,
|
||||||
|
sheet_name,
|
||||||
|
default_value,
|
||||||
|
format_mask,
|
||||||
|
sort_order,
|
||||||
|
deleted_at
|
||||||
|
) values (
|
||||||
|
${crypto.randomUUID()},
|
||||||
|
${organization.id},
|
||||||
|
${resolvedVersionId},
|
||||||
|
${mapping.placeholderKey},
|
||||||
|
${mapping.sourcePath},
|
||||||
|
${mapping.dataType},
|
||||||
|
${null},
|
||||||
|
${mapping.defaultValue ?? null},
|
||||||
|
${mapping.formatMask ?? null},
|
||||||
|
${mapping.sortOrder},
|
||||||
|
${null}
|
||||||
|
)
|
||||||
|
on conflict do nothing
|
||||||
|
returning id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolvedMappingId =
|
||||||
|
mappingRow?.id ??
|
||||||
|
(
|
||||||
|
await sql`
|
||||||
|
select id
|
||||||
|
from crm_document_template_mappings
|
||||||
|
where organization_id = ${organization.id}
|
||||||
|
and template_version_id = ${resolvedVersionId}
|
||||||
|
and placeholder_key = ${mapping.placeholderKey}
|
||||||
|
and deleted_at is null
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
)[0]?.id;
|
||||||
|
|
||||||
|
if (!resolvedMappingId || !mapping.columns?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const column of mapping.columns) {
|
||||||
|
await sql`
|
||||||
|
insert into crm_document_template_table_columns (
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
mapping_id,
|
||||||
|
column_name,
|
||||||
|
source_field,
|
||||||
|
column_letter,
|
||||||
|
sort_order,
|
||||||
|
format_mask,
|
||||||
|
deleted_at
|
||||||
|
) values (
|
||||||
|
${crypto.randomUUID()},
|
||||||
|
${organization.id},
|
||||||
|
${resolvedMappingId},
|
||||||
|
${column.columnName},
|
||||||
|
${column.sourceField},
|
||||||
|
${column.columnLetter ?? null},
|
||||||
|
${column.sortOrder},
|
||||||
|
${column.formatMask ?? null},
|
||||||
|
${null}
|
||||||
|
)
|
||||||
|
on conflict do nothing
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
loadLocalEnv();
|
loadLocalEnv();
|
||||||
|
|
||||||
@@ -390,7 +729,7 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const organizations = await sql`
|
const organizations = await sql`
|
||||||
select id, name
|
select id, name, created_by as "createdBy"
|
||||||
from organizations
|
from organizations
|
||||||
order by name asc
|
order by name asc
|
||||||
`;
|
`;
|
||||||
@@ -405,6 +744,7 @@ async function main() {
|
|||||||
const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id);
|
const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id);
|
||||||
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
|
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
|
||||||
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
|
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
|
||||||
|
await upsertDocumentTemplatesForOrganization(sql, organization);
|
||||||
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
|
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { mutationOptions } from '@tanstack/react-query';
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
||||||
import {
|
import {
|
||||||
createQuotation,
|
createQuotation,
|
||||||
createQuotationAttachment,
|
createQuotationAttachment,
|
||||||
@@ -31,6 +32,11 @@ import type {
|
|||||||
QuotationTopicMutationPayload
|
QuotationTopicMutationPayload
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
function invalidateQuotationDocumentQueries(quotationId: string) {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) });
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) });
|
||||||
|
}
|
||||||
|
|
||||||
export const createQuotationMutation = mutationOptions({
|
export const createQuotationMutation = mutationOptions({
|
||||||
mutationFn: (data: QuotationMutationPayload) => createQuotation(data),
|
mutationFn: (data: QuotationMutationPayload) => createQuotation(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -44,6 +50,7 @@ export const updateQuotationMutation = mutationOptions({
|
|||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.id) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.id) });
|
||||||
|
invalidateQuotationDocumentQueries(variables.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +68,7 @@ export const createQuotationItemMutation = mutationOptions({
|
|||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
|
||||||
|
invalidateQuotationDocumentQueries(variables.quotationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,6 +86,7 @@ export const updateQuotationItemMutation = mutationOptions({
|
|||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
|
||||||
|
invalidateQuotationDocumentQueries(variables.quotationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,6 +97,7 @@ export const deleteQuotationItemMutation = mutationOptions({
|
|||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
|
||||||
|
invalidateQuotationDocumentQueries(variables.quotationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +114,7 @@ export const createQuotationCustomerMutation = mutationOptions({
|
|||||||
queryKey: quotationKeys.customers(variables.quotationId)
|
queryKey: quotationKeys.customers(variables.quotationId)
|
||||||
});
|
});
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
||||||
|
invalidateQuotationDocumentQueries(variables.quotationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,6 +133,7 @@ export const updateQuotationCustomerMutation = mutationOptions({
|
|||||||
queryKey: quotationKeys.customers(variables.quotationId)
|
queryKey: quotationKeys.customers(variables.quotationId)
|
||||||
});
|
});
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
||||||
|
invalidateQuotationDocumentQueries(variables.quotationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,6 +145,7 @@ export const deleteQuotationCustomerMutation = mutationOptions({
|
|||||||
queryKey: quotationKeys.customers(variables.quotationId)
|
queryKey: quotationKeys.customers(variables.quotationId)
|
||||||
});
|
});
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
|
||||||
|
invalidateQuotationDocumentQueries(variables.quotationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,6 +154,7 @@ export const createQuotationTopicMutation = mutationOptions({
|
|||||||
createQuotationTopic(quotationId, values),
|
createQuotationTopic(quotationId, values),
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
|
||||||
|
invalidateQuotationDocumentQueries(variables.quotationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,6 +163,7 @@ export const updateQuotationTopicMutation = mutationOptions({
|
|||||||
updateQuotationTopic(quotationId, values),
|
updateQuotationTopic(quotationId, values),
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
|
||||||
|
invalidateQuotationDocumentQueries(variables.quotationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,6 +172,7 @@ export const deleteQuotationTopicMutation = mutationOptions({
|
|||||||
deleteQuotationTopic(quotationId, topicId),
|
deleteQuotationTopic(quotationId, topicId),
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
|
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
|
||||||
|
invalidateQuotationDocumentQueries(variables.quotationId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import type {
|
|||||||
QuotationTopicRecord
|
QuotationTopicRecord
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
||||||
|
import { QuotationDocumentPreview } from './quotation-document-preview';
|
||||||
import { QuotationFormSheet } from './quotation-form-sheet';
|
import { QuotationFormSheet } from './quotation-form-sheet';
|
||||||
import { QuotationApprovalTab } from './quotation-approval-tab';
|
import { QuotationApprovalTab } from './quotation-approval-tab';
|
||||||
import { QuotationStatusBadge } from './quotation-status-badge';
|
import { QuotationStatusBadge } from './quotation-status-badge';
|
||||||
@@ -481,7 +482,8 @@ export function QuotationDetail({
|
|||||||
canReturnApproval,
|
canReturnApproval,
|
||||||
activeBusinessRole,
|
activeBusinessRole,
|
||||||
isOrgAdmin,
|
isOrgAdmin,
|
||||||
currentUserId
|
currentUserId,
|
||||||
|
canPreviewDocument
|
||||||
}: {
|
}: {
|
||||||
quotationId: string;
|
quotationId: string;
|
||||||
referenceData: QuotationReferenceData;
|
referenceData: QuotationReferenceData;
|
||||||
@@ -499,6 +501,7 @@ export function QuotationDetail({
|
|||||||
activeBusinessRole: string | null;
|
activeBusinessRole: string | null;
|
||||||
isOrgAdmin: boolean;
|
isOrgAdmin: boolean;
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
|
canPreviewDocument: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||||
const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId));
|
const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId));
|
||||||
@@ -1188,15 +1191,19 @@ export function QuotationDetail({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='preview'>
|
<TabsContent value='preview'>
|
||||||
|
{canPreviewDocument ? (
|
||||||
|
<QuotationDocumentPreview quotationId={quotationId} />
|
||||||
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Document Preview Placeholder</CardTitle>
|
<CardTitle>Document Preview</CardTitle>
|
||||||
<CardDescription>PDF and rendered output are intentionally postponed.</CardDescription>
|
<CardDescription>Preview permission is required to load document data.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<EmptyState message='Production preview and PDF generation are outside Task E scope.' />
|
<EmptyState message='You do not have access to quotation document preview.' />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { quotationDocumentPreviewOptions } from '@/features/crm/quotations/document/queries';
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||||
|
<div className='text-sm'>{value || '-'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotationDocumentPreview({ quotationId }: { quotationId: string }) {
|
||||||
|
const { data } = useSuspenseQuery(quotationDocumentPreviewOptions(quotationId));
|
||||||
|
const { documentData, template } = data.preview;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card className='overflow-hidden'>
|
||||||
|
<CardHeader className='border-b bg-muted/20'>
|
||||||
|
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<CardTitle className='text-2xl'>{documentData.company.name}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Quotation Preview using {template.template.templateName} v{template.version.version}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{documentData.watermarkStatus ? (
|
||||||
|
<Badge variant='destructive'>{documentData.watermarkStatus}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge>Approved Ready</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant='outline'>{template.template.fileType}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-8 p-6'>
|
||||||
|
<div className='grid gap-6 lg:grid-cols-2'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quotation Header</CardTitle>
|
||||||
|
<CardDescription>Commercial identity and issue information.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<Field label='Quotation Code' value={documentData.quotation.code} />
|
||||||
|
<Field label='Revision' value={documentData.quotation.revisionLabel} />
|
||||||
|
<Field
|
||||||
|
label='Quotation Date'
|
||||||
|
value={new Date(documentData.quotation.quotationDate).toLocaleDateString()}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Valid Until'
|
||||||
|
value={
|
||||||
|
documentData.quotation.validUntil
|
||||||
|
? new Date(documentData.quotation.validUntil).toLocaleDateString()
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field label='Type' value={documentData.quotation.quotationTypeLabel} />
|
||||||
|
<Field label='Branch' value={documentData.branch?.label} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Customer Block</CardTitle>
|
||||||
|
<CardDescription>Bill-to and attention context for the document.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<Field label='Customer' value={documentData.customer.name} />
|
||||||
|
<Field label='Contact' value={documentData.contact?.name} />
|
||||||
|
<Field label='Phone' value={documentData.customer.phone} />
|
||||||
|
<Field label='Email' value={documentData.customer.email} />
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<Field label='Address' value={documentData.customer.address} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Project Information</CardTitle>
|
||||||
|
<CardDescription>Scope summary and reference data for the customer.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
|
<Field label='Project Name' value={documentData.quotation.projectName} />
|
||||||
|
<Field label='Site Location' value={documentData.quotation.projectLocation} />
|
||||||
|
<Field label='Attention' value={documentData.quotation.attention} />
|
||||||
|
<Field label='Reference' value={documentData.quotation.reference} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Items Table</CardTitle>
|
||||||
|
<CardDescription>Normalized line items prepared for template mapping.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3'>
|
||||||
|
<div className='grid grid-cols-[1.2fr_4fr_1fr_1.2fr_1.4fr_1.4fr] gap-3 rounded-lg border bg-muted/20 px-4 py-3 text-xs font-medium uppercase tracking-wide'>
|
||||||
|
<div>Item</div>
|
||||||
|
<div>Description</div>
|
||||||
|
<div>Qty</div>
|
||||||
|
<div>Unit</div>
|
||||||
|
<div>Unit Price</div>
|
||||||
|
<div>Total</div>
|
||||||
|
</div>
|
||||||
|
{documentData.items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className='grid grid-cols-[1.2fr_4fr_1fr_1.2fr_1.4fr_1.4fr] gap-3 rounded-lg border px-4 py-3 text-sm'
|
||||||
|
>
|
||||||
|
<div>{item.itemNumber}</div>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div>{item.description}</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>{item.productTypeLabel || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>{item.quantity}</div>
|
||||||
|
<div>{item.unitLabel || '-'}</div>
|
||||||
|
<div>{item.unitPrice.toLocaleString()}</div>
|
||||||
|
<div>{item.totalPrice.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className='grid gap-6 lg:grid-cols-[2fr_1fr]'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Topics</CardTitle>
|
||||||
|
<CardDescription>Scope, exclusions, and payment terms from topic records.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-6'>
|
||||||
|
{[
|
||||||
|
{ key: 'scope', title: 'Scope' },
|
||||||
|
{ key: 'exclusion', title: 'Exclusions' },
|
||||||
|
{ key: 'payment', title: 'Payment Terms' }
|
||||||
|
].map((group) => {
|
||||||
|
const entries = documentData.topics[group.key as keyof typeof documentData.topics];
|
||||||
|
return (
|
||||||
|
<div key={group.key} className='space-y-3'>
|
||||||
|
<div className='font-medium'>{group.title}</div>
|
||||||
|
{!entries.length ? (
|
||||||
|
<div className='text-muted-foreground text-sm'>No entries</div>
|
||||||
|
) : (
|
||||||
|
entries.map((entry) => (
|
||||||
|
<div key={`${group.key}-${entry.title}`} className='rounded-lg border p-4'>
|
||||||
|
<div className='mb-2 text-sm font-medium'>{entry.title}</div>
|
||||||
|
<ul className='list-disc space-y-1 pl-5 text-sm'>
|
||||||
|
{entry.items.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Price Summary</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3 text-sm'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground'>Subtotal</span>
|
||||||
|
<span>{documentData.quotation.subtotal.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground'>Tax</span>
|
||||||
|
<span>{documentData.quotation.taxAmount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground'>Total</span>
|
||||||
|
<span className='font-semibold'>{documentData.quotation.totalAmount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Approval Block</CardTitle>
|
||||||
|
<CardDescription>Prepared for watermark and approval snapshot flows.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<Field label='Status' value={documentData.approval.status} />
|
||||||
|
<Field label='Workflow' value={documentData.approval.workflowName} />
|
||||||
|
<Field label='Approved At' value={documentData.approval.approvedAt} />
|
||||||
|
<Separator />
|
||||||
|
{!documentData.approval.approvers.length ? (
|
||||||
|
<div className='text-muted-foreground text-sm'>No approval actions recorded.</div>
|
||||||
|
) : (
|
||||||
|
documentData.approval.approvers.map((item) => (
|
||||||
|
<div key={`${item.stepNumber}-${item.roleCode}`} className='rounded-lg border p-3 text-sm'>
|
||||||
|
<div className='font-medium'>
|
||||||
|
Step {item.stepNumber} - {item.roleName}
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground'>
|
||||||
|
{item.actorName || '-'} {item.actedAt ? `on ${new Date(item.actedAt).toLocaleString()}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Signature Placeholders</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3 text-sm'>
|
||||||
|
<Field label='Prepared By' value={documentData.signatures.preparedBy} />
|
||||||
|
<Field label='Requested By' value={documentData.signatures.requestedBy} />
|
||||||
|
<Field label='Sales Manager' value={documentData.signatures.salesManager} />
|
||||||
|
<Field
|
||||||
|
label='Department Manager'
|
||||||
|
value={documentData.signatures.departmentManager}
|
||||||
|
/>
|
||||||
|
<Field label='Top Manager' value={documentData.signatures.topManager} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/features/crm/quotations/document/queries.ts
Normal file
20
src/features/crm/quotations/document/queries.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
|
import { getQuotationDocumentData, getQuotationDocumentPreview } from './service';
|
||||||
|
|
||||||
|
export const quotationDocumentKeys = {
|
||||||
|
all: ['crm-quotation-document'] as const,
|
||||||
|
data: (id: string) => [...quotationDocumentKeys.all, 'data', id] as const,
|
||||||
|
preview: (id: string) => [...quotationDocumentKeys.all, 'preview', id] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quotationDocumentDataOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: quotationDocumentKeys.data(id),
|
||||||
|
queryFn: () => getQuotationDocumentData(id)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quotationDocumentPreviewOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: quotationDocumentKeys.preview(id),
|
||||||
|
queryFn: () => getQuotationDocumentPreview(id)
|
||||||
|
});
|
||||||
359
src/features/crm/quotations/document/server/service.ts
Normal file
359
src/features/crm/quotations/document/server/service.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
crmCustomerContacts,
|
||||||
|
crmCustomers,
|
||||||
|
crmQuotations,
|
||||||
|
organizations,
|
||||||
|
users
|
||||||
|
} from '@/db/schema';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import { listApprovalRequests, getApprovalRequest } from '@/features/foundation/approval/server/service';
|
||||||
|
import {
|
||||||
|
mapDocumentDataToTemplateInput,
|
||||||
|
resolveTemplateForDocument,
|
||||||
|
resolveTemplateMappings
|
||||||
|
} from '@/features/foundation/document-template/server/service';
|
||||||
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
|
import {
|
||||||
|
getQuotationDetail,
|
||||||
|
listQuotationCustomers,
|
||||||
|
listQuotationItems,
|
||||||
|
listQuotationTopics
|
||||||
|
} from '@/features/crm/quotations/server/service';
|
||||||
|
import type {
|
||||||
|
ApprovedQuotationSnapshot,
|
||||||
|
QuotationDocumentApprovalStep,
|
||||||
|
QuotationDocumentData
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const DOCUMENT_OPTION_CATEGORIES = {
|
||||||
|
branch: 'crm_branch',
|
||||||
|
status: 'crm_quotation_status',
|
||||||
|
quotationType: 'crm_quotation_type',
|
||||||
|
currency: 'crm_currency',
|
||||||
|
discountType: 'crm_discount_type',
|
||||||
|
unit: 'crm_unit',
|
||||||
|
customerRole: 'crm_quotation_customer_role',
|
||||||
|
productType: 'crm_product_type'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function toRevisionLabel(revision: number) {
|
||||||
|
return `R${String(revision).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertQuotationDocumentAccess(quotationId: string, organizationId: string) {
|
||||||
|
const [quotation] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmQuotations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmQuotations.id, quotationId),
|
||||||
|
eq(crmQuotations.organizationId, organizationId),
|
||||||
|
isNull(crmQuotations.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!quotation) {
|
||||||
|
throw new AuthError('Quotation not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return quotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOptionLabel(
|
||||||
|
options: Array<{ id: string; code: string; label: string }>,
|
||||||
|
id: string | null | undefined
|
||||||
|
) {
|
||||||
|
return id ? options.find((option) => option.id === id)?.label ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOptionCode(
|
||||||
|
options: Array<{ id: string; code: string; label: string }>,
|
||||||
|
id: string | null | undefined
|
||||||
|
) {
|
||||||
|
return id ? options.find((option) => option.id === id)?.code ?? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildQuotationDocumentData(
|
||||||
|
quotationId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<QuotationDocumentData> {
|
||||||
|
const quotation = await assertQuotationDocumentAccess(quotationId, organizationId);
|
||||||
|
const [
|
||||||
|
company,
|
||||||
|
quotationDetail,
|
||||||
|
items,
|
||||||
|
quotationCustomers,
|
||||||
|
topics,
|
||||||
|
branchOptions,
|
||||||
|
statusOptions,
|
||||||
|
quotationTypeOptions,
|
||||||
|
currencyOptions,
|
||||||
|
discountTypeOptions,
|
||||||
|
unitOptions,
|
||||||
|
customerRoleOptions,
|
||||||
|
productTypeOptions,
|
||||||
|
approvalRequests
|
||||||
|
] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(organizations)
|
||||||
|
.where(eq(organizations.id, organizationId))
|
||||||
|
.then((rows) => rows[0] ?? null),
|
||||||
|
getQuotationDetail(quotationId, organizationId),
|
||||||
|
listQuotationItems(quotationId, organizationId),
|
||||||
|
listQuotationCustomers(quotationId, organizationId),
|
||||||
|
listQuotationTopics(quotationId, organizationId),
|
||||||
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.currency, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.discountType, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }),
|
||||||
|
listApprovalRequests(organizationId, { entityType: 'quotation', entityId: quotationId, limit: 20 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
throw new AuthError('Organization not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [customer, contact, relatedCustomers, preparer] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(crmCustomers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmCustomers.id, quotation.customerId),
|
||||||
|
eq(crmCustomers.organizationId, organizationId),
|
||||||
|
isNull(crmCustomers.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows[0] ?? null),
|
||||||
|
quotation.contactId
|
||||||
|
? db
|
||||||
|
.select()
|
||||||
|
.from(crmCustomerContacts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmCustomerContacts.id, quotation.contactId),
|
||||||
|
eq(crmCustomerContacts.organizationId, organizationId),
|
||||||
|
isNull(crmCustomerContacts.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows[0] ?? null)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
quotationCustomers.length
|
||||||
|
? db
|
||||||
|
.select({ id: crmCustomers.id, code: crmCustomers.code, name: crmCustomers.name })
|
||||||
|
.from(crmCustomers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmCustomers.organizationId, organizationId),
|
||||||
|
inArray(
|
||||||
|
crmCustomers.id,
|
||||||
|
quotationCustomers.map((item) => item.customerId)
|
||||||
|
),
|
||||||
|
isNull(crmCustomers.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, quotation.createdBy))
|
||||||
|
.then((rows) => rows[0] ?? null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
throw new AuthError('Customer not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvalRequest = approvalRequests.items.find((item) => item.status === 'approved') ?? approvalRequests.items[0] ?? null;
|
||||||
|
const approvalDetail = approvalRequest
|
||||||
|
? await getApprovalRequest(approvalRequest.id, organizationId)
|
||||||
|
: null;
|
||||||
|
const branch = branchOptions.find((option) => option.id === quotation.branchId) ?? null;
|
||||||
|
const relatedCustomerMap = new Map(relatedCustomers.map((item) => [item.id, item]));
|
||||||
|
const statusCode = findOptionCode(statusOptions, quotation.status);
|
||||||
|
const statusLabel = findOptionLabel(statusOptions, quotation.status);
|
||||||
|
const approvalApprovers: QuotationDocumentApprovalStep[] =
|
||||||
|
approvalDetail?.timeline
|
||||||
|
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
|
||||||
|
.map((item) => {
|
||||||
|
const step = approvalDetail.steps.find((candidate) => candidate.stepNumber === item.stepNumber);
|
||||||
|
return {
|
||||||
|
stepNumber: item.stepNumber,
|
||||||
|
roleCode: step?.roleCode ?? '',
|
||||||
|
roleName: step?.roleName ?? `Step ${item.stepNumber}`,
|
||||||
|
action: item.action,
|
||||||
|
actorName: item.actorName,
|
||||||
|
actedAt: item.actedAt,
|
||||||
|
remark: item.remark
|
||||||
|
};
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
company: {
|
||||||
|
id: company.id,
|
||||||
|
name: company.name,
|
||||||
|
slug: company.slug,
|
||||||
|
imageUrl: company.imageUrl,
|
||||||
|
plan: company.plan
|
||||||
|
},
|
||||||
|
branch: branch
|
||||||
|
? {
|
||||||
|
id: branch.id,
|
||||||
|
code: branch.code,
|
||||||
|
label: branch.label,
|
||||||
|
value: branch.value
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
customer: {
|
||||||
|
id: customer.id,
|
||||||
|
code: customer.code,
|
||||||
|
name: customer.name,
|
||||||
|
address: customer.address,
|
||||||
|
phone: customer.phone,
|
||||||
|
email: customer.email,
|
||||||
|
taxId: customer.taxId
|
||||||
|
},
|
||||||
|
contact: contact
|
||||||
|
? {
|
||||||
|
id: contact.id,
|
||||||
|
name: contact.name,
|
||||||
|
email: contact.email,
|
||||||
|
mobile: contact.mobile,
|
||||||
|
position: contact.position
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
quotation: {
|
||||||
|
id: quotationDetail.id,
|
||||||
|
code: quotationDetail.code,
|
||||||
|
quotationDate: quotationDetail.quotationDate,
|
||||||
|
validUntil: quotationDetail.validUntil,
|
||||||
|
projectName: quotationDetail.projectName,
|
||||||
|
projectLocation: quotationDetail.projectLocation,
|
||||||
|
attention: quotationDetail.attention,
|
||||||
|
reference: quotationDetail.reference,
|
||||||
|
revision: quotationDetail.revision,
|
||||||
|
revisionLabel: toRevisionLabel(quotationDetail.revision),
|
||||||
|
statusCode,
|
||||||
|
statusLabel,
|
||||||
|
quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType),
|
||||||
|
quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType),
|
||||||
|
currencyCode: findOptionCode(currencyOptions, quotationDetail.currency),
|
||||||
|
currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency),
|
||||||
|
exchangeRate: quotationDetail.exchangeRate,
|
||||||
|
subtotal: quotationDetail.subtotal,
|
||||||
|
discount: quotationDetail.discount,
|
||||||
|
discountTypeCode: findOptionCode(discountTypeOptions, quotationDetail.discountType),
|
||||||
|
discountTypeLabel: findOptionLabel(discountTypeOptions, quotationDetail.discountType),
|
||||||
|
taxRate: quotationDetail.taxRate,
|
||||||
|
taxAmount: quotationDetail.taxAmount,
|
||||||
|
totalAmount: quotationDetail.totalAmount,
|
||||||
|
notes: quotationDetail.notes
|
||||||
|
},
|
||||||
|
items: items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
itemNumber: item.itemNumber,
|
||||||
|
productTypeCode: findOptionCode(productTypeOptions, item.productType),
|
||||||
|
productTypeLabel: findOptionLabel(productTypeOptions, item.productType),
|
||||||
|
description: item.description,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitCode: findOptionCode(unitOptions, item.unit),
|
||||||
|
unitLabel: findOptionLabel(unitOptions, item.unit),
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
discount: item.discount,
|
||||||
|
discountTypeCode: findOptionCode(discountTypeOptions, item.discountType),
|
||||||
|
taxRate: item.taxRate,
|
||||||
|
totalPrice: item.totalPrice,
|
||||||
|
notes: item.notes
|
||||||
|
})),
|
||||||
|
quotationCustomers: quotationCustomers.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
customerId: item.customerId,
|
||||||
|
customerName: relatedCustomerMap.get(item.customerId)?.name ?? item.customerName,
|
||||||
|
customerCode: relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode,
|
||||||
|
roleCode: item.role,
|
||||||
|
roleLabel: findOptionLabel(customerRoleOptions, item.role),
|
||||||
|
isPrimary: item.isPrimary
|
||||||
|
})),
|
||||||
|
topics: {
|
||||||
|
scope: topics
|
||||||
|
.filter((item) => item.topicType === 'scope')
|
||||||
|
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
|
||||||
|
exclusion: topics
|
||||||
|
.filter((item) => item.topicType === 'exclusion')
|
||||||
|
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
|
||||||
|
payment: topics
|
||||||
|
.filter((item) => item.topicType === 'payment')
|
||||||
|
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
|
||||||
|
other: topics
|
||||||
|
.filter((item) => !['scope', 'exclusion', 'payment'].includes(item.topicType))
|
||||||
|
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) }))
|
||||||
|
},
|
||||||
|
approval: {
|
||||||
|
requestId: approvalDetail?.request.id ?? null,
|
||||||
|
workflowName: approvalDetail?.workflow.name ?? null,
|
||||||
|
status: approvalDetail?.request.status ?? null,
|
||||||
|
approvedAt:
|
||||||
|
approvalDetail?.request.status === 'approved' ? approvalDetail.request.completedAt : null,
|
||||||
|
currentStep: approvalDetail?.request.currentStep ?? null,
|
||||||
|
currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null,
|
||||||
|
approvers: approvalApprovers
|
||||||
|
},
|
||||||
|
signatures: {
|
||||||
|
preparedBy: preparer?.name ?? null,
|
||||||
|
requestedBy: contact?.name ?? customer.name,
|
||||||
|
salesManager:
|
||||||
|
approvalApprovers.find((item) => item.roleCode === 'sales_manager')?.actorName ?? null,
|
||||||
|
departmentManager:
|
||||||
|
approvalApprovers.find((item) => item.roleCode === 'department_manager')?.actorName ?? null,
|
||||||
|
topManager: approvalApprovers.find((item) => item.roleCode === 'top_manager')?.actorName ?? null
|
||||||
|
},
|
||||||
|
watermarkStatus: statusCode && statusCode !== 'approved' ? statusLabel ?? statusCode : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuotationDocumentPreviewData(
|
||||||
|
quotationId: string,
|
||||||
|
organizationId: string
|
||||||
|
) {
|
||||||
|
const documentData = await buildQuotationDocumentData(quotationId, organizationId);
|
||||||
|
const template = await resolveTemplateForDocument(organizationId, {
|
||||||
|
documentType: 'quotation',
|
||||||
|
productType: 'default',
|
||||||
|
fileType: 'pdfme'
|
||||||
|
});
|
||||||
|
const mappings = await resolveTemplateMappings(template.version.id, organizationId);
|
||||||
|
const templateInput = mapDocumentDataToTemplateInput(
|
||||||
|
documentData as unknown as Record<string, unknown>,
|
||||||
|
mappings
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentData,
|
||||||
|
template,
|
||||||
|
mappings,
|
||||||
|
templateInput
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareApprovedQuotationSnapshot(
|
||||||
|
quotationId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<ApprovedQuotationSnapshot> {
|
||||||
|
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quotationId,
|
||||||
|
approvedAt: preview.documentData.approval.approvedAt,
|
||||||
|
documentData: preview.documentData,
|
||||||
|
templateVersionId: preview.template.version.id,
|
||||||
|
templateInput: preview.templateInput
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/features/crm/quotations/document/service.ts
Normal file
13
src/features/crm/quotations/document/service.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import type {
|
||||||
|
QuotationDocumentDataResponse,
|
||||||
|
QuotationDocumentPreviewResponse
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export async function getQuotationDocumentData(id: string) {
|
||||||
|
return apiClient<QuotationDocumentDataResponse>(`/crm/quotations/${id}/document-data`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuotationDocumentPreview(id: string) {
|
||||||
|
return apiClient<QuotationDocumentPreviewResponse>(`/crm/quotations/${id}/document-preview`);
|
||||||
|
}
|
||||||
153
src/features/crm/quotations/document/types.ts
Normal file
153
src/features/crm/quotations/document/types.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import type {
|
||||||
|
DocumentTemplateMappingWithColumns,
|
||||||
|
ResolvedDocumentTemplate
|
||||||
|
} from '@/features/foundation/document-template/types';
|
||||||
|
|
||||||
|
export interface QuotationDocumentTopicGroup {
|
||||||
|
title: string;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotationDocumentApprovalStep {
|
||||||
|
stepNumber: number;
|
||||||
|
roleCode: string;
|
||||||
|
roleName: string;
|
||||||
|
action: string;
|
||||||
|
actorName: string | null;
|
||||||
|
actedAt: string | null;
|
||||||
|
remark: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotationDocumentData {
|
||||||
|
company: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
plan: string;
|
||||||
|
};
|
||||||
|
branch: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
} | null;
|
||||||
|
customer: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
taxId: string | null;
|
||||||
|
};
|
||||||
|
contact: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
mobile: string | null;
|
||||||
|
position: string | null;
|
||||||
|
} | null;
|
||||||
|
quotation: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
quotationDate: string;
|
||||||
|
validUntil: string | null;
|
||||||
|
projectName: string | null;
|
||||||
|
projectLocation: string | null;
|
||||||
|
attention: string | null;
|
||||||
|
reference: string | null;
|
||||||
|
revision: number;
|
||||||
|
revisionLabel: string;
|
||||||
|
statusCode: string | null;
|
||||||
|
statusLabel: string | null;
|
||||||
|
quotationTypeCode: string | null;
|
||||||
|
quotationTypeLabel: string | null;
|
||||||
|
currencyCode: string | null;
|
||||||
|
currencyLabel: string | null;
|
||||||
|
exchangeRate: number;
|
||||||
|
subtotal: number;
|
||||||
|
discount: number;
|
||||||
|
discountTypeCode: string | null;
|
||||||
|
discountTypeLabel: string | null;
|
||||||
|
taxRate: number;
|
||||||
|
taxAmount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
itemNumber: number;
|
||||||
|
productTypeCode: string | null;
|
||||||
|
productTypeLabel: string | null;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitCode: string | null;
|
||||||
|
unitLabel: string | null;
|
||||||
|
unitPrice: number;
|
||||||
|
discount: number;
|
||||||
|
discountTypeCode: string | null;
|
||||||
|
taxRate: number;
|
||||||
|
totalPrice: number;
|
||||||
|
notes: string | null;
|
||||||
|
}>;
|
||||||
|
quotationCustomers: Array<{
|
||||||
|
id: string;
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
customerCode: string;
|
||||||
|
roleCode: string;
|
||||||
|
roleLabel: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}>;
|
||||||
|
topics: {
|
||||||
|
scope: QuotationDocumentTopicGroup[];
|
||||||
|
exclusion: QuotationDocumentTopicGroup[];
|
||||||
|
payment: QuotationDocumentTopicGroup[];
|
||||||
|
other: QuotationDocumentTopicGroup[];
|
||||||
|
};
|
||||||
|
approval: {
|
||||||
|
requestId: string | null;
|
||||||
|
workflowName: string | null;
|
||||||
|
status: string | null;
|
||||||
|
approvedAt: string | null;
|
||||||
|
currentStep: number | null;
|
||||||
|
currentStepRoleName: string | null;
|
||||||
|
approvers: QuotationDocumentApprovalStep[];
|
||||||
|
};
|
||||||
|
signatures: {
|
||||||
|
preparedBy: string | null;
|
||||||
|
requestedBy: string | null;
|
||||||
|
salesManager: string | null;
|
||||||
|
departmentManager: string | null;
|
||||||
|
topManager: string | null;
|
||||||
|
};
|
||||||
|
watermarkStatus: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotationDocumentDataResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
documentData: QuotationDocumentData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotationDocumentPreviewResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
preview: {
|
||||||
|
documentData: QuotationDocumentData;
|
||||||
|
template: ResolvedDocumentTemplate;
|
||||||
|
mappings: DocumentTemplateMappingWithColumns[];
|
||||||
|
templateInput: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovedQuotationSnapshot {
|
||||||
|
quotationId: string;
|
||||||
|
approvedAt: string | null;
|
||||||
|
documentData: QuotationDocumentData;
|
||||||
|
templateVersionId: string;
|
||||||
|
templateInput: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { mutationOptions } from '@tanstack/react-query';
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
|
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
import {
|
import {
|
||||||
approveApproval,
|
approveApproval,
|
||||||
@@ -16,6 +17,8 @@ function invalidateQuotationQueries(quotationId: string) {
|
|||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
queryClient.invalidateQueries({ queryKey: quotationKeys.all });
|
queryClient.invalidateQueries({ queryKey: quotationKeys.all });
|
||||||
queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) });
|
queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateRelatedEntityQueries(approvalId: string) {
|
function invalidateRelatedEntityQueries(approvalId: string) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { documentTemplatesQueryOptions } from '../queries';
|
||||||
|
|
||||||
|
export function TemplateSettings() {
|
||||||
|
const { data } = useSuspenseQuery(
|
||||||
|
documentTemplatesQueryOptions({ documentType: 'quotation', fileType: 'pdfme' })
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{!data.items.length ? (
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
|
No production document templates found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.items.map((template) => (
|
||||||
|
<Card key={template.id}>
|
||||||
|
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<CardTitle>{template.templateName}</CardTitle>
|
||||||
|
<CardDescription>{template.description || 'No description provided.'}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{template.isDefault ? <Badge>Default</Badge> : null}
|
||||||
|
<Badge variant={template.isActive ? 'secondary' : 'outline'}>
|
||||||
|
{template.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant='outline'>{template.fileType}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
|
||||||
|
<div>
|
||||||
|
<div className='text-muted-foreground text-xs'>Document Type</div>
|
||||||
|
<div className='text-sm font-medium'>{template.documentType}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='text-muted-foreground text-xs'>Product Type</div>
|
||||||
|
<div className='text-sm font-medium'>{template.productType}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='text-muted-foreground text-xs'>Latest Version</div>
|
||||||
|
<div className='text-sm font-medium'>{template.latestVersion ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='text-muted-foreground text-xs'>Version Count</div>
|
||||||
|
<div className='text-sm font-medium'>{template.versionCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='text-muted-foreground text-xs'>Mapping Count</div>
|
||||||
|
<div className='text-sm font-medium'>{template.mappingCount}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/features/foundation/document-template/mutations.ts
Normal file
43
src/features/foundation/document-template/mutations.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
import {
|
||||||
|
createDocumentTemplate,
|
||||||
|
createDocumentTemplateVersion,
|
||||||
|
deleteDocumentTemplate,
|
||||||
|
updateDocumentTemplate
|
||||||
|
} from './service';
|
||||||
|
import { documentTemplateKeys } from './queries';
|
||||||
|
import type { DocumentTemplateMutationPayload, DocumentTemplateVersionMutationPayload } from './types';
|
||||||
|
|
||||||
|
export const createDocumentTemplateMutation = mutationOptions({
|
||||||
|
mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateDocumentTemplateMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: Partial<DocumentTemplateMutationPayload> }) =>
|
||||||
|
updateDocumentTemplate(id, values),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(variables.id) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteDocumentTemplateMutation = mutationOptions({
|
||||||
|
mutationFn: (id: string) => deleteDocumentTemplate(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDocumentTemplateVersionMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: DocumentTemplateVersionMutationPayload }) =>
|
||||||
|
createDocumentTemplateVersion(id, values),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(variables.id) });
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(variables.id) });
|
||||||
|
}
|
||||||
|
});
|
||||||
29
src/features/foundation/document-template/queries.ts
Normal file
29
src/features/foundation/document-template/queries.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
|
import { getDocumentTemplateById, getDocumentTemplates, getDocumentTemplateVersions } from './service';
|
||||||
|
import type { DocumentTemplateFilters } from './types';
|
||||||
|
|
||||||
|
export const documentTemplateKeys = {
|
||||||
|
all: ['crm-document-templates'] as const,
|
||||||
|
list: (filters: DocumentTemplateFilters) =>
|
||||||
|
[...documentTemplateKeys.all, 'list', filters] as const,
|
||||||
|
detail: (id: string) => [...documentTemplateKeys.all, 'detail', id] as const,
|
||||||
|
versions: (id: string) => [...documentTemplateKeys.all, 'versions', id] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const documentTemplatesQueryOptions = (filters: DocumentTemplateFilters = {}) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: documentTemplateKeys.list(filters),
|
||||||
|
queryFn: () => getDocumentTemplates(filters)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const documentTemplateByIdOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: documentTemplateKeys.detail(id),
|
||||||
|
queryFn: () => getDocumentTemplateById(id)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const documentTemplateVersionsQueryOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: documentTemplateKeys.versions(id),
|
||||||
|
queryFn: () => getDocumentTemplateVersions(id)
|
||||||
|
});
|
||||||
563
src/features/foundation/document-template/server/service.ts
Normal file
563
src/features/foundation/document-template/server/service.ts
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
crmDocumentTemplateMappings,
|
||||||
|
crmDocumentTemplateTableColumns,
|
||||||
|
crmDocumentTemplateVersions,
|
||||||
|
crmDocumentTemplates
|
||||||
|
} from '@/db/schema';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import type {
|
||||||
|
DocumentTemplateDetail,
|
||||||
|
DocumentTemplateFilters,
|
||||||
|
DocumentTemplateListItem,
|
||||||
|
DocumentTemplateMappingRecord,
|
||||||
|
DocumentTemplateMappingWithColumns,
|
||||||
|
DocumentTemplateMutationPayload,
|
||||||
|
DocumentTemplateRecord,
|
||||||
|
DocumentTemplateTableColumnRecord,
|
||||||
|
DocumentTemplateVersionDetail,
|
||||||
|
DocumentTemplateVersionMutationPayload,
|
||||||
|
DocumentTemplateVersionRecord,
|
||||||
|
ResolveTemplateParams,
|
||||||
|
ResolvedDocumentTemplate
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): DocumentTemplateRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
documentType: row.documentType,
|
||||||
|
productType: row.productType,
|
||||||
|
fileType: row.fileType,
|
||||||
|
templateName: row.templateName,
|
||||||
|
description: row.description,
|
||||||
|
isDefault: row.isDefault,
|
||||||
|
isActive: row.isActive,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
updatedBy: row.updatedBy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapVersionRecord(
|
||||||
|
row: typeof crmDocumentTemplateVersions.$inferSelect
|
||||||
|
): DocumentTemplateVersionRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
templateId: row.templateId,
|
||||||
|
version: row.version,
|
||||||
|
filePath: row.filePath,
|
||||||
|
schemaJson: row.schemaJson,
|
||||||
|
previewImageUrl: row.previewImageUrl,
|
||||||
|
isActive: row.isActive,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||||
|
createdBy: row.createdBy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMappingRecord(
|
||||||
|
row: typeof crmDocumentTemplateMappings.$inferSelect
|
||||||
|
): DocumentTemplateMappingRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
templateVersionId: row.templateVersionId,
|
||||||
|
placeholderKey: row.placeholderKey,
|
||||||
|
sourcePath: row.sourcePath,
|
||||||
|
dataType: row.dataType as DocumentTemplateMappingRecord['dataType'],
|
||||||
|
sheetName: row.sheetName,
|
||||||
|
defaultValue: row.defaultValue,
|
||||||
|
formatMask: row.formatMask,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTableColumnRecord(
|
||||||
|
row: typeof crmDocumentTemplateTableColumns.$inferSelect
|
||||||
|
): DocumentTemplateTableColumnRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
mappingId: row.mappingId,
|
||||||
|
columnName: row.columnName,
|
||||||
|
sourceField: row.sourceField,
|
||||||
|
columnLetter: row.columnLetter,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
formatMask: row.formatMask,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertTemplate(id: string, organizationId: string) {
|
||||||
|
const [template] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplates.id, id),
|
||||||
|
eq(crmDocumentTemplates.organizationId, organizationId),
|
||||||
|
isNull(crmDocumentTemplates.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
throw new AuthError('Document template not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listVersionsByTemplateIds(templateIds: string[], organizationId: string) {
|
||||||
|
if (!templateIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplateVersions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplateVersions.organizationId, organizationId),
|
||||||
|
isNull(crmDocumentTemplateVersions.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTemplateMappings(
|
||||||
|
templateVersionId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<DocumentTemplateMappingWithColumns[]> {
|
||||||
|
const mappings = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplateMappings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplateMappings.templateVersionId, templateVersionId),
|
||||||
|
eq(crmDocumentTemplateMappings.organizationId, organizationId),
|
||||||
|
isNull(crmDocumentTemplateMappings.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmDocumentTemplateMappings.sortOrder), asc(crmDocumentTemplateMappings.createdAt));
|
||||||
|
const columns = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplateTableColumns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplateTableColumns.organizationId, organizationId),
|
||||||
|
isNull(crmDocumentTemplateTableColumns.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
asc(crmDocumentTemplateTableColumns.sortOrder),
|
||||||
|
asc(crmDocumentTemplateTableColumns.createdAt)
|
||||||
|
);
|
||||||
|
const columnMap = new Map<string, DocumentTemplateTableColumnRecord[]>();
|
||||||
|
|
||||||
|
for (const column of columns.map(mapTableColumnRecord)) {
|
||||||
|
const items = columnMap.get(column.mappingId) ?? [];
|
||||||
|
items.push(column);
|
||||||
|
columnMap.set(column.mappingId, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mappings.map((mapping) => ({
|
||||||
|
...mapMappingRecord(mapping),
|
||||||
|
columns: columnMap.get(mapping.id) ?? []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDocumentTemplates(
|
||||||
|
organizationId: string,
|
||||||
|
filters: DocumentTemplateFilters = {}
|
||||||
|
): Promise<DocumentTemplateListItem[]> {
|
||||||
|
const templates = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplates.organizationId, organizationId),
|
||||||
|
isNull(crmDocumentTemplates.deletedAt),
|
||||||
|
...(filters.documentType ? [eq(crmDocumentTemplates.documentType, filters.documentType)] : []),
|
||||||
|
...(filters.fileType ? [eq(crmDocumentTemplates.fileType, filters.fileType)] : []),
|
||||||
|
...(filters.isActive === 'true' ? [eq(crmDocumentTemplates.isActive, true)] : []),
|
||||||
|
...(filters.isActive === 'false' ? [eq(crmDocumentTemplates.isActive, false)] : [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmDocumentTemplates.templateName));
|
||||||
|
const versions = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplateVersions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplateVersions.organizationId, organizationId),
|
||||||
|
isNull(crmDocumentTemplateVersions.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
|
||||||
|
const mappings = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplateMappings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplateMappings.organizationId, organizationId),
|
||||||
|
isNull(crmDocumentTemplateMappings.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return templates.map((template) => {
|
||||||
|
const templateVersions = versions.filter((item) => item.templateId === template.id);
|
||||||
|
const latestVersion = templateVersions.at(-1) ?? null;
|
||||||
|
const templateVersionIds = new Set(templateVersions.map((item) => item.id));
|
||||||
|
const mappingCount = mappings.filter((item) => templateVersionIds.has(item.templateVersionId)).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapTemplateRecord(template),
|
||||||
|
latestVersion: latestVersion?.version ?? null,
|
||||||
|
versionCount: templateVersions.length,
|
||||||
|
mappingCount
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocumentTemplate(
|
||||||
|
id: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<DocumentTemplateDetail> {
|
||||||
|
const template = await assertTemplate(id, organizationId);
|
||||||
|
const versions = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplateVersions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplateVersions.templateId, id),
|
||||||
|
eq(crmDocumentTemplateVersions.organizationId, organizationId),
|
||||||
|
isNull(crmDocumentTemplateVersions.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
|
||||||
|
const versionsWithMappings: DocumentTemplateVersionDetail[] = [];
|
||||||
|
let mappingCount = 0;
|
||||||
|
|
||||||
|
for (const version of versions) {
|
||||||
|
const mappings = await resolveTemplateMappings(version.id, organizationId);
|
||||||
|
mappingCount += mappings.length;
|
||||||
|
versionsWithMappings.push({
|
||||||
|
...mapVersionRecord(version),
|
||||||
|
mappings
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapTemplateRecord(template),
|
||||||
|
versions: versionsWithMappings,
|
||||||
|
mappingCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDocumentTemplateVersions(id: string, organizationId: string) {
|
||||||
|
await assertTemplate(id, organizationId);
|
||||||
|
const versions = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplateVersions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplateVersions.templateId, id),
|
||||||
|
eq(crmDocumentTemplateVersions.organizationId, organizationId),
|
||||||
|
isNull(crmDocumentTemplateVersions.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
versions.map(async (version) => ({
|
||||||
|
...mapVersionRecord(version),
|
||||||
|
mappings: await resolveTemplateMappings(version.id, organizationId)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDocumentTemplate(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: DocumentTemplateMutationPayload
|
||||||
|
) {
|
||||||
|
const [created] = await db
|
||||||
|
.insert(crmDocumentTemplates)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
documentType: payload.documentType,
|
||||||
|
productType: payload.productType,
|
||||||
|
fileType: payload.fileType,
|
||||||
|
templateName: payload.templateName.trim(),
|
||||||
|
description: payload.description?.trim() || null,
|
||||||
|
isDefault: payload.isDefault ?? false,
|
||||||
|
isActive: payload.isActive ?? true,
|
||||||
|
createdBy: userId,
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (created.isDefault) {
|
||||||
|
await normalizeDefaultTemplate(created.id, organizationId, created.documentType, created.fileType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDocumentTemplate(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: Partial<DocumentTemplateMutationPayload>
|
||||||
|
) {
|
||||||
|
const template = await assertTemplate(id, organizationId);
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmDocumentTemplates)
|
||||||
|
.set({
|
||||||
|
documentType: payload.documentType ?? template.documentType,
|
||||||
|
productType: payload.productType ?? template.productType,
|
||||||
|
fileType: payload.fileType ?? template.fileType,
|
||||||
|
templateName: payload.templateName?.trim() ?? template.templateName,
|
||||||
|
description: payload.description === undefined ? template.description : payload.description?.trim() || null,
|
||||||
|
isDefault: payload.isDefault ?? template.isDefault,
|
||||||
|
isActive: payload.isActive ?? template.isActive,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.where(eq(crmDocumentTemplates.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updated.isDefault) {
|
||||||
|
await normalizeDefaultTemplate(updated.id, organizationId, updated.documentType, updated.fileType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeDefaultTemplate(
|
||||||
|
templateId: string,
|
||||||
|
organizationId: string,
|
||||||
|
documentType: string,
|
||||||
|
fileType: string
|
||||||
|
) {
|
||||||
|
await db
|
||||||
|
.update(crmDocumentTemplates)
|
||||||
|
.set({
|
||||||
|
isDefault: false,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplates.organizationId, organizationId),
|
||||||
|
eq(crmDocumentTemplates.documentType, documentType),
|
||||||
|
eq(crmDocumentTemplates.fileType, fileType),
|
||||||
|
isNull(crmDocumentTemplates.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmDocumentTemplates)
|
||||||
|
.set({
|
||||||
|
isDefault: true,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmDocumentTemplates.id, templateId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function softDeleteDocumentTemplate(id: string, organizationId: string, userId: string) {
|
||||||
|
await assertTemplate(id, organizationId);
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmDocumentTemplates)
|
||||||
|
.set({
|
||||||
|
deletedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.where(eq(crmDocumentTemplates.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDocumentTemplateVersion(
|
||||||
|
templateId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: DocumentTemplateVersionMutationPayload
|
||||||
|
) {
|
||||||
|
await assertTemplate(templateId, organizationId);
|
||||||
|
const [created] = await db
|
||||||
|
.insert(crmDocumentTemplateVersions)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
templateId,
|
||||||
|
version: payload.version.trim(),
|
||||||
|
filePath: payload.filePath?.trim() || null,
|
||||||
|
schemaJson: payload.schemaJson,
|
||||||
|
previewImageUrl: payload.previewImageUrl?.trim() || null,
|
||||||
|
isActive: payload.isActive ?? true,
|
||||||
|
createdBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTemplateForDocument(
|
||||||
|
organizationId: string,
|
||||||
|
params: ResolveTemplateParams
|
||||||
|
): Promise<ResolvedDocumentTemplate> {
|
||||||
|
const templates = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplates.organizationId, organizationId),
|
||||||
|
eq(crmDocumentTemplates.documentType, params.documentType),
|
||||||
|
eq(crmDocumentTemplates.fileType, params.fileType),
|
||||||
|
isNull(crmDocumentTemplates.deletedAt),
|
||||||
|
eq(crmDocumentTemplates.isActive, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmDocumentTemplates.createdAt));
|
||||||
|
const exactTemplate =
|
||||||
|
templates.find(
|
||||||
|
(template) => template.productType === (params.productType ?? 'default') && template.isDefault
|
||||||
|
) ??
|
||||||
|
templates.find((template) => template.productType === (params.productType ?? 'default')) ??
|
||||||
|
templates.find((template) => template.productType === 'default' && template.isDefault) ??
|
||||||
|
templates.find((template) => template.productType === 'default') ??
|
||||||
|
templates.find((template) => template.isDefault) ??
|
||||||
|
templates[0];
|
||||||
|
|
||||||
|
if (!exactTemplate) {
|
||||||
|
throw new AuthError('No active document template found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentTemplateVersions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentTemplateVersions.organizationId, organizationId),
|
||||||
|
eq(crmDocumentTemplateVersions.templateId, exactTemplate.id),
|
||||||
|
eq(crmDocumentTemplateVersions.isActive, true),
|
||||||
|
isNull(crmDocumentTemplateVersions.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
|
||||||
|
const version = versions.at(-1);
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
throw new AuthError('No active template version found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
template: mapTemplateRecord(exactTemplate),
|
||||||
|
version: mapVersionRecord(version)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValueByPath(source: unknown, path: string): unknown {
|
||||||
|
return path.split('.').reduce<unknown>((current, segment) => {
|
||||||
|
if (current === null || current === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(current) && /^\d+$/.test(segment)) {
|
||||||
|
return current[Number(segment)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof current === 'object' && current !== null && segment in current) {
|
||||||
|
return (current as Record<string, unknown>)[segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFormatMask(value: unknown, formatMask?: string | null) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formatMask === 'currency' && typeof value === 'number') {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formatMask === 'date' && typeof value === 'string') {
|
||||||
|
return new Date(value).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDocumentDataToTemplateInput(
|
||||||
|
documentData: Record<string, unknown>,
|
||||||
|
mappings: DocumentTemplateMappingWithColumns[]
|
||||||
|
) {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
const rawValue = getValueByPath(documentData, mapping.sourcePath);
|
||||||
|
|
||||||
|
if (mapping.dataType === 'table') {
|
||||||
|
const rows = Array.isArray(rawValue) ? rawValue : [];
|
||||||
|
result[mapping.placeholderKey] = rows.map((row) => {
|
||||||
|
const rowRecord = row as Record<string, unknown>;
|
||||||
|
return mapping.columns.reduce<Record<string, unknown>>((accumulator, column) => {
|
||||||
|
accumulator[column.columnName] = applyFormatMask(
|
||||||
|
rowRecord[column.sourceField],
|
||||||
|
column.formatMask
|
||||||
|
);
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping.dataType === 'multiline') {
|
||||||
|
if (Array.isArray(rawValue)) {
|
||||||
|
const normalized = rawValue
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item && typeof item === 'object' && 'items' in item) {
|
||||||
|
return ((item as { items?: string[] }).items ?? []).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(item ?? '');
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
result[mapping.placeholderKey] = normalized || mapping.defaultValue || '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[mapping.placeholderKey] =
|
||||||
|
typeof rawValue === 'string' ? rawValue : mapping.defaultValue || '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[mapping.placeholderKey] =
|
||||||
|
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
64
src/features/foundation/document-template/service.ts
Normal file
64
src/features/foundation/document-template/service.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import type {
|
||||||
|
DocumentTemplateDetailResponse,
|
||||||
|
DocumentTemplateFilters,
|
||||||
|
DocumentTemplateListResponse,
|
||||||
|
DocumentTemplateMutationPayload,
|
||||||
|
DocumentTemplateVersionMutationPayload,
|
||||||
|
DocumentTemplateVersionsResponse,
|
||||||
|
MutationSuccessResponse
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters.documentType) searchParams.set('documentType', filters.documentType);
|
||||||
|
if (filters.fileType) searchParams.set('fileType', filters.fileType);
|
||||||
|
if (filters.isActive) searchParams.set('isActive', filters.isActive);
|
||||||
|
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return apiClient<DocumentTemplateListResponse>(
|
||||||
|
`/crm/document-templates${query ? `?${query}` : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocumentTemplateById(id: string) {
|
||||||
|
return apiClient<DocumentTemplateDetailResponse>(`/crm/document-templates/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDocumentTemplate(data: DocumentTemplateMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>('/crm/document-templates', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDocumentTemplate(
|
||||||
|
id: string,
|
||||||
|
data: Partial<DocumentTemplateMutationPayload>
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDocumentTemplate(id: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocumentTemplateVersions(id: string) {
|
||||||
|
return apiClient<DocumentTemplateVersionsResponse>(`/crm/document-templates/${id}/versions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDocumentTemplateVersion(
|
||||||
|
id: string,
|
||||||
|
data: DocumentTemplateVersionMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}/versions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
141
src/features/foundation/document-template/types.ts
Normal file
141
src/features/foundation/document-template/types.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
export interface DocumentTemplateRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
documentType: string;
|
||||||
|
productType: string;
|
||||||
|
fileType: string;
|
||||||
|
templateName: string;
|
||||||
|
description: string | null;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateVersionRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
templateId: string;
|
||||||
|
version: string;
|
||||||
|
filePath: string | null;
|
||||||
|
schemaJson: unknown;
|
||||||
|
previewImageUrl: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateMappingRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
templateVersionId: string;
|
||||||
|
placeholderKey: string;
|
||||||
|
sourcePath: string;
|
||||||
|
dataType: 'scalar' | 'multiline' | 'table' | 'image';
|
||||||
|
sheetName: string | null;
|
||||||
|
defaultValue: string | null;
|
||||||
|
formatMask: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateTableColumnRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
mappingId: string;
|
||||||
|
columnName: string;
|
||||||
|
sourceField: string;
|
||||||
|
columnLetter: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
formatMask: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord {
|
||||||
|
columns: DocumentTemplateTableColumnRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateVersionDetail extends DocumentTemplateVersionRecord {
|
||||||
|
mappings: DocumentTemplateMappingWithColumns[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateDetail extends DocumentTemplateRecord {
|
||||||
|
versions: DocumentTemplateVersionDetail[];
|
||||||
|
mappingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateListItem extends DocumentTemplateRecord {
|
||||||
|
latestVersion: string | null;
|
||||||
|
versionCount: number;
|
||||||
|
mappingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateFilters {
|
||||||
|
documentType?: string;
|
||||||
|
fileType?: string;
|
||||||
|
isActive?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateListResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
items: DocumentTemplateListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateDetailResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
template: DocumentTemplateDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateVersionsResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
items: DocumentTemplateVersionDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateMutationPayload {
|
||||||
|
documentType: string;
|
||||||
|
productType: string;
|
||||||
|
fileType: string;
|
||||||
|
templateName: string;
|
||||||
|
description?: string | null;
|
||||||
|
isDefault?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTemplateVersionMutationPayload {
|
||||||
|
version: string;
|
||||||
|
filePath?: string | null;
|
||||||
|
schemaJson: unknown;
|
||||||
|
previewImageUrl?: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveTemplateParams {
|
||||||
|
documentType: string;
|
||||||
|
productType?: string | null;
|
||||||
|
fileType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedDocumentTemplate {
|
||||||
|
template: DocumentTemplateRecord;
|
||||||
|
version: DocumentTemplateVersionRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MutationSuccessResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -52,7 +52,12 @@ export const PERMISSIONS = {
|
|||||||
crmApprovalSubmit: 'crm.approval.submit',
|
crmApprovalSubmit: 'crm.approval.submit',
|
||||||
crmApprovalApprove: 'crm.approval.approve',
|
crmApprovalApprove: 'crm.approval.approve',
|
||||||
crmApprovalReject: 'crm.approval.reject',
|
crmApprovalReject: 'crm.approval.reject',
|
||||||
crmApprovalReturn: 'crm.approval.return'
|
crmApprovalReturn: 'crm.approval.return',
|
||||||
|
crmDocumentTemplateRead: 'crm.document_template.read',
|
||||||
|
crmDocumentTemplateCreate: 'crm.document_template.create',
|
||||||
|
crmDocumentTemplateUpdate: 'crm.document_template.update',
|
||||||
|
crmDocumentTemplateDelete: 'crm.document_template.delete',
|
||||||
|
crmQuotationDocumentPreview: 'crm.quotation.document.preview'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||||
@@ -94,7 +99,12 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.crmApprovalSubmit,
|
PERMISSIONS.crmApprovalSubmit,
|
||||||
PERMISSIONS.crmApprovalApprove,
|
PERMISSIONS.crmApprovalApprove,
|
||||||
PERMISSIONS.crmApprovalReject,
|
PERMISSIONS.crmApprovalReject,
|
||||||
PERMISSIONS.crmApprovalReturn
|
PERMISSIONS.crmApprovalReturn,
|
||||||
|
PERMISSIONS.crmDocumentTemplateRead,
|
||||||
|
PERMISSIONS.crmDocumentTemplateCreate,
|
||||||
|
PERMISSIONS.crmDocumentTemplateUpdate,
|
||||||
|
PERMISSIONS.crmDocumentTemplateDelete,
|
||||||
|
PERMISSIONS.crmQuotationDocumentPreview
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +115,9 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryFollowupRead,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmQuotationRead,
|
PERMISSIONS.crmQuotationRead,
|
||||||
PERMISSIONS.crmApprovalRead
|
PERMISSIONS.crmApprovalRead,
|
||||||
|
PERMISSIONS.crmDocumentTemplateRead,
|
||||||
|
PERMISSIONS.crmQuotationDocumentPreview
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1522
src/pdfme_template/ALLA_template_pdfme_fainal3.json
Normal file
1522
src/pdfme_template/ALLA_template_pdfme_fainal3.json
Normal file
File diff suppressed because one or more lines are too long
1460
src/pdfme_template/ONVALLA_template_pdfme_fainal3.json
Normal file
1460
src/pdfme_template/ONVALLA_template_pdfme_fainal3.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user