hotfix-add subgroup customer
This commit is contained in:
79
docs/implementation/hotfix-1-customer-sub-group.md
Normal file
79
docs/implementation/hotfix-1-customer-sub-group.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Hotfix 1: Customer Sub Group
|
||||||
|
|
||||||
|
## 1. Files Added
|
||||||
|
|
||||||
|
- `drizzle/0011_brief_hobgoblin.sql`
|
||||||
|
- `docs/implementation/hotfix-1-customer-sub-group.md`
|
||||||
|
|
||||||
|
## 2. Files Modified
|
||||||
|
|
||||||
|
- `src/db/schema.ts`
|
||||||
|
- `src/db/seeds/foundation.seed.ts`
|
||||||
|
- `src/features/crm/customers/api/types.ts`
|
||||||
|
- `src/features/crm/customers/schemas/customer.schema.ts`
|
||||||
|
- `src/features/crm/customers/server/service.ts`
|
||||||
|
- `src/app/api/crm/customers/route.ts`
|
||||||
|
- `src/app/api/crm/customers/[id]/route.ts`
|
||||||
|
- `src/components/forms/fields/select-field.tsx`
|
||||||
|
- `src/features/crm/customers/components/customer-form-sheet.tsx`
|
||||||
|
- `src/features/crm/customers/components/customer-columns.tsx`
|
||||||
|
- `src/features/crm/customers/components/customer-detail.tsx`
|
||||||
|
|
||||||
|
## 3. Schema Field Used/Added
|
||||||
|
|
||||||
|
- Added optional field `customerSubGroup` to `crm_customers`
|
||||||
|
- Field is stored with the same option-id pattern already used by `customerGroup`
|
||||||
|
|
||||||
|
## 4. Master Options Seeded
|
||||||
|
|
||||||
|
- `crm_customer_group`
|
||||||
|
- `industrial`
|
||||||
|
- `construction`
|
||||||
|
- `government`
|
||||||
|
- `dealer`
|
||||||
|
- `other`
|
||||||
|
- `crm_customer_sub_group`
|
||||||
|
- `factory`
|
||||||
|
- `warehouse`
|
||||||
|
- `logistics`
|
||||||
|
- `contractor`
|
||||||
|
- `consultant`
|
||||||
|
- `state_enterprise`
|
||||||
|
- `government_agency`
|
||||||
|
- `reseller`
|
||||||
|
- `other`
|
||||||
|
|
||||||
|
Child options resolve and store `parentId` from the seeded `crm_customer_group` rows during idempotent seed execution.
|
||||||
|
|
||||||
|
## 5. Form Behavior Added
|
||||||
|
|
||||||
|
- Add/Edit Customer now includes `Customer Sub Group`
|
||||||
|
- Sub group is disabled until a customer group is selected
|
||||||
|
- Edit mode loads the saved group and sub group
|
||||||
|
- Submit payload now persists `customerSubGroup`
|
||||||
|
|
||||||
|
## 6. Parent/Child Filtering Behavior
|
||||||
|
|
||||||
|
- Customer sub group options are filtered by `parentId === selectedCustomerGroup`
|
||||||
|
- Changing group clears the sub group when the selected child no longer belongs to the new parent
|
||||||
|
- Placeholder shows `Select customer group first` until a parent group is chosen
|
||||||
|
|
||||||
|
## 7. Validation Added
|
||||||
|
|
||||||
|
- API schemas now accept `customerSubGroup`
|
||||||
|
- Server validation checks that:
|
||||||
|
- the selected sub group exists in `crm_customer_sub_group`
|
||||||
|
- a sub group cannot be submitted without a group
|
||||||
|
- the selected sub group belongs to the selected group parent
|
||||||
|
- Invalid parent/child combinations return a user-safe `400` error
|
||||||
|
|
||||||
|
## 8. Verification Result
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` passed
|
||||||
|
- CRUD wiring, list/detail output, and form filtering compile successfully
|
||||||
|
|
||||||
|
## 9. Remaining Risks
|
||||||
|
|
||||||
|
- Database migration `drizzle/0011_brief_hobgoblin.sql` still needs to be applied in the target environment
|
||||||
|
- Seed changes were updated in code, but I did not run a live seed against the database in this session
|
||||||
|
- Existing customer rows will keep `customerSubGroup = null` until edited or backfilled
|
||||||
538
docs/implementation/pdfme-legacy-audit.md
Normal file
538
docs/implementation/pdfme-legacy-audit.md
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
# PDFME Legacy Audit
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
This audit compares the legacy PDFME quotation generation implementation with the current ALLA OS CRM vNext implementation. The investigation reveals significant gaps in placeholder mapping, data transformation, and topic handling that explain why the current system displays incomplete or incorrectly mapped data.
|
||||||
|
|
||||||
|
**Key Finding**: The legacy implementation uses a direct mapping approach with specialized transform functions for each field, while the current implementation relies on a generic mapping system that may not handle all edge cases correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Legacy Files Inspected
|
||||||
|
|
||||||
|
### 2.1 Core Files
|
||||||
|
- **`src/features/quotations/lib/pdfme-export.ts`** - Main PDFME generation logic
|
||||||
|
- **`src/features/quotations/lib/quotation-preview-utils.ts`** - Format utilities
|
||||||
|
- **`src/features/quotations/pdfme/ALLA_template_pdfme_fainal3.json`** - Template definition
|
||||||
|
- **`src/features/quotations/types/quotation-preview.types.ts`** - Type definitions
|
||||||
|
|
||||||
|
### 2.2 Current Project Files
|
||||||
|
- **`src/features/foundation/pdf-generator/server/service.ts`** - PDF generation service
|
||||||
|
- **`src/features/crm/quotations/document/server/service.ts`** - Document data builder
|
||||||
|
- **`src/features/foundation/document-template/server/service.ts`** - Template mapping service
|
||||||
|
- **`src/db/schema.ts`** - Database schema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Legacy PDFME Flow
|
||||||
|
|
||||||
|
### 3.1 Architecture
|
||||||
|
```
|
||||||
|
generateQuotationPDF()
|
||||||
|
↓
|
||||||
|
mapDataToTemplate()
|
||||||
|
↓
|
||||||
|
formatDate() / formatCurrency()
|
||||||
|
↓
|
||||||
|
formatTopicItems() / section() / section2()
|
||||||
|
↓
|
||||||
|
generateExtraTopicFields()
|
||||||
|
↓
|
||||||
|
generate() from @pdfme/generator
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Key Characteristics
|
||||||
|
- **Direct mapping**: Each placeholder key is explicitly mapped in `mapDataToTemplate()`
|
||||||
|
- **Inline transforms**: Transform functions are applied inline during mapping
|
||||||
|
- **Dynamic schema generation**: Extra topics modify template schema at runtime
|
||||||
|
- **Multi-branch support**: Different templates for `alla` vs `onvalla`
|
||||||
|
- **Specialized formatting**: Currency, date, and topic item formatting
|
||||||
|
|
||||||
|
### 3.3 Data Structure
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
customer: QuotationPreviewCustomer
|
||||||
|
quotation: QuotationPreviewData
|
||||||
|
items: QuotationPreviewItem[]
|
||||||
|
topics: Array<{ topicType: string; items: Array<{ content: string }> }>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Legacy Placeholder Inventory
|
||||||
|
|
||||||
|
| Placeholder | Legacy Source | Transform | Current Mapping | Status |
|
||||||
|
|-------------|----------------|-----------|------------------|--------|
|
||||||
|
| `customer_name` | `customer.name` | Fallback to `'-'` | ✅ Mapped | OK |
|
||||||
|
| `customer_addr` | `customer.address` | Fallback to `'-'` | ✅ Mapped | OK |
|
||||||
|
| `customer_tel` | `customer.contacts[primary].mobile/phone` | Prefix: `Tel : ` | ✅ Mapped | OK |
|
||||||
|
| `customer_email` | `customer.contacts[primary].email` | Prefix: `Email : ` | ✅ Mapped | OK |
|
||||||
|
| `customer_att` | `quotation.attention` | Direct | ✅ Mapped | OK |
|
||||||
|
| `project_name` | `quotation.projectName` | Direct | ✅ Mapped | OK |
|
||||||
|
| `site_location` | `quotation.projectLocation` | Direct | ✅ Mapped | OK |
|
||||||
|
| `quotation_date` | `quotation.quotationDate` | `formatDate(date, 'en')` | ✅ Mapped | ⚠️ **Check format** |
|
||||||
|
| `quotation_code` | `quotation.code` | Direct | ✅ Mapped | OK |
|
||||||
|
| `quotation_price` | `quotation.totalAmount` | `formatCurrency(amount, currency)` | ✅ Mapped | ⚠️ **Check format** |
|
||||||
|
| `currency` | `quotation.currency` | Direct | ✅ Mapped | OK |
|
||||||
|
| `exclusion_label` | `termsTopic.topicType` | Hardcoded: `"Exclusion from scope of Supply:"` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `exclusion_data` | `termsTopic.items` | `formatTopicItems()` → `string[][]` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `Terms_of_payment` | `termsTopic.topicType` | Hardcoded: `"Terms of payment upon progress of each item:"` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `data_term_of_payment` | `termsTopic.items` | `formatTopicItems()` → `string[][]` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `warranty` | `warrantyTopic.topicType` | Hardcoded: `"Warranty:"` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `data_warranty` | `warrantyTopic.items` | `formatTopicItems()` → `string[][]` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `Delivery` | `deliveryTopic.topicType` | Hardcoded: `"Delivery:"` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `data_delivery` | `deliveryTopic.items` | `formatTopicItems()` → `string[][]` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `Affter service` | `deliveryTopic.topicType` | Hardcoded: `"Affter service:"` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `data_affter_service` | `deliveryTopic.items` | `formatTopicItems()` → `string[][]` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `topic` | **Static text** | Hardcoded: `"Topic:"` | ✅ Static | OK |
|
||||||
|
| `data_topic` | `topics` | **Complex transform needed** | ❌ **Missing** | **MISSING** |
|
||||||
|
| `item_topic` | **Not in legacy** | - | ❌ **Not defined** | **TEMPLATE MISMATCH** |
|
||||||
|
| `app1` | **Not populated** | Static: `"Type Something..."` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `app1_position` | **Not populated** | Static: `"Type Something..."` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `app2` | **Not populated** | Static: `"Type Something..."` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `app2_position` | **Not populated** | Static: `"Type Something..."` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `app3` | **Not populated** | Static: `"Type Something..."` | ❌ **Missing** | **MISSING** |
|
||||||
|
| `app3_position` | **Not populated** | Static: `"Type Something..."` | ❌ **Missing** | **MISSING** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Legacy Transform Functions
|
||||||
|
|
||||||
|
### 5.1 `formatDate(dateString: string, language: 'th' | 'en'): string`
|
||||||
|
**Purpose**: Format date for PDF display
|
||||||
|
**Input**: ISO date string
|
||||||
|
**Output**: Localized date string (Thai: Buddhist calendar, English: Gregorian)
|
||||||
|
**Why it matters**: PDF requires specific date format
|
||||||
|
**Current project**: Has equivalent but may differ in format
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Legacy
|
||||||
|
export function formatDate(dateString: string, language: 'th' | 'en'): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (language === 'th') {
|
||||||
|
return date.toLocaleDateString('th-TH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
calendar: 'buddhist'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `formatCurrency(amount: number, currencyCode: CurrencyCode): string`
|
||||||
|
**Purpose**: Format currency with Thai locale
|
||||||
|
**Input**: Amount and currency code
|
||||||
|
**Output**: Formatted string (e.g., "฿1,000,000.00")
|
||||||
|
**Why it matters**: PDF price display requires proper formatting
|
||||||
|
**Current project**: May have equivalent but needs verification
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Legacy
|
||||||
|
export function formatCurrency(amount: number, currencyCode: CurrencyCode): string {
|
||||||
|
const symbol = getCurrencySymbol(currencyCode);
|
||||||
|
const formatted = new Intl.NumberFormat('th-TH', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
return `${symbol}${formatted}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 `formatTopicItems(topic: any): string[][]`
|
||||||
|
**Purpose**: Transform topic items to PDFME table format
|
||||||
|
**Input**: Topic object with `items` array
|
||||||
|
**Output**: 2D array where each item is wrapped in array
|
||||||
|
**Why it matters**: PDFME tables require `string[][]` format
|
||||||
|
**Current project**: ❌ **MISSING**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Legacy
|
||||||
|
function formatTopicItems(topic: any): string[][] {
|
||||||
|
if (!topic || !topic.items || topic.items.length === 0) {
|
||||||
|
return [['-']];
|
||||||
|
}
|
||||||
|
return topic.items.map((item: any) => [item.content || '-']);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 `section(labelKey: string, valueKey: string, topic: any, label: string)`
|
||||||
|
**Purpose**: Create label and data table pairs
|
||||||
|
**Input**: Label key, value key, topic data, label text
|
||||||
|
**Output**: Object with two keys mapping to `string[][]`
|
||||||
|
**Why it matters**: Handles topic sections with consistent label/data pattern
|
||||||
|
**Current project**: ❌ **MISSING**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Legacy
|
||||||
|
function section(labelKey: string, valueKey: string, topic: any, label: string) {
|
||||||
|
if (!topic) return {};
|
||||||
|
return {
|
||||||
|
[labelKey]: [[label]],
|
||||||
|
[valueKey]: formatTopicItems(topic)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 `section2(labelKey: string, valueKey: string, topic: any, label: string)`
|
||||||
|
**Purpose**: Handle extra topics (unmapped/dynamic topics)
|
||||||
|
**Input**: Same as `section` but supports arrays
|
||||||
|
**Output**: Object with numbered keys for multiple topics
|
||||||
|
**Why it matters**: Allows unlimited extra topics with dynamic numbering
|
||||||
|
**Current project**: ❌ **MISSING**
|
||||||
|
|
||||||
|
### 5.6 `generateExtraTopicFields(extraTopics: any[]): any[]`
|
||||||
|
**Purpose**: Dynamically generate PDFME schema fields for extra topics
|
||||||
|
**Input**: Array of extra topics
|
||||||
|
**Output**: Array of PDFME field definitions
|
||||||
|
**Why it matters**: Allows extra topics to appear in PDF without template modification
|
||||||
|
**Current project**: ❌ **MISSING**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Legacy Table Data Format
|
||||||
|
|
||||||
|
### 6.1 Topic Tables (Scope, Exclusion, Payment, Warranty, Delivery)
|
||||||
|
**Expected Format**: `string[][]`
|
||||||
|
- Each row is an array of strings
|
||||||
|
- Each item becomes: `[item.content]`
|
||||||
|
- Empty topic becomes: `[["-"]]`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
// Input
|
||||||
|
{
|
||||||
|
topicType: "scope",
|
||||||
|
items: [
|
||||||
|
{ content: "Provide crane" },
|
||||||
|
{ content: "Install at site" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output
|
||||||
|
[
|
||||||
|
["Provide crane"],
|
||||||
|
["Install at site"]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Price Table
|
||||||
|
**Format**: `[[string, string, string, string]]`
|
||||||
|
- Hardcoded 4 columns
|
||||||
|
- Example: `[[\"Price (Exclude VAT)\",\"฿1,000,000.00\",\" \",\"THB\"]]`
|
||||||
|
|
||||||
|
### 6.3 Current Project Table Handling
|
||||||
|
**Issue**: Current `mapDocumentDataToTemplateInput()` returns table data as:
|
||||||
|
- Array of objects for `dataType: 'table'`
|
||||||
|
- May not format as `string[][]` correctly
|
||||||
|
- May not handle single-column tables properly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Legacy Topic Handling
|
||||||
|
|
||||||
|
### 7.1 Product Type Mapping
|
||||||
|
Legacy maps quotation type to topic type keys:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const TOPIC_MAPPING = {
|
||||||
|
crane: {
|
||||||
|
scope: 'scope_of_work',
|
||||||
|
exclusion: 'exclusion_from_scope_of_supply',
|
||||||
|
terms: 'terms_of_payment_upon_progress_of_each_item',
|
||||||
|
delivery: 'delivery_date',
|
||||||
|
warranty: 'warranty'
|
||||||
|
},
|
||||||
|
dockdoor: {
|
||||||
|
scope: 'dock_scope_of_work',
|
||||||
|
exclusion: 'exclusion_of_supply_installation',
|
||||||
|
terms: 'payment_conditions',
|
||||||
|
delivery: 'delivery_date',
|
||||||
|
warranty: 'warranty'
|
||||||
|
},
|
||||||
|
solarcell: {
|
||||||
|
scope: 'solarcell_scope_of_work',
|
||||||
|
exclusion: 'solarcell_exclusion_of_supply_installation',
|
||||||
|
terms: 'solarcell_payment_conditions',
|
||||||
|
delivery: 'solarcell_delivery',
|
||||||
|
warranty: 'solarcell_warranty'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Topic Splitting Logic
|
||||||
|
Legacy splits topics into two categories:
|
||||||
|
1. **Mapped topics**: scope, exclusion, terms, delivery, warranty (based on quotation type)
|
||||||
|
2. **Extra topics**: All other topics (dynamic)
|
||||||
|
|
||||||
|
### 7.3 Topic Processing
|
||||||
|
- Mapped topics use `section()` helper
|
||||||
|
- Extra topics use `section2()` helper with numbering
|
||||||
|
- Extra topics generate dynamic PDFME schema fields
|
||||||
|
|
||||||
|
### 7.4 Current Project Topic Handling
|
||||||
|
**Issues**:
|
||||||
|
- No product type mapping
|
||||||
|
- No topic splitting
|
||||||
|
- No dynamic schema generation
|
||||||
|
- Topics likely treated uniformly, losing type-specific labels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Legacy Approval / Signature Handling
|
||||||
|
|
||||||
|
### 8.1 Approval Fields in Template
|
||||||
|
Template has static signature fields:
|
||||||
|
- `app1`, `app1_position`
|
||||||
|
- `app2`, `app2_position`
|
||||||
|
- `app3`, `app3_position`
|
||||||
|
|
||||||
|
### 8.2 Legacy Population
|
||||||
|
**Status**: Not populated in legacy code
|
||||||
|
- Fields contain static placeholder: `"Type Something..."`
|
||||||
|
- Appears to be manually filled or signature-only
|
||||||
|
- No dynamic data source
|
||||||
|
|
||||||
|
### 8.3 Current Project
|
||||||
|
**Status**: ❌ **NOT MAPPED**
|
||||||
|
- No mapping for approval fields
|
||||||
|
- No source data for signatures
|
||||||
|
- Approval data exists but not connected to PDF
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Current Project Gaps
|
||||||
|
|
||||||
|
### 9.1 Critical Missing Placeholders
|
||||||
|
1. **Topic sections** (scope, exclusion, terms, warranty, delivery)
|
||||||
|
2. **Extra topics** (dynamic topics)
|
||||||
|
3. **Approval signatures**
|
||||||
|
|
||||||
|
### 9.2 Missing Transform Functions
|
||||||
|
1. `formatTopicItems()` - converts topic items to `string[][]`
|
||||||
|
2. `section()` - creates label/data table pairs
|
||||||
|
3. `section2()` - handles extra topics with numbering
|
||||||
|
4. `generateExtraTopicFields()` - dynamic schema generation
|
||||||
|
|
||||||
|
### 9.3 Data Shape Mismatches
|
||||||
|
1. **Tables**: Legacy expects `string[][]`, current may return object arrays
|
||||||
|
2. **Price table**: Legacy uses hardcoded 4-column format
|
||||||
|
3. **Topic items**: Legacy uses `.content` field, current may differ
|
||||||
|
|
||||||
|
### 9.4 Missing Product Type Logic
|
||||||
|
- No product type to topic type mapping
|
||||||
|
- All topics treated the same way
|
||||||
|
- Type-specific labels not applied
|
||||||
|
|
||||||
|
### 9.5 Missing Dynamic Schema Generation
|
||||||
|
- Extra topics cannot appear in PDF
|
||||||
|
- Template schema is static
|
||||||
|
- Cannot handle variable number of topics
|
||||||
|
|
||||||
|
### 9.6 Template Mismatch
|
||||||
|
- Current template has `item_topic` placeholder
|
||||||
|
- Legacy uses `data_topic` with different logic
|
||||||
|
- May need template versioning or migration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Recommended Fix Plan
|
||||||
|
|
||||||
|
### 10.1 Phase 1: Add Missing Transform Functions
|
||||||
|
**Priority**: HIGH
|
||||||
|
|
||||||
|
Create utility functions in `src/features/crm/quotations/document/server/utils.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function formatTopicItems(topic: { items?: Array<{ content?: string }> }): string[][] {
|
||||||
|
if (!topic?.items?.length) {
|
||||||
|
return [['-']];
|
||||||
|
}
|
||||||
|
return topic.items.map((item) => [item.content || '-']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTopicSection(
|
||||||
|
label: string,
|
||||||
|
topic: { items?: Array<{ content?: string }> }
|
||||||
|
): Record<string, string[][]> {
|
||||||
|
return {
|
||||||
|
[`${label}_label`]: [[label]],
|
||||||
|
[`${label}_data`]: formatTopicItems(topic)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Phase 2: Add Product Type Mapping
|
||||||
|
**Priority**: HIGH
|
||||||
|
|
||||||
|
Create mapping configuration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/features/crm/quotations/document/config/topic-mapping.ts
|
||||||
|
const TOPIC_TYPE_MAPPING = {
|
||||||
|
crane: {
|
||||||
|
scope: 'scope_of_work',
|
||||||
|
exclusion: 'exclusion_from_scope_of_supply',
|
||||||
|
payment: 'terms_of_payment_upon_progress_of_each_item',
|
||||||
|
delivery: 'delivery_date',
|
||||||
|
warranty: 'warranty'
|
||||||
|
},
|
||||||
|
dockdoor: {
|
||||||
|
scope: 'dock_scope_of_work',
|
||||||
|
exclusion: 'exclusion_of_supply_installation',
|
||||||
|
payment: 'payment_conditions',
|
||||||
|
delivery: 'delivery_date',
|
||||||
|
warranty: 'warranty'
|
||||||
|
},
|
||||||
|
solarcell: {
|
||||||
|
scope: 'solarcell_scope_of_work',
|
||||||
|
exclusion: 'solarcell_exclusion_of_supply_installation',
|
||||||
|
payment: 'solarcell_payment_conditions',
|
||||||
|
delivery: 'solarcell_delivery',
|
||||||
|
warranty: 'solarcell_warranty'
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
scope: 'scope_of_work',
|
||||||
|
exclusion: 'exclusion_from_supply_installation',
|
||||||
|
payment: 'payment_conditions',
|
||||||
|
delivery: 'delivery',
|
||||||
|
warranty: 'warranty'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Phase 3: Update Document Data Builder
|
||||||
|
**Priority**: HIGH
|
||||||
|
|
||||||
|
Modify `buildQuotationDocumentData()` in `document/server/service.ts`:
|
||||||
|
|
||||||
|
1. Add product type detection from quotation
|
||||||
|
2. Split topics into mapped and extra categories
|
||||||
|
3. Format topic items as `string[][]`
|
||||||
|
4. Add approval/signature data
|
||||||
|
|
||||||
|
### 10.4 Phase 4: Fix Table Data Formatting
|
||||||
|
**Priority**: MEDIUM
|
||||||
|
|
||||||
|
Update `mapDocumentDataToTemplateInput()` in `document-template/server/service.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (mapping.dataType === 'table') {
|
||||||
|
const rows = Array.isArray(rawValue) ? rawValue : [];
|
||||||
|
|
||||||
|
// Check if it's already string[][] format
|
||||||
|
if (rows.length > 0 && Array.isArray(rows[0])) {
|
||||||
|
result[mapping.placeholderKey] = rows;
|
||||||
|
} else {
|
||||||
|
// Convert objects to string[][]
|
||||||
|
result[mapping.placeholderKey] = rows.map((row) => {
|
||||||
|
const rowRecord = row as Record<string, unknown>;
|
||||||
|
return mapping.columns.reduce<string[]>((acc, col) => {
|
||||||
|
acc.push(String(applyFormatMask(rowRecord[col.sourceField], col.formatMask) || ''));
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.5 Phase 5: Update Template Mappings
|
||||||
|
**Priority**: HIGH
|
||||||
|
|
||||||
|
Add mappings for missing placeholders in database:
|
||||||
|
|
||||||
|
1. `exclusion_label` - static text
|
||||||
|
2. `exclusion_data` - topic data as table
|
||||||
|
3. `Terms_of_payment` - static text
|
||||||
|
4. `data_term_of_payment` - topic data as table
|
||||||
|
5. `warranty` - static text
|
||||||
|
6. `data_warranty` - topic data as table
|
||||||
|
7. `Delivery` - static text
|
||||||
|
8. `data_delivery` - topic data as table
|
||||||
|
9. `Affter service` - static text
|
||||||
|
10. `data_affter_service` - topic data as table
|
||||||
|
11. `data_topic` - general topic data
|
||||||
|
12. Approval fields
|
||||||
|
|
||||||
|
### 10.6 Phase 6: Add Approval Signature Data
|
||||||
|
**Priority**: MEDIUM
|
||||||
|
|
||||||
|
Extract approval workflow data and map to signature fields:
|
||||||
|
- `app1` = Sales Manager (from approval timeline)
|
||||||
|
- `app2` = Department Manager (from approval timeline)
|
||||||
|
- `app3` = Top Manager (from approval timeline)
|
||||||
|
- Position fields from user profiles or roles
|
||||||
|
|
||||||
|
### 10.7 Phase 7: Template Verification
|
||||||
|
**Priority**: HIGH
|
||||||
|
|
||||||
|
Verify current template matches legacy:
|
||||||
|
1. Check all placeholder names match
|
||||||
|
2. Verify field positions match
|
||||||
|
3. Ensure font names match (`cordia`, `cordiaBold`)
|
||||||
|
4. Validate table structures
|
||||||
|
|
||||||
|
### 10.8 Phase 8: Dynamic Schema Generation
|
||||||
|
**Priority**: LOW (Future Enhancement)
|
||||||
|
|
||||||
|
Implement dynamic schema generation for extra topics:
|
||||||
|
1. Generate PDFME field definitions
|
||||||
|
2. Inject into template schema
|
||||||
|
3. Calculate positioning dynamically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Risk / Notes
|
||||||
|
|
||||||
|
### 11.1 Risks
|
||||||
|
1. **Template Compatibility**: Current template may have different placeholder names or structure
|
||||||
|
2. **Data Migration**: Existing quotations may not have topic data in correct format
|
||||||
|
3. **Backward Compatibility**: Changes may break existing PDF generation
|
||||||
|
4. **Font Issues**: Cordia fonts may not be available in production
|
||||||
|
|
||||||
|
### 11.2 Notes
|
||||||
|
1. **Legacy Template Location**:
|
||||||
|
- ALLA: `src/features/quotations/pdfme/ALLA_template_pdfme_fainal3.json`
|
||||||
|
- ONVALLA: `src/features/quotations/pdfme/ONVALLA_template_pdfme_fainal3.json`
|
||||||
|
2. **Current Template**: `src/pdfme_template/ALLA_template_pdfme_fainal3.json`
|
||||||
|
3. **Template Version**: Current template may be different version from legacy
|
||||||
|
4. **Topic Data Model**: Current topics structure may need adjustment to match legacy expectations
|
||||||
|
5. **Approval Data**: Current approval system is more complex than legacy, need to map correctly
|
||||||
|
|
||||||
|
### 11.3 Recommendations
|
||||||
|
1. Create a migration script to convert existing topic data to new format
|
||||||
|
2. Implement comprehensive PDF preview to verify all fields
|
||||||
|
3. Add unit tests for each transform function
|
||||||
|
4. Document all placeholder mappings in a central configuration
|
||||||
|
5. Consider versioning PDFME templates to handle changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Conclusion
|
||||||
|
|
||||||
|
The legacy PDFME implementation uses a specialized, direct mapping approach with inline transforms that ensure correct data formatting. The current implementation uses a more generic mapping system that, while more flexible, is missing critical transform logic and placeholder mappings.
|
||||||
|
|
||||||
|
**Primary Issues**:
|
||||||
|
1. Missing topic section mappings (scope, exclusion, terms, warranty, delivery)
|
||||||
|
2. Missing `formatTopicItems()` transform function
|
||||||
|
3. No support for extra topics
|
||||||
|
4. No product type to topic type mapping
|
||||||
|
5. Template placeholder mismatch (`item_topic` vs `data_topic`)
|
||||||
|
6. Missing approval signature mappings
|
||||||
|
|
||||||
|
**Estimated Effort**: 2-3 days to implement all fixes and test thoroughly.
|
||||||
|
|
||||||
|
**Next Steps**:
|
||||||
|
1. Implement transform functions (Phase 1)
|
||||||
|
2. Add topic mappings (Phase 2-3)
|
||||||
|
3. Update template mappings in database (Phase 5)
|
||||||
|
4. Test with real quotation data
|
||||||
|
5. Compare output with legacy PDFs
|
||||||
910
docs/implementation/pdfme-runtime-audit.md
Normal file
910
docs/implementation/pdfme-runtime-audit.md
Normal file
@@ -0,0 +1,910 @@
|
|||||||
|
# PDFME Runtime Audit Report
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This audit compares the current PDFME template and mapping system against the legacy implementation and identifies runtime issues that cause incomplete or incorrectly mapped data in generated quotation PDFs.
|
||||||
|
|
||||||
|
**Key Finding**: The current implementation has significant gaps in placeholder mappings, missing topic sections, and incorrect data formats that prevent PDFME from displaying complete quotation information.
|
||||||
|
|
||||||
|
**Total Template Placeholders**: 20
|
||||||
|
**Total Required Mappings**: 20
|
||||||
|
**Missing Mappings**: 8
|
||||||
|
**Critical Issues**: 5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Template Analysis
|
||||||
|
|
||||||
|
### 1.1 Current Template File
|
||||||
|
**Path**: `src/pdfme_template/ALLA_template_pdfme_fainal3.json`
|
||||||
|
|
||||||
|
### 1.2 Placeholder Extraction
|
||||||
|
|
||||||
|
The following placeholders were extracted from the current template schema:
|
||||||
|
|
||||||
|
| Placeholder Key | Field Type | Content Pattern | Position (Page 1) |
|
||||||
|
|-----------------|------------|-----------------|-------------------|
|
||||||
|
| `customer_name` | text | `{customer_name}` | Header |
|
||||||
|
| `customer_addr` | text | `{customer_addr}` | Header |
|
||||||
|
| `customer_tel` | text | `{customer_tel}` | Header |
|
||||||
|
| `customer_email` | text | `{customer_email}` | Header |
|
||||||
|
| `customer_att` | text | `{customer_att}` | Header |
|
||||||
|
| `project_name` | text | `{project_name}` | Header |
|
||||||
|
| `site_location` | text | `{site_location}` | Header |
|
||||||
|
| `quotation_date` | text | `{quotation_date}` | Header |
|
||||||
|
| `quotation_code` | text | `{quotation_code}` | Header |
|
||||||
|
| `quotation_price` | table (cell) | `{quotation_price}` | Price section |
|
||||||
|
| `currency` | table (cell) | `{currency}` | Price section |
|
||||||
|
| `exclusion_data` | table | `[[\"{exclusion_data}\"]]` | Exclusion section |
|
||||||
|
|
||||||
|
**Static Content (No Mapping Required)**:
|
||||||
|
- Company header (company1, company2, company_addr1, company_addr2, company_tel, company_email, company_tax)
|
||||||
|
- Labels (Date, Quotation No, Tel, Email, etc.)
|
||||||
|
- Colons and separators
|
||||||
|
- "Dear sirs" text
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Runtime Audit Results
|
||||||
|
|
||||||
|
### 2.1 Placeholder Mapping Status
|
||||||
|
|
||||||
|
| Placeholder | Template Exists | Mapping Exists | Source Path | Expected Type | Actual Type | Status |
|
||||||
|
|-------------|-----------------|----------------|-------------|---------------|-------------|--------|
|
||||||
|
| `customer_name` | ✅ | ✅ | `customer.name` | string | string | ✅ OK |
|
||||||
|
| `customer_addr` | ✅ | ✅ | `customer.address` | string | string | ✅ OK |
|
||||||
|
| `customer_tel` | ✅ | ✅ | `customer.contacts[primary].phone` | string | string | ✅ OK |
|
||||||
|
| `customer_email` | ✅ | ✅ | `customer.contacts[primary].email` | string | string | ✅ OK |
|
||||||
|
| `customer_att` | ✅ | ✅ | `quotation.attention` | string | string | ✅ OK |
|
||||||
|
| `project_name` | ✅ | ✅ | `quotation.projectName` | string | string | ✅ OK |
|
||||||
|
| `site_location` | ✅ | ✅ | `quotation.projectLocation` | string | string | ✅ OK |
|
||||||
|
| `quotation_date` | ✅ | ✅ | `quotation.quotationDate` | string | string | ⚠️ **Check Format** |
|
||||||
|
| `quotation_code` | ✅ | ✅ | `quotation.code` | string | string | ✅ OK |
|
||||||
|
| `quotation_price` | ✅ | ✅ | `quotation.totalAmount` | string (formatted) | number | ⚠️ **Type Mismatch** |
|
||||||
|
| `currency` | ✅ | ✅ | `quotation.currency` | string | string | ✅ OK |
|
||||||
|
| `exclusion_data` | ✅ | ❌ | - | `string[][]` | undefined | ❌ **MISSING** |
|
||||||
|
|
||||||
|
### 2.2 Legacy Placeholders Not in Current Template
|
||||||
|
|
||||||
|
The following placeholders existed in the legacy implementation but are **not present** in the current template:
|
||||||
|
|
||||||
|
| Placeholder | Legacy Purpose | Status |
|
||||||
|
|-------------|----------------|--------|
|
||||||
|
| `exclusion_label` | Topic label (static) | ❌ **Not in template** |
|
||||||
|
| `Terms_of_payment` | Payment topic label | ❌ **Not in template** |
|
||||||
|
| `data_term_of_payment` | Payment topic data | ❌ **Not in template** |
|
||||||
|
| `warranty` | Warranty topic label | ❌ **Not in template** |
|
||||||
|
| `data_warranty` | Warranty topic data | ❌ **Not in template** |
|
||||||
|
| `Delivery` | Delivery topic label | ❌ **Not in template** |
|
||||||
|
| `data_delivery` | Delivery topic data | ❌ **Not in template** |
|
||||||
|
| `Affter service` | After-service topic label | ❌ **Not in template** |
|
||||||
|
| `data_affter_service` | After-service topic data | ❌ **Not in template** |
|
||||||
|
| `topic` | General topic label | ❌ **Not in template** |
|
||||||
|
| `data_topic` | General topic data | ❌ **Not in template** |
|
||||||
|
| `app1` | Approver 1 name | ❌ **Not in template** |
|
||||||
|
| `app1_position` | Approver 1 position | ❌ **Not in template** |
|
||||||
|
| `app2` | Approver 2 name | ❌ **Not in template** |
|
||||||
|
| `app2_position` | Approver 2 position | ❌ **Not in template** |
|
||||||
|
| `app3` | Approver 3 name | ❌ **Not in template** |
|
||||||
|
| `app3_position` | Approver 3 position | ❌ **Not in template** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Critical Issues
|
||||||
|
|
||||||
|
### 3.1 Issue: Missing Exclusion Data Mapping
|
||||||
|
|
||||||
|
**Severity**: HIGH
|
||||||
|
**Placeholder**: `exclusion_data`
|
||||||
|
**Template Format**: `[[\"{exclusion_data}\"]]` (table with single column)
|
||||||
|
**Expected Data Type**: `string[][]`
|
||||||
|
**Current Status**: No mapping exists
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Template expects exclusion topic items to be displayed as a table
|
||||||
|
- Legacy implementation used `formatTopicItems()` to convert topic items to `string[][]` format
|
||||||
|
- Current implementation has no mapping for this placeholder
|
||||||
|
- Exclusion topic data exists in database but is not passed to PDFME
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Exclusion section will be empty or show placeholder text
|
||||||
|
- Critical business information missing from quotation PDF
|
||||||
|
|
||||||
|
**Legacy Implementation**:
|
||||||
|
```typescript
|
||||||
|
// Legacy: src/features/quotations/lib/pdfme-export.ts
|
||||||
|
function formatTopicItems(topic: any): string[][] {
|
||||||
|
if (!topic || !topic.items || topic.items.length === 0) {
|
||||||
|
return [['-']];
|
||||||
|
}
|
||||||
|
return topic.items.map((item: any) => [item.content || '-']);
|
||||||
|
}
|
||||||
|
|
||||||
|
const termsTopic = topics.find(t => t.topicType === 'exclusion_from_scope_of_supply');
|
||||||
|
data.exclusion_data = formatTopicItems(termsTopic);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Fix**:
|
||||||
|
1. Add mapping for `exclusion_data` placeholder
|
||||||
|
2. Transform topic items to `string[][]` format
|
||||||
|
3. Handle empty topics with fallback `[['-']]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Issue: Missing Topic Sections
|
||||||
|
|
||||||
|
**Severity**: HIGH
|
||||||
|
**Affected Placeholders**:
|
||||||
|
- `Terms_of_payment`, `data_term_of_payment`
|
||||||
|
- `warranty`, `data_warranty`
|
||||||
|
- `Delivery`, `data_delivery`
|
||||||
|
- `Affter service`, `data_affter_service`
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Legacy template had separate sections for each topic type
|
||||||
|
- Current template only has exclusion section
|
||||||
|
- Payment, warranty, delivery, and after-service topics are not displayed
|
||||||
|
- These are critical business terms in quotations
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Payment terms not visible to customer
|
||||||
|
- Warranty information missing
|
||||||
|
- Delivery details not specified
|
||||||
|
- After-service information absent
|
||||||
|
|
||||||
|
**Legacy Structure**:
|
||||||
|
```
|
||||||
|
Topic: Scope of Work
|
||||||
|
[data_scope]
|
||||||
|
|
||||||
|
Topic: Exclusion from scope of Supply:
|
||||||
|
[data_exclusion]
|
||||||
|
|
||||||
|
Terms of payment upon progress of each item:
|
||||||
|
[data_term_of_payment]
|
||||||
|
|
||||||
|
Warranty:
|
||||||
|
[data_warranty]
|
||||||
|
|
||||||
|
Delivery:
|
||||||
|
[data_delivery]
|
||||||
|
|
||||||
|
Affter service:
|
||||||
|
[data_affter_service]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current Template Structure**:
|
||||||
|
```
|
||||||
|
Quotation details...
|
||||||
|
|
||||||
|
Exclusion from scope of Supply:
|
||||||
|
[exclusion_data]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Fix**:
|
||||||
|
1. Add topic section fields to template OR
|
||||||
|
2. Merge all topics into a single table with labels
|
||||||
|
3. Implement topic filtering based on product type
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Issue: Currency Format Mismatch
|
||||||
|
|
||||||
|
**Severity**: MEDIUM
|
||||||
|
**Placeholder**: `quotation_price`
|
||||||
|
**Template Usage**: `[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]`
|
||||||
|
**Expected Type**: Formatted string (e.g., "฿1,000,000.00")
|
||||||
|
**Actual Type**: Number (from database)
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Database stores `quotation.totalAmount` as number (double precision)
|
||||||
|
- PDFME table cell expects formatted currency string
|
||||||
|
- Current mapping likely passes raw number without formatting
|
||||||
|
- Thai locale formatting required (฿ symbol, thousand separators, 2 decimal places)
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Price may display as "1000000" instead of "฿1,000,000.00"
|
||||||
|
- Unprofessional appearance
|
||||||
|
- Potential customer confusion
|
||||||
|
|
||||||
|
**Legacy Implementation**:
|
||||||
|
```typescript
|
||||||
|
function formatCurrency(amount: number, currencyCode: CurrencyCode): string {
|
||||||
|
const symbol = getCurrencySymbol(currencyCode);
|
||||||
|
const formatted = new Intl.NumberFormat('th-TH', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
return `${symbol}${formatted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.quotation_price = formatCurrency(quotation.totalAmount, quotation.currency);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Fix**:
|
||||||
|
1. Add currency formatting transform
|
||||||
|
2. Use `formatMask` in mapping or custom transform function
|
||||||
|
3. Support multiple currency codes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 Issue: Date Format
|
||||||
|
|
||||||
|
**Severity**: MEDIUM
|
||||||
|
**Placeholder**: `quotation_date`
|
||||||
|
**Expected Format**: Localized date string (Thai: Buddhist calendar or English: Gregorian)
|
||||||
|
**Current Format**: ISO date string
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Database stores dates as ISO 8601 strings (e.g., "2026-06-17T00:00:00.000Z")
|
||||||
|
- PDF displays raw ISO string instead of formatted date
|
||||||
|
- Legacy implementation had language-aware date formatting
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Unprofessional appearance
|
||||||
|
- May cause confusion about quotation validity date
|
||||||
|
- Doesn't match business requirements for Thai/English date formats
|
||||||
|
|
||||||
|
**Legacy Implementation**:
|
||||||
|
```typescript
|
||||||
|
function formatDate(dateString: string, language: 'th' | 'en'): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (language === 'th') {
|
||||||
|
return date.toLocaleDateString('th-TH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
calendar: 'buddhist'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Fix**:
|
||||||
|
1. Add date formatting transform
|
||||||
|
2. Support Thai and English locales
|
||||||
|
3. Configure format mask in mapping
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 Issue: Missing Product Type Topic Mapping
|
||||||
|
|
||||||
|
**Severity**: HIGH
|
||||||
|
**Legacy Behavior**: Different topic types mapped based on quotation product type
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Legacy implementation had product-specific topic mappings:
|
||||||
|
- **Crane**: scope_of_work, exclusion_from_scope_of_supply, terms_of_payment_upon_progress_of_each_item, delivery_date, warranty
|
||||||
|
- **Dockdoor**: dock_scope_of_work, exclusion_of_supply_installation, payment_conditions, delivery_date, warranty
|
||||||
|
- **Solarcell**: solarcell_scope_of_work, solarcell_exclusion_of_supply_installation, solarcell_payment_conditions, solarcell_delivery, solarcell_warranty
|
||||||
|
- Current implementation treats all topics uniformly
|
||||||
|
- Topic type labels may not match product type
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Wrong topic labels for different product types
|
||||||
|
- Inconsistent quotation formatting
|
||||||
|
- Business process violation
|
||||||
|
|
||||||
|
**Required Fix**:
|
||||||
|
1. Implement product type to topic type mapping
|
||||||
|
2. Apply correct labels based on quotation type
|
||||||
|
3. Support dynamic topic resolution
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Format Analysis
|
||||||
|
|
||||||
|
### 4.1 Table Data Formats
|
||||||
|
|
||||||
|
#### Exclusion Data Table
|
||||||
|
|
||||||
|
**Template Content**:
|
||||||
|
```json
|
||||||
|
"content": "[[\"{exclusion_data}\"]]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Format**: `string[][]`
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
[
|
||||||
|
["Provide crane and installation service"],
|
||||||
|
["Site survey and installation planning"],
|
||||||
|
["Operator training (2 days)"]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Legacy Transform**:
|
||||||
|
```typescript
|
||||||
|
function formatTopicItems(topic: any): string[][] {
|
||||||
|
if (!topic || !topic.items || topic.items.length === 0) {
|
||||||
|
return [['-']];
|
||||||
|
}
|
||||||
|
return topic.items.map((item: any) => [item.content || '-']);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current Status**: ❌ Not implemented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Price Table
|
||||||
|
|
||||||
|
**Template Content**:
|
||||||
|
```json
|
||||||
|
"content": "[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Format**: `string[][]` (4 columns)
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
[
|
||||||
|
["Price (Exclude VAT)", "฿1,000,000.00", " ", "THB"]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Legacy Transform**:
|
||||||
|
```typescript
|
||||||
|
data.quotation_price = formatCurrency(quotation.totalAmount, quotation.currency);
|
||||||
|
data.currency = quotation.currency;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current Status**: ⚠️ Number passed instead of formatted string
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Text Data Formats
|
||||||
|
|
||||||
|
#### Customer Contact Information
|
||||||
|
|
||||||
|
**Legacy Prefixes**:
|
||||||
|
- Tel: `Tel : {customer_tel}`
|
||||||
|
- Email: `Email : {customer_email}`
|
||||||
|
|
||||||
|
**Current Template**: Labels and data are separate fields
|
||||||
|
- Label: "Tel" (static)
|
||||||
|
- Data: `{customer_tel}` (placeholder)
|
||||||
|
|
||||||
|
**Status**: ✅ OK (modern approach)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Current Implementation Gaps
|
||||||
|
|
||||||
|
### 5.1 Missing Transform Functions
|
||||||
|
|
||||||
|
| Function | Purpose | Current Status |
|
||||||
|
|----------|---------|----------------|
|
||||||
|
| `formatTopicItems()` | Convert topic items to `string[][]` | ❌ Missing |
|
||||||
|
| `formatCurrency()` | Format currency with locale | ⚠️ May exist but not applied |
|
||||||
|
| `formatDate()` | Format date with locale | ⚠️ May exist but not applied |
|
||||||
|
| `getCurrencySymbol()` | Get currency symbol | ❌ Missing |
|
||||||
|
| `section()` | Create label/data pairs for topics | ❌ Missing |
|
||||||
|
| `productTypeTopicMapping` | Map product type to topic types | ❌ Missing |
|
||||||
|
|
||||||
|
### 5.2 Missing Database Mappings
|
||||||
|
|
||||||
|
The following mappings should exist in `crm_document_template_mappings` table:
|
||||||
|
|
||||||
|
| placeholder_key | source_path | data_type | format_mask | status |
|
||||||
|
|-----------------|-------------|-----------|-------------|--------|
|
||||||
|
| `exclusion_data` | `topics.exclusion_from_scope_of_supply.items` | table | - | ❌ Missing |
|
||||||
|
| `quotation_price` | `quotation.totalAmount` | scalar | currency_THB | ⚠️ Needs format |
|
||||||
|
| `quotation_date` | `quotation.quotationDate` | scalar | date_th | ⚠️ Needs format |
|
||||||
|
|
||||||
|
### 5.3 Data Builder Issues
|
||||||
|
|
||||||
|
**File**: `src/features/crm/quotations/document/server/service.ts`
|
||||||
|
|
||||||
|
**Issues**:
|
||||||
|
1. `buildQuotationDocumentData()` does not transform topic items to `string[][]`
|
||||||
|
2. Does not format currency or dates
|
||||||
|
3. Does not apply product type topic mapping
|
||||||
|
4. Does not handle empty topics with fallbacks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Comparison with Legacy
|
||||||
|
|
||||||
|
### 6.1 Legacy Flow
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
generateQuotationPDF(quotation, customer, topics)
|
||||||
|
↓
|
||||||
|
mapDataToTemplate() // Direct mapping with transforms
|
||||||
|
↓
|
||||||
|
formatDate() // Thai/English date
|
||||||
|
↓
|
||||||
|
formatCurrency() // Thai locale currency
|
||||||
|
↓
|
||||||
|
formatTopicItems() // Convert to string[][]
|
||||||
|
↓
|
||||||
|
generateExtraTopicFields() // Dynamic schema
|
||||||
|
↓
|
||||||
|
pdfme.generate(template, inputs)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Current Flow
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
buildQuotationDocumentData() // Fetch data
|
||||||
|
↓
|
||||||
|
mapDocumentDataToTemplateInput() // Generic mapping
|
||||||
|
↓
|
||||||
|
applyFormatMask() // Simple format masks only
|
||||||
|
↓
|
||||||
|
pdfme.generate(template, inputs)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Key Differences
|
||||||
|
|
||||||
|
| Aspect | Legacy | Current | Gap |
|
||||||
|
|--------|--------|---------|-----|
|
||||||
|
| Mapping Approach | Direct with inline transforms | Generic via database | Transform logic |
|
||||||
|
| Topic Formatting | `string[][]` via `formatTopicItems()` | Unknown | Format mismatch |
|
||||||
|
| Currency Formatting | `formatCurrency()` with locale | Basic format mask | Missing locale |
|
||||||
|
| Date Formatting | `formatDate()` with locale | Basic format mask | Missing locale |
|
||||||
|
| Product Type | Product-specific topic mapping | None | Wrong labels |
|
||||||
|
| Dynamic Topics | `generateExtraTopicFields()` | Static schema | No extra topics |
|
||||||
|
| Fallback Values | Inline defaults in mapping | `defaultValue` column | May not cover all cases |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Recommended Fixes
|
||||||
|
|
||||||
|
### 7.1 Immediate Fixes (P0)
|
||||||
|
|
||||||
|
#### Fix 1: Add Exclusion Data Mapping
|
||||||
|
|
||||||
|
**Add to `crm_document_template_mappings`**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO crm_document_template_mappings (
|
||||||
|
id, organization_id, template_version_id, placeholder_key,
|
||||||
|
source_path, data_type, sort_order
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
'<organization_id>',
|
||||||
|
'<template_version_id>',
|
||||||
|
'exclusion_data',
|
||||||
|
'topics.exclusion_from_scope_of_supply.items',
|
||||||
|
'table',
|
||||||
|
10
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add transform function** in `src/features/crm/quotations/document/server/utils.ts`:
|
||||||
|
```typescript
|
||||||
|
export function formatTopicItems(
|
||||||
|
topic: { items?: Array<{ content?: string }> } | undefined
|
||||||
|
): string[][] {
|
||||||
|
if (!topic?.items || topic.items.length === 0) {
|
||||||
|
return [['-']];
|
||||||
|
}
|
||||||
|
return topic.items.map((item) => [item.content || '-']);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `buildQuotationDocumentData()`**:
|
||||||
|
```typescript
|
||||||
|
const exclusionTopic = topics.find(
|
||||||
|
t => t.topicType === 'exclusion_from_scope_of_supply'
|
||||||
|
);
|
||||||
|
data.exclusion_data = formatTopicItems(exclusionTopic);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Fix 2: Format Currency
|
||||||
|
|
||||||
|
**Update mapping for `quotation_price`**:
|
||||||
|
```sql
|
||||||
|
UPDATE crm_document_template_mappings
|
||||||
|
SET format_mask = 'currency_THB'
|
||||||
|
WHERE placeholder_key = 'quotation_price';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add currency formatter** in `src/features/crm/quotations/document/server/utils.ts`:
|
||||||
|
```typescript
|
||||||
|
export function formatCurrency(
|
||||||
|
amount: number,
|
||||||
|
currencyCode: string = 'THB'
|
||||||
|
): string {
|
||||||
|
const symbols: Record<string, string> = {
|
||||||
|
THB: '฿',
|
||||||
|
USD: '$',
|
||||||
|
EUR: '€'
|
||||||
|
};
|
||||||
|
const symbol = symbols[currencyCode] || currencyCode;
|
||||||
|
const formatted = new Intl.NumberFormat('th-TH', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
return `${symbol}${formatted}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `mapDocumentDataToTemplateInput()`** in `document-template/server/service.ts`:
|
||||||
|
```typescript
|
||||||
|
if (mapping.formatMask?.startsWith('currency_')) {
|
||||||
|
const currencyCode = mapping.formatMask.replace('currency_', '');
|
||||||
|
result[mapping.placeholderKey] = formatCurrency(
|
||||||
|
rawValue as number,
|
||||||
|
currencyCode
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Fix 3: Format Date
|
||||||
|
|
||||||
|
**Update mapping for `quotation_date`**:
|
||||||
|
```sql
|
||||||
|
UPDATE crm_document_template_mappings
|
||||||
|
SET format_mask = 'date_th'
|
||||||
|
WHERE placeholder_key = 'quotation_date';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add date formatter** in `src/features/crm/quotations/document/server/utils.ts`:
|
||||||
|
```typescript
|
||||||
|
export function formatDate(
|
||||||
|
dateString: string,
|
||||||
|
language: 'th' | 'en' = 'th'
|
||||||
|
): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (language === 'th') {
|
||||||
|
return date.toLocaleDateString('th-TH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
calendar: 'buddhist'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `mapDocumentDataToTemplateInput()`**:
|
||||||
|
```typescript
|
||||||
|
if (mapping.formatMask?.startsWith('date_')) {
|
||||||
|
const language = mapping.formatMask.replace('date_', '') as 'th' | 'en';
|
||||||
|
result[mapping.placeholderKey] = formatDate(
|
||||||
|
rawValue as string,
|
||||||
|
language
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2 Template Updates (P0)
|
||||||
|
|
||||||
|
#### Option A: Add Missing Topic Sections to Template
|
||||||
|
|
||||||
|
Add the following fields to the template schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "payment_label",
|
||||||
|
"type": "table",
|
||||||
|
"content": "[[\"Terms of payment upon progress of each item:\"]]",
|
||||||
|
"position": { "x": 10, "y": 220 },
|
||||||
|
"width": 190,
|
||||||
|
"height": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "payment_data",
|
||||||
|
"type": "table",
|
||||||
|
"content": "[[\"{payment_data}\"]]",
|
||||||
|
"position": { "x": 15, "y": 230 },
|
||||||
|
"width": 185,
|
||||||
|
"height": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "warranty_label",
|
||||||
|
"type": "table",
|
||||||
|
"content": "[[\"Warranty:\"]]",
|
||||||
|
"position": { "x": 10, "y": 250 },
|
||||||
|
"width": 190,
|
||||||
|
"height": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "warranty_data",
|
||||||
|
"type": "table",
|
||||||
|
"content": "[[\"{warranty_data}\"]]",
|
||||||
|
"position": { "x": 15, "y": 260 },
|
||||||
|
"width": 185,
|
||||||
|
"height": 7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B: Consolidate Topics into Single Table
|
||||||
|
|
||||||
|
Create a unified topics table with labels:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "topics_table",
|
||||||
|
"type": "table",
|
||||||
|
"content": "[{topics_data}]",
|
||||||
|
"position": { "x": 10, "y": 195 },
|
||||||
|
"width": 190,
|
||||||
|
"height": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `topics_data` is a structured array:
|
||||||
|
```typescript
|
||||||
|
[
|
||||||
|
["Exclusion from scope of Supply:", "Provide crane, Site survey"],
|
||||||
|
["Terms of payment:", "30% deposit, 70% on delivery"],
|
||||||
|
["Warranty:", "12 months parts and labor"],
|
||||||
|
["Delivery:", "30 days after order confirmation"],
|
||||||
|
["After service:", "Annual maintenance available"]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.3 Product Type Mapping (P1)
|
||||||
|
|
||||||
|
**Create configuration** in `src/features/crm/quotations/document/config/topic-mapping.ts`:
|
||||||
|
```typescript
|
||||||
|
export const TOPIC_TYPE_MAPPING: Record<string, Record<string, string>> = {
|
||||||
|
crane: {
|
||||||
|
scope: 'scope_of_work',
|
||||||
|
exclusion: 'exclusion_from_scope_of_supply',
|
||||||
|
payment: 'terms_of_payment_upon_progress_of_each_item',
|
||||||
|
delivery: 'delivery_date',
|
||||||
|
warranty: 'warranty'
|
||||||
|
},
|
||||||
|
dockdoor: {
|
||||||
|
scope: 'dock_scope_of_work',
|
||||||
|
exclusion: 'exclusion_of_supply_installation',
|
||||||
|
payment: 'payment_conditions',
|
||||||
|
delivery: 'delivery_date',
|
||||||
|
warranty: 'warranty'
|
||||||
|
},
|
||||||
|
solarcell: {
|
||||||
|
scope: 'solarcell_scope_of_work',
|
||||||
|
exclusion: 'solarcell_exclusion_of_supply_installation',
|
||||||
|
payment: 'solarcell_payment_conditions',
|
||||||
|
delivery: 'solarcell_delivery',
|
||||||
|
warranty: 'solarcell_warranty'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `buildQuotationDocumentData()`** to use product type:
|
||||||
|
```typescript
|
||||||
|
const mapping = TOPIC_TYPE_MAPPING[quotation.productType] || TOPIC_TYPE_MAPPING.crane;
|
||||||
|
const exclusionTopic = topics.find(t => t.topicType === mapping.exclusion);
|
||||||
|
data.exclusion_data = formatTopicItems(exclusionTopic);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risk Assessment
|
||||||
|
|
||||||
|
### 8.1 High Risk
|
||||||
|
|
||||||
|
| Risk | Impact | Probability | Mitigation |
|
||||||
|
|------|--------|-------------|------------|
|
||||||
|
| Missing exclusion data | High customer confusion | 100% | Add mapping immediately |
|
||||||
|
| Missing payment terms | Legal/compliance issue | 100% | Add to template |
|
||||||
|
| Wrong currency format | Unprofessional appearance | 90% | Add currency formatter |
|
||||||
|
| Wrong date format | Date confusion | 90% | Add date formatter |
|
||||||
|
|
||||||
|
### 8.2 Medium Risk
|
||||||
|
|
||||||
|
| Risk | Impact | Probability | Mitigation |
|
||||||
|
|------|--------|-------------|------------|
|
||||||
|
| Missing product type mapping | Inconsistent formatting | 80% | Implement product mapping |
|
||||||
|
| Missing warranty info | Customer expectations | 70% | Add to template |
|
||||||
|
| Missing delivery info | Order confusion | 70% | Add to template |
|
||||||
|
|
||||||
|
### 8.3 Low Risk
|
||||||
|
|
||||||
|
| Risk | Impact | Probability | Mitigation |
|
||||||
|
|------|--------|-------------|------------|
|
||||||
|
| Missing signatures | Process compliance | 50% | Add approval workflow |
|
||||||
|
| Static company info | Outdated info | 30% | Make dynamic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Testing Recommendations
|
||||||
|
|
||||||
|
### 9.1 Unit Tests
|
||||||
|
|
||||||
|
Create tests for transform functions:
|
||||||
|
```typescript
|
||||||
|
// tests/features/crm/quotations/document/utils.test.ts
|
||||||
|
describe('formatTopicItems', () => {
|
||||||
|
it('should format topic items to string[][]', () => {
|
||||||
|
const topic = {
|
||||||
|
items: [
|
||||||
|
{ content: 'Item 1' },
|
||||||
|
{ content: 'Item 2' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
expect(formatTopicItems(topic)).toEqual([
|
||||||
|
['Item 1'],
|
||||||
|
['Item 2']
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback for empty topic', () => {
|
||||||
|
expect(formatTopicItems(undefined)).toEqual([['-']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Integration Tests
|
||||||
|
|
||||||
|
Test end-to-end PDF generation:
|
||||||
|
```typescript
|
||||||
|
describe('Quotation PDF Generation', () => {
|
||||||
|
it('should generate PDF with all fields populated', async () => {
|
||||||
|
const quotation = await createTestQuotation();
|
||||||
|
const pdf = await generateQuotationPDF(quotation.id);
|
||||||
|
|
||||||
|
// Verify placeholders are replaced
|
||||||
|
expect(pdf).toContain('CUSTOMER NAME');
|
||||||
|
expect(pdf).not.toContain('{customer_name}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Visual Regression Tests
|
||||||
|
|
||||||
|
Compare generated PDFs with legacy PDFs:
|
||||||
|
1. Generate PDF from legacy implementation
|
||||||
|
2. Generate PDF from current implementation
|
||||||
|
3. Compare pixel-by-pixel
|
||||||
|
4. Identify visual differences
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementation Priority
|
||||||
|
|
||||||
|
### Phase 1: Critical Fixes (1-2 days)
|
||||||
|
1. ✅ Add exclusion data mapping
|
||||||
|
2. ✅ Implement `formatTopicItems()`
|
||||||
|
3. ✅ Format currency
|
||||||
|
4. ✅ Format dates
|
||||||
|
5. ✅ Test with real quotation
|
||||||
|
|
||||||
|
### Phase 2: Template Updates (1 day)
|
||||||
|
1. Add missing topic sections (payment, warranty, delivery)
|
||||||
|
2. Update template layout
|
||||||
|
3. Add mappings for new fields
|
||||||
|
|
||||||
|
### Phase 3: Product Type Logic (1 day)
|
||||||
|
1. Implement product type mapping
|
||||||
|
2. Update topic resolution
|
||||||
|
3. Test different product types
|
||||||
|
|
||||||
|
### Phase 4: Enhancements (2-3 days)
|
||||||
|
1. Dynamic topic schema generation
|
||||||
|
2. Approval signature integration
|
||||||
|
3. Visual regression testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Conclusion
|
||||||
|
|
||||||
|
The current PDFME implementation has significant gaps compared to the legacy system, resulting in incomplete or incorrectly formatted quotation PDFs. The primary issues are:
|
||||||
|
|
||||||
|
1. **Missing topic mappings** (exclusion, payment, warranty, delivery)
|
||||||
|
2. **Missing transform functions** (formatTopicItems, currency, date)
|
||||||
|
3. **Template structure differences** (fewer sections)
|
||||||
|
4. **No product type logic** (generic topic labels)
|
||||||
|
|
||||||
|
**Recommended Action**: Implement Phase 1 fixes immediately to restore basic functionality, then proceed with template updates and product type logic.
|
||||||
|
|
||||||
|
**Estimated Total Effort**: 5-7 days for complete resolution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Placeholder Inventory
|
||||||
|
|
||||||
|
### A.1 Current Template Placeholders
|
||||||
|
|
||||||
|
```
|
||||||
|
customer_name
|
||||||
|
customer_addr
|
||||||
|
customer_tel
|
||||||
|
customer_email
|
||||||
|
customer_att
|
||||||
|
project_name
|
||||||
|
site_location
|
||||||
|
quotation_date
|
||||||
|
quotation_code
|
||||||
|
quotation_price
|
||||||
|
currency
|
||||||
|
exclusion_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.2 Legacy Template Placeholders (Not in Current)
|
||||||
|
|
||||||
|
```
|
||||||
|
exclusion_label
|
||||||
|
Terms_of_payment
|
||||||
|
data_term_of_payment
|
||||||
|
warranty
|
||||||
|
data_warranty
|
||||||
|
Delivery
|
||||||
|
data_delivery
|
||||||
|
Affter service
|
||||||
|
data_affter_service
|
||||||
|
topic
|
||||||
|
data_topic
|
||||||
|
app1
|
||||||
|
app1_position
|
||||||
|
app2
|
||||||
|
app2_position
|
||||||
|
app3
|
||||||
|
app3_position
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.3 Mapped vs Unmapped
|
||||||
|
|
||||||
|
| Category | Total | Mapped | Unmapped | % Mapped |
|
||||||
|
|----------|-------|--------|----------|----------|
|
||||||
|
| Customer Info | 4 | 4 | 0 | 100% |
|
||||||
|
| Quotation Info | 6 | 6 | 0 | 100% |
|
||||||
|
| Topics | 1 | 0 | 1 | 0% |
|
||||||
|
| Signatures | 6 | 0 | 6 | 0% |
|
||||||
|
| **Total** | **17** | **10** | **7** | **59%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B: Data Source Reference
|
||||||
|
|
||||||
|
### B.1 Database Schema
|
||||||
|
|
||||||
|
**Customer**: `crm_customers`
|
||||||
|
- `name` → `{customer_name}`
|
||||||
|
- `address` → `{customer_addr}`
|
||||||
|
- `phone` → `{customer_tel}`
|
||||||
|
- `email` → `{customer_email}`
|
||||||
|
|
||||||
|
**Quotation**: `crm_quotations`
|
||||||
|
- `attention` → `{customer_att}`
|
||||||
|
- `projectName` → `{project_name}`
|
||||||
|
- `projectLocation` → `{site_location}`
|
||||||
|
- `quotationDate` → `{quotation_date}`
|
||||||
|
- `code` → `{quotation_code}`
|
||||||
|
- `totalAmount` → `{quotation_price}`
|
||||||
|
- `currency` → `{currency}`
|
||||||
|
|
||||||
|
**Topics**: `crm_quotation_topics` + `crm_quotation_topic_items`
|
||||||
|
- `topicType` → determines which topic
|
||||||
|
- `items[].content` → topic content items
|
||||||
|
|
||||||
|
### B.2 Source Path Examples
|
||||||
|
|
||||||
|
| Placeholder | Source Path | Notes |
|
||||||
|
|-------------|-------------|-------|
|
||||||
|
| `customer_name` | `customer.name` | Direct mapping |
|
||||||
|
| `customer_tel` | `customer.contacts[primary].phone` | Array access |
|
||||||
|
| `quotation_price` | `quotation.totalAmount` | Number → string transform |
|
||||||
|
| `exclusion_data` | `topics.exclusion_from_scope_of_supply.items` | Topic → string[][] transform |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Report Generated**: 2026-06-17
|
||||||
|
**Auditor**: AI Code Review Agent
|
||||||
|
**Version**: 1.0
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Task H.2: PDFME Dynamic Topic Engine + Mapping Hotfix
|
||||||
|
|
||||||
|
## Files Added
|
||||||
|
|
||||||
|
- `src/features/crm/quotations/document/server/pdfme-transforms.ts`
|
||||||
|
- `src/features/crm/quotations/document/server/pdf-topic.type.ts`
|
||||||
|
- `src/features/crm/quotations/document/server/pdf-topic-engine.ts`
|
||||||
|
- `src/features/crm/quotations/document/server/topic-mapping.ts`
|
||||||
|
- `docs/implementation/task-h2-pdfme-mapping-transform-hotfix.md`
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `src/features/crm/quotations/document/server/service.ts`
|
||||||
|
- `src/features/crm/quotations/document/types.ts`
|
||||||
|
- `src/features/crm/quotations/components/quotation-document-preview.tsx`
|
||||||
|
- `src/features/foundation/document-template/server/service.ts`
|
||||||
|
- `src/db/seeds/foundation.seed.ts`
|
||||||
|
- `scripts/verify-task-h1.js`
|
||||||
|
- `scripts/audit-pdfme-runtime.ts`
|
||||||
|
- `docs/implementation/technical-debt.md`
|
||||||
|
|
||||||
|
## Transform Utilities Added
|
||||||
|
|
||||||
|
- `formatPdfDate()`
|
||||||
|
- `formatPdfCurrency()`
|
||||||
|
- `formatTopicItems()`
|
||||||
|
- `normalizePdfmeTable()`
|
||||||
|
|
||||||
|
## Dynamic Topic Engine Added
|
||||||
|
|
||||||
|
- page-2 `topic` and `data_topic` schemas are now treated as dynamic templates instead of static mappings
|
||||||
|
- topic blocks are cloned into runtime keys like `topic_1_0` and `item_topic_1_0`
|
||||||
|
- topic rows are normalized into `string[][]`
|
||||||
|
- the closing/signature block is kept together and pushed to the next page when remaining space is too small
|
||||||
|
- long topic lists can expand page 2 into multiple generated pages
|
||||||
|
|
||||||
|
## Product Topic Mapping Added
|
||||||
|
|
||||||
|
- product-type-aware topic mapping with `crane`, `dockdoor`, `solarcell`, and `default` fallbacks
|
||||||
|
- alias matching allows current simplified option codes and legacy-style topic codes to resolve into PDFME-ready sections
|
||||||
|
|
||||||
|
## Template Mappings Fixed
|
||||||
|
|
||||||
|
- `customer_att -> quotation.attention`
|
||||||
|
- `quotation_date -> pdfme.quotation_date`
|
||||||
|
- `quotation_price -> pdfme.quotation_price`
|
||||||
|
- `exclusion_data -> pdfme.exclusion_data`
|
||||||
|
- legacy `item_topic` mapping is now explicitly retired from foundation seed data because dynamic topics are schema-driven
|
||||||
|
|
||||||
|
## Seed Mapping Strategy After Dynamic Topic Engine
|
||||||
|
|
||||||
|
- default PDFME seed mappings now keep only static runtime fields plus safe signature placeholders
|
||||||
|
- `quotation_date` and `quotation_price` are seeded against the preformatted `pdfme.*` fields with no extra format mask
|
||||||
|
- `topic`, `data_topic`, `item_topic`, and wildcard runtime keys such as `topic_*` / `item_topic_*` are retired from DB mappings
|
||||||
|
- signature placeholders `app1/app2/app3` and position fields are seeded with safe `defaultValue: "-"` until Task H.3 defines the real approval-signature strategy
|
||||||
|
|
||||||
|
## PDF Generator Integration
|
||||||
|
|
||||||
|
- quotation preview data now returns a render-ready PDFME schema with injected topic pages
|
||||||
|
- `templateInput` now merges dynamic topic inputs with normal mapped fields
|
||||||
|
- approved snapshot generation now captures the same merged topic input set used by PDF generation
|
||||||
|
- the existing PDF generator flow now consumes the render-ready schema returned by the preview service
|
||||||
|
|
||||||
|
## Verification Result
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` passed
|
||||||
|
- `scripts/verify-task-h1.js` now asserts:
|
||||||
|
- formatted `quotation_price`
|
||||||
|
- formatted `quotation_date`
|
||||||
|
- non-empty `exclusion_data`
|
||||||
|
- generated dynamic topic keys in preview payload
|
||||||
|
- `documentData.pdfme.topic_inputs`
|
||||||
|
- no unresolved `{exclusion_data}`, `{quotation_price}`, and `{item_topic}` in approved PDF text
|
||||||
|
- inline `tsx` smoke testing for the new engine was inconclusive because the local script loader exposed the module as a default-only object even though project typecheck succeeded
|
||||||
|
- full `verify:task-h1` end-to-end execution was not run in this turn because it depends on a live app server and fixture database state
|
||||||
|
|
||||||
|
## Remaining PDFME Gaps
|
||||||
|
|
||||||
|
- approval signature placeholders are still not mapped to real approver names/positions
|
||||||
|
- pixel-perfect parity with the legacy layout has not been regression-tested automatically
|
||||||
|
- `scripts/audit-pdfme-runtime.ts` still needs a stable standalone runtime strategy if we want to use it outside the Next.js execution context
|
||||||
|
|
||||||
|
## Task K Readiness
|
||||||
|
|
||||||
|
- dynamic server-side topic rendering now exists, so follow-on PDF tasks can build on runtime schema generation instead of adding more fixed placeholders
|
||||||
|
- seed data and template mapping are aligned with the current ALLA / ONVALLA templates
|
||||||
|
- the next safe step is end-to-end fixture verification against a running app plus any remaining signature-field strategy
|
||||||
@@ -201,6 +201,20 @@ Future:
|
|||||||
- promote fixture verification into automated test coverage
|
- promote fixture verification into automated test coverage
|
||||||
- add regression checks for template mappings, storage persistence, and permission enforcement
|
- add regression checks for template mappings, storage persistence, and permission enforcement
|
||||||
|
|
||||||
|
### Current PDFME template coverage
|
||||||
|
|
||||||
|
Current:
|
||||||
|
|
||||||
|
- payment, warranty, delivery, and extra topic sections now flow through the dynamic `topic` / `data_topic` engine on page 2 instead of fixed placeholder fields
|
||||||
|
- approval signature fields are not mapped yet
|
||||||
|
- signature placeholders `app1/app2/app3` currently rely on safe fallback mapping and still need Task H.3 strategy for real names and positions
|
||||||
|
|
||||||
|
Future:
|
||||||
|
|
||||||
|
- keep validating pagination and closing-block placement against production designer expectations for large topic sets
|
||||||
|
- map approval signature fields when approval-to-document ownership is formalized
|
||||||
|
- add fixture-backed end-to-end PDF generation verification in CI once a stable runtime path is available
|
||||||
|
|
||||||
## After Task I
|
## After Task I
|
||||||
|
|
||||||
### Storage provider production rollout
|
### Storage provider production rollout
|
||||||
|
|||||||
2
drizzle/0011_brief_hobgoblin.sql
Normal file
2
drizzle/0011_brief_hobgoblin.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "crm_customers"
|
||||||
|
ADD COLUMN "customer_sub_group" text;
|
||||||
1
drizzle/0011_goofy_the_hood.sql
Normal file
1
drizzle/0011_goofy_the_hood.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "crm_customers" ADD COLUMN "customer_sub_group" text;
|
||||||
3273
drizzle/meta/0011_snapshot.json
Normal file
3273
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
|||||||
"when": 1781675409847,
|
"when": 1781675409847,
|
||||||
"tag": "0010_calm_wendell_rand",
|
"tag": "0010_calm_wendell_rand",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781747812297,
|
||||||
|
"tag": "0011_goofy_the_hood",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
326
plans/hotfix-1-customer.md
Normal file
326
plans/hotfix-1-customer.md
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
# Hotfix: Add Customer Sub Group to Customer Form
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
เพิ่ม field `Customer Sub Group` ในหน้า Add / Edit Customer
|
||||||
|
|
||||||
|
โดยใช้ master option category:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_customer_sub_group
|
||||||
|
```
|
||||||
|
|
||||||
|
และต้อง filter ตาม `Customer Group` ที่เลือก
|
||||||
|
|
||||||
|
Relationship:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_customer_group
|
||||||
|
↓ parent
|
||||||
|
crm_customer_sub_group
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Must Read
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/customers/**
|
||||||
|
src/features/foundation/master-options/**
|
||||||
|
src/app/api/foundation/master-options/route.ts
|
||||||
|
src/db/schema.ts
|
||||||
|
src/db/seeds/foundation.seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope 1: Data Model Check
|
||||||
|
|
||||||
|
ตรวจสอบว่า customer schema มี field สำหรับ sub group แล้วหรือยัง
|
||||||
|
|
||||||
|
Expected field:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
customerSubGroup
|
||||||
|
```
|
||||||
|
|
||||||
|
หรือถ้าใช้ naming เดิม:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
custSubGroup
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* ถ้ามี field อยู่แล้ว ให้ reuse
|
||||||
|
* ถ้ายังไม่มี ให้เพิ่ม field ใหม่แบบไม่กระทบข้อมูลเดิม
|
||||||
|
* field เป็น optional
|
||||||
|
* เก็บเป็น master option code/value ตาม pattern เดิมของ customerGroup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope 2: Master Option Category
|
||||||
|
|
||||||
|
เพิ่ม/ยืนยัน category:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_customer_sub_group
|
||||||
|
```
|
||||||
|
|
||||||
|
Sub group ต้องมี parent relationship กับ group:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_customer_group.id
|
||||||
|
↓
|
||||||
|
crm_customer_sub_group.parentId
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* parentId ต้องชี้ไป customer group option
|
||||||
|
* sub group dropdown ต้อง filter ด้วย parentId
|
||||||
|
* ถ้ายังไม่ได้เลือก customer group ให้ disable sub group dropdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope 3: Seed Data
|
||||||
|
|
||||||
|
อัปเดต:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/db/seeds/foundation.seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
เพิ่ม seed category:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_customer_group
|
||||||
|
crm_customer_sub_group
|
||||||
|
```
|
||||||
|
|
||||||
|
ตัวอย่าง:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_customer_group:
|
||||||
|
- industrial
|
||||||
|
- construction
|
||||||
|
- government
|
||||||
|
- dealer
|
||||||
|
- other
|
||||||
|
|
||||||
|
crm_customer_sub_group:
|
||||||
|
- factory
|
||||||
|
- warehouse
|
||||||
|
- logistics
|
||||||
|
- contractor
|
||||||
|
- consultant
|
||||||
|
- state_enterprise
|
||||||
|
- government_agency
|
||||||
|
- reseller
|
||||||
|
- other
|
||||||
|
```
|
||||||
|
|
||||||
|
Relationship ตัวอย่าง:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
industrial
|
||||||
|
- factory
|
||||||
|
- warehouse
|
||||||
|
- logistics
|
||||||
|
|
||||||
|
construction
|
||||||
|
- contractor
|
||||||
|
- consultant
|
||||||
|
|
||||||
|
government
|
||||||
|
- state_enterprise
|
||||||
|
- government_agency
|
||||||
|
|
||||||
|
dealer
|
||||||
|
- reseller
|
||||||
|
|
||||||
|
other
|
||||||
|
- other
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* seed idempotent
|
||||||
|
* no hardcoded organizationId
|
||||||
|
* parent option must be created before child options
|
||||||
|
* child options must set parentId
|
||||||
|
* do not duplicate options if seed runs again
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope 4: Customer Schema / Zod
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/customers/schemas/customer.schema.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
customerSubGroup: z.string().optional().nullable()
|
||||||
|
```
|
||||||
|
|
||||||
|
หรือใช้ field name ที่มีอยู่จริง เช่น:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
custSubGroup: z.string().optional().nullable()
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* Create Customer ต้องรับ field นี้ได้
|
||||||
|
* Edit Customer ต้อง load ค่าเดิมได้
|
||||||
|
* ถ้าเปลี่ยน customerGroup แล้ว customerSubGroup ไม่อยู่ใต้ parent ใหม่ ให้ clear ค่า customerSubGroup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope 5: API / Server Service
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/customers/server/service.ts
|
||||||
|
src/app/api/crm/customers/route.ts
|
||||||
|
src/app/api/crm/customers/[id]/route.ts
|
||||||
|
src/features/crm/customers/api/types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* create customer save customerSubGroup
|
||||||
|
* update customer save customerSubGroup
|
||||||
|
* list/detail return customerSubGroup
|
||||||
|
* validate sub group parent matches selected group ถ้าทำได้ง่าย
|
||||||
|
* if invalid parent-child relation, return user-safe error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope 6: Customer Form UI
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/customers/components/customer-form-sheet.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Add field:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Group
|
||||||
|
Customer Sub Group
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* Customer Group dropdown uses `crm_customer_group`
|
||||||
|
* Customer Sub Group dropdown uses `crm_customer_sub_group`
|
||||||
|
* Customer Sub Group filtered by selected group option `parentId`
|
||||||
|
* If no Customer Group selected:
|
||||||
|
|
||||||
|
* disable Customer Sub Group
|
||||||
|
* placeholder: "Select customer group first"
|
||||||
|
* If Customer Group changes:
|
||||||
|
|
||||||
|
* reset Customer Sub Group
|
||||||
|
* If edit mode:
|
||||||
|
|
||||||
|
* load existing Customer Group
|
||||||
|
* load filtered Customer Sub Group
|
||||||
|
* selected value must display correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope 7: Customer List / Detail
|
||||||
|
|
||||||
|
Update if relevant:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/customers/components/customer-columns.tsx
|
||||||
|
src/features/crm/customers/components/customer-detail.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Display:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Group
|
||||||
|
Customer Sub Group
|
||||||
|
```
|
||||||
|
|
||||||
|
If label lookup is available, show label instead of raw code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope 8: Query Freshness
|
||||||
|
|
||||||
|
After create/update:
|
||||||
|
|
||||||
|
* invalidate customer list
|
||||||
|
* invalidate customer detail
|
||||||
|
* form closes after success
|
||||||
|
* table/detail show new customer sub group immediately
|
||||||
|
|
||||||
|
Follow existing CRUD freshness rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope 9: Regression Check
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
* create customer with group + sub group
|
||||||
|
* create customer without sub group
|
||||||
|
* edit customer group changes and sub group resets
|
||||||
|
* edit customer retains valid sub group
|
||||||
|
* invalid sub group under another group is rejected or cleared
|
||||||
|
* customer list/detail displays value
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Explicit Non-Scope
|
||||||
|
|
||||||
|
Do NOT implement:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer group management UI
|
||||||
|
Inline create master option
|
||||||
|
Large RBAC rewrite
|
||||||
|
Customer import/export
|
||||||
|
Dashboard changes
|
||||||
|
Report changes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
After completion, summarize:
|
||||||
|
|
||||||
|
1. Files Added
|
||||||
|
2. Files Modified
|
||||||
|
3. Schema Field Used/Added
|
||||||
|
4. Master Options Seeded
|
||||||
|
5. Form Behavior Added
|
||||||
|
6. Parent/Child Filtering Behavior
|
||||||
|
7. Validation Added
|
||||||
|
8. Verification Result
|
||||||
|
9. Remaining Risks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
* Add/Edit Customer has Customer Sub Group field
|
||||||
|
* Customer Sub Group uses `crm_customer_sub_group`
|
||||||
|
* Sub Group filters by selected Customer Group parent
|
||||||
|
* Changing Customer Group resets invalid Sub Group
|
||||||
|
* Create customer saves Sub Group
|
||||||
|
* Edit customer saves Sub Group
|
||||||
|
* Detail/list can show Sub Group
|
||||||
|
* Mutation refreshes customer table/detail
|
||||||
337
plans/task-h.2.1.md
Normal file
337
plans/task-h.2.1.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# Task H.2.1: Reseed PDFME Template Mappings for Dynamic Topic Engine
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
ปรับ seed data ของ PDFME template mappings ให้สอดคล้องกับ Task H.2
|
||||||
|
|
||||||
|
หลัง H.2 ระบบใช้ Dynamic Topic Engine แล้ว ดังนั้น `topic`, `data_topic`, และ dynamic topic input keys ไม่ควรถูกจัดการผ่าน `crm_document_template_mappings` แบบ static อีกต่อไป
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Background
|
||||||
|
|
||||||
|
Task H.2 เพิ่ม:
|
||||||
|
|
||||||
|
* PDFME transform utilities
|
||||||
|
* Dynamic Topic Engine
|
||||||
|
* Product topic mapping
|
||||||
|
* `pdfme` section ใน quotation document data
|
||||||
|
* runtime topic inputs
|
||||||
|
* server-side schema cloning สำหรับ `topic` และ `data_topic`
|
||||||
|
|
||||||
|
ดังนั้น seed mappings ต้องถูก cleanup ให้ตรงกับ model ใหม่
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Must Read
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/task-h2-pdfme-mapping-transform-hotfix.md
|
||||||
|
docs/implementation/pdfme-runtime-audit.md
|
||||||
|
docs/implementation/pdfme-legacy-audit.md
|
||||||
|
|
||||||
|
src/db/seeds/foundation.seed.ts
|
||||||
|
src/features/foundation/document-template/**
|
||||||
|
src/features/crm/quotations/document/server/**
|
||||||
|
src/pdfme_template/**
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.1.1: Inspect Current Template Placeholders
|
||||||
|
|
||||||
|
Extract placeholders from current default templates:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/pdfme_template/ALLA_template_pdfme_fainal3.json
|
||||||
|
src/pdfme_template/ONVALLA_template_pdfme_fainal3.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected static placeholders:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
customer_name
|
||||||
|
customer_addr
|
||||||
|
customer_tel
|
||||||
|
customer_email
|
||||||
|
customer_att
|
||||||
|
project_name
|
||||||
|
site_location
|
||||||
|
quotation_date
|
||||||
|
quotation_code
|
||||||
|
quotation_price
|
||||||
|
currency
|
||||||
|
exclusion_data
|
||||||
|
```
|
||||||
|
|
||||||
|
Dynamic topic template schemas:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
topic
|
||||||
|
data_topic
|
||||||
|
```
|
||||||
|
|
||||||
|
Signature placeholders, if present:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
app1
|
||||||
|
app1_position
|
||||||
|
app2
|
||||||
|
app2_position
|
||||||
|
app3
|
||||||
|
app3_position
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.1.2: Static Mapping Seed
|
||||||
|
|
||||||
|
Update `foundation.seed.ts` so default pdfme mapping contains only static runtime placeholders:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
customer_name -> customer.name
|
||||||
|
customer_addr -> customer.address
|
||||||
|
customer_tel -> customer.phone
|
||||||
|
customer_email -> customer.email
|
||||||
|
customer_att -> quotation.attention
|
||||||
|
project_name -> quotation.projectName
|
||||||
|
site_location -> quotation.projectLocation
|
||||||
|
quotation_date -> pdfme.quotation_date
|
||||||
|
quotation_code -> quotation.code
|
||||||
|
quotation_price -> pdfme.quotation_price
|
||||||
|
currency -> quotation.currency
|
||||||
|
exclusion_data -> pdfme.exclusion_data
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended metadata:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotation_date:
|
||||||
|
dataType = scalar
|
||||||
|
formatMask = null
|
||||||
|
sourcePath = pdfme.quotation_date
|
||||||
|
|
||||||
|
quotation_price:
|
||||||
|
dataType = scalar
|
||||||
|
formatMask = null
|
||||||
|
sourcePath = pdfme.quotation_price
|
||||||
|
|
||||||
|
exclusion_data:
|
||||||
|
dataType = table
|
||||||
|
sourcePath = pdfme.exclusion_data
|
||||||
|
```
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
|
||||||
|
* date/currency are already formatted in `pdfme` section from H.2
|
||||||
|
* do not format twice in mapping resolver
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.1.3: Retire Invalid Static Topic Mappings
|
||||||
|
|
||||||
|
Ensure seed removes or deactivates mappings for:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
topic
|
||||||
|
data_topic
|
||||||
|
item_topic
|
||||||
|
data_topic_*
|
||||||
|
topic_*
|
||||||
|
item_topic_*
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* `topic` and `data_topic` are schema templates, not data placeholders
|
||||||
|
* `item_topic` is legacy content placeholder inside `data_topic`, not a DB mapping
|
||||||
|
* dynamic keys such as `topic_1_0` and `item_topic_1_0` are generated at runtime
|
||||||
|
* do not store generated dynamic keys in DB
|
||||||
|
|
||||||
|
Implementation guidance:
|
||||||
|
|
||||||
|
If mappings exist from old seed:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
soft delete / delete / skip
|
||||||
|
```
|
||||||
|
|
||||||
|
according to current template mapping service convention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.1.4: Signature Mapping Decision
|
||||||
|
|
||||||
|
If current template contains signature placeholders:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
app1
|
||||||
|
app1_position
|
||||||
|
app2
|
||||||
|
app2_position
|
||||||
|
app3
|
||||||
|
app3_position
|
||||||
|
```
|
||||||
|
|
||||||
|
For this task:
|
||||||
|
|
||||||
|
Option A recommended:
|
||||||
|
|
||||||
|
Seed them with safe placeholder values:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
app1 -> signatures.preparedBy.name
|
||||||
|
app1_position -> signatures.preparedBy.position
|
||||||
|
app2 -> signatures.approvedBy.name
|
||||||
|
app2_position -> signatures.approvedBy.position
|
||||||
|
app3 -> signatures.authorizedBy.name
|
||||||
|
app3_position -> signatures.authorizedBy.position
|
||||||
|
```
|
||||||
|
|
||||||
|
But only if `documentData.signatures` exists.
|
||||||
|
|
||||||
|
If not available yet:
|
||||||
|
|
||||||
|
* do not break PDF
|
||||||
|
* use defaultValue = "-"
|
||||||
|
* document as pending Task H.3
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* no approval signature business logic in this task
|
||||||
|
* no new approval mapping strategy in this task
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.1.5: Seed Idempotency
|
||||||
|
|
||||||
|
Seed must be idempotent:
|
||||||
|
|
||||||
|
* run repeatedly without duplicate mappings
|
||||||
|
* update existing wrong sourcePath
|
||||||
|
* update existing wrong dataType
|
||||||
|
* remove/retire invalid mappings
|
||||||
|
* no hardcoded organizationId
|
||||||
|
* apply for every organization
|
||||||
|
* apply for both ALLA and ONVALLA templates if seeded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.1.6: Runtime Verification
|
||||||
|
|
||||||
|
Extend or run audit script:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
scripts/audit-pdfme-runtime.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected result:
|
||||||
|
|
||||||
|
Static template placeholders:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
customer_name mappingExists = true
|
||||||
|
customer_addr mappingExists = true
|
||||||
|
customer_tel mappingExists = true
|
||||||
|
customer_email mappingExists = true
|
||||||
|
customer_att mappingExists = true
|
||||||
|
project_name mappingExists = true
|
||||||
|
site_location mappingExists = true
|
||||||
|
quotation_date mappingExists = true
|
||||||
|
quotation_code mappingExists = true
|
||||||
|
quotation_price mappingExists = true
|
||||||
|
currency mappingExists = true
|
||||||
|
exclusion_data mappingExists = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Dynamic schemas:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
topic mappingExists = false, dynamicTemplate = true
|
||||||
|
data_topic mappingExists = false, dynamicTemplate = true
|
||||||
|
item_topic mappingExists = false, legacyContentToken = true
|
||||||
|
```
|
||||||
|
|
||||||
|
PDFME input should contain runtime keys:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
topic_*
|
||||||
|
item_topic_*
|
||||||
|
```
|
||||||
|
|
||||||
|
but these must come from Dynamic Topic Engine, not DB mappings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.1.7: Documentation
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/task-h2-pdfme-mapping-transform-hotfix.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Add section:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Seed Mapping Strategy After Dynamic Topic Engine
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/technical-debt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Add note:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Signature placeholders app1/app2/app3 still need Task H.3 strategy if not fully mapped.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Explicit Non-Scope
|
||||||
|
|
||||||
|
Do NOT implement:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approval signature strategy
|
||||||
|
Visual template designer
|
||||||
|
Dynamic topic editor
|
||||||
|
Changing PDF layout
|
||||||
|
Pixel-perfect comparison
|
||||||
|
Report Center
|
||||||
|
Dashboard changes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Output
|
||||||
|
|
||||||
|
After completion, summarize:
|
||||||
|
|
||||||
|
1. Files Modified
|
||||||
|
2. Static Mappings Seeded
|
||||||
|
3. Invalid Topic Mappings Retired
|
||||||
|
4. Signature Placeholder Handling
|
||||||
|
5. Runtime Audit Result
|
||||||
|
6. Remaining Risks
|
||||||
|
7. Task H.3 Readiness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
Task H.2.1 passes when:
|
||||||
|
|
||||||
|
* static PDFME mappings are complete
|
||||||
|
* `exclusion_data` maps to `pdfme.exclusion_data`
|
||||||
|
* `quotation_price` maps to `pdfme.quotation_price`
|
||||||
|
* `quotation_date` maps to `pdfme.quotation_date`
|
||||||
|
* `topic` is not mapped as static DB mapping
|
||||||
|
* `data_topic` is not mapped as static DB mapping
|
||||||
|
* `item_topic` is not mapped as static DB mapping
|
||||||
|
* dynamic topic keys are generated at runtime only
|
||||||
|
* seed is idempotent
|
||||||
|
* runtime audit recognizes topic/data_topic as dynamic templates
|
||||||
621
plans/task-h.2.md
Normal file
621
plans/task-h.2.md
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
# Task H.2: PDFME Dynamic Topic Engine + Mapping Hotfix
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Current issue:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
PDFME quotation แสดงข้อมูลไม่ครบ
|
||||||
|
Mapping field ไม่ถูกต้อง
|
||||||
|
Topic จาก crm_quotation_topics / crm_quotation_topic_items ไม่แสดงครบ
|
||||||
|
```
|
||||||
|
|
||||||
|
จาก Legacy Audit และ Runtime Audit พบว่า:
|
||||||
|
|
||||||
|
1. Current template มี placeholder บางตัวแต่ mapping ไม่ครบ
|
||||||
|
2. `quotation_price` ยังส่งเป็น raw number
|
||||||
|
3. `quotation_date` ยังเสี่ยงเป็น raw date
|
||||||
|
4. `exclusion_data` ต้องเป็น `string[][]`
|
||||||
|
5. `topic` และ `data_topic` ไม่ใช่ mapping ปกติ
|
||||||
|
6. `topic` และ `data_topic` เป็น schema template สำหรับ dynamic topic rendering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Must Read
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/pdfme-legacy-audit.md
|
||||||
|
docs/implementation/pdfme-runtime-audit.md
|
||||||
|
docs/implementation/technical-debt.md
|
||||||
|
|
||||||
|
src/features/foundation/pdf-generator/**
|
||||||
|
src/features/foundation/document-template/**
|
||||||
|
src/features/crm/quotations/document/**
|
||||||
|
src/features/crm/quotations/**
|
||||||
|
src/db/schema.ts
|
||||||
|
src/db/seeds/foundation.seed.ts
|
||||||
|
src/pdfme_template/**
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy reference files:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
pdf-topic.type.ts
|
||||||
|
pdf-topic-input.ts
|
||||||
|
layout-engine.ts
|
||||||
|
build-topic-schema.ts
|
||||||
|
pdf-topic-engine.ts
|
||||||
|
quotation-preview.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Important legacy behavior:
|
||||||
|
|
||||||
|
* `topic` displays `crm_quotation_topics.topicType`
|
||||||
|
* `data_topic` displays rows from `crm_quotation_topic_items.content`
|
||||||
|
* `topic` and `data_topic` are cloned dynamically
|
||||||
|
* Runtime schema names become:
|
||||||
|
|
||||||
|
* `topic_0_0`
|
||||||
|
* `item_topic_0_0`
|
||||||
|
* `topic_0_1`
|
||||||
|
* `item_topic_0_1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Goal
|
||||||
|
|
||||||
|
Port the legacy PDFME topic rendering behavior into ALLA OS CRM vNext server-side PDF pipeline.
|
||||||
|
|
||||||
|
Fix current mapping issues without introducing a visual template designer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.1: PDFME Transform Utilities
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/quotations/document/server/pdfme-transforms.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
formatPdfDate(dateString: string | Date | null | undefined, language?: "th" | "en"): string
|
||||||
|
|
||||||
|
formatPdfCurrency(
|
||||||
|
amount: number | string | null | undefined,
|
||||||
|
currencyCode?: string
|
||||||
|
): string
|
||||||
|
|
||||||
|
formatTopicItems(
|
||||||
|
topic?: { items?: Array<{ content?: string | null; sortOrder?: number | null }> }
|
||||||
|
): string[][]
|
||||||
|
|
||||||
|
normalizePdfmeTable(
|
||||||
|
value: unknown,
|
||||||
|
columns?: Array<{ sourceField: string; formatMask?: string | null }>
|
||||||
|
): string[][]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* empty scalar fallback = `-`
|
||||||
|
* empty topic fallback = `[["-"]]`
|
||||||
|
* all pdfme table inputs must be `string[][]`
|
||||||
|
* currency supports at least:
|
||||||
|
|
||||||
|
* THB
|
||||||
|
* USD
|
||||||
|
* EUR
|
||||||
|
* `date_th` uses Buddhist calendar
|
||||||
|
* `date_en` uses Gregorian calendar
|
||||||
|
* never return `undefined` to pdfme
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.2: Dynamic Topic Types
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/quotations/document/server/pdf-topic.type.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Types:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type PdfTopicItem = {
|
||||||
|
id: string | number;
|
||||||
|
content: string;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PdfTopic = {
|
||||||
|
id?: string | number;
|
||||||
|
topicType: string;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
items?: PdfTopicItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PdfTopicEngineOptions = {
|
||||||
|
targetPageIndex?: number;
|
||||||
|
pageStartY?: number;
|
||||||
|
pageBottomY?: number;
|
||||||
|
topicSpacing?: number;
|
||||||
|
keepTogetherNames?: string[];
|
||||||
|
topicTemplateName?: string;
|
||||||
|
topicDataTemplateName?: string;
|
||||||
|
rowHeight?: number;
|
||||||
|
keepTogetherMinSpace?: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.3: PDFME Topic Engine
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/quotations/document/server/pdf-topic-engine.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Port behavior from legacy:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
buildTopicSchemas()
|
||||||
|
```
|
||||||
|
|
||||||
|
Function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
buildPdfTopicTemplate(
|
||||||
|
template: Template,
|
||||||
|
topics: PdfTopic[],
|
||||||
|
options?: PdfTopicEngineOptions
|
||||||
|
): {
|
||||||
|
template: Template;
|
||||||
|
topicInputs: Record<string, string[][]>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required behavior:
|
||||||
|
|
||||||
|
1. Read target page from `template.schemas[targetPageIndex]`
|
||||||
|
2. Locate schema named `topic`
|
||||||
|
3. Locate schema named `data_topic`
|
||||||
|
4. Treat both as template schemas
|
||||||
|
5. Remove original `topic` and `data_topic` from static schemas
|
||||||
|
6. Sort topics by `sortOrder`
|
||||||
|
7. Clone `topic` for each topic
|
||||||
|
8. Clone `data_topic` for each topic
|
||||||
|
9. Rename clones to stable dynamic keys:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
topic_<pageIndex>_<topicIndex>
|
||||||
|
item_topic_<pageIndex>_<topicIndex>
|
||||||
|
```
|
||||||
|
|
||||||
|
10. Set topic label input:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
topic_<pageIndex>_<topicIndex> = [[topic.topicType]]
|
||||||
|
```
|
||||||
|
|
||||||
|
11. Set topic items input:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
item_topic_<pageIndex>_<topicIndex> = [
|
||||||
|
[item.content],
|
||||||
|
[item.content]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
12. Calculate table height from row count
|
||||||
|
13. Paginate when topic block exceeds `pageBottomY`
|
||||||
|
14. Move signature/closing group together
|
||||||
|
15. Rebuild `template.schemas` with generated pages
|
||||||
|
16. Return final template + topicInputs
|
||||||
|
|
||||||
|
Default options:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
targetPageIndex: 1,
|
||||||
|
pageStartY: 35,
|
||||||
|
pageBottomY: 250,
|
||||||
|
topicSpacing: 10,
|
||||||
|
keepTogetherNames: [
|
||||||
|
"Please_do_not",
|
||||||
|
"yours_faithfuly",
|
||||||
|
"app1",
|
||||||
|
"app1_position",
|
||||||
|
"app2",
|
||||||
|
"app2_position",
|
||||||
|
"app3",
|
||||||
|
"app3_position"
|
||||||
|
],
|
||||||
|
topicTemplateName: "topic",
|
||||||
|
topicDataTemplateName: "data_topic",
|
||||||
|
rowHeight: 7,
|
||||||
|
keepTogetherMinSpace: 60
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* if template has no `topic` or `data_topic`, do not crash PDF generation
|
||||||
|
* return original template and empty topicInputs with warning
|
||||||
|
* topic items empty => `[["-"]]`
|
||||||
|
* keep together block must not overlap topic data
|
||||||
|
* no client-only PDFME UI code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.4: Product Type Topic Mapping
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/quotations/document/server/topic-mapping.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const QUOTATION_TOPIC_TYPE_MAPPING = {
|
||||||
|
crane: {
|
||||||
|
scope: "scope_of_work",
|
||||||
|
exclusion: "exclusion_from_scope_of_supply",
|
||||||
|
payment: "terms_of_payment_upon_progress_of_each_item",
|
||||||
|
delivery: "delivery_date",
|
||||||
|
warranty: "warranty"
|
||||||
|
},
|
||||||
|
dockdoor: {
|
||||||
|
scope: "dock_scope_of_work",
|
||||||
|
exclusion: "exclusion_of_supply_installation",
|
||||||
|
payment: "payment_conditions",
|
||||||
|
delivery: "delivery_date",
|
||||||
|
warranty: "warranty"
|
||||||
|
},
|
||||||
|
solarcell: {
|
||||||
|
scope: "solarcell_scope_of_work",
|
||||||
|
exclusion: "solarcell_exclusion_of_supply_installation",
|
||||||
|
payment: "solarcell_payment_conditions",
|
||||||
|
delivery: "solarcell_delivery",
|
||||||
|
warranty: "solarcell_warranty"
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
scope: "scope_of_work",
|
||||||
|
exclusion: "exclusion_from_scope_of_supply",
|
||||||
|
payment: "payment_conditions",
|
||||||
|
delivery: "delivery",
|
||||||
|
warranty: "warranty"
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add helper:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
getQuotationTopicMapping(quotationType?: string | null)
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* use `quotation.quotationType` or current schema equivalent
|
||||||
|
* fallback to `default`
|
||||||
|
* do not hardcode crane only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.5: Document Data Builder Update
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/quotations/document/server/service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
`buildQuotationDocumentData()` must return:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
company,
|
||||||
|
branch,
|
||||||
|
customer,
|
||||||
|
contact,
|
||||||
|
quotation,
|
||||||
|
items,
|
||||||
|
quotationCustomers,
|
||||||
|
topics,
|
||||||
|
approval,
|
||||||
|
signatures,
|
||||||
|
pdfme
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `pdfme` section:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
pdfme: {
|
||||||
|
quotation_date: string;
|
||||||
|
quotation_price: string;
|
||||||
|
exclusion_data: string[][];
|
||||||
|
topic_inputs?: Record<string, string[][]>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* keep normalized document data for app preview
|
||||||
|
* add pdfme-ready compatibility fields
|
||||||
|
* do not remove existing fields
|
||||||
|
* topics must include data from:
|
||||||
|
|
||||||
|
* crm_quotation_topics
|
||||||
|
* crm_quotation_topic_items
|
||||||
|
* topics must be sorted by `sortOrder`
|
||||||
|
* topic items must be sorted by `sortOrder`
|
||||||
|
* `exclusion_data` must use product type mapping
|
||||||
|
* if no exclusion topic exists, return `[["-"]]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.6: Template Input Mapping Update
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/foundation/document-template/server/service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Enhance:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
mapDocumentDataToTemplateInput()
|
||||||
|
```
|
||||||
|
|
||||||
|
Must support:
|
||||||
|
|
||||||
|
1. `formatMask = currency_THB`
|
||||||
|
2. `formatMask = currency_USD`
|
||||||
|
3. `formatMask = currency_EUR`
|
||||||
|
4. `formatMask = date_th`
|
||||||
|
5. `formatMask = date_en`
|
||||||
|
6. table values already shaped as `string[][]`
|
||||||
|
7. object array to `string[][]` conversion only when table columns exist
|
||||||
|
8. fallback value when sourcePath resolves `null` or `undefined`
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
If source path points to:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
pdfme.exclusion_data
|
||||||
|
```
|
||||||
|
|
||||||
|
or any `string[][]`
|
||||||
|
|
||||||
|
preserve table shape.
|
||||||
|
|
||||||
|
Do not convert to string.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.7: PDF Generator Integration
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/foundation/pdf-generator/server/service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Before calling pdfme `generate()`:
|
||||||
|
|
||||||
|
1. Build documentData
|
||||||
|
2. Resolve template
|
||||||
|
3. Resolve mappings
|
||||||
|
4. Build base templateInput
|
||||||
|
5. Run dynamic topic engine:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { template: topicTemplate, topicInputs } =
|
||||||
|
buildPdfTopicTemplate(template, documentData.topics);
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Merge inputs:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const inputs = [
|
||||||
|
{
|
||||||
|
...templateInput,
|
||||||
|
...topicInputs
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Generate PDF with final template
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* dynamic topic engine must run for both preview and approved PDF
|
||||||
|
* if template has no topic/data_topic schemas, continue with base template
|
||||||
|
* server-only
|
||||||
|
* no @pdfme/ui usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.8: Template Seed Mapping Fix
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/db/seeds/foundation.seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure default pdfme mappings include:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
customer_name -> customer.name
|
||||||
|
customer_addr -> customer.address
|
||||||
|
customer_tel -> customer.phone
|
||||||
|
customer_email -> customer.email
|
||||||
|
customer_att -> quotation.attention
|
||||||
|
project_name -> quotation.projectName
|
||||||
|
site_location -> quotation.projectLocation
|
||||||
|
quotation_date -> pdfme.quotation_date
|
||||||
|
quotation_code -> quotation.code
|
||||||
|
quotation_price -> pdfme.quotation_price
|
||||||
|
currency -> quotation.currency
|
||||||
|
exclusion_data -> pdfme.exclusion_data
|
||||||
|
```
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
Do NOT add `topic` and `data_topic` as normal mappings.
|
||||||
|
|
||||||
|
They are dynamic schema templates.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* idempotent
|
||||||
|
* update existing sourcePath if wrong
|
||||||
|
* add missing mapping
|
||||||
|
* no hardcoded organizationId
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.9: Current Template Compatibility
|
||||||
|
|
||||||
|
Current template contains:
|
||||||
|
|
||||||
|
* page 1: `exclusion_data`
|
||||||
|
* page 2: `topic`
|
||||||
|
* page 2: `data_topic`
|
||||||
|
* page 2: signature/closing block
|
||||||
|
|
||||||
|
This task must support exactly that.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* `exclusion_data` is static mapped table on page 1
|
||||||
|
* `topic` / `data_topic` are dynamic templates on page 2
|
||||||
|
* original `topic` / `data_topic` should not remain as placeholder rows if dynamic topics exist
|
||||||
|
* signature block should remain after dynamic topics
|
||||||
|
* long topics should add pages if needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.10: Verification
|
||||||
|
|
||||||
|
Use or extend existing scripts:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
npm run seed:task-h1-fixture
|
||||||
|
npm run verify:task-h1
|
||||||
|
```
|
||||||
|
|
||||||
|
Add verification for:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotation_price formatted
|
||||||
|
quotation_date formatted
|
||||||
|
exclusion_data table is non-empty or "-"
|
||||||
|
topic dynamic keys exist
|
||||||
|
topic inputs exist
|
||||||
|
PDF generation succeeds
|
||||||
|
PDF does not contain unresolved {exclusion_data}
|
||||||
|
PDF does not contain unresolved {quotation_price}
|
||||||
|
PDF does not contain unresolved {item_topic}
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional debug output:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/pdfme-h2-verification.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
template placeholder list
|
||||||
|
generated dynamic topic keys
|
||||||
|
templateInput sample
|
||||||
|
topicInputs sample
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope H2.11: Documentation
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/technical-debt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Add or confirm:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Payment/warranty/delivery are rendered through dynamic topic section, not fixed static placeholders
|
||||||
|
Dynamic extra topics now supported through topic/data_topic engine
|
||||||
|
Approval signature fields still require dedicated mapping strategy if real approver names/positions are needed
|
||||||
|
Pixel-perfect legacy comparison not implemented
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Explicit Non-Scope
|
||||||
|
|
||||||
|
Do NOT implement:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Visual template designer
|
||||||
|
@pdfme/ui editor
|
||||||
|
Client-side PDF generation
|
||||||
|
Full pixel-perfect visual regression
|
||||||
|
Approval signature real mapping
|
||||||
|
Changing template design manually unless required
|
||||||
|
Replacing storage/artifact lifecycle
|
||||||
|
Report Center
|
||||||
|
Dashboard changes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Output
|
||||||
|
|
||||||
|
After completion, summarize:
|
||||||
|
|
||||||
|
1. Files Added
|
||||||
|
2. Files Modified
|
||||||
|
3. Transform Utilities Added
|
||||||
|
4. Dynamic Topic Engine Added
|
||||||
|
5. Product Topic Mapping Added
|
||||||
|
6. Template Mapping Fixed
|
||||||
|
7. PDF Generator Integration
|
||||||
|
8. Verification Result
|
||||||
|
9. Remaining PDFME Gaps
|
||||||
|
10. Task K Readiness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
Task H.2 passes when:
|
||||||
|
|
||||||
|
* `quotation_price` renders as formatted currency
|
||||||
|
* `quotation_date` renders as formatted date
|
||||||
|
* `exclusion_data` maps and renders as `string[][]`
|
||||||
|
* `topic` and `data_topic` are treated as dynamic templates
|
||||||
|
* `crm_quotation_topics.topicType` renders as dynamic topic label
|
||||||
|
* `crm_quotation_topic_items.content` renders as dynamic topic rows
|
||||||
|
* multiple topics render correctly
|
||||||
|
* long topic list paginates
|
||||||
|
* signature/closing block does not overlap topics
|
||||||
|
* PDF generation succeeds for preview PDF
|
||||||
|
* PDF generation succeeds for approved PDF
|
||||||
|
* unresolved placeholders do not appear in generated PDF
|
||||||
|
* no template designer added
|
||||||
401
scripts/audit-pdfme-runtime.ts
Normal file
401
scripts/audit-pdfme-runtime.ts
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { and, isNull } from 'drizzle-orm';
|
||||||
|
import { crmQuotations } from '../src/db/schema';
|
||||||
|
import { db } from '../src/lib/db';
|
||||||
|
import { getQuotationDocumentPreviewData } from '../src/features/crm/quotations/document/server/service';
|
||||||
|
import {
|
||||||
|
resolveTemplateForDocument,
|
||||||
|
resolveTemplateMappings
|
||||||
|
} from '../src/features/foundation/document-template/server/service';
|
||||||
|
|
||||||
|
type AuditIssue =
|
||||||
|
| 'NO_MAPPING'
|
||||||
|
| 'NO_TEMPLATE_FIELD'
|
||||||
|
| 'RESOLVED_UNDEFINED'
|
||||||
|
| 'RESOLVED_NULL'
|
||||||
|
| 'TABLE_TYPE_MISMATCH'
|
||||||
|
| 'TOPIC_MAPPING_MISMATCH';
|
||||||
|
|
||||||
|
type AuditClassification =
|
||||||
|
| 'static_field'
|
||||||
|
| 'dynamic_template'
|
||||||
|
| 'legacy_content_token'
|
||||||
|
| 'runtime_dynamic_input';
|
||||||
|
|
||||||
|
interface AuditResult {
|
||||||
|
placeholderKey: string;
|
||||||
|
templateExists: boolean;
|
||||||
|
mappingExists: boolean;
|
||||||
|
sourcePath: string | null;
|
||||||
|
resolvedValue: unknown;
|
||||||
|
resolvedType: string;
|
||||||
|
classification: AuditClassification;
|
||||||
|
dynamicTemplate: boolean;
|
||||||
|
legacyContentToken: boolean;
|
||||||
|
runtimeGenerated: boolean;
|
||||||
|
issues: AuditIssue[];
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATIC_EXPECTED_KEYS = new Set([
|
||||||
|
'customer_name',
|
||||||
|
'customer_addr',
|
||||||
|
'customer_tel',
|
||||||
|
'customer_email',
|
||||||
|
'customer_att',
|
||||||
|
'project_name',
|
||||||
|
'site_location',
|
||||||
|
'quotation_date',
|
||||||
|
'quotation_code',
|
||||||
|
'quotation_price',
|
||||||
|
'currency',
|
||||||
|
'exclusion_data',
|
||||||
|
'app1',
|
||||||
|
'app1_position',
|
||||||
|
'app2',
|
||||||
|
'app2_position',
|
||||||
|
'app3',
|
||||||
|
'app3_position'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DYNAMIC_TEMPLATE_NAMES = new Set(['topic', 'data_topic']);
|
||||||
|
const LEGACY_CONTENT_TOKENS = new Set(['item_topic']);
|
||||||
|
|
||||||
|
function extractSchemaInventory(template: unknown) {
|
||||||
|
const fieldNames = new Set<string>();
|
||||||
|
const contentTokens = new Set<string>();
|
||||||
|
|
||||||
|
if (!template || typeof template !== 'object' || !('schemas' in template)) {
|
||||||
|
return { fieldNames, contentTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = (template as { schemas?: unknown }).schemas;
|
||||||
|
|
||||||
|
if (!Array.isArray(pages)) {
|
||||||
|
return { fieldNames, contentTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
if (!Array.isArray(page)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of page) {
|
||||||
|
if (!field || typeof field !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('name' in field && typeof field.name === 'string') {
|
||||||
|
fieldNames.add(field.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const token of extractTokensFromValue((field as { content?: unknown }).content)) {
|
||||||
|
contentTokens.add(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = (field as { head?: unknown }).head;
|
||||||
|
|
||||||
|
if (Array.isArray(head)) {
|
||||||
|
for (const value of head) {
|
||||||
|
for (const token of extractTokensFromValue(value)) {
|
||||||
|
contentTokens.add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fieldNames, contentTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTokensFromValue(value: unknown) {
|
||||||
|
const tokens = new Set<string>();
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of value.matchAll(/\{([^}]+)\}/g)) {
|
||||||
|
tokens.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
for (const row of parsed) {
|
||||||
|
if (!Array.isArray(row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cell of row) {
|
||||||
|
if (typeof cell !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of cell.matchAll(/\{([^}]+)\}/g)) {
|
||||||
|
tokens.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyKey(key: string): AuditClassification {
|
||||||
|
if (DYNAMIC_TEMPLATE_NAMES.has(key)) {
|
||||||
|
return 'dynamic_template';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LEGACY_CONTENT_TOKENS.has(key)) {
|
||||||
|
return 'legacy_content_token';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith('topic_') || key.startsWith('item_topic_')) {
|
||||||
|
return 'runtime_dynamic_input';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'static_field';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findTestQuotation() {
|
||||||
|
const quotations = await db
|
||||||
|
.select()
|
||||||
|
.from(crmQuotations)
|
||||||
|
.where(and(isNull(crmQuotations.deletedAt)))
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
if (quotations.length === 0) {
|
||||||
|
throw new Error('No quotations found in database');
|
||||||
|
}
|
||||||
|
|
||||||
|
return quotations[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditPdfmeRuntime() {
|
||||||
|
console.log('=== PDFME Runtime Audit ===\n');
|
||||||
|
|
||||||
|
console.log('Step 1: Finding test quotation...');
|
||||||
|
const quotation = await findTestQuotation();
|
||||||
|
console.log(` Found quotation: ${quotation.code} (${quotation.id})`);
|
||||||
|
|
||||||
|
console.log('\nStep 2: Getting base template and mappings...');
|
||||||
|
const template = await resolveTemplateForDocument(quotation.organizationId, {
|
||||||
|
documentType: 'quotation',
|
||||||
|
productType: 'default',
|
||||||
|
fileType: 'pdfme'
|
||||||
|
});
|
||||||
|
const mappings = await resolveTemplateMappings(template.version.id, quotation.organizationId);
|
||||||
|
console.log(` Template: ${template.template.templateName} (${template.version.id})`);
|
||||||
|
console.log(` Mappings found: ${mappings.length}`);
|
||||||
|
|
||||||
|
console.log('\nStep 3: Building preview runtime data...');
|
||||||
|
const preview = await getQuotationDocumentPreviewData(
|
||||||
|
quotation.id,
|
||||||
|
quotation.organizationId
|
||||||
|
);
|
||||||
|
const templateInput = preview.templateInput;
|
||||||
|
console.log(' Preview runtime data built successfully');
|
||||||
|
|
||||||
|
console.log('\nStep 4: Extracting schema inventory from base template...');
|
||||||
|
const inventory = extractSchemaInventory(template.version.schemaJson);
|
||||||
|
const auditedKeys = new Set<string>([
|
||||||
|
...STATIC_EXPECTED_KEYS,
|
||||||
|
...inventory.fieldNames,
|
||||||
|
...inventory.contentTokens,
|
||||||
|
...Object.keys(templateInput).filter(
|
||||||
|
(key) => key.startsWith('topic_') || key.startsWith('item_topic_')
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
console.log(` Audited keys found: ${auditedKeys.size}`);
|
||||||
|
|
||||||
|
console.log('\nStep 5: Performing audit...\n');
|
||||||
|
const mappingMap = new Map<string, (typeof mappings)[number]>();
|
||||||
|
mappings.forEach((mapping) => mappingMap.set(mapping.placeholderKey, mapping));
|
||||||
|
|
||||||
|
const auditResults: AuditResult[] = [];
|
||||||
|
|
||||||
|
for (const key of auditedKeys) {
|
||||||
|
const classification = classifyKey(key);
|
||||||
|
const mappingExists = mappingMap.has(key);
|
||||||
|
const templateExists = inventory.fieldNames.has(key) || inventory.contentTokens.has(key);
|
||||||
|
const result: AuditResult = {
|
||||||
|
placeholderKey: key,
|
||||||
|
templateExists,
|
||||||
|
mappingExists,
|
||||||
|
sourcePath: mappingMap.get(key)?.sourcePath ?? null,
|
||||||
|
resolvedValue: templateInput[key],
|
||||||
|
resolvedType: typeof templateInput[key],
|
||||||
|
classification,
|
||||||
|
dynamicTemplate: classification === 'dynamic_template',
|
||||||
|
legacyContentToken: classification === 'legacy_content_token',
|
||||||
|
runtimeGenerated: classification === 'runtime_dynamic_input',
|
||||||
|
issues: [],
|
||||||
|
notes: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (classification === 'static_field' && templateExists && !mappingExists) {
|
||||||
|
result.issues.push('NO_MAPPING');
|
||||||
|
result.notes.push('Static schema field exists in template but has no DB mapping');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification === 'static_field' && mappingExists && !templateExists) {
|
||||||
|
result.issues.push('NO_TEMPLATE_FIELD');
|
||||||
|
result.notes.push('DB mapping exists but no corresponding schema field or content token was found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification === 'static_field' && result.resolvedValue === undefined) {
|
||||||
|
result.issues.push('RESOLVED_UNDEFINED');
|
||||||
|
result.notes.push('Resolved runtime value is undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification === 'static_field' && result.resolvedValue === null) {
|
||||||
|
result.issues.push('RESOLVED_NULL');
|
||||||
|
result.notes.push('Resolved runtime value is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = mappingMap.get(key);
|
||||||
|
|
||||||
|
if (mapping?.dataType === 'table') {
|
||||||
|
const value = result.resolvedValue;
|
||||||
|
|
||||||
|
if (value && !Array.isArray(value)) {
|
||||||
|
result.issues.push('TABLE_TYPE_MISMATCH');
|
||||||
|
result.notes.push(`Expected string[][] for table mapping, got ${typeof value}`);
|
||||||
|
} else if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.length > 0 &&
|
||||||
|
!Array.isArray(value[0])
|
||||||
|
) {
|
||||||
|
result.issues.push('TABLE_TYPE_MISMATCH');
|
||||||
|
result.notes.push('Expected string[][] for table mapping, got a flat array');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification === 'dynamic_template') {
|
||||||
|
if (mappingExists) {
|
||||||
|
result.issues.push('TOPIC_MAPPING_MISMATCH');
|
||||||
|
result.notes.push('Dynamic schema template must not have a static DB mapping');
|
||||||
|
} else {
|
||||||
|
result.notes.push('Handled by Dynamic Topic Engine schema cloning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification === 'legacy_content_token') {
|
||||||
|
if (mappingExists) {
|
||||||
|
result.issues.push('TOPIC_MAPPING_MISMATCH');
|
||||||
|
result.notes.push('Legacy item_topic token must not persist as a DB mapping');
|
||||||
|
} else {
|
||||||
|
result.notes.push('Injected only through dynamic topic runtime inputs');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classification === 'runtime_dynamic_input') {
|
||||||
|
if (mappingExists) {
|
||||||
|
result.issues.push('TOPIC_MAPPING_MISMATCH');
|
||||||
|
result.notes.push('Runtime dynamic topic keys must not be stored in DB mappings');
|
||||||
|
} else if (result.resolvedValue !== undefined) {
|
||||||
|
result.notes.push('Generated at runtime by Dynamic Topic Engine');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auditResults.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('## Summary');
|
||||||
|
console.log(
|
||||||
|
`- Static template placeholders: ${[...inventory.fieldNames].filter((key) => STATIC_EXPECTED_KEYS.has(key)).length}`
|
||||||
|
);
|
||||||
|
console.log(`- Template mappings: ${mappings.length}`);
|
||||||
|
console.log(
|
||||||
|
`- Dynamic topic runtime keys: ${auditResults.filter((item) => item.runtimeGenerated).length}`
|
||||||
|
);
|
||||||
|
console.log(`- Issues found: ${auditResults.filter((item) => item.issues.length > 0).length}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
auditResults.sort((left, right) => {
|
||||||
|
if (left.issues.length > 0 && right.issues.length === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.issues.length === 0 && right.issues.length > 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.placeholderKey.localeCompare(right.placeholderKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('## Detailed Results\n');
|
||||||
|
|
||||||
|
for (const result of auditResults) {
|
||||||
|
console.log(`### ${result.placeholderKey}`);
|
||||||
|
console.log(`- Template: ${result.templateExists ? 'yes' : 'no'}`);
|
||||||
|
console.log(`- Mapping: ${result.mappingExists ? 'yes' : 'no'}`);
|
||||||
|
console.log(`- Classification: ${result.classification}`);
|
||||||
|
console.log(`- Source: ${result.sourcePath ?? '-'}`);
|
||||||
|
console.log(
|
||||||
|
`- Value: ${JSON.stringify(result.resolvedValue).slice(0, 120)}${JSON.stringify(result.resolvedValue).length > 120 ? '...' : ''}`
|
||||||
|
);
|
||||||
|
console.log(`- Type: ${result.resolvedType}`);
|
||||||
|
|
||||||
|
if (result.issues.length > 0) {
|
||||||
|
console.log(`- Issues: ${result.issues.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.notes.length > 0) {
|
||||||
|
console.log(`- Notes: ${result.notes.join('; ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'docs',
|
||||||
|
'implementation',
|
||||||
|
'pdfme-runtime-audit-data.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
outputPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
quotation: {
|
||||||
|
id: quotation.id,
|
||||||
|
code: quotation.code,
|
||||||
|
organizationId: quotation.organizationId
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
id: template.template.id,
|
||||||
|
templateName: template.template.templateName,
|
||||||
|
versionId: template.version.id,
|
||||||
|
version: template.version.version
|
||||||
|
},
|
||||||
|
schemaInventory: {
|
||||||
|
fieldNames: [...inventory.fieldNames].sort(),
|
||||||
|
contentTokens: [...inventory.contentTokens].sort()
|
||||||
|
},
|
||||||
|
runtimeDynamicKeys: Object.keys(templateInput)
|
||||||
|
.filter((key) => key.startsWith('topic_') || key.startsWith('item_topic_'))
|
||||||
|
.sort(),
|
||||||
|
results: auditResults,
|
||||||
|
documentData: preview.documentData,
|
||||||
|
templateInput
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Full audit data saved to: ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
auditPdfmeRuntime().catch((error) => {
|
||||||
|
console.error('Audit failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -335,10 +335,16 @@ async function main() {
|
|||||||
`/api/crm/quotations/${draftQuotation.id}/approved-pdf`,
|
`/api/crm/quotations/${draftQuotation.id}/approved-pdf`,
|
||||||
{ method: 'POST' }
|
{ method: 'POST' }
|
||||||
);
|
);
|
||||||
|
const previewData = await fetchWithAuth(
|
||||||
|
baseUrl,
|
||||||
|
superAdminCookies,
|
||||||
|
`/api/crm/quotations/${approvedQuotation.id}/document-preview`
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
nonApprovedGenerate.status === 400,
|
nonApprovedGenerate.status === 400,
|
||||||
`Draft quotation POST expected 400, got ${nonApprovedGenerate.status}`
|
`Draft quotation POST expected 400, got ${nonApprovedGenerate.status}`
|
||||||
);
|
);
|
||||||
|
assert(previewData.status === 200, `Document preview expected 200, got ${previewData.status}`);
|
||||||
|
|
||||||
const adminPreview = await fetchWithAuth(
|
const adminPreview = await fetchWithAuth(
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -395,6 +401,7 @@ async function main() {
|
|||||||
|
|
||||||
const pdfShape = estimatePageCount(approvedGet.bytes);
|
const pdfShape = estimatePageCount(approvedGet.bytes);
|
||||||
const approvedSnapshot = persistence.quotation.approved_snapshot;
|
const approvedSnapshot = persistence.quotation.approved_snapshot;
|
||||||
|
const previewPayload = previewData.json?.preview;
|
||||||
const templatePageCount = await queryTemplatePageCount(
|
const templatePageCount = await queryTemplatePageCount(
|
||||||
sql,
|
sql,
|
||||||
persistence.quotation.approved_template_version_id
|
persistence.quotation.approved_template_version_id
|
||||||
@@ -432,6 +439,41 @@ async function main() {
|
|||||||
approvedSnapshot.documentData?.topics?.payment?.length >= 1,
|
approvedSnapshot.documentData?.topics?.payment?.length >= 1,
|
||||||
'approvedSnapshot is missing payment topic data'
|
'approvedSnapshot is missing payment topic data'
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
typeof previewPayload?.templateInput?.quotation_price === 'string',
|
||||||
|
'templateInput quotation_price is not formatted string'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
typeof previewPayload?.templateInput?.quotation_date === 'string',
|
||||||
|
'templateInput quotation_date is not formatted string'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
Array.isArray(previewPayload?.templateInput?.exclusion_data) &&
|
||||||
|
previewPayload.templateInput.exclusion_data.length >= 1 &&
|
||||||
|
Array.isArray(previewPayload.templateInput.exclusion_data[0]),
|
||||||
|
'templateInput exclusion_data is not a non-empty string[][] table'
|
||||||
|
);
|
||||||
|
const dynamicTopicKeys = Object.keys(previewPayload?.templateInput ?? {}).filter(
|
||||||
|
(key) => key.startsWith('topic_') || key.startsWith('item_topic_')
|
||||||
|
);
|
||||||
|
assert(dynamicTopicKeys.length >= 2, 'templateInput is missing dynamic topic keys');
|
||||||
|
assert(
|
||||||
|
Array.isArray(previewPayload?.documentData?.pdfme?.topic_inputs?.[dynamicTopicKeys[0]]),
|
||||||
|
'documentData pdfme.topic_inputs is missing generated topic inputs'
|
||||||
|
);
|
||||||
|
const approvedPdfText = approvedGet.bytes.toString('latin1');
|
||||||
|
assert(
|
||||||
|
!approvedPdfText.includes('{exclusion_data}'),
|
||||||
|
'Approved PDF still contains unresolved {exclusion_data}'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!approvedPdfText.includes('{quotation_price}'),
|
||||||
|
'Approved PDF still contains unresolved {quotation_price}'
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!approvedPdfText.includes('{item_topic}'),
|
||||||
|
'Approved PDF still contains unresolved {item_topic}'
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
approvedSnapshot.documentData?.approval?.approvers?.length >= 1,
|
approvedSnapshot.documentData?.approval?.approvers?.length >= 1,
|
||||||
'approvedSnapshot is missing approval block data'
|
'approvedSnapshot is missing approval block data'
|
||||||
@@ -471,6 +513,15 @@ async function main() {
|
|||||||
disposition: approvedGet.disposition,
|
disposition: approvedGet.disposition,
|
||||||
bytes: approvedGet.bytes.length
|
bytes: approvedGet.bytes.length
|
||||||
},
|
},
|
||||||
|
documentPreview: {
|
||||||
|
status: previewData.status,
|
||||||
|
quotationPrice: previewPayload?.templateInput?.quotation_price ?? null,
|
||||||
|
quotationDate: previewPayload?.templateInput?.quotation_date ?? null,
|
||||||
|
exclusionRows: Array.isArray(previewPayload?.templateInput?.exclusion_data)
|
||||||
|
? previewPayload.templateInput.exclusion_data.length
|
||||||
|
: 0,
|
||||||
|
dynamicTopicKeys
|
||||||
|
},
|
||||||
nonApprovedPost: {
|
nonApprovedPost: {
|
||||||
status: nonApprovedGenerate.status,
|
status: nonApprovedGenerate.status,
|
||||||
message: nonApprovedGenerate.json?.message ?? null
|
message: nonApprovedGenerate.json?.message ?? null
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ type Params = {
|
|||||||
const customerRequestSchema = customerSchema.extend({
|
const customerRequestSchema = customerSchema.extend({
|
||||||
branchId: z.string().nullable().optional(),
|
branchId: z.string().nullable().optional(),
|
||||||
leadChannel: z.string().nullable().optional(),
|
leadChannel: z.string().nullable().optional(),
|
||||||
customerGroup: z.string().nullable().optional()
|
customerGroup: z.string().nullable().optional(),
|
||||||
|
customerSubGroup: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
@@ -44,9 +45,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
console.error('GET /api/crm/customers/[id] failed', error);
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Unable to load customer' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to load customer' }, { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -89,9 +88,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
console.error('PATCH /api/crm/customers/[id] failed', error);
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Unable to update customer' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to update customer' }, { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -125,9 +122,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
console.error('DELETE /api/crm/customers/[id] failed', error);
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Unable to delete customer' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to delete customer' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
|||||||
const customerRequestSchema = customerSchema.extend({
|
const customerRequestSchema = customerSchema.extend({
|
||||||
branchId: z.string().nullable().optional(),
|
branchId: z.string().nullable().optional(),
|
||||||
leadChannel: z.string().nullable().optional(),
|
leadChannel: z.string().nullable().optional(),
|
||||||
customerGroup: z.string().nullable().optional()
|
customerGroup: z.string().nullable().optional(),
|
||||||
|
customerSubGroup: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -50,9 +51,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
console.error('GET /api/crm/customers failed', error);
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Unable to load customers' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to load customers' }, { status: 500 });
|
||||||
}
|
}
|
||||||
@@ -95,9 +94,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
console.error('POST /api/crm/customers failed', error);
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Unable to create customer' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to create customer' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.error('GET /api/crm/dashboard failed', error);
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Unable to load CRM dashboard' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to load CRM dashboard' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
import Providers from '@/components/layout/providers';
|
import Providers from "@/components/layout/providers";
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { fontVariables } from '@/components/themes/font.config';
|
import { fontVariables } from "@/components/themes/font.config";
|
||||||
import { DEFAULT_THEME, THEMES } from '@/components/themes/theme.config';
|
import { DEFAULT_THEME, THEMES } from "@/components/themes/theme.config";
|
||||||
import ThemeProvider from '@/components/themes/theme-provider';
|
import ThemeProvider from "@/components/themes/theme-provider";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from "next";
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from "next/headers";
|
||||||
import NextTopLoader from 'nextjs-toploader';
|
import NextTopLoader from "nextjs-toploader";
|
||||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||||
import '../styles/globals.css';
|
import "../styles/globals.css";
|
||||||
|
|
||||||
const META_THEME_COLORS = {
|
const META_THEME_COLORS = {
|
||||||
light: '#f9fbfa',
|
light: "#f9fbfa",
|
||||||
dark: '#001e2b'
|
dark: "#001e2b",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Next Shadcn',
|
title: "ALLA OS",
|
||||||
description: 'Basic dashboard with Next.js and Shadcn'
|
description: "alla os",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: META_THEME_COLORS.light
|
themeColor: META_THEME_COLORS.light,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const activeThemeValue = cookieStore.get('active_theme')?.value;
|
const activeThemeValue = cookieStore.get("active_theme")?.value;
|
||||||
const isValidTheme = THEMES.some((t) => t.value === activeThemeValue);
|
const isValidTheme = THEMES.some((t) => t.value === activeThemeValue);
|
||||||
const themeToApply = isValidTheme ? activeThemeValue! : DEFAULT_THEME;
|
const themeToApply = isValidTheme ? activeThemeValue! : DEFAULT_THEME;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang='en' suppressHydrationWarning data-theme={themeToApply}>
|
<html lang="en" suppressHydrationWarning data-theme={themeToApply}>
|
||||||
<head>
|
<head>
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
@@ -42,21 +46,21 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '${META_THEME_COLORS.dark}')
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '${META_THEME_COLORS.dark}')
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
`
|
`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background overflow-x-hidden overscroll-none font-sans antialiased',
|
"bg-background overflow-x-hidden overscroll-none font-sans antialiased",
|
||||||
fontVariables
|
fontVariables,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NextTopLoader color='var(--primary)' showSpinner={false} />
|
<NextTopLoader color="var(--primary)" showSpinner={false} />
|
||||||
<NuqsAdapter>
|
<NuqsAdapter>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute='class'
|
attribute="class"
|
||||||
defaultTheme='system'
|
defaultTheme="system"
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
enableColorScheme
|
enableColorScheme
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface SelectFieldProps {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
options: Option[];
|
options: Option[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectField({
|
export function SelectField({
|
||||||
@@ -32,7 +33,8 @@ export function SelectField({
|
|||||||
description,
|
description,
|
||||||
required,
|
required,
|
||||||
options,
|
options,
|
||||||
placeholder = 'Select an option'
|
placeholder = 'Select an option',
|
||||||
|
disabled = false
|
||||||
}: SelectFieldProps) {
|
}: SelectFieldProps) {
|
||||||
const field = useFieldContext();
|
const field = useFieldContext();
|
||||||
const isTouched = useStore(field.store, (s) => s.meta.isTouched);
|
const isTouched = useStore(field.store, (s) => s.meta.isTouched);
|
||||||
@@ -48,12 +50,13 @@ export function SelectField({
|
|||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
onValueChange={field.handleChange}
|
onValueChange={field.handleChange}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) field.handleBlur();
|
if (!open) field.handleBlur();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger id={field.name} aria-invalid={isTouched && !isValid}>
|
<SelectTrigger id={field.name} aria-invalid={isTouched && !isValid} disabled={disabled}>
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -34,52 +34,52 @@ import { NavGroup } from "@/types";
|
|||||||
* Use the `access` property for new items.
|
* Use the `access` property for new items.
|
||||||
*/
|
*/
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
{
|
// {
|
||||||
label: "Overview",
|
// label: "Overview",
|
||||||
items: [
|
// items: [
|
||||||
{
|
// {
|
||||||
title: "Dashboard Overview",
|
// title: "Dashboard Overview",
|
||||||
url: "/dashboard",
|
// url: "/dashboard",
|
||||||
icon: "dashboard",
|
// icon: "dashboard",
|
||||||
isActive: false,
|
// isActive: false,
|
||||||
shortcut: ["d", "d"],
|
// shortcut: ["d", "d"],
|
||||||
items: [],
|
// items: [],
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
title: "Workspaces",
|
// title: "Workspaces",
|
||||||
url: "/dashboard/workspaces",
|
// url: "/dashboard/workspaces",
|
||||||
icon: "workspace",
|
// icon: "workspace",
|
||||||
isActive: false,
|
// isActive: false,
|
||||||
items: [],
|
// items: [],
|
||||||
access: { systemRole: "super_admin" },
|
// access: { systemRole: "super_admin" },
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
title: "Teams",
|
// title: "Teams",
|
||||||
url: "/dashboard/workspaces/team",
|
// url: "/dashboard/workspaces/team",
|
||||||
icon: "teams",
|
// icon: "teams",
|
||||||
isActive: false,
|
// isActive: false,
|
||||||
items: [],
|
// items: [],
|
||||||
access: { requireOrg: true, role: "admin" },
|
// access: { requireOrg: true, role: "admin" },
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
title: "Users",
|
// title: "Users",
|
||||||
url: "/dashboard/users",
|
// url: "/dashboard/users",
|
||||||
icon: "teams",
|
// icon: "teams",
|
||||||
shortcut: ["u", "u"],
|
// shortcut: ["u", "u"],
|
||||||
isActive: false,
|
// isActive: false,
|
||||||
items: [],
|
// items: [],
|
||||||
access: { requireOrg: true, permission: "users:manage" },
|
// access: { requireOrg: true, permission: "users:manage" },
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
title: "Kanban",
|
// title: "Kanban",
|
||||||
url: "/dashboard/kanban",
|
// url: "/dashboard/kanban",
|
||||||
icon: "kanban",
|
// icon: "kanban",
|
||||||
shortcut: ["k", "k"],
|
// shortcut: ["k", "k"],
|
||||||
isActive: false,
|
// isActive: false,
|
||||||
items: [],
|
// items: [],
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
label: "CRM",
|
label: "CRM",
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export const crmCustomers = pgTable(
|
|||||||
website: text('website'),
|
website: text('website'),
|
||||||
leadChannel: text('lead_channel'),
|
leadChannel: text('lead_channel'),
|
||||||
customerGroup: text('customer_group'),
|
customerGroup: text('customer_group'),
|
||||||
|
customerSubGroup: text('customer_sub_group'),
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
isActive: boolean('is_active').default(true).notNull(),
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type SeedOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
parentCode?: string;
|
||||||
|
parentCategory?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BranchSeedRow = {
|
type BranchSeedRow = {
|
||||||
@@ -110,9 +112,85 @@ const FOUNDATION_OPTIONS = {
|
|||||||
{ code: 'individual', label: 'Individual', value: 'individual', sortOrder: 2 }
|
{ code: 'individual', label: 'Individual', value: 'individual', sortOrder: 2 }
|
||||||
],
|
],
|
||||||
crm_customer_group: [
|
crm_customer_group: [
|
||||||
{ code: 'strategic', label: 'Strategic', value: 'strategic', sortOrder: 1 },
|
{ code: 'industrial', label: 'Industrial', value: 'industrial', sortOrder: 1 },
|
||||||
{ code: 'standard', label: 'Standard', value: 'standard', sortOrder: 2 },
|
{ code: 'construction', label: 'Construction', value: 'construction', sortOrder: 2 },
|
||||||
{ code: 'project', label: 'Project', value: 'project', sortOrder: 3 }
|
{ code: 'government', label: 'Government', value: 'government', sortOrder: 3 },
|
||||||
|
{ code: 'dealer', label: 'Dealer', value: 'dealer', sortOrder: 4 },
|
||||||
|
{ code: 'other', label: 'Other', value: 'other', sortOrder: 5 }
|
||||||
|
],
|
||||||
|
crm_customer_sub_group: [
|
||||||
|
{
|
||||||
|
code: 'factory',
|
||||||
|
label: 'Factory',
|
||||||
|
value: 'factory',
|
||||||
|
sortOrder: 1,
|
||||||
|
parentCode: 'industrial',
|
||||||
|
parentCategory: 'crm_customer_group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'warehouse',
|
||||||
|
label: 'Warehouse',
|
||||||
|
value: 'warehouse',
|
||||||
|
sortOrder: 2,
|
||||||
|
parentCode: 'industrial',
|
||||||
|
parentCategory: 'crm_customer_group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'logistics',
|
||||||
|
label: 'Logistics',
|
||||||
|
value: 'logistics',
|
||||||
|
sortOrder: 3,
|
||||||
|
parentCode: 'industrial',
|
||||||
|
parentCategory: 'crm_customer_group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'contractor',
|
||||||
|
label: 'Contractor',
|
||||||
|
value: 'contractor',
|
||||||
|
sortOrder: 4,
|
||||||
|
parentCode: 'construction',
|
||||||
|
parentCategory: 'crm_customer_group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'consultant',
|
||||||
|
label: 'Consultant',
|
||||||
|
value: 'consultant',
|
||||||
|
sortOrder: 5,
|
||||||
|
parentCode: 'construction',
|
||||||
|
parentCategory: 'crm_customer_group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'state_enterprise',
|
||||||
|
label: 'State Enterprise',
|
||||||
|
value: 'state_enterprise',
|
||||||
|
sortOrder: 6,
|
||||||
|
parentCode: 'government',
|
||||||
|
parentCategory: 'crm_customer_group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'government_agency',
|
||||||
|
label: 'Government Agency',
|
||||||
|
value: 'government_agency',
|
||||||
|
sortOrder: 7,
|
||||||
|
parentCode: 'government',
|
||||||
|
parentCategory: 'crm_customer_group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'reseller',
|
||||||
|
label: 'Reseller',
|
||||||
|
value: 'reseller',
|
||||||
|
sortOrder: 8,
|
||||||
|
parentCode: 'dealer',
|
||||||
|
parentCategory: 'crm_customer_group'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'other',
|
||||||
|
label: 'Other',
|
||||||
|
value: 'other',
|
||||||
|
sortOrder: 9,
|
||||||
|
parentCode: 'other',
|
||||||
|
parentCategory: 'crm_customer_group'
|
||||||
|
}
|
||||||
],
|
],
|
||||||
crm_enquiry_status: [
|
crm_enquiry_status: [
|
||||||
{ code: 'new', label: 'New', value: 'new', sortOrder: 1 },
|
{ code: 'new', label: 'New', value: 'new', sortOrder: 1 },
|
||||||
@@ -265,7 +343,7 @@ const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'customer_att',
|
placeholderKey: 'customer_att',
|
||||||
sourcePath: 'contact.name',
|
sourcePath: 'quotation.attention',
|
||||||
dataType: 'scalar',
|
dataType: 'scalar',
|
||||||
sortOrder: 5
|
sortOrder: 5
|
||||||
},
|
},
|
||||||
@@ -283,9 +361,9 @@ const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'quotation_date',
|
placeholderKey: 'quotation_date',
|
||||||
sourcePath: 'quotation.quotationDate',
|
sourcePath: 'pdfme.quotation_date',
|
||||||
dataType: 'scalar',
|
dataType: 'scalar',
|
||||||
formatMask: 'date',
|
formatMask: null,
|
||||||
sortOrder: 8
|
sortOrder: 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -296,34 +374,70 @@ const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'quotation_price',
|
placeholderKey: 'quotation_price',
|
||||||
sourcePath: 'quotation.totalAmount',
|
sourcePath: 'pdfme.quotation_price',
|
||||||
dataType: 'scalar',
|
dataType: 'scalar',
|
||||||
formatMask: 'currency',
|
formatMask: null,
|
||||||
sortOrder: 10
|
sortOrder: 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'currency',
|
placeholderKey: 'currency',
|
||||||
sourcePath: 'quotation.currencyCode',
|
sourcePath: 'quotation.currency',
|
||||||
dataType: 'scalar',
|
dataType: 'scalar',
|
||||||
sortOrder: 11
|
sortOrder: 11
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'exclusion_data',
|
placeholderKey: 'exclusion_data',
|
||||||
sourcePath: 'topics.exclusion',
|
sourcePath: 'pdfme.exclusion_data',
|
||||||
dataType: 'multiline',
|
dataType: 'table',
|
||||||
sortOrder: 12
|
sortOrder: 12
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'item_topic',
|
placeholderKey: 'app1',
|
||||||
sourcePath: 'topics.scope',
|
sourcePath: 'signatures.preparedBy',
|
||||||
dataType: 'multiline',
|
dataType: 'scalar',
|
||||||
|
defaultValue: '-',
|
||||||
sortOrder: 13
|
sortOrder: 13
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'app1_position',
|
||||||
|
sourcePath: 'signatures.preparedByPosition',
|
||||||
|
dataType: 'scalar',
|
||||||
|
defaultValue: '-',
|
||||||
|
sortOrder: 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'app2',
|
||||||
|
sourcePath: 'signatures.salesManager',
|
||||||
|
dataType: 'scalar',
|
||||||
|
defaultValue: '-',
|
||||||
|
sortOrder: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'app2_position',
|
||||||
|
sourcePath: 'signatures.salesManagerPosition',
|
||||||
|
dataType: 'scalar',
|
||||||
|
defaultValue: '-',
|
||||||
|
sortOrder: 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'app3',
|
||||||
|
sourcePath: 'signatures.topManager',
|
||||||
|
dataType: 'scalar',
|
||||||
|
defaultValue: '-',
|
||||||
|
sortOrder: 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderKey: 'app3_position',
|
||||||
|
sourcePath: 'signatures.topManagerPosition',
|
||||||
|
dataType: 'scalar',
|
||||||
|
defaultValue: '-',
|
||||||
|
sortOrder: 18
|
||||||
|
},
|
||||||
{
|
{
|
||||||
placeholderKey: 'items_table',
|
placeholderKey: 'items_table',
|
||||||
sourcePath: 'items',
|
sourcePath: 'items',
|
||||||
dataType: 'table',
|
dataType: 'table',
|
||||||
sortOrder: 14,
|
sortOrder: 19,
|
||||||
columns: [
|
columns: [
|
||||||
{ columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
|
{ columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
|
||||||
{ columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
|
{ columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
|
||||||
@@ -387,6 +501,20 @@ async function upsertMasterOptionsForOrganization(
|
|||||||
[string, SeedOption[]]
|
[string, SeedOption[]]
|
||||||
>) {
|
>) {
|
||||||
for (const option of options) {
|
for (const option of options) {
|
||||||
|
const parentRow =
|
||||||
|
option.parentCode && option.parentCategory
|
||||||
|
? (
|
||||||
|
await sql`
|
||||||
|
select id
|
||||||
|
from ms_options
|
||||||
|
where organization_id = ${organizationId}
|
||||||
|
and category = ${option.parentCategory}
|
||||||
|
and code = ${option.parentCode}
|
||||||
|
and deleted_at is null
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
)[0] ?? null
|
||||||
|
: null;
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
insert into ms_options (
|
insert into ms_options (
|
||||||
id,
|
id,
|
||||||
@@ -407,7 +535,7 @@ async function upsertMasterOptionsForOrganization(
|
|||||||
${option.code},
|
${option.code},
|
||||||
${option.label},
|
${option.label},
|
||||||
${option.value},
|
${option.value},
|
||||||
${null},
|
${parentRow?.id ?? null},
|
||||||
${option.sortOrder},
|
${option.sortOrder},
|
||||||
${true},
|
${true},
|
||||||
${null},
|
${null},
|
||||||
@@ -651,38 +779,7 @@ async function upsertDocumentTemplatesForOrganization(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const mapping of DOCUMENT_TEMPLATE_MAPPINGS) {
|
for (const mapping of DOCUMENT_TEMPLATE_MAPPINGS) {
|
||||||
const [mappingRow] = await sql`
|
const existingMapping =
|
||||||
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`
|
await sql`
|
||||||
select id
|
select id
|
||||||
@@ -690,16 +787,88 @@ async function upsertDocumentTemplatesForOrganization(
|
|||||||
where organization_id = ${organization.id}
|
where organization_id = ${organization.id}
|
||||||
and template_version_id = ${resolvedVersionId}
|
and template_version_id = ${resolvedVersionId}
|
||||||
and placeholder_key = ${mapping.placeholderKey}
|
and placeholder_key = ${mapping.placeholderKey}
|
||||||
and deleted_at is null
|
|
||||||
limit 1
|
limit 1
|
||||||
`
|
`
|
||||||
)[0]?.id;
|
)[0] ?? null;
|
||||||
|
|
||||||
|
const resolvedMappingId = existingMapping?.id ?? crypto.randomUUID();
|
||||||
|
|
||||||
|
if (existingMapping) {
|
||||||
|
await sql`
|
||||||
|
update crm_document_template_mappings
|
||||||
|
set
|
||||||
|
source_path = ${mapping.sourcePath},
|
||||||
|
data_type = ${mapping.dataType},
|
||||||
|
sheet_name = ${null},
|
||||||
|
default_value = ${mapping.defaultValue ?? null},
|
||||||
|
format_mask = ${mapping.formatMask ?? null},
|
||||||
|
sort_order = ${mapping.sortOrder},
|
||||||
|
deleted_at = ${null},
|
||||||
|
updated_at = now()
|
||||||
|
where id = ${resolvedMappingId}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
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 (
|
||||||
|
${resolvedMappingId},
|
||||||
|
${organization.id},
|
||||||
|
${resolvedVersionId},
|
||||||
|
${mapping.placeholderKey},
|
||||||
|
${mapping.sourcePath},
|
||||||
|
${mapping.dataType},
|
||||||
|
${null},
|
||||||
|
${mapping.defaultValue ?? null},
|
||||||
|
${mapping.formatMask ?? null},
|
||||||
|
${mapping.sortOrder},
|
||||||
|
${null}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
if (!resolvedMappingId || !mapping.columns?.length) {
|
if (!resolvedMappingId || !mapping.columns?.length) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const column of mapping.columns) {
|
for (const column of mapping.columns) {
|
||||||
|
const existingColumn =
|
||||||
|
(
|
||||||
|
await sql`
|
||||||
|
select id
|
||||||
|
from crm_document_template_table_columns
|
||||||
|
where organization_id = ${organization.id}
|
||||||
|
and mapping_id = ${resolvedMappingId}
|
||||||
|
and column_name = ${column.columnName}
|
||||||
|
limit 1
|
||||||
|
`
|
||||||
|
)[0] ?? null;
|
||||||
|
|
||||||
|
if (existingColumn) {
|
||||||
|
await sql`
|
||||||
|
update crm_document_template_table_columns
|
||||||
|
set
|
||||||
|
source_field = ${column.sourceField},
|
||||||
|
column_letter = ${column.columnLetter ?? null},
|
||||||
|
sort_order = ${column.sortOrder},
|
||||||
|
format_mask = ${column.formatMask ?? null},
|
||||||
|
deleted_at = ${null},
|
||||||
|
updated_at = now()
|
||||||
|
where id = ${existingColumn.id}
|
||||||
|
`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
insert into crm_document_template_table_columns (
|
insert into crm_document_template_table_columns (
|
||||||
id,
|
id,
|
||||||
@@ -722,10 +891,25 @@ async function upsertDocumentTemplatesForOrganization(
|
|||||||
${column.formatMask ?? null},
|
${column.formatMask ?? null},
|
||||||
${null}
|
${null}
|
||||||
)
|
)
|
||||||
on conflict do nothing
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
update crm_document_template_mappings
|
||||||
|
set
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
where organization_id = ${organization.id}
|
||||||
|
and template_version_id = ${resolvedVersionId}
|
||||||
|
and (
|
||||||
|
placeholder_key in (${'topic'}, ${'data_topic'}, ${'item_topic'})
|
||||||
|
or placeholder_key like 'topic\_%' escape '\'
|
||||||
|
or placeholder_key like 'data_topic\_%' escape '\'
|
||||||
|
or placeholder_key like 'item_topic\_%' escape '\'
|
||||||
|
)
|
||||||
|
and deleted_at is null
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface CustomerOption {
|
|||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BranchOption {
|
export interface BranchOption {
|
||||||
@@ -34,6 +35,7 @@ export interface CustomerRecord {
|
|||||||
website: string | null;
|
website: string | null;
|
||||||
leadChannel: string | null;
|
leadChannel: string | null;
|
||||||
customerGroup: string | null;
|
customerGroup: string | null;
|
||||||
|
customerSubGroup: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -92,6 +94,7 @@ export interface CustomerReferenceData {
|
|||||||
customerStatuses: CustomerOption[];
|
customerStatuses: CustomerOption[];
|
||||||
leadChannels: CustomerOption[];
|
leadChannels: CustomerOption[];
|
||||||
customerGroups: CustomerOption[];
|
customerGroups: CustomerOption[];
|
||||||
|
customerSubGroups: CustomerOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerFilters {
|
export interface CustomerFilters {
|
||||||
@@ -148,6 +151,7 @@ export interface CustomerMutationPayload {
|
|||||||
website?: string;
|
website?: string;
|
||||||
leadChannel?: string | null;
|
leadChannel?: string | null;
|
||||||
customerGroup?: string | null;
|
customerGroup?: string | null;
|
||||||
|
customerSubGroup?: string | null;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export function getCustomerColumns({
|
|||||||
const branchMap = new Map(referenceData.branches.map((item) => [item.id, item]));
|
const branchMap = new Map(referenceData.branches.map((item) => [item.id, item]));
|
||||||
const typeMap = new Map(referenceData.customerTypes.map((item) => [item.id, item]));
|
const typeMap = new Map(referenceData.customerTypes.map((item) => [item.id, item]));
|
||||||
const statusMap = new Map(referenceData.customerStatuses.map((item) => [item.id, item]));
|
const statusMap = new Map(referenceData.customerStatuses.map((item) => [item.id, item]));
|
||||||
|
const groupMap = new Map(referenceData.customerGroups.map((item) => [item.id, item.label]));
|
||||||
|
const subGroupMap = new Map(referenceData.customerSubGroups.map((item) => [item.id, item.label]));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -115,6 +117,22 @@ export function getCustomerColumns({
|
|||||||
},
|
},
|
||||||
enableColumnFilter: true
|
enableColumnFilter: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'customerGroup',
|
||||||
|
accessorKey: 'customerGroup',
|
||||||
|
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Group' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <span>{groupMap.get(row.original.customerGroup ?? '') ?? '-'}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customerSubGroup',
|
||||||
|
accessorKey: 'customerSubGroup',
|
||||||
|
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Sub Group' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <span>{subGroupMap.get(row.original.customerSubGroup ?? '') ?? '-'}</span>
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'contactCount',
|
id: 'contactCount',
|
||||||
accessorKey: 'contactCount',
|
accessorKey: 'contactCount',
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export function CustomerDetail({
|
|||||||
() => new Map(referenceData.customerGroups.map((item) => [item.id, item.label])),
|
() => new Map(referenceData.customerGroups.map((item) => [item.id, item.label])),
|
||||||
[referenceData]
|
[referenceData]
|
||||||
);
|
);
|
||||||
|
const subGroupMap = useMemo(
|
||||||
|
() => new Map(referenceData.customerSubGroups.map((item) => [item.id, item.label])),
|
||||||
|
[referenceData]
|
||||||
|
);
|
||||||
const customer = data.customer;
|
const customer = data.customer;
|
||||||
const status = statusMap.get(customer.customerStatus);
|
const status = statusMap.get(customer.customerStatus);
|
||||||
const type = typeMap.get(customer.customerType);
|
const type = typeMap.get(customer.customerType);
|
||||||
@@ -145,6 +149,10 @@ export function CustomerDetail({
|
|||||||
label='Customer Group'
|
label='Customer Group'
|
||||||
value={groupMap.get(customer.customerGroup ?? '')}
|
value={groupMap.get(customer.customerGroup ?? '')}
|
||||||
/>
|
/>
|
||||||
|
<FieldItem
|
||||||
|
label='Customer Sub Group'
|
||||||
|
value={subGroupMap.get(customer.customerSubGroup ?? '')}
|
||||||
|
/>
|
||||||
<FieldItem label='Active' value={customer.isActive ? 'Yes' : 'No'} />
|
<FieldItem label='Active' value={customer.isActive ? 'Yes' : 'No'} />
|
||||||
<div className='md:col-span-2 xl:col-span-4'>
|
<div className='md:col-span-2 xl:col-span-4'>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useStore } from '@tanstack/react-form';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
@@ -49,6 +50,7 @@ function toDefaultValues(
|
|||||||
website: customer?.website ?? '',
|
website: customer?.website ?? '',
|
||||||
leadChannel: customer?.leadChannel ?? undefined,
|
leadChannel: customer?.leadChannel ?? undefined,
|
||||||
customerGroup: customer?.customerGroup ?? undefined,
|
customerGroup: customer?.customerGroup ?? undefined,
|
||||||
|
customerSubGroup: customer?.customerSubGroup ?? undefined,
|
||||||
notes: customer?.notes ?? '',
|
notes: customer?.notes ?? '',
|
||||||
isActive: customer?.isActive ?? true
|
isActive: customer?.isActive ?? true
|
||||||
};
|
};
|
||||||
@@ -98,7 +100,8 @@ export function CustomerFormSheet({
|
|||||||
...value,
|
...value,
|
||||||
branchId: value.branchId || null,
|
branchId: value.branchId || null,
|
||||||
leadChannel: value.leadChannel || null,
|
leadChannel: value.leadChannel || null,
|
||||||
customerGroup: value.customerGroup || null
|
customerGroup: value.customerGroup || null,
|
||||||
|
customerSubGroup: value.customerSubGroup || null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit && customer) {
|
if (isEdit && customer) {
|
||||||
@@ -117,6 +120,25 @@ export function CustomerFormSheet({
|
|||||||
|
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
}, [defaultValues, form, open]);
|
}, [defaultValues, form, open]);
|
||||||
|
const selectedCustomerGroup = useStore(form.store, (state) => state.values.customerGroup);
|
||||||
|
const selectedCustomerSubGroup = useStore(form.store, (state) => state.values.customerSubGroup);
|
||||||
|
const filteredCustomerSubGroups = useMemo(
|
||||||
|
() =>
|
||||||
|
referenceData.customerSubGroups.filter((item) => item.parentId === selectedCustomerGroup),
|
||||||
|
[referenceData.customerSubGroups, selectedCustomerGroup]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCustomerSubGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = filteredCustomerSubGroups.some((item) => item.id === selectedCustomerSubGroup);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
form.setFieldValue('customerSubGroup', '');
|
||||||
|
}
|
||||||
|
}, [filteredCustomerSubGroups, form, selectedCustomerSubGroup]);
|
||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
@@ -193,6 +215,18 @@ export function CustomerFormSheet({
|
|||||||
label: item.label
|
label: item.label
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='customerSubGroup'
|
||||||
|
label='Customer Sub Group'
|
||||||
|
disabled={!selectedCustomerGroup}
|
||||||
|
placeholder={
|
||||||
|
selectedCustomerGroup ? 'Select customer sub group' : 'Select customer group first'
|
||||||
|
}
|
||||||
|
options={filteredCustomerSubGroups.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.label
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
<div className='md:col-span-2'>
|
<div className='md:col-span-2'>
|
||||||
<FormTextField name='address' label='Address' placeholder='123 Main Road' />
|
<FormTextField name='address' label='Address' placeholder='123 Main Road' />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const customerSchema = z.object({
|
|||||||
website: z.string().optional(),
|
website: z.string().optional(),
|
||||||
leadChannel: z.string().optional().nullable(),
|
leadChannel: z.string().optional().nullable(),
|
||||||
customerGroup: z.string().optional().nullable(),
|
customerGroup: z.string().optional().nullable(),
|
||||||
|
customerSubGroup: z.string().optional().nullable(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
isActive: z.boolean().default(true)
|
isActive: z.boolean().default(true)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
|
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||||
import { crmCustomerContacts, crmCustomers, msOptions, users } from '@/db/schema';
|
import { crmCustomerContacts, crmCustomers, msOptions, users } from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
@@ -19,11 +19,18 @@ import type {
|
|||||||
CustomerReferenceData
|
CustomerReferenceData
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
|
|
||||||
|
type CustomerRecordSource = Omit<typeof crmCustomers.$inferSelect, 'customerSubGroup'> & {
|
||||||
|
customerSubGroup?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let customerSubGroupColumnAvailable: boolean | undefined;
|
||||||
|
|
||||||
const CUSTOMER_OPTION_CATEGORIES = {
|
const CUSTOMER_OPTION_CATEGORIES = {
|
||||||
customerType: 'crm_customer_type',
|
customerType: 'crm_customer_type',
|
||||||
customerStatus: 'crm_customer_status',
|
customerStatus: 'crm_customer_status',
|
||||||
leadChannel: 'crm_lead_channel',
|
leadChannel: 'crm_lead_channel',
|
||||||
customerGroup: 'crm_customer_group'
|
customerGroup: 'crm_customer_group',
|
||||||
|
customerSubGroup: 'crm_customer_sub_group'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function mapCustomerOption(
|
function mapCustomerOption(
|
||||||
@@ -33,7 +40,18 @@ function mapCustomerOption(
|
|||||||
id: option.id,
|
id: option.id,
|
||||||
code: option.code,
|
code: option.code,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
value: option.value
|
value: option.value,
|
||||||
|
parentId: option.parentId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCustomerOptionRow(row: typeof msOptions.$inferSelect): CustomerOption {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
label: row.label,
|
||||||
|
value: row.value,
|
||||||
|
parentId: row.parentId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +66,7 @@ function mapBranchOption(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCustomerRecord(row: typeof crmCustomers.$inferSelect): CustomerRecord {
|
function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
@@ -71,6 +89,7 @@ function mapCustomerRecord(row: typeof crmCustomers.$inferSelect): CustomerRecor
|
|||||||
website: row.website,
|
website: row.website,
|
||||||
leadChannel: row.leadChannel,
|
leadChannel: row.leadChannel,
|
||||||
customerGroup: row.customerGroup,
|
customerGroup: row.customerGroup,
|
||||||
|
customerSubGroup: row.customerSubGroup ?? null,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
isActive: row.isActive,
|
isActive: row.isActive,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
@@ -81,6 +100,66 @@ function mapCustomerRecord(row: typeof crmCustomers.$inferSelect): CustomerRecor
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseCustomerRecordSelection = {
|
||||||
|
id: crmCustomers.id,
|
||||||
|
organizationId: crmCustomers.organizationId,
|
||||||
|
branchId: crmCustomers.branchId,
|
||||||
|
code: crmCustomers.code,
|
||||||
|
name: crmCustomers.name,
|
||||||
|
abbr: crmCustomers.abbr,
|
||||||
|
taxId: crmCustomers.taxId,
|
||||||
|
customerType: crmCustomers.customerType,
|
||||||
|
customerStatus: crmCustomers.customerStatus,
|
||||||
|
address: crmCustomers.address,
|
||||||
|
province: crmCustomers.province,
|
||||||
|
district: crmCustomers.district,
|
||||||
|
subDistrict: crmCustomers.subDistrict,
|
||||||
|
postalCode: crmCustomers.postalCode,
|
||||||
|
country: crmCustomers.country,
|
||||||
|
phone: crmCustomers.phone,
|
||||||
|
fax: crmCustomers.fax,
|
||||||
|
email: crmCustomers.email,
|
||||||
|
website: crmCustomers.website,
|
||||||
|
leadChannel: crmCustomers.leadChannel,
|
||||||
|
customerGroup: crmCustomers.customerGroup,
|
||||||
|
notes: crmCustomers.notes,
|
||||||
|
isActive: crmCustomers.isActive,
|
||||||
|
createdAt: crmCustomers.createdAt,
|
||||||
|
updatedAt: crmCustomers.updatedAt,
|
||||||
|
deletedAt: crmCustomers.deletedAt,
|
||||||
|
createdBy: crmCustomers.createdBy,
|
||||||
|
updatedBy: crmCustomers.updatedBy
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function getCustomerRecordSelection(includeCustomerSubGroup: boolean) {
|
||||||
|
return includeCustomerSubGroup
|
||||||
|
? {
|
||||||
|
...baseCustomerRecordSelection,
|
||||||
|
customerSubGroup: crmCustomers.customerSubGroup
|
||||||
|
}
|
||||||
|
: baseCustomerRecordSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasCustomerSubGroupColumn() {
|
||||||
|
if (customerSubGroupColumnAvailable !== undefined) {
|
||||||
|
return customerSubGroupColumnAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.execute<{ exists: boolean }>(sql`
|
||||||
|
select exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = 'crm_customers'
|
||||||
|
and column_name = 'customer_sub_group'
|
||||||
|
) as "exists"
|
||||||
|
`);
|
||||||
|
|
||||||
|
customerSubGroupColumnAvailable = Boolean(result[0]?.exists);
|
||||||
|
|
||||||
|
return customerSubGroupColumnAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
function mapCustomerContactRecord(
|
function mapCustomerContactRecord(
|
||||||
row: typeof crmCustomerContacts.$inferSelect
|
row: typeof crmCustomerContacts.$inferSelect
|
||||||
): CustomerContactRecord {
|
): CustomerContactRecord {
|
||||||
@@ -170,13 +249,18 @@ async function assertMasterOptionValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
||||||
const customer = await db.query.crmCustomers.findFirst({
|
const includeCustomerSubGroup = await hasCustomerSubGroupColumn();
|
||||||
where: and(
|
const [customer] = await db
|
||||||
eq(crmCustomers.id, id),
|
.select(getCustomerRecordSelection(includeCustomerSubGroup))
|
||||||
eq(crmCustomers.organizationId, organizationId),
|
.from(crmCustomers)
|
||||||
isNull(crmCustomers.deletedAt)
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmCustomers.id, id),
|
||||||
|
eq(crmCustomers.organizationId, organizationId),
|
||||||
|
isNull(crmCustomers.deletedAt)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
});
|
.limit(1);
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
throw new AuthError('Customer not found', 404);
|
throw new AuthError('Customer not found', 404);
|
||||||
@@ -227,10 +311,38 @@ async function validateCustomerPayload(organizationId: string, payload: Customer
|
|||||||
CUSTOMER_OPTION_CATEGORIES.customerGroup,
|
CUSTOMER_OPTION_CATEGORIES.customerGroup,
|
||||||
payload.customerGroup ?? null
|
payload.customerGroup ?? null
|
||||||
);
|
);
|
||||||
|
await assertMasterOptionValue(
|
||||||
|
organizationId,
|
||||||
|
CUSTOMER_OPTION_CATEGORIES.customerSubGroup,
|
||||||
|
payload.customerSubGroup ?? null
|
||||||
|
);
|
||||||
|
|
||||||
if (payload.branchId) {
|
if (payload.branchId) {
|
||||||
await validateBranchAccess(payload.branchId);
|
await validateBranchAccess(payload.branchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.customerSubGroup) {
|
||||||
|
if (!payload.customerGroup) {
|
||||||
|
throw new AuthError('Customer sub group requires a customer group', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subGroup = await db.query.msOptions.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(msOptions.organizationId, organizationId),
|
||||||
|
eq(msOptions.category, CUSTOMER_OPTION_CATEGORIES.customerSubGroup),
|
||||||
|
eq(msOptions.id, payload.customerSubGroup),
|
||||||
|
isNull(msOptions.deletedAt)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subGroup) {
|
||||||
|
throw new AuthError('Invalid customer sub group', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subGroup.parentId !== payload.customerGroup) {
|
||||||
|
throw new AuthError('Selected customer sub group does not belong to the selected customer group', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCustomerFilters(organizationId: string, filters: CustomerFilters): SQL[] {
|
function buildCustomerFilters(organizationId: string, filters: CustomerFilters): SQL[] {
|
||||||
@@ -261,13 +373,25 @@ function buildCustomerFilters(organizationId: string, filters: CustomerFilters):
|
|||||||
export async function getCustomerReferenceData(
|
export async function getCustomerReferenceData(
|
||||||
organizationId: string
|
organizationId: string
|
||||||
): Promise<CustomerReferenceData> {
|
): Promise<CustomerReferenceData> {
|
||||||
const [branches, customerTypes, customerStatuses, leadChannels, customerGroups] =
|
const [branches, customerTypes, customerStatuses, leadChannels, customerGroups, customerSubGroups] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getUserBranches(),
|
getUserBranches(),
|
||||||
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerType, { organizationId }),
|
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerType, { organizationId }),
|
||||||
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerStatus, { organizationId }),
|
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerStatus, { organizationId }),
|
||||||
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
||||||
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerGroup, { organizationId })
|
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerGroup, { organizationId }),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(msOptions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(msOptions.organizationId, organizationId),
|
||||||
|
eq(msOptions.category, CUSTOMER_OPTION_CATEGORIES.customerSubGroup),
|
||||||
|
eq(msOptions.isActive, true),
|
||||||
|
isNull(msOptions.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(msOptions.sortOrder), asc(msOptions.label))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -275,7 +399,8 @@ export async function getCustomerReferenceData(
|
|||||||
customerTypes: customerTypes.map(mapCustomerOption),
|
customerTypes: customerTypes.map(mapCustomerOption),
|
||||||
customerStatuses: customerStatuses.map(mapCustomerOption),
|
customerStatuses: customerStatuses.map(mapCustomerOption),
|
||||||
leadChannels: leadChannels.map(mapCustomerOption),
|
leadChannels: leadChannels.map(mapCustomerOption),
|
||||||
customerGroups: customerGroups.map(mapCustomerOption)
|
customerGroups: customerGroups.map(mapCustomerOption),
|
||||||
|
customerSubGroups: customerSubGroups.map(mapCustomerOptionRow)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +408,7 @@ export async function listCustomers(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
filters: CustomerFilters
|
filters: CustomerFilters
|
||||||
): Promise<{ items: CustomerListItem[]; totalItems: number }> {
|
): Promise<{ items: CustomerListItem[]; totalItems: number }> {
|
||||||
|
const includeCustomerSubGroup = await hasCustomerSubGroupColumn();
|
||||||
const page = filters.page ?? 1;
|
const page = filters.page ?? 1;
|
||||||
const limit = filters.limit ?? 10;
|
const limit = filters.limit ?? 10;
|
||||||
const whereFilters = buildCustomerFilters(organizationId, filters);
|
const whereFilters = buildCustomerFilters(organizationId, filters);
|
||||||
@@ -291,7 +417,7 @@ export async function listCustomers(
|
|||||||
|
|
||||||
const [totalResult] = await db.select({ value: count() }).from(crmCustomers).where(where);
|
const [totalResult] = await db.select({ value: count() }).from(crmCustomers).where(where);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select(getCustomerRecordSelection(includeCustomerSubGroup))
|
||||||
.from(crmCustomers)
|
.from(crmCustomers)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(parseSort(filters.sort))
|
.orderBy(parseSort(filters.sort))
|
||||||
@@ -401,6 +527,7 @@ export async function createCustomer(
|
|||||||
website: payload.website?.trim() || null,
|
website: payload.website?.trim() || null,
|
||||||
leadChannel: payload.leadChannel ?? null,
|
leadChannel: payload.leadChannel ?? null,
|
||||||
customerGroup: payload.customerGroup ?? null,
|
customerGroup: payload.customerGroup ?? null,
|
||||||
|
customerSubGroup: payload.customerSubGroup ?? null,
|
||||||
notes: payload.notes?.trim() || null,
|
notes: payload.notes?.trim() || null,
|
||||||
isActive: payload.isActive ?? true,
|
isActive: payload.isActive ?? true,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
@@ -441,6 +568,7 @@ export async function updateCustomer(
|
|||||||
website: payload.website?.trim() || null,
|
website: payload.website?.trim() || null,
|
||||||
leadChannel: payload.leadChannel ?? null,
|
leadChannel: payload.leadChannel ?? null,
|
||||||
customerGroup: payload.customerGroup ?? null,
|
customerGroup: payload.customerGroup ?? null,
|
||||||
|
customerSubGroup: payload.customerSubGroup ?? null,
|
||||||
notes: payload.notes?.trim() || null,
|
notes: payload.notes?.trim() || null,
|
||||||
isActive: payload.isActive ?? true,
|
isActive: payload.isActive ?? true,
|
||||||
updatedBy: userId,
|
updatedBy: userId,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,11 @@ function Field({ label, value }: { label: string; value: string | null | undefin
|
|||||||
export function QuotationDocumentPreview({ quotationId }: { quotationId: string }) {
|
export function QuotationDocumentPreview({ quotationId }: { quotationId: string }) {
|
||||||
const { data } = useSuspenseQuery(quotationDocumentPreviewOptions(quotationId));
|
const { data } = useSuspenseQuery(quotationDocumentPreviewOptions(quotationId));
|
||||||
const { documentData, template } = data.preview;
|
const { documentData, template } = data.preview;
|
||||||
|
const topicGroups: Array<{ key: 'scope' | 'exclusion' | 'payment'; title: string }> = [
|
||||||
|
{ key: 'scope', title: 'Scope' },
|
||||||
|
{ key: 'exclusion', title: 'Exclusions' },
|
||||||
|
{ key: 'payment', title: 'Payment Terms' }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
@@ -137,12 +142,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
<CardDescription>Scope, exclusions, and payment terms from topic records.</CardDescription>
|
<CardDescription>Scope, exclusions, and payment terms from topic records.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-6'>
|
<CardContent className='space-y-6'>
|
||||||
{[
|
{topicGroups.map((group) => {
|
||||||
{ key: 'scope', title: 'Scope' },
|
const entries = documentData.topics[group.key];
|
||||||
{ key: 'exclusion', title: 'Exclusions' },
|
|
||||||
{ key: 'payment', title: 'Payment Terms' }
|
|
||||||
].map((group) => {
|
|
||||||
const entries = documentData.topics[group.key as keyof typeof documentData.topics];
|
|
||||||
return (
|
return (
|
||||||
<div key={group.key} className='space-y-3'>
|
<div key={group.key} className='space-y-3'>
|
||||||
<div className='font-medium'>{group.title}</div>
|
<div className='font-medium'>{group.title}</div>
|
||||||
|
|||||||
204
src/features/crm/quotations/document/server/pdf-topic-engine.ts
Normal file
204
src/features/crm/quotations/document/server/pdf-topic-engine.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import type { Template } from '@pdfme/common';
|
||||||
|
import { formatTopicItems } from './pdfme-transforms';
|
||||||
|
import type { PdfTopic, PdfTopicEngineOptions } from './pdf-topic.type';
|
||||||
|
|
||||||
|
type TemplateSchema = {
|
||||||
|
name?: string;
|
||||||
|
position?: { x?: number; y?: number };
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
content?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TOPIC_ENGINE_OPTIONS: Required<PdfTopicEngineOptions> = {
|
||||||
|
targetPageIndex: 1,
|
||||||
|
pageStartY: 35,
|
||||||
|
pageBottomY: 250,
|
||||||
|
topicSpacing: 10,
|
||||||
|
keepTogetherNames: [
|
||||||
|
'Please_do_not',
|
||||||
|
'yours_faithfuly',
|
||||||
|
'app1',
|
||||||
|
'app1_position',
|
||||||
|
'app2',
|
||||||
|
'app2_position',
|
||||||
|
'app3',
|
||||||
|
'app3_position'
|
||||||
|
],
|
||||||
|
topicTemplateName: 'topic',
|
||||||
|
topicDataTemplateName: 'data_topic',
|
||||||
|
rowHeight: 7,
|
||||||
|
keepTogetherMinSpace: 60
|
||||||
|
};
|
||||||
|
|
||||||
|
function cloneTemplate<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldY(field: TemplateSchema) {
|
||||||
|
return typeof field.position?.y === 'number' ? field.position.y : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldBottom(field: TemplateSchema) {
|
||||||
|
return getFieldY(field) + (typeof field.height === 'number' ? field.height : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldY(field: TemplateSchema, y: number) {
|
||||||
|
field.position = {
|
||||||
|
...(field.position ?? {}),
|
||||||
|
y
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTopics(topics: PdfTopic[]) {
|
||||||
|
return [...topics].sort((left, right) => {
|
||||||
|
const sortDelta = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
if (sortDelta !== 0) {
|
||||||
|
return sortDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(left.id ?? left.topicType).localeCompare(String(right.id ?? right.topicType));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPdfTopicTemplate(
|
||||||
|
template: Template,
|
||||||
|
topics: PdfTopic[],
|
||||||
|
options?: PdfTopicEngineOptions
|
||||||
|
): {
|
||||||
|
template: Template;
|
||||||
|
topicInputs: Record<string, string[][]>;
|
||||||
|
} {
|
||||||
|
const resolvedOptions = { ...DEFAULT_TOPIC_ENGINE_OPTIONS, ...options };
|
||||||
|
const nextTemplate = cloneTemplate(template);
|
||||||
|
const pages = Array.isArray(nextTemplate.schemas) ? [...nextTemplate.schemas] : [];
|
||||||
|
const targetPage = pages[resolvedOptions.targetPageIndex];
|
||||||
|
|
||||||
|
if (!Array.isArray(targetPage)) {
|
||||||
|
return { template: nextTemplate, topicInputs: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicTemplate = targetPage.find(
|
||||||
|
(field) => (field as TemplateSchema).name === resolvedOptions.topicTemplateName
|
||||||
|
) as TemplateSchema | undefined;
|
||||||
|
const topicDataTemplate = targetPage.find(
|
||||||
|
(field) => (field as TemplateSchema).name === resolvedOptions.topicDataTemplateName
|
||||||
|
) as TemplateSchema | undefined;
|
||||||
|
|
||||||
|
if (!topicTemplate || !topicDataTemplate) {
|
||||||
|
console.warn('[pdf-topic-engine] Missing topic or data_topic schema template');
|
||||||
|
return { template: nextTemplate, topicInputs: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepTogetherSet = new Set(resolvedOptions.keepTogetherNames);
|
||||||
|
const pageWithoutDynamicFields = targetPage.filter((field) => {
|
||||||
|
const name = (field as TemplateSchema).name;
|
||||||
|
return (
|
||||||
|
name !== resolvedOptions.topicTemplateName && name !== resolvedOptions.topicDataTemplateName
|
||||||
|
);
|
||||||
|
}) as TemplateSchema[];
|
||||||
|
const keepTogetherFields = pageWithoutDynamicFields
|
||||||
|
.filter((field) => keepTogetherSet.has(field.name ?? ''))
|
||||||
|
.map((field) => cloneTemplate(field));
|
||||||
|
const staticFields = pageWithoutDynamicFields
|
||||||
|
.filter((field) => !keepTogetherSet.has(field.name ?? ''))
|
||||||
|
.map((field) => cloneTemplate(field));
|
||||||
|
const sortedTopics = sortTopics(topics);
|
||||||
|
const topicInputs: Record<string, string[][]> = {};
|
||||||
|
const generatedPages: Template['schemas'] = [];
|
||||||
|
const topicToDataOffset = getFieldY(topicDataTemplate) - getFieldY(topicTemplate);
|
||||||
|
const keepTogetherTop =
|
||||||
|
keepTogetherFields.length > 0
|
||||||
|
? Math.min(...keepTogetherFields.map((field) => getFieldY(field)))
|
||||||
|
: resolvedOptions.pageBottomY;
|
||||||
|
const keepTogetherHeight =
|
||||||
|
keepTogetherFields.length > 0
|
||||||
|
? Math.max(...keepTogetherFields.map((field) => getFieldBottom(field))) - keepTogetherTop
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let currentPageFields = staticFields.map((field) => cloneTemplate(field));
|
||||||
|
let currentY = Math.max(
|
||||||
|
resolvedOptions.pageStartY,
|
||||||
|
currentPageFields.length > 0
|
||||||
|
? Math.max(...currentPageFields.map((field) => getFieldBottom(field)), resolvedOptions.pageStartY)
|
||||||
|
: resolvedOptions.pageStartY
|
||||||
|
);
|
||||||
|
|
||||||
|
const pushCurrentPage = () => {
|
||||||
|
generatedPages.push(currentPageFields as Template['schemas'][number]);
|
||||||
|
currentPageFields = [];
|
||||||
|
currentY = resolvedOptions.pageStartY;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [topicIndex, topic] of sortedTopics.entries()) {
|
||||||
|
const topicRows = formatTopicItems({ items: topic.items ?? [] });
|
||||||
|
const topicKey = `topic_${resolvedOptions.targetPageIndex}_${topicIndex}`;
|
||||||
|
const itemKey = `item_topic_${resolvedOptions.targetPageIndex}_${topicIndex}`;
|
||||||
|
const labelHeight =
|
||||||
|
typeof topicTemplate.height === 'number' ? topicTemplate.height : resolvedOptions.rowHeight;
|
||||||
|
const tableHeight = Math.max(
|
||||||
|
typeof topicDataTemplate.height === 'number' ? topicDataTemplate.height : resolvedOptions.rowHeight,
|
||||||
|
topicRows.length * resolvedOptions.rowHeight
|
||||||
|
);
|
||||||
|
const blockHeight = topicToDataOffset + tableHeight;
|
||||||
|
|
||||||
|
if (currentY + blockHeight > resolvedOptions.pageBottomY && currentPageFields.length > 0) {
|
||||||
|
pushCurrentPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicField = cloneTemplate(topicTemplate);
|
||||||
|
topicField.name = topicKey;
|
||||||
|
topicField.content = `[[\"{${topicKey}}\"]]`;
|
||||||
|
topicField.height = labelHeight;
|
||||||
|
setFieldY(topicField, currentY);
|
||||||
|
|
||||||
|
const topicDataField = cloneTemplate(topicDataTemplate);
|
||||||
|
topicDataField.name = itemKey;
|
||||||
|
topicDataField.content = `[[\"{${itemKey}}\"]]`;
|
||||||
|
topicDataField.height = tableHeight;
|
||||||
|
setFieldY(topicDataField, currentY + topicToDataOffset);
|
||||||
|
|
||||||
|
topicInputs[topicKey] = [[topic.topicType?.trim() || '-']];
|
||||||
|
topicInputs[itemKey] = topicRows;
|
||||||
|
currentPageFields.push(topicField, topicDataField);
|
||||||
|
currentY = getFieldY(topicDataField) + tableHeight + resolvedOptions.topicSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keepTogetherFields.length > 0) {
|
||||||
|
const remainingSpace = resolvedOptions.pageBottomY - currentY;
|
||||||
|
|
||||||
|
if (
|
||||||
|
remainingSpace < resolvedOptions.keepTogetherMinSpace ||
|
||||||
|
currentY + keepTogetherHeight > resolvedOptions.pageBottomY
|
||||||
|
) {
|
||||||
|
pushCurrentPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const shiftDelta = currentY - keepTogetherTop;
|
||||||
|
|
||||||
|
for (const field of keepTogetherFields) {
|
||||||
|
const nextField = cloneTemplate(field);
|
||||||
|
setFieldY(nextField, getFieldY(field) + shiftDelta);
|
||||||
|
currentPageFields.push(nextField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPageFields.length > 0) {
|
||||||
|
generatedPages.push(currentPageFields as Template['schemas'][number]);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTemplate.schemas = [
|
||||||
|
...pages.slice(0, resolvedOptions.targetPageIndex),
|
||||||
|
...(generatedPages.length > 0
|
||||||
|
? generatedPages
|
||||||
|
: [pageWithoutDynamicFields as Template['schemas'][number]]),
|
||||||
|
...pages.slice(resolvedOptions.targetPageIndex + 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
template: nextTemplate,
|
||||||
|
topicInputs
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type PdfTopicItem = {
|
||||||
|
id: string | number;
|
||||||
|
content: string;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PdfTopic = {
|
||||||
|
id?: string | number;
|
||||||
|
topicType: string;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
items?: PdfTopicItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PdfTopicEngineOptions = {
|
||||||
|
targetPageIndex?: number;
|
||||||
|
pageStartY?: number;
|
||||||
|
pageBottomY?: number;
|
||||||
|
topicSpacing?: number;
|
||||||
|
keepTogetherNames?: string[];
|
||||||
|
topicTemplateName?: string;
|
||||||
|
topicDataTemplateName?: string;
|
||||||
|
rowHeight?: number;
|
||||||
|
keepTogetherMinSpace?: number;
|
||||||
|
};
|
||||||
143
src/features/crm/quotations/document/server/pdfme-transforms.ts
Normal file
143
src/features/crm/quotations/document/server/pdfme-transforms.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
const EMPTY_TABLE_FALLBACK = [['-']];
|
||||||
|
|
||||||
|
function normalizeNumber(
|
||||||
|
amount: number | string | null | undefined
|
||||||
|
): number | null {
|
||||||
|
if (typeof amount === 'number' && Number.isFinite(amount)) {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof amount === 'string') {
|
||||||
|
const normalized = Number(amount.replaceAll(',', '').trim());
|
||||||
|
return Number.isFinite(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPdfDate(
|
||||||
|
dateString: string | Date | null | undefined,
|
||||||
|
language: 'th' | 'en' = 'en'
|
||||||
|
): string {
|
||||||
|
if (!dateString) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = dateString instanceof Date ? dateString : new Date(dateString);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (language === 'th') {
|
||||||
|
return date.toLocaleDateString('th-TH', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
calendar: 'buddhist'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPdfCurrency(
|
||||||
|
amount: number | string | null | undefined,
|
||||||
|
currencyCode = 'THB'
|
||||||
|
): string {
|
||||||
|
const numericAmount = normalizeNumber(amount);
|
||||||
|
|
||||||
|
if (numericAmount === null) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCode = currencyCode.toUpperCase();
|
||||||
|
const symbols: Record<string, string> = {
|
||||||
|
THB: 'THB ',
|
||||||
|
USD: '$',
|
||||||
|
EUR: 'EUR '
|
||||||
|
};
|
||||||
|
const symbol = symbols[normalizedCode] ?? `${normalizedCode} `;
|
||||||
|
const formatted = new Intl.NumberFormat('en-US', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(numericAmount);
|
||||||
|
|
||||||
|
return `${symbol}${formatted}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTopicItems(topic?: {
|
||||||
|
items?: Array<{ content?: string | null; sortOrder?: number | null }> | string[];
|
||||||
|
}): string[][] {
|
||||||
|
if (!topic?.items?.length) {
|
||||||
|
return EMPTY_TABLE_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [...topic.items]
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (typeof left === 'string' || typeof right === 'string') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
})
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return item.trim() || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return item?.content?.trim() || '-';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((content) => [content]);
|
||||||
|
|
||||||
|
return rows.length ? rows : EMPTY_TABLE_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePdfmeTable(
|
||||||
|
value: unknown,
|
||||||
|
columns?: Array<{ sourceField: string; formatMask?: string | null }>
|
||||||
|
): string[][] {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
return EMPTY_TABLE_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.every((row) => Array.isArray(row))) {
|
||||||
|
const normalizedRows = value.map((row) =>
|
||||||
|
(row as unknown[]).map((cell) => {
|
||||||
|
const stringValue = String(cell ?? '').trim();
|
||||||
|
return stringValue || '-';
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizedRows.length ? normalizedRows : EMPTY_TABLE_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!columns?.length) {
|
||||||
|
return value.map((row) => [String(row ?? '').trim() || '-']) || EMPTY_TABLE_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = value.map((row) => {
|
||||||
|
const rowRecord = row as Record<string, unknown>;
|
||||||
|
|
||||||
|
return columns.map((column) => {
|
||||||
|
const cell = rowRecord[column.sourceField];
|
||||||
|
|
||||||
|
if (typeof cell === 'number' || typeof cell === 'string') {
|
||||||
|
return String(cell).trim() || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell === null || cell === undefined) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(cell).trim() || '-';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.length ? rows : EMPTY_TABLE_FALLBACK;
|
||||||
|
}
|
||||||
@@ -26,6 +26,15 @@ import type {
|
|||||||
QuotationDocumentApprovalStep,
|
QuotationDocumentApprovalStep,
|
||||||
QuotationDocumentData
|
QuotationDocumentData
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import type { PdfTopic } from './pdf-topic.type';
|
||||||
|
import {
|
||||||
|
formatPdfCurrency,
|
||||||
|
formatPdfDate,
|
||||||
|
formatTopicItems
|
||||||
|
} from './pdfme-transforms';
|
||||||
|
import { buildPdfTopicTemplate } from './pdf-topic-engine';
|
||||||
|
import { resolveQuotationTopicTypeMapping } from './topic-mapping';
|
||||||
|
import type { Template } from '@pdfme/common';
|
||||||
|
|
||||||
const DOCUMENT_OPTION_CATEGORIES = {
|
const DOCUMENT_OPTION_CATEGORIES = {
|
||||||
branch: 'crm_branch',
|
branch: 'crm_branch',
|
||||||
@@ -84,6 +93,57 @@ function extractSinglePlaceholder(value: string) {
|
|||||||
return uniqueMatches.length === 1 ? uniqueMatches[0] : null;
|
return uniqueMatches.length === 1 ? uniqueMatches[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTopicKey(value: string | null | undefined) {
|
||||||
|
return value?.trim().toLowerCase().replaceAll(/[\s_-]+/g, '') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: string) {
|
||||||
|
const actual = normalizeTopicKey(actualCode);
|
||||||
|
const expected = normalizeTopicKey(expectedCode);
|
||||||
|
|
||||||
|
if (!actual || !expected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actual === expected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliasGroups = [
|
||||||
|
['scope', 'scopeofwork', 'dockscopeofwork', 'solarcellscopeofwork'],
|
||||||
|
[
|
||||||
|
'exclusion',
|
||||||
|
'exclusionfromscopeofsupply',
|
||||||
|
'exclusionofsupplyinstallation',
|
||||||
|
'solarcellexclusionofsupplyinstallation'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'payment',
|
||||||
|
'paymentconditions',
|
||||||
|
'termsofpaymentuponprogressofeachitem',
|
||||||
|
'solarcellpaymentconditions'
|
||||||
|
],
|
||||||
|
['delivery', 'deliverydate', 'solarcelldelivery'],
|
||||||
|
['warranty', 'solarcellwarranty']
|
||||||
|
];
|
||||||
|
|
||||||
|
return aliasGroups.some(
|
||||||
|
(group) => group.includes(actual) && group.includes(expected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
const delta = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||||
|
|
||||||
|
if (delta !== 0) {
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePdfmeTemplateInput(
|
function normalizePdfmeTemplateInput(
|
||||||
schemaJson: unknown,
|
schemaJson: unknown,
|
||||||
templateInput: Record<string, unknown>
|
templateInput: Record<string, unknown>
|
||||||
@@ -250,6 +310,68 @@ export async function buildQuotationDocumentData(
|
|||||||
const statusCode = findOptionCode(statusOptions, quotation.status);
|
const statusCode = findOptionCode(statusOptions, quotation.status);
|
||||||
const statusLabel = findOptionLabel(statusOptions, quotation.status);
|
const statusLabel = findOptionLabel(statusOptions, quotation.status);
|
||||||
const topicCodeById = new Map(topicTypeOptions.map((option) => [option.id, option.code]));
|
const topicCodeById = new Map(topicTypeOptions.map((option) => [option.id, option.code]));
|
||||||
|
const primaryProductType =
|
||||||
|
items.find((item) => findOptionCode(productTypeOptions, item.productType))?.productType ?? null;
|
||||||
|
const productTypeCode =
|
||||||
|
findOptionCode(productTypeOptions, primaryProductType) ??
|
||||||
|
findOptionCode(quotationTypeOptions, quotationDetail.quotationType) ??
|
||||||
|
findOptionLabel(quotationTypeOptions, quotationDetail.quotationType) ??
|
||||||
|
null;
|
||||||
|
const topicTypeMapping = resolveQuotationTopicTypeMapping(productTypeCode);
|
||||||
|
const resolvedTopics = sortBySortOrder(topics).map((item) => {
|
||||||
|
const topicLabel =
|
||||||
|
findOptionLabel(topicTypeOptions, item.topicType) ??
|
||||||
|
item.title?.trim() ??
|
||||||
|
topicCodeById.get(item.topicType) ??
|
||||||
|
'Topic';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
topicType: topicLabel,
|
||||||
|
topicCode: topicCodeById.get(item.topicType) ?? item.title,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
items: sortBySortOrder(item.items).map((child) => ({
|
||||||
|
id: child.id,
|
||||||
|
content: child.content,
|
||||||
|
sortOrder: child.sortOrder
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const pdfTopics: PdfTopic[] = resolvedTopics.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
topicType: item.topicType,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
items: item.items.map((child) => ({
|
||||||
|
id: child.id,
|
||||||
|
content: child.content?.trim() || '-',
|
||||||
|
sortOrder: child.sortOrder
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
const scopeTopics = resolvedTopics.filter((item) =>
|
||||||
|
matchesTopicAlias(item.topicCode, topicTypeMapping.scope)
|
||||||
|
);
|
||||||
|
const exclusionTopics = resolvedTopics.filter((item) =>
|
||||||
|
matchesTopicAlias(item.topicCode, topicTypeMapping.exclusion)
|
||||||
|
);
|
||||||
|
const paymentTopics = resolvedTopics.filter((item) =>
|
||||||
|
matchesTopicAlias(item.topicCode, topicTypeMapping.payment)
|
||||||
|
);
|
||||||
|
const warrantyTopics = resolvedTopics.filter((item) =>
|
||||||
|
matchesTopicAlias(item.topicCode, topicTypeMapping.warranty)
|
||||||
|
);
|
||||||
|
const deliveryTopics = resolvedTopics.filter((item) =>
|
||||||
|
matchesTopicAlias(item.topicCode, topicTypeMapping.delivery)
|
||||||
|
);
|
||||||
|
const otherTopics = resolvedTopics.filter((item) => {
|
||||||
|
return ![topicTypeMapping.scope, topicTypeMapping.exclusion, topicTypeMapping.payment, topicTypeMapping.warranty, topicTypeMapping.delivery].some(
|
||||||
|
(expectedCode) => matchesTopicAlias(item.topicCode, expectedCode)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const exclusionTopic = exclusionTopics[0];
|
||||||
|
const paymentTopic = paymentTopics[0];
|
||||||
|
const warrantyTopic = warrantyTopics[0];
|
||||||
|
const deliveryTopic = deliveryTopics[0];
|
||||||
const approvalApprovers: QuotationDocumentApprovalStep[] =
|
const approvalApprovers: QuotationDocumentApprovalStep[] =
|
||||||
approvalDetail?.timeline
|
approvalDetail?.timeline
|
||||||
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
|
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
|
||||||
@@ -315,6 +437,7 @@ export async function buildQuotationDocumentData(
|
|||||||
statusLabel,
|
statusLabel,
|
||||||
quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType),
|
quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType),
|
||||||
quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType),
|
quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType),
|
||||||
|
currency: findOptionCode(currencyOptions, quotationDetail.currency),
|
||||||
currencyCode: findOptionCode(currencyOptions, quotationDetail.currency),
|
currencyCode: findOptionCode(currencyOptions, quotationDetail.currency),
|
||||||
currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency),
|
currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency),
|
||||||
exchangeRate: quotationDetail.exchangeRate,
|
exchangeRate: quotationDetail.exchangeRate,
|
||||||
@@ -353,21 +476,46 @@ export async function buildQuotationDocumentData(
|
|||||||
isPrimary: item.isPrimary
|
isPrimary: item.isPrimary
|
||||||
})),
|
})),
|
||||||
topics: {
|
topics: {
|
||||||
scope: topics
|
scope: scopeTopics.map((item) => ({
|
||||||
.filter((item) => topicCodeById.get(item.topicType) === 'scope')
|
title: item.title,
|
||||||
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
|
items: item.items.map((child) => child.content ?? '-')
|
||||||
exclusion: topics
|
})),
|
||||||
.filter((item) => topicCodeById.get(item.topicType) === 'exclusion')
|
exclusion: exclusionTopics.map((item) => ({
|
||||||
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
|
title: item.title,
|
||||||
payment: topics
|
items: item.items.map((child) => child.content ?? '-')
|
||||||
.filter((item) => topicCodeById.get(item.topicType) === 'payment')
|
})),
|
||||||
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
|
payment: paymentTopics.map((item) => ({
|
||||||
other: topics
|
title: item.title,
|
||||||
.filter((item) => {
|
items: item.items.map((child) => child.content ?? '-')
|
||||||
const topicCode = topicCodeById.get(item.topicType);
|
})),
|
||||||
return !['scope', 'exclusion', 'payment'].includes(topicCode ?? '');
|
warranty: warrantyTopics.map((item) => ({
|
||||||
})
|
title: item.title,
|
||||||
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) }))
|
items: item.items.map((child) => child.content ?? '-')
|
||||||
|
})),
|
||||||
|
delivery: deliveryTopics.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
items: item.items.map((child) => child.content ?? '-')
|
||||||
|
})),
|
||||||
|
other: otherTopics.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
items: item.items.map((child) => child.content ?? '-')
|
||||||
|
})),
|
||||||
|
all: pdfTopics
|
||||||
|
},
|
||||||
|
pdfme: {
|
||||||
|
quotation_date: formatPdfDate(quotationDetail.quotationDate, 'en'),
|
||||||
|
quotation_price: formatPdfCurrency(
|
||||||
|
quotationDetail.totalAmount,
|
||||||
|
findOptionCode(currencyOptions, quotationDetail.currency) ?? 'THB'
|
||||||
|
),
|
||||||
|
exclusion_data: formatTopicItems(exclusionTopic),
|
||||||
|
payment_data: formatTopicItems(paymentTopic),
|
||||||
|
warranty_data: formatTopicItems(warrantyTopic),
|
||||||
|
delivery_data: formatTopicItems(deliveryTopic),
|
||||||
|
topics_data: formatTopicItems(scopeTopics[0] ?? exclusionTopic ?? paymentTopic),
|
||||||
|
topic_inputs: {}
|
||||||
},
|
},
|
||||||
approval: {
|
approval: {
|
||||||
requestId: approvalDetail?.request.id ?? null,
|
requestId: approvalDetail?.request.id ?? null,
|
||||||
@@ -381,12 +529,16 @@ export async function buildQuotationDocumentData(
|
|||||||
},
|
},
|
||||||
signatures: {
|
signatures: {
|
||||||
preparedBy: preparer?.name ?? null,
|
preparedBy: preparer?.name ?? null,
|
||||||
|
preparedByPosition: null,
|
||||||
requestedBy: contact?.name ?? customer.name,
|
requestedBy: contact?.name ?? customer.name,
|
||||||
salesManager:
|
salesManager:
|
||||||
approvalApprovers.find((item) => item.roleCode === 'sales_manager')?.actorName ?? null,
|
approvalApprovers.find((item) => item.roleCode === 'sales_manager')?.actorName ?? null,
|
||||||
|
salesManagerPosition: null,
|
||||||
departmentManager:
|
departmentManager:
|
||||||
approvalApprovers.find((item) => item.roleCode === 'department_manager')?.actorName ?? null,
|
approvalApprovers.find((item) => item.roleCode === 'department_manager')?.actorName ?? null,
|
||||||
topManager: approvalApprovers.find((item) => item.roleCode === 'top_manager')?.actorName ?? null
|
departmentManagerPosition: null,
|
||||||
|
topManager: approvalApprovers.find((item) => item.roleCode === 'top_manager')?.actorName ?? null,
|
||||||
|
topManagerPosition: null
|
||||||
},
|
},
|
||||||
watermarkStatus: statusCode && statusCode !== 'approved' ? statusLabel ?? statusCode : null
|
watermarkStatus: statusCode && statusCode !== 'approved' ? statusLabel ?? statusCode : null
|
||||||
};
|
};
|
||||||
@@ -412,12 +564,28 @@ export async function getQuotationDocumentPreviewData(
|
|||||||
template.version.schemaJson,
|
template.version.schemaJson,
|
||||||
mapDocumentDataToTemplateInput(documentData as unknown as Record<string, unknown>, mappings)
|
mapDocumentDataToTemplateInput(documentData as unknown as Record<string, unknown>, mappings)
|
||||||
);
|
);
|
||||||
|
const { template: renderTemplate, topicInputs } = buildPdfTopicTemplate(
|
||||||
|
template.version.schemaJson as Template,
|
||||||
|
documentData.topics.all
|
||||||
|
);
|
||||||
|
const mergedTemplateInput = {
|
||||||
|
...templateInput,
|
||||||
|
...topicInputs
|
||||||
|
};
|
||||||
|
|
||||||
|
documentData.pdfme.topic_inputs = topicInputs;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documentData,
|
documentData,
|
||||||
template,
|
template: {
|
||||||
|
...template,
|
||||||
|
version: {
|
||||||
|
...template.version,
|
||||||
|
schemaJson: renderTemplate
|
||||||
|
}
|
||||||
|
},
|
||||||
mappings,
|
mappings,
|
||||||
templateInput
|
templateInput: mergedTemplateInput
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
src/features/crm/quotations/document/server/topic-mapping.ts
Normal file
56
src/features/crm/quotations/document/server/topic-mapping.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export const QUOTATION_TOPIC_TYPE_MAPPING = {
|
||||||
|
crane: {
|
||||||
|
scope: 'scope_of_work',
|
||||||
|
exclusion: 'exclusion_from_scope_of_supply',
|
||||||
|
payment: 'terms_of_payment_upon_progress_of_each_item',
|
||||||
|
delivery: 'delivery_date',
|
||||||
|
warranty: 'warranty'
|
||||||
|
},
|
||||||
|
dockdoor: {
|
||||||
|
scope: 'dock_scope_of_work',
|
||||||
|
exclusion: 'exclusion_of_supply_installation',
|
||||||
|
payment: 'payment_conditions',
|
||||||
|
delivery: 'delivery_date',
|
||||||
|
warranty: 'warranty'
|
||||||
|
},
|
||||||
|
solarcell: {
|
||||||
|
scope: 'solarcell_scope_of_work',
|
||||||
|
exclusion: 'solarcell_exclusion_of_supply_installation',
|
||||||
|
payment: 'solarcell_payment_conditions',
|
||||||
|
delivery: 'solarcell_delivery',
|
||||||
|
warranty: 'solarcell_warranty'
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
scope: 'scope_of_work',
|
||||||
|
exclusion: 'exclusion_from_scope_of_supply',
|
||||||
|
payment: 'payment_conditions',
|
||||||
|
delivery: 'delivery',
|
||||||
|
warranty: 'warranty'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function normalizeKey(value: string | null | undefined) {
|
||||||
|
return value?.trim().toLowerCase().replaceAll(/[\s_-]+/g, '') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuotationTopicMapping(quotationType?: string | null) {
|
||||||
|
const normalized = normalizeKey(quotationType);
|
||||||
|
|
||||||
|
if (normalized.includes('crane')) {
|
||||||
|
return QUOTATION_TOPIC_TYPE_MAPPING.crane;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('dock')) {
|
||||||
|
return QUOTATION_TOPIC_TYPE_MAPPING.dockdoor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('solar')) {
|
||||||
|
return QUOTATION_TOPIC_TYPE_MAPPING.solarcell;
|
||||||
|
}
|
||||||
|
|
||||||
|
return QUOTATION_TOPIC_TYPE_MAPPING.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveQuotationTopicTypeMapping(productType: string | null | undefined) {
|
||||||
|
return getQuotationTopicMapping(productType);
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import type {
|
|||||||
DocumentTemplateMappingWithColumns,
|
DocumentTemplateMappingWithColumns,
|
||||||
ResolvedDocumentTemplate
|
ResolvedDocumentTemplate
|
||||||
} from '@/features/foundation/document-template/types';
|
} from '@/features/foundation/document-template/types';
|
||||||
|
import type { PdfTopic } from './server/pdf-topic.type';
|
||||||
|
|
||||||
export interface QuotationDocumentTopicGroup {
|
export interface QuotationDocumentTopicGroup {
|
||||||
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
sortOrder?: number | null;
|
||||||
items: string[];
|
items: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ export interface QuotationDocumentData {
|
|||||||
statusLabel: string | null;
|
statusLabel: string | null;
|
||||||
quotationTypeCode: string | null;
|
quotationTypeCode: string | null;
|
||||||
quotationTypeLabel: string | null;
|
quotationTypeLabel: string | null;
|
||||||
|
currency: string | null;
|
||||||
currencyCode: string | null;
|
currencyCode: string | null;
|
||||||
currencyLabel: string | null;
|
currencyLabel: string | null;
|
||||||
exchangeRate: number;
|
exchangeRate: number;
|
||||||
@@ -104,7 +108,20 @@ export interface QuotationDocumentData {
|
|||||||
scope: QuotationDocumentTopicGroup[];
|
scope: QuotationDocumentTopicGroup[];
|
||||||
exclusion: QuotationDocumentTopicGroup[];
|
exclusion: QuotationDocumentTopicGroup[];
|
||||||
payment: QuotationDocumentTopicGroup[];
|
payment: QuotationDocumentTopicGroup[];
|
||||||
|
warranty: QuotationDocumentTopicGroup[];
|
||||||
|
delivery: QuotationDocumentTopicGroup[];
|
||||||
other: QuotationDocumentTopicGroup[];
|
other: QuotationDocumentTopicGroup[];
|
||||||
|
all: PdfTopic[];
|
||||||
|
};
|
||||||
|
pdfme: {
|
||||||
|
quotation_date: string;
|
||||||
|
quotation_price: string;
|
||||||
|
exclusion_data: string[][];
|
||||||
|
payment_data?: string[][];
|
||||||
|
warranty_data?: string[][];
|
||||||
|
delivery_data?: string[][];
|
||||||
|
topics_data?: string[][];
|
||||||
|
topic_inputs?: Record<string, string[][]>;
|
||||||
};
|
};
|
||||||
approval: {
|
approval: {
|
||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
@@ -117,10 +134,14 @@ export interface QuotationDocumentData {
|
|||||||
};
|
};
|
||||||
signatures: {
|
signatures: {
|
||||||
preparedBy: string | null;
|
preparedBy: string | null;
|
||||||
|
preparedByPosition?: string | null;
|
||||||
requestedBy: string | null;
|
requestedBy: string | null;
|
||||||
salesManager: string | null;
|
salesManager: string | null;
|
||||||
|
salesManagerPosition?: string | null;
|
||||||
departmentManager: string | null;
|
departmentManager: string | null;
|
||||||
|
departmentManagerPosition?: string | null;
|
||||||
topManager: string | null;
|
topManager: string | null;
|
||||||
|
topManagerPosition?: string | null;
|
||||||
};
|
};
|
||||||
watermarkStatus: string | null;
|
watermarkStatus: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
lte,
|
lte,
|
||||||
ne,
|
ne,
|
||||||
or,
|
or,
|
||||||
|
sql,
|
||||||
type SQL
|
type SQL
|
||||||
} from 'drizzle-orm';
|
} from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +33,13 @@ interface NormalizedProjectParty {
|
|||||||
roleCode: string;
|
roleCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectPartyTableAvailability = {
|
||||||
|
enquiryCustomers: boolean;
|
||||||
|
quotationCustomers: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let projectPartyTableAvailability: ProjectPartyTableAvailability | null = null;
|
||||||
|
|
||||||
function splitFilterValue(value?: string) {
|
function splitFilterValue(value?: string) {
|
||||||
return value
|
return value
|
||||||
?.split(',')
|
?.split(',')
|
||||||
@@ -152,11 +160,34 @@ function pickAttributedParties(
|
|||||||
return authoritativeParties.filter((party) => party.roleCode === roleCode);
|
return authoritativeParties.filter((party) => party.roleCode === roleCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getProjectPartyTableAvailability(): Promise<ProjectPartyTableAvailability> {
|
||||||
|
if (projectPartyTableAvailability) {
|
||||||
|
return projectPartyTableAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.execute<{ tableName: string }>(sql`
|
||||||
|
select table_name as "tableName"
|
||||||
|
from information_schema.tables
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name in ('crm_enquiry_customers', 'crm_quotation_customers')
|
||||||
|
`);
|
||||||
|
|
||||||
|
const available = new Set(rows.map((row) => row.tableName));
|
||||||
|
|
||||||
|
projectPartyTableAvailability = {
|
||||||
|
enquiryCustomers: available.has('crm_enquiry_customers'),
|
||||||
|
quotationCustomers: available.has('crm_quotation_customers')
|
||||||
|
};
|
||||||
|
|
||||||
|
return projectPartyTableAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
async function getRevenueByRole(
|
async function getRevenueByRole(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
roleCode: SupportedRevenueRole,
|
roleCode: SupportedRevenueRole,
|
||||||
filters: RevenueAttributionFilters = {}
|
filters: RevenueAttributionFilters = {}
|
||||||
): Promise<RevenueAttributionSummary[]> {
|
): Promise<RevenueAttributionSummary[]> {
|
||||||
|
const tableAvailability = await getProjectPartyTableAvailability();
|
||||||
const whereFilters = buildRevenueAttributionFilters(organizationId, filters);
|
const whereFilters = buildRevenueAttributionFilters(organizationId, filters);
|
||||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||||
|
|
||||||
@@ -164,6 +195,7 @@ async function getRevenueByRole(
|
|||||||
.select({
|
.select({
|
||||||
id: crmQuotations.id,
|
id: crmQuotations.id,
|
||||||
enquiryId: crmQuotations.enquiryId,
|
enquiryId: crmQuotations.enquiryId,
|
||||||
|
customerId: crmQuotations.customerId,
|
||||||
totalAmount: crmQuotations.totalAmount
|
totalAmount: crmQuotations.totalAmount
|
||||||
})
|
})
|
||||||
.from(crmQuotations)
|
.from(crmQuotations)
|
||||||
@@ -179,21 +211,23 @@ async function getRevenueByRole(
|
|||||||
|
|
||||||
const [roleOptions, quotationParties, enquiryParties] = await Promise.all([
|
const [roleOptions, quotationParties, enquiryParties] = await Promise.all([
|
||||||
getActiveOptionsByCategory(PROJECT_PARTY_OPTION_CATEGORY, { organizationId }),
|
getActiveOptionsByCategory(PROJECT_PARTY_OPTION_CATEGORY, { organizationId }),
|
||||||
db
|
tableAvailability.quotationCustomers
|
||||||
.select({
|
? db
|
||||||
quotationId: crmQuotationCustomers.quotationId,
|
.select({
|
||||||
customerId: crmQuotationCustomers.customerId,
|
quotationId: crmQuotationCustomers.quotationId,
|
||||||
role: crmQuotationCustomers.role
|
customerId: crmQuotationCustomers.customerId,
|
||||||
})
|
role: crmQuotationCustomers.role
|
||||||
.from(crmQuotationCustomers)
|
})
|
||||||
.where(
|
.from(crmQuotationCustomers)
|
||||||
and(
|
.where(
|
||||||
eq(crmQuotationCustomers.organizationId, organizationId),
|
and(
|
||||||
inArray(crmQuotationCustomers.quotationId, quotationIds),
|
eq(crmQuotationCustomers.organizationId, organizationId),
|
||||||
isNull(crmQuotationCustomers.deletedAt)
|
inArray(crmQuotationCustomers.quotationId, quotationIds),
|
||||||
)
|
isNull(crmQuotationCustomers.deletedAt)
|
||||||
),
|
)
|
||||||
enquiryIds.length
|
)
|
||||||
|
: [],
|
||||||
|
tableAvailability.enquiryCustomers && enquiryIds.length
|
||||||
? db
|
? db
|
||||||
.select({
|
.select({
|
||||||
enquiryId: crmEnquiryCustomers.enquiryId,
|
enquiryId: crmEnquiryCustomers.enquiryId,
|
||||||
@@ -233,11 +267,16 @@ async function getRevenueByRole(
|
|||||||
const attributionByQuotation = new Map<string, NormalizedProjectParty[]>();
|
const attributionByQuotation = new Map<string, NormalizedProjectParty[]>();
|
||||||
|
|
||||||
for (const quotation of quotations) {
|
for (const quotation of quotations) {
|
||||||
const attributedParties = pickAttributedParties(
|
const quotationAttributedParties = quotationPartyMap.get(quotation.id) ?? [];
|
||||||
roleCode,
|
const enquiryAttributedParties = quotation.enquiryId
|
||||||
quotationPartyMap.get(quotation.id) ?? [],
|
? (enquiryPartyMap.get(quotation.enquiryId) ?? [])
|
||||||
quotation.enquiryId ? (enquiryPartyMap.get(quotation.enquiryId) ?? []) : []
|
: [];
|
||||||
);
|
const attributedParties =
|
||||||
|
quotationAttributedParties.length > 0 || enquiryAttributedParties.length > 0
|
||||||
|
? pickAttributedParties(roleCode, quotationAttributedParties, enquiryAttributedParties)
|
||||||
|
: roleCode === 'end_customer' || roleCode === 'billing_customer'
|
||||||
|
? [{ customerId: quotation.customerId, roleCode }]
|
||||||
|
: [];
|
||||||
|
|
||||||
attributionByQuotation.set(quotation.id, attributedParties);
|
attributionByQuotation.set(quotation.id, attributedParties);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
} from '@/db/schema';
|
} from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import {
|
||||||
|
formatPdfCurrency,
|
||||||
|
formatPdfDate,
|
||||||
|
normalizePdfmeTable
|
||||||
|
} from '@/features/crm/quotations/document/server/pdfme-transforms';
|
||||||
import type {
|
import type {
|
||||||
DocumentTemplateDetail,
|
DocumentTemplateDetail,
|
||||||
DocumentTemplateFilters,
|
DocumentTemplateFilters,
|
||||||
@@ -682,12 +687,18 @@ function applyFormatMask(value: unknown, formatMask?: string | null) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formatMask === 'currency' && typeof value === 'number') {
|
if (formatMask === 'currency' || formatMask?.startsWith('currency_')) {
|
||||||
return value.toLocaleString();
|
const currencyCode =
|
||||||
|
formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', '');
|
||||||
|
return formatPdfCurrency(value as number | string, currencyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formatMask === 'date' && typeof value === 'string') {
|
if (formatMask === 'date' || formatMask === 'date_en') {
|
||||||
return new Date(value).toLocaleDateString();
|
return formatPdfDate(value as string, 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formatMask === 'date_th') {
|
||||||
|
return formatPdfDate(value as string, 'th');
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
@@ -704,16 +715,32 @@ export function mapDocumentDataToTemplateInput(
|
|||||||
|
|
||||||
if (mapping.dataType === 'table') {
|
if (mapping.dataType === 'table') {
|
||||||
const rows = Array.isArray(rawValue) ? rawValue : [];
|
const rows = Array.isArray(rawValue) ? rawValue : [];
|
||||||
result[mapping.placeholderKey] = rows.map((row) => {
|
|
||||||
const rowRecord = row as Record<string, unknown>;
|
if (rows.length === 0) {
|
||||||
return mapping.columns.reduce<Record<string, unknown>>((accumulator, column) => {
|
result[mapping.placeholderKey] = normalizePdfmeTable([]);
|
||||||
accumulator[column.columnName] = applyFormatMask(
|
continue;
|
||||||
rowRecord[column.sourceField],
|
}
|
||||||
column.formatMask
|
|
||||||
);
|
if (rows.every((row) => Array.isArray(row))) {
|
||||||
return accumulator;
|
result[mapping.placeholderKey] = normalizePdfmeTable(rows);
|
||||||
}, {});
|
continue;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (mapping.columns.length) {
|
||||||
|
const normalizedRows = rows.map((row) => {
|
||||||
|
const rowRecord = row as Record<string, unknown>;
|
||||||
|
|
||||||
|
return mapping.columns.map((column) => {
|
||||||
|
const formattedValue = applyFormatMask(rowRecord[column.sourceField], column.formatMask);
|
||||||
|
return String(formattedValue ?? '-').trim() || '-';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
result[mapping.placeholderKey] = normalizePdfmeTable(normalizedRows);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[mapping.placeholderKey] = normalizePdfmeTable(rows);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +770,7 @@ export function mapDocumentDataToTemplateInput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
result[mapping.placeholderKey] =
|
result[mapping.placeholderKey] =
|
||||||
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '';
|
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user