task-e
This commit is contained in:
18
docs/adr/0007-quotation-revision-strategy.md
Normal file
18
docs/adr/0007-quotation-revision-strategy.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
Status:
|
||||||
|
accepted
|
||||||
|
|
||||||
|
Context:
|
||||||
|
Quotation revision was introduced in Task E, but the rules before approval workflow were still ambiguous.
|
||||||
|
Task E.1 needs a stable rule so Task F can build approval on top without changing quotation lineage again.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
- Revision is allowed only when the current quotation status is `approved`, `sent`, `accepted`, or `rejected`.
|
||||||
|
- Revision is not allowed for `draft` or `cancelled`.
|
||||||
|
- A new revision keeps the same quotation family using `parentQuotationId`.
|
||||||
|
- A new revision copies header, items, customers, and topics only.
|
||||||
|
- A new revision is created with the `revised` status when that master option exists.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
- Sales can revise commercially meaningful documents without reopening early drafts.
|
||||||
|
- Approval work can later key off a stable quotation family and revision chain.
|
||||||
|
- Follow-ups and attachments are still out of the revision snapshot and remain technical debt until explicitly designed.
|
||||||
15
docs/adr/0008-attachment-storage-strategy.md
Normal file
15
docs/adr/0008-attachment-storage-strategy.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Status:
|
||||||
|
accepted
|
||||||
|
|
||||||
|
Context:
|
||||||
|
Task E introduced quotation attachments as metadata only.
|
||||||
|
Task E.1 confirms that approval should not start on top of an unstable file-storage design.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
- Keep quotation attachments metadata-only for now.
|
||||||
|
- Store file identity, original name, path, type, size, uploader, and timestamps in CRM tables.
|
||||||
|
- Do not build upload transport, storage provider logic, signed download URLs, or binary retention policy in Task E or E.1.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
- Task F can reference attachment metadata without being blocked by storage implementation details.
|
||||||
|
- A later storage task must define provider abstraction, permission checks, retention rules, and migration strategy for existing metadata rows.
|
||||||
96
docs/implementation/task-e1-quotation-stabilization.md
Normal file
96
docs/implementation/task-e1-quotation-stabilization.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Task E.1: Quotation Stabilization Before Approval
|
||||||
|
|
||||||
|
## 1. Files Added
|
||||||
|
|
||||||
|
- `docs/adr/0007-quotation-revision-strategy.md`
|
||||||
|
- `docs/adr/0008-attachment-storage-strategy.md`
|
||||||
|
|
||||||
|
## 2. Files Modified
|
||||||
|
|
||||||
|
- `src/db/seeds/foundation.seed.ts`
|
||||||
|
- `src/features/foundation/master-options/types.ts`
|
||||||
|
- `src/features/crm/quotations/api/types.ts`
|
||||||
|
- `src/features/crm/quotations/server/service.ts`
|
||||||
|
- `src/features/crm/quotations/components/quotation-form-sheet.tsx`
|
||||||
|
- `src/features/crm/quotations/components/quotation-detail.tsx`
|
||||||
|
- `src/features/crm/enquiries/server/service.ts`
|
||||||
|
- `docs/implementation/technical-debt.md`
|
||||||
|
|
||||||
|
## 3. Options Seeded
|
||||||
|
|
||||||
|
- `crm_quotation_type`
|
||||||
|
- `crane`
|
||||||
|
- `dockdoor`
|
||||||
|
- `solarcell`
|
||||||
|
- `service`
|
||||||
|
- `other`
|
||||||
|
- `crm_discount_type`
|
||||||
|
- `fixed`
|
||||||
|
- `percentage`
|
||||||
|
- `crm_unit`
|
||||||
|
- `pcs`
|
||||||
|
- `set`
|
||||||
|
- `lot`
|
||||||
|
- `job`
|
||||||
|
- `crm_sent_via`
|
||||||
|
- `email`
|
||||||
|
- `manual`
|
||||||
|
- `system`
|
||||||
|
- `crm_quotation_customer_role`
|
||||||
|
- `owner`
|
||||||
|
- `consultant`
|
||||||
|
- `contractor`
|
||||||
|
- `billing`
|
||||||
|
- `crm_quotation_topic_type`
|
||||||
|
- `scope`
|
||||||
|
- `exclusion`
|
||||||
|
- `payment`
|
||||||
|
|
||||||
|
## 4. Revision Rule Confirmed
|
||||||
|
|
||||||
|
- `draft`: cannot revise
|
||||||
|
- `cancelled`: cannot revise
|
||||||
|
- `accepted`: can revise
|
||||||
|
- `rejected`: can revise
|
||||||
|
- `sent`: can revise
|
||||||
|
- `approved`: can revise
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- revision guard now runs in quotation server service before a new revision is created
|
||||||
|
- revision readiness is based on quotation status master-option code, not on UI-only conditions
|
||||||
|
- new revision rows attempt to start with `revised` status when that option exists
|
||||||
|
|
||||||
|
## 5. Approval Readiness
|
||||||
|
|
||||||
|
- quotation status seed now explicitly supports:
|
||||||
|
- `draft`
|
||||||
|
- `pending_approval`
|
||||||
|
- `approved`
|
||||||
|
- `rejected`
|
||||||
|
- `sent`
|
||||||
|
- `accepted`
|
||||||
|
- `lost`
|
||||||
|
- `cancelled`
|
||||||
|
- `revised`
|
||||||
|
- quotation detail now exposes a placeholder `Submit for approval` action for `draft` and `revised` quotations
|
||||||
|
- no approval table or approval workflow was added in Task E.1
|
||||||
|
|
||||||
|
## 6. Remaining Risks
|
||||||
|
|
||||||
|
- quotation summary still does not support mixed tax rollup from per-line tax strategies
|
||||||
|
- attachments are still metadata-only and not backed by upload/storage plumbing
|
||||||
|
- revision snapshot still excludes follow-ups and attachments
|
||||||
|
- CRM entity relationships still rely on organization-scoped validation instead of database foreign keys
|
||||||
|
- approval workflow is still pending and starts in Task F
|
||||||
|
|
||||||
|
## 7. Task F Readiness
|
||||||
|
|
||||||
|
- quotation forms now read status, quotation type, currency, discount type, unit, sent via, customer role, and topic type from master options instead of hardcoded UI choices where practical
|
||||||
|
- quotation revision eligibility is explicit and server-enforced
|
||||||
|
- approval-ready status vocabulary exists before approval workflow work begins
|
||||||
|
- ADR notes now capture the revision and attachment strategies so Task F can build on stable assumptions
|
||||||
|
|
||||||
|
## 8. Verification
|
||||||
|
|
||||||
|
- `npx tsc --noEmit`
|
||||||
@@ -115,3 +115,28 @@ Future:
|
|||||||
Priority:
|
Priority:
|
||||||
|
|
||||||
* High
|
* High
|
||||||
|
|
||||||
|
### 9. Approval Workflow Pending
|
||||||
|
|
||||||
|
Quotation stabilization in Task E.1 only prepares:
|
||||||
|
|
||||||
|
* approval-ready statuses
|
||||||
|
* revision guard
|
||||||
|
* placeholder submit-for-approval action
|
||||||
|
|
||||||
|
Current:
|
||||||
|
|
||||||
|
* no approval table
|
||||||
|
* no approval requester/approver history
|
||||||
|
* no multi-step approval rule
|
||||||
|
* no approval notification
|
||||||
|
|
||||||
|
Future:
|
||||||
|
|
||||||
|
* introduce approval aggregate in Task F
|
||||||
|
* separate workflow state from quotation commercial state where needed
|
||||||
|
* define approver resolution by branch / amount / role
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
|
||||||
|
* High
|
||||||
|
|||||||
202
plans/task-e.1.md
Normal file
202
plans/task-e.1.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Task E.1: Quotation Stabilization Before Approval
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Stabilize Quotation foundation before starting Task F: Approval Production Module.
|
||||||
|
|
||||||
|
## Must Read
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/task-a-template-audit.md
|
||||||
|
docs/implementation/task-b-template-audit.md
|
||||||
|
docs/implementation/task-b1-foundation-stabilization.md
|
||||||
|
docs/implementation/technical-debt.md
|
||||||
|
src/features/crm/quotations/**
|
||||||
|
src/features/foundation/**
|
||||||
|
src/db/schema.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
ทำเฉพาะ stabilization:
|
||||||
|
|
||||||
|
1. Review quotation master options
|
||||||
|
2. Seed missing options
|
||||||
|
3. Review quotation revision rule
|
||||||
|
4. Review quotation totals rule
|
||||||
|
5. Review approval readiness
|
||||||
|
6. Update technical debt / ADR notes if needed
|
||||||
|
|
||||||
|
## Part 1: Master Options Seed
|
||||||
|
|
||||||
|
ตรวจสอบและ seed เพิ่มถ้ายังไม่มี:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_quotation_type
|
||||||
|
crm_discount_type
|
||||||
|
crm_unit
|
||||||
|
crm_sent_via
|
||||||
|
crm_quotation_customer_role
|
||||||
|
crm_quotation_topic_type
|
||||||
|
```
|
||||||
|
|
||||||
|
ตัวอย่างค่า:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_quotation_type:
|
||||||
|
- crane
|
||||||
|
- dockdoor
|
||||||
|
- solarcell
|
||||||
|
- service
|
||||||
|
- other
|
||||||
|
|
||||||
|
crm_discount_type:
|
||||||
|
- percentage
|
||||||
|
- fixed
|
||||||
|
|
||||||
|
crm_unit:
|
||||||
|
- pcs
|
||||||
|
- set
|
||||||
|
- lot
|
||||||
|
- job
|
||||||
|
|
||||||
|
crm_sent_via:
|
||||||
|
- email
|
||||||
|
- manual
|
||||||
|
- system
|
||||||
|
|
||||||
|
crm_quotation_customer_role:
|
||||||
|
- owner
|
||||||
|
- consultant
|
||||||
|
- contractor
|
||||||
|
- billing
|
||||||
|
|
||||||
|
crm_quotation_topic_type:
|
||||||
|
- scope
|
||||||
|
- exclusion
|
||||||
|
- payment
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* seed idempotent
|
||||||
|
* ห้าม hardcode organizationId
|
||||||
|
* update `foundation.seed.ts` เท่านั้นถ้าเหมาะสม
|
||||||
|
|
||||||
|
## Part 2: Form Option Review
|
||||||
|
|
||||||
|
ตรวจสอบ quotation form ว่า:
|
||||||
|
|
||||||
|
* status มาจาก master options
|
||||||
|
* quotationType มาจาก master options
|
||||||
|
* currency มาจาก master options
|
||||||
|
* discountType มาจาก master options
|
||||||
|
* unit มาจาก master options ถ้ามี item form
|
||||||
|
* customer role/topic type ไม่ hardcode ถ้าดึง option ได้ง่าย
|
||||||
|
|
||||||
|
## Part 3: Revision Rule Review
|
||||||
|
|
||||||
|
ตรวจสอบและสรุป rule:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
draft revise ได้ไหม
|
||||||
|
cancelled revise ได้ไหม
|
||||||
|
accepted revise ได้ไหม
|
||||||
|
rejected revise ได้ไหม
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
draft: no
|
||||||
|
cancelled: no
|
||||||
|
accepted: yes
|
||||||
|
rejected: yes
|
||||||
|
sent: yes
|
||||||
|
approved: yes
|
||||||
|
```
|
||||||
|
|
||||||
|
ถ้า code ยังไม่ตรง ให้แก้เฉพาะ revision guard
|
||||||
|
|
||||||
|
## Part 4: Approval Readiness
|
||||||
|
|
||||||
|
ตรวจสอบว่า quotation status รองรับ:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
draft
|
||||||
|
pending_approval
|
||||||
|
approved
|
||||||
|
rejected
|
||||||
|
sent
|
||||||
|
accepted
|
||||||
|
lost
|
||||||
|
cancelled
|
||||||
|
revised
|
||||||
|
```
|
||||||
|
|
||||||
|
ยังไม่ต้องสร้าง approval table
|
||||||
|
|
||||||
|
ให้เพิ่ม placeholder action ได้เท่านั้นถ้าจำเป็น:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Submit for approval
|
||||||
|
```
|
||||||
|
|
||||||
|
แต่ action นี้ยังไม่ต้องทำ workflow จริง
|
||||||
|
|
||||||
|
## Part 5: Documentation
|
||||||
|
|
||||||
|
อัปเดต:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/implementation/technical-debt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
เพิ่ม/ยืนยันหัวข้อ:
|
||||||
|
|
||||||
|
* mixed tax rollup
|
||||||
|
* attachment storage
|
||||||
|
* revision snapshot
|
||||||
|
* CRM foreign keys
|
||||||
|
* approval workflow pending
|
||||||
|
|
||||||
|
ถ้ามี ADR folder ให้สร้าง note ได้:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/adr/0007-quotation-revision-strategy.md
|
||||||
|
docs/adr/0008-attachment-storage-strategy.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## ห้ามทำ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approval table
|
||||||
|
Approval workflow
|
||||||
|
PDF generation
|
||||||
|
Dashboard KPI
|
||||||
|
Report
|
||||||
|
Notification
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
สรุป:
|
||||||
|
|
||||||
|
1. Files Added
|
||||||
|
2. Files Modified
|
||||||
|
3. Options Seeded
|
||||||
|
4. Revision Rule Confirmed
|
||||||
|
5. Approval Readiness
|
||||||
|
6. Remaining Risks
|
||||||
|
7. Task F Readiness
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Task E.1 ผ่านเมื่อ:
|
||||||
|
|
||||||
|
* quotation options seed ครบ
|
||||||
|
* quotation form ไม่ hardcode option สำคัญ
|
||||||
|
* revision guard ชัดเจน
|
||||||
|
* technical debt updated
|
||||||
|
* approval readiness confirmed
|
||||||
|
* ยังไม่สร้าง approval workflow จริง
|
||||||
@@ -122,9 +122,11 @@ const FOUNDATION_OPTIONS = {
|
|||||||
{ code: 'revised', label: 'Revised', value: 'revised', sortOrder: 9 }
|
{ code: 'revised', label: 'Revised', value: 'revised', sortOrder: 9 }
|
||||||
],
|
],
|
||||||
crm_quotation_type: [
|
crm_quotation_type: [
|
||||||
{ code: 'standard', label: 'Standard', value: 'standard', sortOrder: 1 },
|
{ code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 },
|
||||||
{ code: 'budgetary', label: 'Budgetary', value: 'budgetary', sortOrder: 2 },
|
{ code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 },
|
||||||
{ code: 'service', label: 'Service', value: 'service', sortOrder: 3 }
|
{ code: 'solarcell', label: 'Solar Cell', value: 'solarcell', sortOrder: 3 },
|
||||||
|
{ code: 'service', label: 'Service', value: 'service', sortOrder: 4 },
|
||||||
|
{ code: 'other', label: 'Other', value: 'other', sortOrder: 5 }
|
||||||
],
|
],
|
||||||
crm_product_type: [
|
crm_product_type: [
|
||||||
{ code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 },
|
{ code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 },
|
||||||
@@ -137,10 +139,27 @@ const FOUNDATION_OPTIONS = {
|
|||||||
{ code: 'EUR', label: 'EUR', value: 'EUR', sortOrder: 3 }
|
{ code: 'EUR', label: 'EUR', value: 'EUR', sortOrder: 3 }
|
||||||
],
|
],
|
||||||
crm_discount_type: [
|
crm_discount_type: [
|
||||||
{ code: 'amount', label: 'Amount', value: 'amount', sortOrder: 1 },
|
{ code: 'fixed', label: 'Fixed', value: 'fixed', sortOrder: 1 },
|
||||||
{ code: 'percent', label: 'Percent', value: 'percent', sortOrder: 2 }
|
{ code: 'percentage', label: 'Percentage', value: 'percentage', sortOrder: 2 }
|
||||||
],
|
],
|
||||||
crm_topic_type: [
|
crm_unit: [
|
||||||
|
{ code: 'pcs', label: 'PCS', value: 'pcs', sortOrder: 1 },
|
||||||
|
{ code: 'set', label: 'Set', value: 'set', sortOrder: 2 },
|
||||||
|
{ code: 'lot', label: 'Lot', value: 'lot', sortOrder: 3 },
|
||||||
|
{ code: 'job', label: 'Job', value: 'job', sortOrder: 4 }
|
||||||
|
],
|
||||||
|
crm_sent_via: [
|
||||||
|
{ code: 'email', label: 'Email', value: 'email', sortOrder: 1 },
|
||||||
|
{ code: 'manual', label: 'Manual', value: 'manual', sortOrder: 2 },
|
||||||
|
{ code: 'system', label: 'System', value: 'system', sortOrder: 3 }
|
||||||
|
],
|
||||||
|
crm_quotation_customer_role: [
|
||||||
|
{ code: 'owner', label: 'Owner', value: 'owner', sortOrder: 1 },
|
||||||
|
{ code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 2 },
|
||||||
|
{ code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 3 },
|
||||||
|
{ code: 'billing', label: 'Billing', value: 'billing', sortOrder: 4 }
|
||||||
|
],
|
||||||
|
crm_quotation_topic_type: [
|
||||||
{ code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 },
|
{ code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 },
|
||||||
{ code: 'exclusion', label: 'Exclusion', value: 'exclusion', sortOrder: 2 },
|
{ code: 'exclusion', label: 'Exclusion', value: 'exclusion', sortOrder: 2 },
|
||||||
{ code: 'payment', label: 'Payment', value: 'payment', sortOrder: 3 }
|
{ code: 'payment', label: 'Payment', value: 'payment', sortOrder: 3 }
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import { getActiveOptionsByCategory } from '@/features/foundation/master-options
|
|||||||
import type {
|
import type {
|
||||||
EnquiryActivityRecord,
|
EnquiryActivityRecord,
|
||||||
EnquiryBranchOption,
|
EnquiryBranchOption,
|
||||||
EnquiryContactLookup,
|
|
||||||
EnquiryCustomerLookup,
|
|
||||||
EnquiryCustomerRelationItem,
|
EnquiryCustomerRelationItem,
|
||||||
EnquiryFilters,
|
EnquiryFilters,
|
||||||
EnquiryFollowupMutationPayload,
|
EnquiryFollowupMutationPayload,
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ export interface QuotationReferenceData {
|
|||||||
quotationTypes: QuotationOption[];
|
quotationTypes: QuotationOption[];
|
||||||
currencies: QuotationOption[];
|
currencies: QuotationOption[];
|
||||||
discountTypes: QuotationOption[];
|
discountTypes: QuotationOption[];
|
||||||
|
units: QuotationOption[];
|
||||||
|
sentVias: QuotationOption[];
|
||||||
|
customerRoles: QuotationOption[];
|
||||||
topicTypes: QuotationOption[];
|
topicTypes: QuotationOption[];
|
||||||
followupTypes: QuotationOption[];
|
followupTypes: QuotationOption[];
|
||||||
productTypes: QuotationOption[];
|
productTypes: QuotationOption[];
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
deleteQuotationFollowupMutation,
|
deleteQuotationFollowupMutation,
|
||||||
deleteQuotationItemMutation,
|
deleteQuotationItemMutation,
|
||||||
deleteQuotationTopicMutation,
|
deleteQuotationTopicMutation,
|
||||||
|
updateQuotationMutation,
|
||||||
updateQuotationAttachmentMutation,
|
updateQuotationAttachmentMutation,
|
||||||
updateQuotationCustomerMutation,
|
updateQuotationCustomerMutation,
|
||||||
updateQuotationFollowupMutation,
|
updateQuotationFollowupMutation,
|
||||||
@@ -134,7 +135,15 @@ function ItemDialog({
|
|||||||
<Input value={values.description} onChange={(e) => setValues((s) => ({ ...s, description: e.target.value }))} placeholder='Description' />
|
<Input value={values.description} onChange={(e) => setValues((s) => ({ ...s, description: e.target.value }))} placeholder='Description' />
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<Input value={values.quantity} onChange={(e) => setValues((s) => ({ ...s, quantity: e.target.value }))} type='number' placeholder='Quantity' />
|
<Input value={values.quantity} onChange={(e) => setValues((s) => ({ ...s, quantity: e.target.value }))} type='number' placeholder='Quantity' />
|
||||||
<Input value={values.unit} onChange={(e) => setValues((s) => ({ ...s, unit: e.target.value }))} placeholder='Unit' />
|
<Select value={values.unit || '__none__'} onValueChange={(value) => setValues((s) => ({ ...s, unit: value === '__none__' ? '' : value }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder='Unit' /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__none__'>No unit</SelectItem>
|
||||||
|
{referenceData.units.map((option) => (
|
||||||
|
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<Input value={values.unitPrice} onChange={(e) => setValues((s) => ({ ...s, unitPrice: e.target.value }))} type='number' placeholder='Unit price' />
|
<Input value={values.unitPrice} onChange={(e) => setValues((s) => ({ ...s, unitPrice: e.target.value }))} type='number' placeholder='Unit price' />
|
||||||
@@ -219,8 +228,8 @@ function CustomerDialog({
|
|||||||
<Select value={role} onValueChange={setRole}>
|
<Select value={role} onValueChange={setRole}>
|
||||||
<SelectTrigger><SelectValue placeholder='Role' /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder='Role' /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{['owner', 'consultant', 'contractor', 'billing'].map((item) => (
|
{referenceData.customerRoles.map((item) => (
|
||||||
<SelectItem key={item} value={item}>{item}</SelectItem>
|
<SelectItem key={item.id} value={item.id}>{item.label}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -601,6 +610,12 @@ export function QuotationDetail({
|
|||||||
onSuccess: () => toast.success('Revision created successfully'),
|
onSuccess: () => toast.success('Revision created successfully'),
|
||||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create revision')
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create revision')
|
||||||
});
|
});
|
||||||
|
const submitForApproval = useMutation({
|
||||||
|
...updateQuotationMutation,
|
||||||
|
onSuccess: () => toast.success('Quotation moved to pending approval'),
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
|
||||||
|
});
|
||||||
|
|
||||||
const branchMap = useMemo(
|
const branchMap = useMemo(
|
||||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||||
@@ -637,6 +652,10 @@ export function QuotationDetail({
|
|||||||
const customer = customerMap.get(quotation.customerId);
|
const customer = customerMap.get(quotation.customerId);
|
||||||
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
|
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
|
||||||
const status = statusMap.get(quotation.status);
|
const status = statusMap.get(quotation.status);
|
||||||
|
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||||
|
status?.code ?? ''
|
||||||
|
);
|
||||||
|
const canSubmitApproval = ['draft', 'revised'].includes(status?.code ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
@@ -795,7 +814,49 @@ export function QuotationDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{canCreateRevision ? (
|
{canUpdate && canSubmitApproval ? (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
isLoading={submitForApproval.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
submitForApproval.mutate({
|
||||||
|
id: quotationId,
|
||||||
|
values: {
|
||||||
|
enquiryId: quotation.enquiryId,
|
||||||
|
customerId: quotation.customerId,
|
||||||
|
contactId: quotation.contactId,
|
||||||
|
quotationDate: quotation.quotationDate,
|
||||||
|
validUntil: quotation.validUntil,
|
||||||
|
quotationType: quotation.quotationType,
|
||||||
|
projectName: quotation.projectName ?? '',
|
||||||
|
projectLocation: quotation.projectLocation ?? '',
|
||||||
|
attention: quotation.attention ?? '',
|
||||||
|
branchId: quotation.branchId,
|
||||||
|
currency: quotation.currency,
|
||||||
|
exchangeRate: quotation.exchangeRate,
|
||||||
|
status:
|
||||||
|
referenceData.statuses.find((item) => item.code === 'pending_approval')?.id ??
|
||||||
|
quotation.status,
|
||||||
|
chancePercent: quotation.chancePercent,
|
||||||
|
isHotProject: quotation.isHotProject,
|
||||||
|
competitor: quotation.competitor ?? '',
|
||||||
|
reference: quotation.reference ?? '',
|
||||||
|
notes: quotation.notes ?? '',
|
||||||
|
salesmanId: quotation.salesmanId,
|
||||||
|
discount: quotation.discount,
|
||||||
|
discountType: quotation.discountType,
|
||||||
|
taxRate: quotation.taxRate,
|
||||||
|
sentVia: quotation.sentVia,
|
||||||
|
revisionRemark: quotation.revisionRemark,
|
||||||
|
isActive: quotation.isActive
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icons.send className='mr-2 h-4 w-4' /> Submit for approval
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canCreateRevision && canReviseByStatus ? (
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
isLoading={createRevision.isPending}
|
isLoading={createRevision.isPending}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type FormState = {
|
|||||||
discount: string;
|
discount: string;
|
||||||
discountType: string;
|
discountType: string;
|
||||||
taxRate: string;
|
taxRate: string;
|
||||||
|
sentVia: string;
|
||||||
revisionRemark: string;
|
revisionRemark: string;
|
||||||
isHotProject: boolean;
|
isHotProject: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -84,6 +85,7 @@ function toFormState(
|
|||||||
discount: String(quotation?.discount ?? 0),
|
discount: String(quotation?.discount ?? 0),
|
||||||
discountType: quotation?.discountType ?? '',
|
discountType: quotation?.discountType ?? '',
|
||||||
taxRate: String(quotation?.taxRate ?? 0),
|
taxRate: String(quotation?.taxRate ?? 0),
|
||||||
|
sentVia: quotation?.sentVia ?? '',
|
||||||
revisionRemark: quotation?.revisionRemark ?? '',
|
revisionRemark: quotation?.revisionRemark ?? '',
|
||||||
isHotProject: quotation?.isHotProject ?? false,
|
isHotProject: quotation?.isHotProject ?? false,
|
||||||
isActive: quotation?.isActive ?? true
|
isActive: quotation?.isActive ?? true
|
||||||
@@ -196,6 +198,7 @@ export function QuotationFormSheet({
|
|||||||
discount: state.discount ? Number(state.discount) : 0,
|
discount: state.discount ? Number(state.discount) : 0,
|
||||||
discountType: state.discountType || null,
|
discountType: state.discountType || null,
|
||||||
taxRate: state.taxRate ? Number(state.taxRate) : 0,
|
taxRate: state.taxRate ? Number(state.taxRate) : 0,
|
||||||
|
sentVia: state.sentVia || null,
|
||||||
revisionRemark: state.revisionRemark || null,
|
revisionRemark: state.revisionRemark || null,
|
||||||
isActive: state.isActive
|
isActive: state.isActive
|
||||||
};
|
};
|
||||||
@@ -411,6 +414,22 @@ export function QuotationFormSheet({
|
|||||||
<Input value={state.taxRate} onChange={(e) => setField('taxRate', e.target.value)} type='number' step='0.01' />
|
<Input value={state.taxRate} onChange={(e) => setField('taxRate', e.target.value)} type='number' step='0.01' />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Sent Via'>
|
||||||
|
<Select value={state.sentVia || '__none__'} onValueChange={(value) => setField('sentVia', value === '__none__' ? '' : value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select sent via' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__none__'>Not sent yet</SelectItem>
|
||||||
|
{referenceData.sentVias.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label='Competitor'>
|
<Field label='Competitor'>
|
||||||
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
|
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -61,14 +61,17 @@ import type {
|
|||||||
const QUOTATION_OPTION_CATEGORIES = {
|
const QUOTATION_OPTION_CATEGORIES = {
|
||||||
status: 'crm_quotation_status',
|
status: 'crm_quotation_status',
|
||||||
quotationType: 'crm_quotation_type',
|
quotationType: 'crm_quotation_type',
|
||||||
currency: 'currency',
|
currency: 'crm_currency',
|
||||||
discountType: 'crm_discount_type',
|
discountType: 'crm_discount_type',
|
||||||
topicType: 'crm_topic_type',
|
unit: 'crm_unit',
|
||||||
|
sentVia: 'crm_sent_via',
|
||||||
|
customerRole: 'crm_quotation_customer_role',
|
||||||
|
topicType: 'crm_quotation_topic_type',
|
||||||
followupType: 'crm_followup_type',
|
followupType: 'crm_followup_type',
|
||||||
productType: 'crm_product_type'
|
productType: 'crm_product_type'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const QUOTATION_CUSTOMER_ROLES = ['owner', 'consultant', 'contractor', 'billing'] as const;
|
const REVISION_ALLOWED_STATUS_CODES = new Set(['accepted', 'rejected', 'sent', 'approved']);
|
||||||
|
|
||||||
function mapOption(
|
function mapOption(
|
||||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||||
@@ -291,7 +294,7 @@ function calculateDiscountAmount(baseAmount: number, discount = 0, discountType?
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (discountType === 'percent') {
|
if (discountType === 'percent' || discountType === 'percentage') {
|
||||||
return (baseAmount * discount) / 100;
|
return (baseAmount * discount) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +317,28 @@ async function resolveValidOptionIds(category: string, organizationId: string) {
|
|||||||
return new Set(options.map((option) => option.id));
|
return new Set(options.map((option) => option.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveOptionCodeById(
|
||||||
|
organizationId: string,
|
||||||
|
category: string,
|
||||||
|
optionId?: string | null
|
||||||
|
) {
|
||||||
|
if (!optionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||||
|
return options.find((option) => option.id === optionId)?.code ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveOptionIdByCode(
|
||||||
|
organizationId: string,
|
||||||
|
category: string,
|
||||||
|
code: string
|
||||||
|
) {
|
||||||
|
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||||
|
return options.find((option) => option.code === code)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async function assertMasterOptionValue(
|
async function assertMasterOptionValue(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
category: string,
|
category: string,
|
||||||
@@ -591,6 +616,11 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
|
|||||||
QUOTATION_OPTION_CATEGORIES.discountType,
|
QUOTATION_OPTION_CATEGORIES.discountType,
|
||||||
payload.discountType ?? null
|
payload.discountType ?? null
|
||||||
);
|
);
|
||||||
|
await assertMasterOptionValue(
|
||||||
|
organizationId,
|
||||||
|
QUOTATION_OPTION_CATEGORIES.sentVia,
|
||||||
|
payload.sentVia ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateQuotationItemPayload(
|
async function validateQuotationItemPayload(
|
||||||
@@ -607,6 +637,11 @@ async function validateQuotationItemPayload(
|
|||||||
QUOTATION_OPTION_CATEGORIES.discountType,
|
QUOTATION_OPTION_CATEGORIES.discountType,
|
||||||
payload.discountType ?? null
|
payload.discountType ?? null
|
||||||
);
|
);
|
||||||
|
await assertMasterOptionValue(
|
||||||
|
organizationId,
|
||||||
|
QUOTATION_OPTION_CATEGORIES.unit,
|
||||||
|
payload.unit ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateQuotationCustomerPayload(
|
async function validateQuotationCustomerPayload(
|
||||||
@@ -614,10 +649,11 @@ async function validateQuotationCustomerPayload(
|
|||||||
payload: QuotationCustomerMutationPayload
|
payload: QuotationCustomerMutationPayload
|
||||||
) {
|
) {
|
||||||
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
||||||
|
await assertMasterOptionValue(
|
||||||
if (!QUOTATION_CUSTOMER_ROLES.includes(payload.role as (typeof QUOTATION_CUSTOMER_ROLES)[number])) {
|
organizationId,
|
||||||
throw new AuthError('Invalid quotation customer role', 400);
|
QUOTATION_OPTION_CATEGORIES.customerRole,
|
||||||
}
|
payload.role
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateQuotationTopicPayload(
|
async function validateQuotationTopicPayload(
|
||||||
@@ -739,6 +775,9 @@ export async function getQuotationReferenceData(
|
|||||||
quotationTypes,
|
quotationTypes,
|
||||||
currencies,
|
currencies,
|
||||||
discountTypes,
|
discountTypes,
|
||||||
|
units,
|
||||||
|
sentVias,
|
||||||
|
customerRoles,
|
||||||
topicTypes,
|
topicTypes,
|
||||||
followupTypes,
|
followupTypes,
|
||||||
productTypes,
|
productTypes,
|
||||||
@@ -752,6 +791,9 @@ export async function getQuotationReferenceData(
|
|||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.quotationType, { organizationId }),
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.quotationType, { organizationId }),
|
||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.currency, { organizationId }),
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.currency, { organizationId }),
|
||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.discountType, { organizationId }),
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.discountType, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.unit, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.sentVia, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.customerRole, { organizationId }),
|
||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.topicType, { organizationId }),
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.topicType, { organizationId }),
|
||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }),
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }),
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }),
|
||||||
@@ -789,6 +831,9 @@ export async function getQuotationReferenceData(
|
|||||||
quotationTypes: quotationTypes.map(mapOption),
|
quotationTypes: quotationTypes.map(mapOption),
|
||||||
currencies: currencies.map(mapOption),
|
currencies: currencies.map(mapOption),
|
||||||
discountTypes: discountTypes.map(mapOption),
|
discountTypes: discountTypes.map(mapOption),
|
||||||
|
units: units.map(mapOption),
|
||||||
|
sentVias: sentVias.map(mapOption),
|
||||||
|
customerRoles: customerRoles.map(mapOption),
|
||||||
topicTypes: topicTypes.map(mapOption),
|
topicTypes: topicTypes.map(mapOption),
|
||||||
followupTypes: followupTypes.map(mapOption),
|
followupTypes: followupTypes.map(mapOption),
|
||||||
productTypes: productTypes.map(mapOption),
|
productTypes: productTypes.map(mapOption),
|
||||||
@@ -1642,6 +1687,25 @@ export async function createQuotationRevision(
|
|||||||
revisionRemark?: string
|
revisionRemark?: string
|
||||||
) {
|
) {
|
||||||
const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId);
|
const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId);
|
||||||
|
const statusCode = await resolveOptionCodeById(
|
||||||
|
organizationId,
|
||||||
|
QUOTATION_OPTION_CATEGORIES.status,
|
||||||
|
parent.status
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!statusCode || !REVISION_ALLOWED_STATUS_CODES.has(statusCode)) {
|
||||||
|
throw new AuthError(
|
||||||
|
'Revision is allowed only for approved, sent, accepted, or rejected quotations',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const revisedStatusId =
|
||||||
|
(await resolveOptionIdByCode(
|
||||||
|
organizationId,
|
||||||
|
QUOTATION_OPTION_CATEGORIES.status,
|
||||||
|
'revised'
|
||||||
|
)) ?? parent.status;
|
||||||
|
|
||||||
const familyRootId = parent.parentQuotationId ?? parent.id;
|
const familyRootId = parent.parentQuotationId ?? parent.id;
|
||||||
const familyRows = await db
|
const familyRows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -1683,7 +1747,7 @@ export async function createQuotationRevision(
|
|||||||
attention: parent.attention,
|
attention: parent.attention,
|
||||||
reference: parent.reference,
|
reference: parent.reference,
|
||||||
notes: parent.notes,
|
notes: parent.notes,
|
||||||
status: parent.status,
|
status: revisedStatusId,
|
||||||
revision: nextRevision,
|
revision: nextRevision,
|
||||||
parentQuotationId: familyRootId,
|
parentQuotationId: familyRootId,
|
||||||
revisionRemark: revisionRemark?.trim() || null,
|
revisionRemark: revisionRemark?.trim() || null,
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ export const CRM_MASTER_OPTION_CATEGORIES = [
|
|||||||
'crm_customer_type',
|
'crm_customer_type',
|
||||||
'crm_enquiry_status',
|
'crm_enquiry_status',
|
||||||
'crm_quotation_status',
|
'crm_quotation_status',
|
||||||
|
'crm_quotation_type',
|
||||||
|
'crm_discount_type',
|
||||||
|
'crm_unit',
|
||||||
|
'crm_sent_via',
|
||||||
|
'crm_quotation_customer_role',
|
||||||
|
'crm_quotation_topic_type',
|
||||||
'crm_product_type',
|
'crm_product_type',
|
||||||
'crm_currency',
|
'crm_currency',
|
||||||
'crm_payment_term',
|
'crm_payment_term',
|
||||||
|
|||||||
Reference in New Issue
Block a user