task-c.1
This commit is contained in:
78
docs/adr/0015-customer-ownership-contact-sharing.md
Normal file
78
docs/adr/0015-customer-ownership-contact-sharing.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# ADR 0015: Customer Ownership and Contact Sharing
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The CRM customer domain already supported customer masters, contacts, leads/enquiries, and quotations, but it still lacked two production behaviors:
|
||||||
|
|
||||||
|
- a first-class primary owner for each customer
|
||||||
|
- a persistent sharing model for customer contacts
|
||||||
|
|
||||||
|
That gap caused two operational problems:
|
||||||
|
|
||||||
|
- Marketing could not reliably suggest the right sales owner when creating a lead.
|
||||||
|
- Contact visibility depended on creator/admin fallbacks instead of explicit business governance.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We introduce two persistent governance models.
|
||||||
|
|
||||||
|
### 1. Customer owner
|
||||||
|
|
||||||
|
`crm_customers` now stores:
|
||||||
|
|
||||||
|
- `owner_user_id`
|
||||||
|
- `owner_assigned_at`
|
||||||
|
- `owner_assigned_by`
|
||||||
|
|
||||||
|
Ownership changes are recorded in `crm_customer_owner_history`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- one primary owner at a time
|
||||||
|
- owner is organization-scoped
|
||||||
|
- owner changes are auditable
|
||||||
|
- customer owner gains visibility to the customer and related CRM work, subject to CRM scope rules
|
||||||
|
|
||||||
|
### 2. Contact sharing
|
||||||
|
|
||||||
|
`crm_contact_shares` becomes the production source of truth for persistent contact sharing.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- creator keeps access
|
||||||
|
- explicitly shared users gain access
|
||||||
|
- customer owner gains access
|
||||||
|
- CRM admin and broader organization/team scopes keep access
|
||||||
|
- removing a share revokes access by deactivating the share row
|
||||||
|
|
||||||
|
### 3. Lead assignment suggestion
|
||||||
|
|
||||||
|
Lead/enquiry creation now accepts an optional assignee suggestion.
|
||||||
|
|
||||||
|
When a selected customer has an owner:
|
||||||
|
|
||||||
|
- the form pre-selects that owner as the suggested sales owner
|
||||||
|
- users may override before submit
|
||||||
|
- the create route records whether the suggestion was used or overridden
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Positive:
|
||||||
|
|
||||||
|
- customer responsibility is now explicit and historical
|
||||||
|
- contact access is governed by durable business state instead of demo-only behavior
|
||||||
|
- lead routing is more accurate at creation time
|
||||||
|
|
||||||
|
Tradeoffs:
|
||||||
|
|
||||||
|
- visibility logic across customer/contact/enquiry flows becomes more stateful
|
||||||
|
- team-scope remains approximate until a first-class team graph exists
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This ADR does not introduce multiple primary owners, temporary shares, or territory management.
|
||||||
|
- Customer owner does not imply approval authority.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Task C.1: Customer Ownership and Contact Sharing Governance
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Task C.1 completes the missing customer-governance layer by adding:
|
||||||
|
|
||||||
|
- persistent customer owner fields and owner history
|
||||||
|
- persistent contact sharing
|
||||||
|
- owner/share-aware customer and contact visibility
|
||||||
|
- lead owner suggestion during lead/enquiry creation
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
- Added `ownerUserId`, `ownerAssignedAt`, and `ownerAssignedBy` to `crm_customers`
|
||||||
|
- Added `crm_customer_owner_history`
|
||||||
|
- Added `crm_contact_shares`
|
||||||
|
- Added customer owner management route:
|
||||||
|
- `PATCH /api/crm/customers/[id]/owner`
|
||||||
|
- `DELETE /api/crm/customers/[id]/owner`
|
||||||
|
- Added contact sharing routes:
|
||||||
|
- `GET /api/crm/customers/[id]/contacts/[contactId]/shares`
|
||||||
|
- `POST /api/crm/customers/[id]/contacts/[contactId]/shares`
|
||||||
|
- `DELETE /api/crm/customers/[id]/contacts/[contactId]/shares/[shareId]`
|
||||||
|
- Expanded customer list/detail contracts with owner and share metadata
|
||||||
|
- Added customer owner card with history on customer detail
|
||||||
|
- Added contact sharing management UI on customer detail
|
||||||
|
- Added owner columns and owner filter on customer list
|
||||||
|
- Added new permissions:
|
||||||
|
- `crm.customer.owner.read`
|
||||||
|
- `crm.customer.owner.manage`
|
||||||
|
- `crm.contact.share.read`
|
||||||
|
- `crm.contact.share.create`
|
||||||
|
- `crm.contact.share.delete`
|
||||||
|
- `crm.contact.share.manage`
|
||||||
|
- Added lead/enquiry assignee suggestion from selected customer owner
|
||||||
|
- Added audit events for:
|
||||||
|
- `assign_owner`
|
||||||
|
- `change_owner`
|
||||||
|
- `clear_owner`
|
||||||
|
- `share`
|
||||||
|
- `unshare`
|
||||||
|
- `lead_owner_suggestion_used`
|
||||||
|
- `lead_owner_suggestion_overridden`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npx tsc --noEmit`
|
||||||
|
|
||||||
|
## Remaining limitation
|
||||||
|
|
||||||
|
- Team-scope visibility is still approximate because the production model still lacks an explicit CRM team hierarchy.
|
||||||
@@ -434,9 +434,8 @@ Future:
|
|||||||
### Contact-sharing persistence gap
|
### Contact-sharing persistence gap
|
||||||
|
|
||||||
Current:
|
Current:
|
||||||
Task L.3.1 hardens customer/contact visibility, but the production schema still has no persisted contact-sharing relation. Legacy shared-contact behavior exists only in demo/mock material.
|
Task C.1 closes the contact-sharing persistence gap, but cross-feature regression coverage for owner/share visibility is still light and team-scope remains approximate.
|
||||||
|
|
||||||
Future:
|
Future:
|
||||||
|
- add deeper automated regression coverage for owner/share visibility across customer, lead, enquiry, and quotation flows
|
||||||
- add a first-class contact-sharing table and audit trail if the business still needs shared-contact workflows
|
- introduce a first-class CRM team hierarchy if the business still needs inherited team visibility
|
||||||
- extend security verification from creator/customer/admin visibility to explicit share-based visibility once persistence exists
|
|
||||||
|
|||||||
@@ -29,11 +29,11 @@
|
|||||||
## High-Risk Findings
|
## High-Risk Findings
|
||||||
|
|
||||||
1. Team-scope semantics are still coarse because the current model does not yet carry a first-class subordinate/team graph.
|
1. Team-scope semantics are still coarse because the current model does not yet carry a first-class subordinate/team graph.
|
||||||
2. Production contact sharing is not yet backed by a persisted sharing table; current enforcement covers creator/customer/admin visibility only.
|
2. Contact sharing is now persisted, but team-scope still remains approximate because there is no first-class CRM team hierarchy.
|
||||||
3. Enquiry services already enforce meaningful scope, but several route files still pass legacy role-shaped context instead of a dedicated security context object.
|
3. Enquiry services already enforce meaningful scope, but several route files still pass legacy role-shaped context instead of a dedicated security context object.
|
||||||
|
|
||||||
## Follow-up Focus
|
## Follow-up Focus
|
||||||
|
|
||||||
- Normalize remaining enquiry routes onto the same security-context builder used by quotations and dashboard.
|
- Normalize remaining enquiry routes onto the same security-context builder used by quotations and dashboard.
|
||||||
- Add runtime seeded scenario tests once dedicated security fixtures are available.
|
- Add runtime seeded scenario tests once dedicated security fixtures are available.
|
||||||
- Introduce a first-class team hierarchy and production contact-sharing model if the business requires them.
|
- Introduce a first-class CRM team hierarchy if the business requires true team-based visibility.
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ Contact visibility is enforced through customer visibility plus contact ownershi
|
|||||||
- if a user can access the parent customer, they can access its contact list within their role boundary
|
- if a user can access the parent customer, they can access its contact list within their role boundary
|
||||||
- contact creators retain access to their own contacts
|
- contact creators retain access to their own contacts
|
||||||
- admin/organization scope retains access
|
- admin/organization scope retains access
|
||||||
- production shared-contact persistence does not exist yet, so legacy demo sharing is not part of the live authorization boundary
|
- production contact sharing is persisted through `crm_contact_shares`, so explicit share grants are part of the live authorization boundary
|
||||||
|
|
||||||
## Approval Boundary
|
## Approval Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ That means the system does **not** currently know:
|
|||||||
|
|
||||||
Because of that, `team` currently behaves as an approximate operational scope rather than a strict org-chart scope.
|
Because of that, `team` currently behaves as an approximate operational scope rather than a strict org-chart scope.
|
||||||
|
|
||||||
## Contact Sharing Limitation
|
## Contact Sharing Status
|
||||||
|
|
||||||
The production schema currently has no persisted contact-sharing table.
|
The production schema now includes persisted contact sharing.
|
||||||
|
|
||||||
Result:
|
Result:
|
||||||
|
|
||||||
- contact visibility is enforced through customer ownership plus contact creator visibility
|
- contact visibility is enforced through customer ownership, contact creator visibility, and explicit contact-share grants
|
||||||
- mock/demo shared-contact behavior from legacy demo data is not part of the production boundary yet
|
- cross-owner contact access can now be granted and revoked without relying on demo/mock behavior
|
||||||
|
|
||||||
## Future Team Hierarchy Model
|
## Future Team Hierarchy Model
|
||||||
|
|
||||||
@@ -37,5 +37,5 @@ Recommended future direction:
|
|||||||
|
|
||||||
1. add a first-class manager/team relationship model
|
1. add a first-class manager/team relationship model
|
||||||
2. resolve `team` scope from that relationship instead of permissive approximation
|
2. resolve `team` scope from that relationship instead of permissive approximation
|
||||||
3. add explicit contact-sharing persistence if the business still requires cross-owner contact access
|
3. keep expanding security fixtures around owner/share behavior once a first-class team graph exists
|
||||||
4. expand runtime security fixtures around manager-team boundaries after real hierarchy data exists
|
4. expand runtime security fixtures around manager-team boundaries after real hierarchy data exists
|
||||||
|
|||||||
35
drizzle/0015_customer_ownership_contact_shares.sql
Normal file
35
drizzle/0015_customer_ownership_contact_shares.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
ALTER TABLE "crm_customers"
|
||||||
|
ADD COLUMN IF NOT EXISTS "owner_user_id" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "owner_assigned_at" timestamp with time zone,
|
||||||
|
ADD COLUMN IF NOT EXISTS "owner_assigned_by" text;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "crm_customer_owner_history" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"customer_id" text NOT NULL,
|
||||||
|
"old_owner_user_id" text,
|
||||||
|
"new_owner_user_id" text,
|
||||||
|
"changed_by" text NOT NULL,
|
||||||
|
"changed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"remark" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "crm_contact_shares" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"contact_id" text NOT NULL,
|
||||||
|
"shared_to_user_id" text NOT NULL,
|
||||||
|
"shared_by_user_id" text NOT NULL,
|
||||||
|
"shared_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"remark" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "crm_contact_shares_org_contact_shared_user_idx"
|
||||||
|
ON "crm_contact_shares" ("organization_id", "contact_id", "shared_to_user_id");
|
||||||
@@ -106,6 +106,13 @@
|
|||||||
"when": 1782099743528,
|
"when": 1782099743528,
|
||||||
"tag": "0014_bored_valkyrie",
|
"tag": "0014_bored_valkyrie",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782549600000,
|
||||||
|
"tag": "0015_customer_ownership_contact_shares",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
653
plans/task-c.1.md
Normal file
653
plans/task-c.1.md
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
# Task C.1: Customer Ownership & Contact Sharing Governance
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Complete the Customer and Contact domain by introducing:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Owner
|
||||||
|
Contact Sharing
|
||||||
|
Ownership Governance
|
||||||
|
```
|
||||||
|
|
||||||
|
This closes the final gap in CRM customer management and improves Lead assignment accuracy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Business Motivation
|
||||||
|
|
||||||
|
Current problem:
|
||||||
|
|
||||||
|
Marketing creates a Lead and must manually determine:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Which Sales owns this customer?
|
||||||
|
```
|
||||||
|
|
||||||
|
The system currently knows:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer
|
||||||
|
Contacts
|
||||||
|
Lead
|
||||||
|
Enquiry
|
||||||
|
Quotation
|
||||||
|
```
|
||||||
|
|
||||||
|
but does not know:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Owner
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Who can access shared contacts?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frozen Business Rules
|
||||||
|
|
||||||
|
## Customer Owner
|
||||||
|
|
||||||
|
Each customer may have:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
1 Primary Owner
|
||||||
|
```
|
||||||
|
|
||||||
|
Definition:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Primary Sales responsible for the customer relationship.
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* Owner must be an active CRM user
|
||||||
|
* Owner should normally be:
|
||||||
|
|
||||||
|
* sales
|
||||||
|
* sales_manager
|
||||||
|
* Customer owner may change
|
||||||
|
* Owner history must be retained
|
||||||
|
* Customer owner is organization-scoped
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact Sharing
|
||||||
|
|
||||||
|
Contacts remain owned by a creator.
|
||||||
|
|
||||||
|
However:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Contact
|
||||||
|
├─ Owner
|
||||||
|
├─ Shared User A
|
||||||
|
├─ Shared User B
|
||||||
|
└─ CRM Admin
|
||||||
|
```
|
||||||
|
|
||||||
|
may all access the same contact.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* creator retains access
|
||||||
|
* shared users gain access
|
||||||
|
* CRM Admin retains access
|
||||||
|
* removing a share revokes access
|
||||||
|
* sharing is persistent until revoked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lead Assignment Suggestion
|
||||||
|
|
||||||
|
When Marketing creates a Lead:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer = SCG
|
||||||
|
```
|
||||||
|
|
||||||
|
System checks:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
SCG.ownerUserId
|
||||||
|
```
|
||||||
|
|
||||||
|
If found:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Assign Sales
|
||||||
|
→ pre-selected owner
|
||||||
|
```
|
||||||
|
|
||||||
|
Marketing can still override.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.1 Customer Owner Model
|
||||||
|
|
||||||
|
Add fields:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_customers
|
||||||
|
|
||||||
|
ownerUserId
|
||||||
|
ownerAssignedAt
|
||||||
|
ownerAssignedBy
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Primary Customer Owner
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* nullable
|
||||||
|
* one owner at a time
|
||||||
|
* organization scoped
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.2 Customer Owner History
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_customer_owner_history
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
customerId
|
||||||
|
|
||||||
|
oldOwnerUserId
|
||||||
|
newOwnerUserId
|
||||||
|
|
||||||
|
changedBy
|
||||||
|
changedAt
|
||||||
|
|
||||||
|
remark
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Audit ownership changes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.3 Customer Ownership UI
|
||||||
|
|
||||||
|
Customer Detail:
|
||||||
|
|
||||||
|
Add section:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Owner
|
||||||
|
```
|
||||||
|
|
||||||
|
Display:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Owner
|
||||||
|
Assigned Date
|
||||||
|
Assigned By
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Assign Owner
|
||||||
|
Change Owner
|
||||||
|
Clear Owner
|
||||||
|
```
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm.customer.owner.manage
|
||||||
|
```
|
||||||
|
|
||||||
|
required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.4 Customer List Enhancements
|
||||||
|
|
||||||
|
Add columns:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Owner
|
||||||
|
Owner Assigned At
|
||||||
|
```
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Owner
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
My Customers
|
||||||
|
```
|
||||||
|
|
||||||
|
quick filter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.5 Lead Assignment Integration
|
||||||
|
|
||||||
|
When creating Lead:
|
||||||
|
|
||||||
|
If:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer has Owner
|
||||||
|
```
|
||||||
|
|
||||||
|
then:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Assigned Sales
|
||||||
|
```
|
||||||
|
|
||||||
|
auto-populates.
|
||||||
|
|
||||||
|
Display:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Suggested Sales Owner
|
||||||
|
```
|
||||||
|
|
||||||
|
Marketing may override.
|
||||||
|
|
||||||
|
Audit:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
lead owner suggestion used
|
||||||
|
lead owner suggestion overridden
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.6 Contact Sharing Model
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_contact_shares
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
contactId
|
||||||
|
|
||||||
|
sharedToUserId
|
||||||
|
|
||||||
|
sharedByUserId
|
||||||
|
sharedAt
|
||||||
|
|
||||||
|
isActive
|
||||||
|
|
||||||
|
remark
|
||||||
|
```
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
contactId + sharedToUserId
|
||||||
|
```
|
||||||
|
|
||||||
|
unique.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.7 Contact Sharing Service
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Share Contact
|
||||||
|
Unshare Contact
|
||||||
|
List Shares
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
User may access contact if:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Creator
|
||||||
|
OR
|
||||||
|
Shared User
|
||||||
|
OR
|
||||||
|
Customer Owner
|
||||||
|
OR
|
||||||
|
CRM Admin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.8 Contact Sharing UI
|
||||||
|
|
||||||
|
Contact Detail:
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Shared With
|
||||||
|
```
|
||||||
|
|
||||||
|
Section.
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Share
|
||||||
|
Remove Share
|
||||||
|
```
|
||||||
|
|
||||||
|
Display:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
User
|
||||||
|
Shared By
|
||||||
|
Shared At
|
||||||
|
Remark
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.9 Customer Owner Visibility Rules
|
||||||
|
|
||||||
|
Customer Owner automatically gains visibility to:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer
|
||||||
|
Contacts
|
||||||
|
Related Leads
|
||||||
|
Related Enquiries
|
||||||
|
Related Quotations
|
||||||
|
```
|
||||||
|
|
||||||
|
subject to CRM authorization.
|
||||||
|
|
||||||
|
Customer Owner does NOT automatically gain:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approval Authority
|
||||||
|
CRM Admin Access
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.10 CRM Access Resolver Integration
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
resolveCrmAccess()
|
||||||
|
```
|
||||||
|
|
||||||
|
Support:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Owner Access
|
||||||
|
Contact Share Access
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional helpers:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
canAccessCustomer()
|
||||||
|
canAccessContact()
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules centralized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.11 Permissions
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm.customer.owner.read
|
||||||
|
crm.customer.owner.manage
|
||||||
|
|
||||||
|
crm.contact.share.read
|
||||||
|
crm.contact.share.create
|
||||||
|
crm.contact.share.delete
|
||||||
|
crm.contact.share.manage
|
||||||
|
```
|
||||||
|
|
||||||
|
Default:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_admin
|
||||||
|
sales_manager
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
sales
|
||||||
|
```
|
||||||
|
|
||||||
|
depending on organization policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.12 Security Enforcement
|
||||||
|
|
||||||
|
Update:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customers
|
||||||
|
Contacts
|
||||||
|
Leads
|
||||||
|
Enquiries
|
||||||
|
```
|
||||||
|
|
||||||
|
to recognize:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Owner
|
||||||
|
Shared Contact User
|
||||||
|
```
|
||||||
|
|
||||||
|
visibility.
|
||||||
|
|
||||||
|
No UI-only enforcement.
|
||||||
|
|
||||||
|
Server-side required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.13 Audit Logging
|
||||||
|
|
||||||
|
Entity Types:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_customer_owner
|
||||||
|
crm_contact_share
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
assign_owner
|
||||||
|
change_owner
|
||||||
|
clear_owner
|
||||||
|
|
||||||
|
share
|
||||||
|
unshare
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
customerId
|
||||||
|
contactId
|
||||||
|
|
||||||
|
oldOwner
|
||||||
|
newOwner
|
||||||
|
|
||||||
|
sharedUser
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope C1.14 Verification Matrix
|
||||||
|
|
||||||
|
## Marketing
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
See Customer Owner
|
||||||
|
Use Suggested Sales
|
||||||
|
```
|
||||||
|
|
||||||
|
Cannot:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Manage Customer Owner
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sales Owner
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
See Owned Customers
|
||||||
|
See Related Contacts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared User
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
See Shared Contact
|
||||||
|
```
|
||||||
|
|
||||||
|
Cannot:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
See Unshared Contact
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRM Admin
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Full Visibility
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Owner Change
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
History Created
|
||||||
|
Audit Created
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/adr/0015-customer-ownership-contact-sharing.md
|
||||||
|
docs/implementation/task-c1-customer-ownership-contact-sharing-governance.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Document:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Owner lifecycle
|
||||||
|
Contact Sharing lifecycle
|
||||||
|
Lead Assignment suggestion flow
|
||||||
|
Visibility model
|
||||||
|
Ownership model
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Explicit Non-Scope
|
||||||
|
|
||||||
|
Do NOT implement:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer ownership percentages
|
||||||
|
Multiple primary owners
|
||||||
|
Temporary shares
|
||||||
|
External customer portal
|
||||||
|
Account hierarchy
|
||||||
|
Territory management
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deliverables
|
||||||
|
|
||||||
|
1. Customer Owner Model
|
||||||
|
2. Owner History
|
||||||
|
3. Owner Management UI
|
||||||
|
4. Lead Assignment Suggestion
|
||||||
|
5. Contact Sharing Model
|
||||||
|
6. Contact Sharing UI
|
||||||
|
7. Resolver Integration
|
||||||
|
8. Security Enforcement
|
||||||
|
9. Audit Logging
|
||||||
|
10. Governance Documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
Task C.1 is complete when:
|
||||||
|
|
||||||
|
* customer owner exists
|
||||||
|
* owner history exists
|
||||||
|
* lead assignment can suggest owner
|
||||||
|
* contacts can be shared
|
||||||
|
* shared contacts persist
|
||||||
|
* customer owner gains visibility
|
||||||
|
* access resolver supports sharing
|
||||||
|
* audits are recorded
|
||||||
|
* verification matrix passes
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Customer Domain = COMPLETE
|
||||||
|
Contact Domain = COMPLETE
|
||||||
|
Lead Assignment Accuracy = IMPROVED
|
||||||
|
CRM Core = FUNCTIONALLY COMPLETE
|
||||||
|
```
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { unshareCustomerContact } from '@/features/crm/customers/server/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string; contactId: string; shareId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id, contactId, shareId } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmContactShareDelete
|
||||||
|
});
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const removed = await unshareCustomerContact(
|
||||||
|
id,
|
||||||
|
contactId,
|
||||||
|
shareId,
|
||||||
|
organization.id,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_contact_share',
|
||||||
|
entityId: shareId,
|
||||||
|
action: 'unshare',
|
||||||
|
beforeData: {
|
||||||
|
customerId: id,
|
||||||
|
contactId,
|
||||||
|
sharedUser: removed.sharedToUserId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Contact share removed successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
'DELETE /api/crm/customers/[id]/contacts/[contactId]/shares/[shareId] failed',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return NextResponse.json({ message: 'Unable to remove contact share' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import {
|
||||||
|
listCustomerContactShares,
|
||||||
|
shareCustomerContact
|
||||||
|
} from '@/features/crm/customers/server/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string; contactId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareSchema = z.object({
|
||||||
|
sharedToUserId: z.string().min(1, 'Shared user is required'),
|
||||||
|
remark: z.string().optional().nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id, contactId } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmContactShareRead
|
||||||
|
});
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const items = await listCustomerContactShares(
|
||||||
|
id,
|
||||||
|
contactId,
|
||||||
|
organization.id,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Contact shares loaded successfully',
|
||||||
|
items
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('GET /api/crm/customers/[id]/contacts/[contactId]/shares failed', error);
|
||||||
|
return NextResponse.json({ message: 'Unable to load contact shares' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id, contactId } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmContactShareCreate
|
||||||
|
});
|
||||||
|
const payload = shareSchema.parse(await request.json());
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const created = await shareCustomerContact(
|
||||||
|
id,
|
||||||
|
contactId,
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
payload,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_contact_share',
|
||||||
|
entityId: created.id,
|
||||||
|
action: 'share',
|
||||||
|
afterData: {
|
||||||
|
customerId: id,
|
||||||
|
contactId,
|
||||||
|
sharedUser: payload.sharedToUserId,
|
||||||
|
remark: payload.remark ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Contact shared successfully'
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('POST /api/crm/customers/[id]/contacts/[contactId]/shares failed', error);
|
||||||
|
return NextResponse.json({ message: 'Unable to share contact' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/app/api/crm/customers/[id]/owner/route.ts
Normal file
146
src/app/api/crm/customers/[id]/owner/route.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import {
|
||||||
|
assignCustomerOwner,
|
||||||
|
clearCustomerOwner,
|
||||||
|
getCustomerDetail
|
||||||
|
} from '@/features/crm/customers/server/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownerSchema = z.object({
|
||||||
|
ownerUserId: z.string().min(1, 'Owner user is required'),
|
||||||
|
remark: z.string().optional().nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearOwnerSchema = z.object({
|
||||||
|
remark: z.string().optional().nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmCustomerOwnerManage
|
||||||
|
});
|
||||||
|
const payload = ownerSchema.parse(await request.json());
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const before = await getCustomerDetail(id, organization.id, accessContext);
|
||||||
|
const updated = await assignCustomerOwner(
|
||||||
|
id,
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
payload,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: updated.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_customer_owner',
|
||||||
|
entityId: id,
|
||||||
|
action: before.ownerUserId ? 'change_owner' : 'assign_owner',
|
||||||
|
beforeData: {
|
||||||
|
customerId: id,
|
||||||
|
oldOwnerUserId: before.ownerUserId
|
||||||
|
},
|
||||||
|
afterData: {
|
||||||
|
customerId: id,
|
||||||
|
newOwnerUserId: payload.ownerUserId,
|
||||||
|
remark: payload.remark ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Customer owner updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('PATCH /api/crm/customers/[id]/owner failed', error);
|
||||||
|
return NextResponse.json({ message: 'Unable to update customer owner' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmCustomerOwnerManage
|
||||||
|
});
|
||||||
|
const payload = clearOwnerSchema.parse(
|
||||||
|
request.method === 'DELETE' ? (await request.json().catch(() => ({}))) : {}
|
||||||
|
);
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const before = await getCustomerDetail(id, organization.id, accessContext);
|
||||||
|
const updated = await clearCustomerOwner(
|
||||||
|
id,
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
payload,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: updated.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_customer_owner',
|
||||||
|
entityId: id,
|
||||||
|
action: 'clear_owner',
|
||||||
|
beforeData: {
|
||||||
|
customerId: id,
|
||||||
|
oldOwnerUserId: before.ownerUserId
|
||||||
|
},
|
||||||
|
afterData: {
|
||||||
|
customerId: id,
|
||||||
|
newOwnerUserId: null,
|
||||||
|
remark: payload.remark ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Customer owner cleared successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('DELETE /api/crm/customers/[id]/owner failed', error);
|
||||||
|
return NextResponse.json({ message: 'Unable to clear customer owner' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getCustomerActivity,
|
getCustomerActivity,
|
||||||
getCustomerDetail,
|
getCustomerDetail,
|
||||||
|
getCustomerOwnerHistory,
|
||||||
softDeleteCustomer,
|
softDeleteCustomer,
|
||||||
updateCustomer
|
updateCustomer
|
||||||
} from '@/features/crm/customers/server/service';
|
} from '@/features/crm/customers/server/service';
|
||||||
@@ -37,9 +38,10 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membership
|
membership
|
||||||
});
|
});
|
||||||
const [customer, activity] = await Promise.all([
|
const [customer, activity, ownerHistory] = await Promise.all([
|
||||||
getCustomerDetail(id, organization.id, accessContext),
|
getCustomerDetail(id, organization.id, accessContext),
|
||||||
getCustomerActivity(id, organization.id)
|
getCustomerActivity(id, organization.id),
|
||||||
|
getCustomerOwnerHistory(id, organization.id, accessContext)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -47,7 +49,8 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Customer loaded successfully',
|
message: 'Customer loaded successfully',
|
||||||
customer,
|
customer,
|
||||||
activity
|
activity,
|
||||||
|
ownerHistory
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const customerStatus = searchParams.get('customerStatus') ?? undefined;
|
const customerStatus = searchParams.get('customerStatus') ?? undefined;
|
||||||
const customerType = searchParams.get('customerType') ?? undefined;
|
const customerType = searchParams.get('customerType') ?? undefined;
|
||||||
const branch = searchParams.get('branch') ?? undefined;
|
const branch = searchParams.get('branch') ?? undefined;
|
||||||
|
const ownerUserId = searchParams.get('ownerUserId') ?? undefined;
|
||||||
|
const myCustomers = searchParams.get('myCustomers') ?? undefined;
|
||||||
const sort = searchParams.get('sort') ?? undefined;
|
const sort = searchParams.get('sort') ?? undefined;
|
||||||
const accessContext = buildCrmSecurityContext({
|
const accessContext = buildCrmSecurityContext({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -42,6 +44,8 @@ export async function GET(request: NextRequest) {
|
|||||||
customerStatus,
|
customerStatus,
|
||||||
customerType,
|
customerType,
|
||||||
branch,
|
branch,
|
||||||
|
ownerUserId,
|
||||||
|
myCustomers,
|
||||||
sort
|
sort
|
||||||
}, accessContext);
|
}, accessContext);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditCreate } from '@/features/foundation/audit-log/service';
|
import { auditAction, auditCreate } from '@/features/foundation/audit-log/service';
|
||||||
import { createEnquiry, listEnquiries } from '@/features/crm/enquiries/server/service';
|
import { createEnquiry, listEnquiries } from '@/features/crm/enquiries/server/service';
|
||||||
|
import { getCustomerDetail } from '@/features/crm/customers/server/service';
|
||||||
import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
|
import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
const enquiryRequestSchema = enquirySchema.extend({
|
const enquiryRequestSchema = enquirySchema.extend({
|
||||||
contactId: z.string().nullable().optional(),
|
contactId: z.string().nullable().optional(),
|
||||||
|
assignedToUserId: z.string().nullable().optional(),
|
||||||
branchId: z.string().nullable().optional(),
|
branchId: z.string().nullable().optional(),
|
||||||
leadChannel: z.string().nullable().optional(),
|
leadChannel: z.string().nullable().optional(),
|
||||||
expectedCloseDate: z.string().nullable().optional()
|
expectedCloseDate: z.string().nullable().optional()
|
||||||
@@ -102,6 +104,7 @@ export async function POST(request: NextRequest) {
|
|||||||
accessContext,
|
accessContext,
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
|
const customer = await getCustomerDetail(payload.customerId, organization.id);
|
||||||
|
|
||||||
await auditCreate({
|
await auditCreate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -112,6 +115,25 @@ export async function POST(request: NextRequest) {
|
|||||||
afterData: created
|
afterData: created
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (customer.ownerUserId) {
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: created.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_enquiry',
|
||||||
|
entityId: created.id,
|
||||||
|
action:
|
||||||
|
payload.assignedToUserId && payload.assignedToUserId !== customer.ownerUserId
|
||||||
|
? 'lead_owner_suggestion_overridden'
|
||||||
|
: 'lead_owner_suggestion_used',
|
||||||
|
afterData: {
|
||||||
|
customerId: payload.customerId,
|
||||||
|
suggestedOwnerUserId: customer.ownerUserId,
|
||||||
|
assignedToUserId: payload.assignedToUserId ?? customer.ownerUserId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -49,6 +49,22 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
|||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmContactDelete)));
|
session.user.activePermissions.includes(PERMISSIONS.crmContactDelete)));
|
||||||
|
const canOwnerManage =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmCustomerOwnerManage)));
|
||||||
|
const canContactShareRead =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmContactShareRead)));
|
||||||
|
const canContactShareManage =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmContactShareManage) ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmContactShareCreate)));
|
||||||
const accessContext =
|
const accessContext =
|
||||||
session?.user?.activeOrganizationId && session?.user?.id
|
session?.user?.activeOrganizationId && session?.user?.id
|
||||||
? {
|
? {
|
||||||
@@ -116,6 +132,9 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
|||||||
update: canContactUpdate,
|
update: canContactUpdate,
|
||||||
delete: canContactDelete
|
delete: canContactDelete
|
||||||
}}
|
}}
|
||||||
|
canReadContactShares={canContactShareRead}
|
||||||
|
canManageContactShares={canContactShareManage}
|
||||||
|
canManageOwner={canOwnerManage}
|
||||||
relatedEnquiries={relatedEnquiries}
|
relatedEnquiries={relatedEnquiries}
|
||||||
relatedQuotations={relatedQuotations}
|
relatedQuotations={relatedQuotations}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -213,6 +213,9 @@ export const crmCustomers = pgTable(
|
|||||||
leadChannel: text('lead_channel'),
|
leadChannel: text('lead_channel'),
|
||||||
customerGroup: text('customer_group'),
|
customerGroup: text('customer_group'),
|
||||||
customerSubGroup: text('customer_sub_group'),
|
customerSubGroup: text('customer_sub_group'),
|
||||||
|
ownerUserId: text('owner_user_id'),
|
||||||
|
ownerAssignedAt: timestamp('owner_assigned_at', { withTimezone: true }),
|
||||||
|
ownerAssignedBy: text('owner_assigned_by'),
|
||||||
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(),
|
||||||
@@ -229,6 +232,20 @@ export const crmCustomers = pgTable(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const crmCustomerOwnerHistory = pgTable('crm_customer_owner_history', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
customerId: text('customer_id').notNull(),
|
||||||
|
oldOwnerUserId: text('old_owner_user_id'),
|
||||||
|
newOwnerUserId: text('new_owner_user_id'),
|
||||||
|
changedBy: text('changed_by').notNull(),
|
||||||
|
changedAt: timestamp('changed_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
remark: text('remark'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
|
});
|
||||||
|
|
||||||
export const crmCustomerContacts = pgTable('crm_customer_contacts', {
|
export const crmCustomerContacts = pgTable('crm_customer_contacts', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
@@ -249,6 +266,30 @@ export const crmCustomerContacts = pgTable('crm_customer_contacts', {
|
|||||||
updatedBy: text('updated_by').notNull()
|
updatedBy: text('updated_by').notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const crmContactShares = pgTable(
|
||||||
|
'crm_contact_shares',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
contactId: text('contact_id').notNull(),
|
||||||
|
sharedToUserId: text('shared_to_user_id').notNull(),
|
||||||
|
sharedByUserId: text('shared_by_user_id').notNull(),
|
||||||
|
sharedAt: timestamp('shared_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
remark: text('remark'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
contactSharedUserIdx: uniqueIndex('crm_contact_shares_org_contact_shared_user_idx').on(
|
||||||
|
table.organizationId,
|
||||||
|
table.contactId,
|
||||||
|
table.sharedToUserId
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const crmEnquiries = pgTable(
|
export const crmEnquiries = pgTable(
|
||||||
'crm_enquiries',
|
'crm_enquiries',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { mutationOptions } from '@tanstack/react-query';
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
import {
|
import {
|
||||||
|
assignCustomerOwner,
|
||||||
|
clearCustomerOwner,
|
||||||
createCustomer,
|
createCustomer,
|
||||||
createCustomerContact,
|
createCustomerContact,
|
||||||
deleteCustomer,
|
deleteCustomer,
|
||||||
deleteCustomerContact,
|
deleteCustomerContact,
|
||||||
|
shareCustomerContact,
|
||||||
|
unshareCustomerContact,
|
||||||
updateCustomer,
|
updateCustomer,
|
||||||
updateCustomerContact
|
updateCustomerContact
|
||||||
} from './service';
|
} from './service';
|
||||||
import { customerKeys } from './queries';
|
import { customerKeys } from './queries';
|
||||||
import type { CustomerContactMutationPayload, CustomerMutationPayload } from './types';
|
import type {
|
||||||
|
ContactShareMutationPayload,
|
||||||
|
CustomerContactMutationPayload,
|
||||||
|
CustomerMutationPayload,
|
||||||
|
CustomerOwnerMutationPayload
|
||||||
|
} from './types';
|
||||||
|
|
||||||
async function invalidateCustomerLists() {
|
async function invalidateCustomerLists() {
|
||||||
await getQueryClient().invalidateQueries({ queryKey: customerKeys.lists() });
|
await getQueryClient().invalidateQueries({ queryKey: customerKeys.lists() });
|
||||||
@@ -23,6 +32,12 @@ async function invalidateCustomerContacts(id: string) {
|
|||||||
await getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(id) });
|
await getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invalidateCustomerContactShares(customerId: string, contactId: string) {
|
||||||
|
await getQueryClient().invalidateQueries({
|
||||||
|
queryKey: customerKeys.contactShares(customerId, contactId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function invalidateCustomerMutationQueries(id: string) {
|
export async function invalidateCustomerMutationQueries(id: string) {
|
||||||
await Promise.all([invalidateCustomerLists(), invalidateCustomerDetail(id)]);
|
await Promise.all([invalidateCustomerLists(), invalidateCustomerDetail(id)]);
|
||||||
}
|
}
|
||||||
@@ -65,6 +80,26 @@ export const deleteCustomerMutation = mutationOptions({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const assignCustomerOwnerMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: CustomerOwnerMutationPayload }) =>
|
||||||
|
assignCustomerOwner(id, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateCustomerMutationQueries(variables.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clearCustomerOwnerMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values?: Pick<CustomerOwnerMutationPayload, 'remark'> }) =>
|
||||||
|
clearCustomerOwner(id, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateCustomerMutationQueries(variables.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const createCustomerContactMutation = mutationOptions({
|
export const createCustomerContactMutation = mutationOptions({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
customerId,
|
customerId,
|
||||||
@@ -106,3 +141,43 @@ export const deleteCustomerContactMutation = mutationOptions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const shareCustomerContactMutation = mutationOptions({
|
||||||
|
mutationFn: ({
|
||||||
|
customerId,
|
||||||
|
contactId,
|
||||||
|
values
|
||||||
|
}: {
|
||||||
|
customerId: string;
|
||||||
|
contactId: string;
|
||||||
|
values: ContactShareMutationPayload;
|
||||||
|
}) => shareCustomerContact(customerId, contactId, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await Promise.all([
|
||||||
|
invalidateCustomerContactMutationQueries(variables.customerId),
|
||||||
|
invalidateCustomerContactShares(variables.customerId, variables.contactId)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const unshareCustomerContactMutation = mutationOptions({
|
||||||
|
mutationFn: ({
|
||||||
|
customerId,
|
||||||
|
contactId,
|
||||||
|
shareId
|
||||||
|
}: {
|
||||||
|
customerId: string;
|
||||||
|
contactId: string;
|
||||||
|
shareId: string;
|
||||||
|
}) => unshareCustomerContact(customerId, contactId, shareId),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await Promise.all([
|
||||||
|
invalidateCustomerContactMutationQueries(variables.customerId),
|
||||||
|
invalidateCustomerContactShares(variables.customerId, variables.contactId)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query';
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
import { getCustomerById, getCustomerContacts, getCustomers } from './service';
|
import {
|
||||||
|
getCustomerById,
|
||||||
|
getCustomerContactShares,
|
||||||
|
getCustomerContacts,
|
||||||
|
getCustomers
|
||||||
|
} from './service';
|
||||||
import type { CustomerFilters } from './types';
|
import type { CustomerFilters } from './types';
|
||||||
|
|
||||||
export const customerKeys = {
|
export const customerKeys = {
|
||||||
@@ -9,7 +14,10 @@ export const customerKeys = {
|
|||||||
details: () => [...customerKeys.all, 'detail'] as const,
|
details: () => [...customerKeys.all, 'detail'] as const,
|
||||||
detail: (id: string) => [...customerKeys.details(), id] as const,
|
detail: (id: string) => [...customerKeys.details(), id] as const,
|
||||||
contactsRoot: () => [...customerKeys.all, 'contacts'] as const,
|
contactsRoot: () => [...customerKeys.all, 'contacts'] as const,
|
||||||
contacts: (id: string) => [...customerKeys.contactsRoot(), id] as const
|
contacts: (id: string) => [...customerKeys.contactsRoot(), id] as const,
|
||||||
|
contactSharesRoot: () => [...customerKeys.all, 'contact-shares'] as const,
|
||||||
|
contactShares: (customerId: string, contactId: string) =>
|
||||||
|
[...customerKeys.contactSharesRoot(), customerId, contactId] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
export const customersQueryOptions = (filters: CustomerFilters) =>
|
export const customersQueryOptions = (filters: CustomerFilters) =>
|
||||||
@@ -29,3 +37,9 @@ export const customerContactsOptions = (id: string) =>
|
|||||||
queryKey: customerKeys.contacts(id),
|
queryKey: customerKeys.contacts(id),
|
||||||
queryFn: () => getCustomerContacts(id)
|
queryFn: () => getCustomerContacts(id)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const customerContactSharesOptions = (customerId: string, contactId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: customerKeys.contactShares(customerId, contactId),
|
||||||
|
queryFn: () => getCustomerContactShares(customerId, contactId)
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import type {
|
import type {
|
||||||
|
ContactShareMutationPayload,
|
||||||
CustomerContactMutationPayload,
|
CustomerContactMutationPayload,
|
||||||
|
CustomerContactSharesResponse,
|
||||||
CustomerContactsResponse,
|
CustomerContactsResponse,
|
||||||
CustomerDetailResponse,
|
CustomerDetailResponse,
|
||||||
CustomerFilters,
|
CustomerFilters,
|
||||||
CustomerListResponse,
|
CustomerListResponse,
|
||||||
CustomerMutationPayload,
|
CustomerMutationPayload,
|
||||||
|
CustomerOwnerMutationPayload,
|
||||||
MutationSuccessResponse
|
MutationSuccessResponse
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -18,6 +21,8 @@ export async function getCustomers(filters: CustomerFilters): Promise<CustomerLi
|
|||||||
if (filters.customerStatus) searchParams.set('customerStatus', filters.customerStatus);
|
if (filters.customerStatus) searchParams.set('customerStatus', filters.customerStatus);
|
||||||
if (filters.customerType) searchParams.set('customerType', filters.customerType);
|
if (filters.customerType) searchParams.set('customerType', filters.customerType);
|
||||||
if (filters.branch) searchParams.set('branch', filters.branch);
|
if (filters.branch) searchParams.set('branch', filters.branch);
|
||||||
|
if (filters.ownerUserId) searchParams.set('ownerUserId', filters.ownerUserId);
|
||||||
|
if (filters.myCustomers) searchParams.set('myCustomers', filters.myCustomers);
|
||||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||||
|
|
||||||
const query = searchParams.toString();
|
const query = searchParams.toString();
|
||||||
@@ -49,6 +54,20 @@ export async function deleteCustomer(id: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function assignCustomerOwner(id: string, data: CustomerOwnerMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/customers/${id}/owner`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCustomerOwner(id: string, data?: Pick<CustomerOwnerMutationPayload, 'remark'>) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/customers/${id}/owner`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify(data ?? {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCustomerContacts(id: string): Promise<CustomerContactsResponse> {
|
export async function getCustomerContacts(id: string): Promise<CustomerContactsResponse> {
|
||||||
return apiClient<CustomerContactsResponse>(`/crm/customers/${id}/contacts`);
|
return apiClient<CustomerContactsResponse>(`/crm/customers/${id}/contacts`);
|
||||||
}
|
}
|
||||||
@@ -79,3 +98,39 @@ export async function deleteCustomerContact(customerId: string, contactId: strin
|
|||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCustomerContactShares(
|
||||||
|
customerId: string,
|
||||||
|
contactId: string
|
||||||
|
): Promise<CustomerContactSharesResponse> {
|
||||||
|
return apiClient<CustomerContactSharesResponse>(
|
||||||
|
`/crm/customers/${customerId}/contacts/${contactId}/shares`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareCustomerContact(
|
||||||
|
customerId: string,
|
||||||
|
contactId: string,
|
||||||
|
data: ContactShareMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(
|
||||||
|
`/crm/customers/${customerId}/contacts/${contactId}/shares`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unshareCustomerContact(
|
||||||
|
customerId: string,
|
||||||
|
contactId: string,
|
||||||
|
shareId: string
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(
|
||||||
|
`/crm/customers/${customerId}/contacts/${contactId}/shares/${shareId}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export interface CustomerRecord {
|
|||||||
leadChannel: string | null;
|
leadChannel: string | null;
|
||||||
customerGroup: string | null;
|
customerGroup: string | null;
|
||||||
customerSubGroup: string | null;
|
customerSubGroup: string | null;
|
||||||
|
ownerUserId: string | null;
|
||||||
|
ownerAssignedAt: string | null;
|
||||||
|
ownerAssignedBy: string | null;
|
||||||
|
ownerName: string | null;
|
||||||
|
ownerAssignedByName: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -49,6 +54,38 @@ export interface CustomerListItem extends CustomerRecord {
|
|||||||
contactCount: number;
|
contactCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomerUserLookup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
membershipRole: 'admin' | 'user';
|
||||||
|
businessRole: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerOwnerHistoryRecord {
|
||||||
|
id: string;
|
||||||
|
customerId: string;
|
||||||
|
oldOwnerUserId: string | null;
|
||||||
|
oldOwnerName: string | null;
|
||||||
|
newOwnerUserId: string | null;
|
||||||
|
newOwnerName: string | null;
|
||||||
|
changedBy: string;
|
||||||
|
changedByName: string | null;
|
||||||
|
changedAt: string;
|
||||||
|
remark: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactShareRecord {
|
||||||
|
id: string;
|
||||||
|
contactId: string;
|
||||||
|
sharedToUserId: string;
|
||||||
|
sharedToName: string | null;
|
||||||
|
sharedByUserId: string;
|
||||||
|
sharedByName: string | null;
|
||||||
|
sharedAt: string;
|
||||||
|
isActive: boolean;
|
||||||
|
remark: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CustomerContactRecord {
|
export interface CustomerContactRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -67,6 +104,7 @@ export interface CustomerContactRecord {
|
|||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
|
shares: ContactShareRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerActivityRecord {
|
export interface CustomerActivityRecord {
|
||||||
@@ -95,6 +133,7 @@ export interface CustomerReferenceData {
|
|||||||
leadChannels: CustomerOption[];
|
leadChannels: CustomerOption[];
|
||||||
customerGroups: CustomerOption[];
|
customerGroups: CustomerOption[];
|
||||||
customerSubGroups: CustomerOption[];
|
customerSubGroups: CustomerOption[];
|
||||||
|
crmUsers: CustomerUserLookup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerFilters {
|
export interface CustomerFilters {
|
||||||
@@ -104,6 +143,8 @@ export interface CustomerFilters {
|
|||||||
customerStatus?: string;
|
customerStatus?: string;
|
||||||
customerType?: string;
|
customerType?: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
|
ownerUserId?: string;
|
||||||
|
myCustomers?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +164,7 @@ export interface CustomerDetailResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
customer: CustomerRecord;
|
customer: CustomerRecord;
|
||||||
activity: CustomerActivityRecord[];
|
activity: CustomerActivityRecord[];
|
||||||
|
ownerHistory: CustomerOwnerHistoryRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerContactsResponse {
|
export interface CustomerContactsResponse {
|
||||||
@@ -132,6 +174,12 @@ export interface CustomerContactsResponse {
|
|||||||
items: CustomerContactRecord[];
|
items: CustomerContactRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomerContactSharesResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
items: ContactShareRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CustomerMutationPayload {
|
export interface CustomerMutationPayload {
|
||||||
name: string;
|
name: string;
|
||||||
abbr?: string;
|
abbr?: string;
|
||||||
@@ -168,6 +216,16 @@ export interface CustomerContactMutationPayload {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomerOwnerMutationPayload {
|
||||||
|
ownerUserId?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactShareMutationPayload {
|
||||||
|
sharedToUserId: string;
|
||||||
|
remark?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationSuccessResponse {
|
export interface MutationSuccessResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
|
|||||||
173
src/features/crm/customers/components/contact-share-sheet.tsx
Normal file
173
src/features/crm/customers/components/contact-share-sheet.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
shareCustomerContactMutation,
|
||||||
|
unshareCustomerContactMutation
|
||||||
|
} from '../api/mutations';
|
||||||
|
import type { CustomerContactRecord, CustomerReferenceData } from '../api/types';
|
||||||
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
|
||||||
|
export function ContactShareSheet({
|
||||||
|
customerId,
|
||||||
|
contact,
|
||||||
|
referenceData,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
canManage
|
||||||
|
}: {
|
||||||
|
customerId: string;
|
||||||
|
contact: CustomerContactRecord | null;
|
||||||
|
referenceData: CustomerReferenceData;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
canManage: boolean;
|
||||||
|
}) {
|
||||||
|
const [sharedToUserId, setSharedToUserId] = useState('');
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
|
const existingSharedIds = useMemo(
|
||||||
|
() => new Set(contact?.shares.map((item) => item.sharedToUserId) ?? []),
|
||||||
|
[contact]
|
||||||
|
);
|
||||||
|
const candidates = useMemo(
|
||||||
|
() => referenceData.crmUsers.filter((user) => !existingSharedIds.has(user.id)),
|
||||||
|
[existingSharedIds, referenceData.crmUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const shareMutation = useMutation({
|
||||||
|
...shareCustomerContactMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('แชร์ contact เรียบร้อย');
|
||||||
|
setSharedToUserId('');
|
||||||
|
setRemark('');
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'ไม่สามารถแชร์ contact ได้')
|
||||||
|
});
|
||||||
|
const unshareMutation = useMutation({
|
||||||
|
...unshareCustomerContactMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('ยกเลิกการแชร์เรียบร้อย');
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'ไม่สามารถยกเลิกการแชร์ได้')
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Shared With</DialogTitle>
|
||||||
|
<DialogDescription>จัดการผู้ใช้ที่เข้าถึง contact นี้ได้</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{!contact?.shares.length ? (
|
||||||
|
<div className='text-muted-foreground text-sm'>ยังไม่มีการแชร์ contact นี้</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{contact.shares.map((share) => (
|
||||||
|
<div
|
||||||
|
key={share.id}
|
||||||
|
className='flex items-start justify-between gap-3 rounded-lg border p-3'
|
||||||
|
>
|
||||||
|
<div className='space-y-1 text-sm'>
|
||||||
|
<div className='font-medium'>{share.sharedToName ?? share.sharedToUserId}</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
{formatDateTime(share.sharedAt)} โดย {share.sharedByName ?? share.sharedByUserId}
|
||||||
|
</div>
|
||||||
|
{share.remark ? <div className='text-xs'>{share.remark}</div> : null}
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() =>
|
||||||
|
unshareMutation.mutate({
|
||||||
|
customerId,
|
||||||
|
contactId: contact.id,
|
||||||
|
shareId: share.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icons.trash className='mr-2 h-4 w-4' />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canManage ? (
|
||||||
|
<div className='space-y-3 border-t pt-4'>
|
||||||
|
<Select value={sharedToUserId} onValueChange={setSharedToUserId}>
|
||||||
|
<SelectTrigger aria-label='Shared user'>
|
||||||
|
<SelectValue placeholder='Select user' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{candidates.map((user) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Textarea
|
||||||
|
value={remark}
|
||||||
|
onChange={(event) => setRemark(event.target.value)}
|
||||||
|
placeholder='Remark'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
ปิด
|
||||||
|
</Button>
|
||||||
|
{canManage ? (
|
||||||
|
<Button
|
||||||
|
isLoading={shareMutation.isPending}
|
||||||
|
disabled={!contact || !sharedToUserId}
|
||||||
|
onClick={() => {
|
||||||
|
if (!contact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shareMutation.mutate({
|
||||||
|
customerId,
|
||||||
|
contactId: contact.id,
|
||||||
|
values: {
|
||||||
|
sharedToUserId,
|
||||||
|
remark
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.check className='mr-2 h-4 w-4' />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export function getCustomerColumns({
|
|||||||
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 groupMap = new Map(referenceData.customerGroups.map((item) => [item.id, item.label]));
|
||||||
const subGroupMap = new Map(referenceData.customerSubGroups.map((item) => [item.id, item.label]));
|
const subGroupMap = new Map(referenceData.customerSubGroups.map((item) => [item.id, item.label]));
|
||||||
|
const userMap = new Map(referenceData.crmUsers.map((item) => [item.id, item.name]));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -133,6 +134,31 @@ export function getCustomerColumns({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{subGroupMap.get(row.original.customerSubGroup ?? '') ?? '-'}</span>
|
cell: ({ row }) => <span>{subGroupMap.get(row.original.customerSubGroup ?? '') ?? '-'}</span>
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'ownerUserId',
|
||||||
|
accessorKey: 'ownerUserId',
|
||||||
|
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Owner' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <span>{row.original.ownerName ?? userMap.get(row.original.ownerUserId ?? '') ?? '-'}</span>,
|
||||||
|
meta: {
|
||||||
|
label: 'Owner',
|
||||||
|
variant: 'multiSelect' as const,
|
||||||
|
options: referenceData.crmUsers.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.name
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
enableColumnFilter: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ownerAssignedAt',
|
||||||
|
accessorKey: 'ownerAssignedAt',
|
||||||
|
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Owner Assigned' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <span>{row.original.ownerAssignedAt ? new Date(row.original.ownerAssignedAt).toLocaleDateString() : '-'}</span>
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'contactCount',
|
id: 'contactCount',
|
||||||
accessorKey: 'contactCount',
|
accessorKey: 'contactCount',
|
||||||
|
|||||||
@@ -11,22 +11,31 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { deleteCustomerContactMutation } from '../api/mutations';
|
import { deleteCustomerContactMutation } from '../api/mutations';
|
||||||
import { customerContactsOptions } from '../api/queries';
|
import { customerContactsOptions } from '../api/queries';
|
||||||
import { ContactFormSheet } from './contact-form-sheet';
|
import { ContactFormSheet } from './contact-form-sheet';
|
||||||
|
import { ContactShareSheet } from './contact-share-sheet';
|
||||||
|
import type { CustomerReferenceData } from '../api/types';
|
||||||
|
|
||||||
export function CustomerContactsTab({
|
export function CustomerContactsTab({
|
||||||
customerId,
|
customerId,
|
||||||
canCreate,
|
canCreate,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete
|
canDelete,
|
||||||
|
canShareRead,
|
||||||
|
canShareManage,
|
||||||
|
referenceData
|
||||||
}: {
|
}: {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
canShareRead: boolean;
|
||||||
|
canShareManage: boolean;
|
||||||
|
referenceData: CustomerReferenceData;
|
||||||
}) {
|
}) {
|
||||||
const { data } = useSuspenseQuery(customerContactsOptions(customerId));
|
const { data } = useSuspenseQuery(customerContactsOptions(customerId));
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
const selectedContact = data.items.find((contact) => contact.id === selectedId);
|
const selectedContact = data.items.find((contact) => contact.id === selectedId);
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -84,6 +93,14 @@ export function CustomerContactsTab({
|
|||||||
{[contact.phone, contact.mobile, contact.email].filter(Boolean).join(' • ') ||
|
{[contact.phone, contact.mobile, contact.email].filter(Boolean).join(' • ') ||
|
||||||
'ยังไม่มีช่องทางติดต่อ'}
|
'ยังไม่มีช่องทางติดต่อ'}
|
||||||
</div>
|
</div>
|
||||||
|
{contact.shares.length ? (
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
Shared with:{' '}
|
||||||
|
{contact.shares
|
||||||
|
.map((share) => share.sharedToName ?? share.sharedToUserId)
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{contact.notes ? <p className='text-sm'>{contact.notes}</p> : null}
|
{contact.notes ? <p className='text-sm'>{contact.notes}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
@@ -98,6 +115,18 @@ export function CustomerContactsTab({
|
|||||||
>
|
>
|
||||||
<Icons.edit className='mr-2 h-4 w-4' /> แก้ไข
|
<Icons.edit className='mr-2 h-4 w-4' /> แก้ไข
|
||||||
</Button>
|
</Button>
|
||||||
|
{canShareRead ? (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(contact.id);
|
||||||
|
setShareOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.user className='mr-2 h-4 w-4' /> Share
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -121,6 +150,14 @@ export function CustomerContactsTab({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
/>
|
/>
|
||||||
|
<ContactShareSheet
|
||||||
|
customerId={customerId}
|
||||||
|
contact={selectedContact ?? null}
|
||||||
|
referenceData={referenceData}
|
||||||
|
open={shareOpen}
|
||||||
|
onOpenChange={setShareOpen}
|
||||||
|
canManage={canShareManage}
|
||||||
|
/>
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={deleteOpen}
|
isOpen={deleteOpen}
|
||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { customerByIdOptions } from '../api/queries';
|
|||||||
import type { CustomerReferenceData } from '../api/types';
|
import type { CustomerReferenceData } from '../api/types';
|
||||||
import { CustomerFormSheet } from './customer-form-sheet';
|
import { CustomerFormSheet } from './customer-form-sheet';
|
||||||
import { CustomerContactsTab } from './customer-contacts-tab';
|
import { CustomerContactsTab } from './customer-contacts-tab';
|
||||||
|
import { CustomerOwnerCard } from './customer-owner-card';
|
||||||
import { CustomerStatusBadge } from './customer-status-badge';
|
import { CustomerStatusBadge } from './customer-status-badge';
|
||||||
import type { CustomerRelatedEnquiryItem } from '../api/types';
|
import type { CustomerRelatedEnquiryItem } from '../api/types';
|
||||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||||
@@ -33,6 +34,9 @@ export function CustomerDetail({
|
|||||||
canUpdate,
|
canUpdate,
|
||||||
canReadContacts,
|
canReadContacts,
|
||||||
canManageContacts,
|
canManageContacts,
|
||||||
|
canReadContactShares,
|
||||||
|
canManageContactShares,
|
||||||
|
canManageOwner,
|
||||||
relatedEnquiries,
|
relatedEnquiries,
|
||||||
relatedQuotations
|
relatedQuotations
|
||||||
}: {
|
}: {
|
||||||
@@ -45,6 +49,9 @@ export function CustomerDetail({
|
|||||||
update: boolean;
|
update: boolean;
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
};
|
};
|
||||||
|
canReadContactShares: boolean;
|
||||||
|
canManageContactShares: boolean;
|
||||||
|
canManageOwner: boolean;
|
||||||
relatedEnquiries: CustomerRelatedEnquiryItem[];
|
relatedEnquiries: CustomerRelatedEnquiryItem[];
|
||||||
relatedQuotations: QuotationRelationItem[];
|
relatedQuotations: QuotationRelationItem[];
|
||||||
}) {
|
}) {
|
||||||
@@ -182,6 +189,9 @@ export function CustomerDetail({
|
|||||||
canCreate={canManageContacts.create}
|
canCreate={canManageContacts.create}
|
||||||
canUpdate={canManageContacts.update}
|
canUpdate={canManageContacts.update}
|
||||||
canDelete={canManageContacts.delete}
|
canDelete={canManageContacts.delete}
|
||||||
|
canShareRead={canReadContactShares}
|
||||||
|
canShareManage={canManageContactShares}
|
||||||
|
referenceData={referenceData}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -280,6 +290,11 @@ export function CustomerDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
|
<CustomerOwnerCard
|
||||||
|
customerId={customerId}
|
||||||
|
referenceData={referenceData}
|
||||||
|
canManage={canManageOwner}
|
||||||
|
/>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
<CardTitle>ข้อมูลระบบ</CardTitle>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default function CustomerListing({
|
|||||||
const customerStatus = searchParamsCache.get('customerStatus');
|
const customerStatus = searchParamsCache.get('customerStatus');
|
||||||
const customerType = searchParamsCache.get('customerType');
|
const customerType = searchParamsCache.get('customerType');
|
||||||
const branch = searchParamsCache.get('branch');
|
const branch = searchParamsCache.get('branch');
|
||||||
|
const ownerUserId = searchParamsCache.get('ownerUserId');
|
||||||
const sort = searchParamsCache.get('sort');
|
const sort = searchParamsCache.get('sort');
|
||||||
const filters = {
|
const filters = {
|
||||||
page,
|
page,
|
||||||
@@ -28,6 +29,7 @@ export default function CustomerListing({
|
|||||||
...(customerStatus && { customerStatus }),
|
...(customerStatus && { customerStatus }),
|
||||||
...(customerType && { customerType }),
|
...(customerType && { customerType }),
|
||||||
...(branch && { branch }),
|
...(branch && { branch }),
|
||||||
|
...(ownerUserId && { ownerUserId }),
|
||||||
...(sort && { sort })
|
...(sort && { sort })
|
||||||
};
|
};
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|||||||
187
src/features/crm/customers/components/customer-owner-card.tsx
Normal file
187
src/features/crm/customers/components/customer-owner-card.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { assignCustomerOwnerMutation, clearCustomerOwnerMutation } from '../api/mutations';
|
||||||
|
import { customerByIdOptions } from '../api/queries';
|
||||||
|
import type { CustomerReferenceData } from '../api/types';
|
||||||
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
|
||||||
|
export function CustomerOwnerCard({
|
||||||
|
customerId,
|
||||||
|
referenceData,
|
||||||
|
canManage
|
||||||
|
}: {
|
||||||
|
customerId: string;
|
||||||
|
referenceData: CustomerReferenceData;
|
||||||
|
canManage: boolean;
|
||||||
|
}) {
|
||||||
|
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedOwnerUserId, setSelectedOwnerUserId] = useState('');
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
|
const customer = data.customer;
|
||||||
|
const ownerCandidates = useMemo(
|
||||||
|
() =>
|
||||||
|
referenceData.crmUsers.filter((user) =>
|
||||||
|
['sales', 'sales_manager', 'crm_admin'].includes(user.businessRole)
|
||||||
|
),
|
||||||
|
[referenceData.crmUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignMutation = useMutation({
|
||||||
|
...assignCustomerOwnerMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('บันทึกเจ้าของลูกค้าเรียบร้อย');
|
||||||
|
setOpen(false);
|
||||||
|
setRemark('');
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'ไม่สามารถบันทึกเจ้าของลูกค้าได้')
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearMutation = useMutation({
|
||||||
|
...clearCustomerOwnerMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('ล้างเจ้าของลูกค้าเรียบร้อย');
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'ไม่สามารถล้างเจ้าของลูกค้าได้')
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Customer Owner</CardTitle>
|
||||||
|
<CardDescription>ผู้ดูแลความสัมพันธ์หลักของลูกค้ารายนี้</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='text-muted-foreground text-xs'>Owner</div>
|
||||||
|
<div className='text-sm font-medium'>{customer.ownerName ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='text-muted-foreground text-xs'>Assigned At</div>
|
||||||
|
<div className='text-sm'>
|
||||||
|
{customer.ownerAssignedAt ? formatDateTime(customer.ownerAssignedAt) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='text-muted-foreground text-xs'>Assigned By</div>
|
||||||
|
<div className='text-sm'>{customer.ownerAssignedByName ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedOwnerUserId(customer.ownerUserId ?? ownerCandidates[0]?.id ?? '');
|
||||||
|
setRemark('');
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.edit className='mr-2 h-4 w-4' />
|
||||||
|
{customer.ownerUserId ? 'Change Owner' : 'Assign Owner'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={!customer.ownerUserId || clearMutation.isPending}
|
||||||
|
onClick={() => clearMutation.mutate({ id: customerId })}
|
||||||
|
>
|
||||||
|
<Icons.trash className='mr-2 h-4 w-4' />
|
||||||
|
Clear Owner
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className='space-y-3 border-t pt-4'>
|
||||||
|
<div className='text-sm font-medium'>Owner History</div>
|
||||||
|
{!data.ownerHistory.length ? (
|
||||||
|
<div className='text-muted-foreground text-sm'>ยังไม่มีประวัติการเปลี่ยน owner</div>
|
||||||
|
) : (
|
||||||
|
data.ownerHistory.slice(0, 5).map((item) => (
|
||||||
|
<div key={item.id} className='rounded-lg border p-3 text-sm'>
|
||||||
|
<div>
|
||||||
|
{item.oldOwnerName ?? '-'} {'->'} {item.newOwnerName ?? '-'}
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
{formatDateTime(item.changedAt)} โดย {item.changedByName ?? item.changedBy}
|
||||||
|
</div>
|
||||||
|
{item.remark ? <div className='mt-1 text-xs'>{item.remark}</div> : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{customer.ownerUserId ? 'Change Owner' : 'Assign Owner'}</DialogTitle>
|
||||||
|
<DialogDescription>เลือกผู้รับผิดชอบหลักของลูกค้ารายนี้</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<Select value={selectedOwnerUserId} onValueChange={setSelectedOwnerUserId}>
|
||||||
|
<SelectTrigger aria-label='Customer owner'>
|
||||||
|
<SelectValue placeholder='Select owner' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ownerCandidates.map((user) => (
|
||||||
|
<SelectItem key={user.id} value={user.id}>
|
||||||
|
{user.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Textarea
|
||||||
|
value={remark}
|
||||||
|
onChange={(event) => setRemark(event.target.value)}
|
||||||
|
placeholder='Remark'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant='outline' onClick={() => setOpen(false)}>
|
||||||
|
ยกเลิก
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={assignMutation.isPending}
|
||||||
|
disabled={!selectedOwnerUserId}
|
||||||
|
onClick={() =>
|
||||||
|
assignMutation.mutate({
|
||||||
|
id: customerId,
|
||||||
|
values: { ownerUserId: selectedOwnerUserId, remark }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icons.check className='mr-2 h-4 w-4' />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ export function CustomersTable({
|
|||||||
customerStatus: parseAsString,
|
customerStatus: parseAsString,
|
||||||
customerType: parseAsString,
|
customerType: parseAsString,
|
||||||
branch: parseAsString,
|
branch: parseAsString,
|
||||||
|
ownerUserId: parseAsString,
|
||||||
sort: getSortingStateParser(columnIds).withDefault([])
|
sort: getSortingStateParser(columnIds).withDefault([])
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export function CustomersTable({
|
|||||||
...(params.customerStatus && { customerStatus: params.customerStatus }),
|
...(params.customerStatus && { customerStatus: params.customerStatus }),
|
||||||
...(params.customerType && { customerType: params.customerType }),
|
...(params.customerType && { customerType: params.customerType }),
|
||||||
...(params.branch && { branch: params.branch }),
|
...(params.branch && { branch: params.branch }),
|
||||||
|
...(params.ownerUserId && { ownerUserId: params.ownerUserId }),
|
||||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||||
import { crmCustomerContacts, crmCustomers, crmEnquiries, crmQuotations, msOptions, users } from '@/db/schema';
|
import {
|
||||||
|
crmContactShares,
|
||||||
|
crmCustomerContacts,
|
||||||
|
crmCustomerOwnerHistory,
|
||||||
|
crmCustomers,
|
||||||
|
crmEnquiries,
|
||||||
|
crmQuotations,
|
||||||
|
memberships,
|
||||||
|
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';
|
||||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||||
@@ -7,7 +17,11 @@ import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
|||||||
import { validateBranchAccess, getUserBranches } from '@/features/foundation/branch-scope/service';
|
import { validateBranchAccess, getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
import { type CrmSecurityContext } from '@/features/crm/security/server/service';
|
import {
|
||||||
|
canAccessContact,
|
||||||
|
canAccessCustomer,
|
||||||
|
type CrmSecurityContext
|
||||||
|
} from '@/features/crm/security/server/service';
|
||||||
import type {
|
import type {
|
||||||
BranchOption,
|
BranchOption,
|
||||||
CustomerActivityRecord,
|
CustomerActivityRecord,
|
||||||
@@ -17,12 +31,16 @@ import type {
|
|||||||
CustomerListItem,
|
CustomerListItem,
|
||||||
CustomerMutationPayload,
|
CustomerMutationPayload,
|
||||||
CustomerOption,
|
CustomerOption,
|
||||||
|
CustomerOwnerHistoryRecord,
|
||||||
|
CustomerOwnerMutationPayload,
|
||||||
CustomerRecord,
|
CustomerRecord,
|
||||||
CustomerReferenceData
|
CustomerReferenceData
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
|
|
||||||
type CustomerRecordSource = Omit<typeof crmCustomers.$inferSelect, 'customerSubGroup'> & {
|
type CustomerRecordSource = Omit<typeof crmCustomers.$inferSelect, 'customerSubGroup'> & {
|
||||||
customerSubGroup?: string | null;
|
customerSubGroup?: string | null;
|
||||||
|
ownerName?: string | null;
|
||||||
|
ownerAssignedByName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let customerSubGroupColumnAvailable: boolean | undefined;
|
let customerSubGroupColumnAvailable: boolean | undefined;
|
||||||
@@ -70,6 +88,41 @@ function mapBranchOption(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapCustomerUserLookup(row: {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
membershipRole: string;
|
||||||
|
businessRole: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: row.userId,
|
||||||
|
name: row.name,
|
||||||
|
membershipRole: row.membershipRole === 'admin' ? 'admin' : 'user',
|
||||||
|
businessRole: row.businessRole
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOwnerHistoryRecord(
|
||||||
|
row: typeof crmCustomerOwnerHistory.$inferSelect & {
|
||||||
|
oldOwnerName?: string | null;
|
||||||
|
newOwnerName?: string | null;
|
||||||
|
changedByName?: string | null;
|
||||||
|
}
|
||||||
|
): CustomerOwnerHistoryRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
customerId: row.customerId,
|
||||||
|
oldOwnerUserId: row.oldOwnerUserId ?? null,
|
||||||
|
oldOwnerName: row.oldOwnerName ?? null,
|
||||||
|
newOwnerUserId: row.newOwnerUserId ?? null,
|
||||||
|
newOwnerName: row.newOwnerName ?? null,
|
||||||
|
changedBy: row.changedBy,
|
||||||
|
changedByName: row.changedByName ?? null,
|
||||||
|
changedAt: row.changedAt.toISOString(),
|
||||||
|
remark: row.remark ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord {
|
function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -94,6 +147,11 @@ function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord {
|
|||||||
leadChannel: row.leadChannel,
|
leadChannel: row.leadChannel,
|
||||||
customerGroup: row.customerGroup,
|
customerGroup: row.customerGroup,
|
||||||
customerSubGroup: row.customerSubGroup ?? null,
|
customerSubGroup: row.customerSubGroup ?? null,
|
||||||
|
ownerUserId: row.ownerUserId ?? null,
|
||||||
|
ownerAssignedAt: row.ownerAssignedAt?.toISOString() ?? null,
|
||||||
|
ownerAssignedBy: row.ownerAssignedBy ?? null,
|
||||||
|
ownerName: row.ownerName ?? null,
|
||||||
|
ownerAssignedByName: row.ownerAssignedByName ?? null,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
isActive: row.isActive,
|
isActive: row.isActive,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
@@ -127,6 +185,9 @@ const baseCustomerRecordSelection = {
|
|||||||
leadChannel: crmCustomers.leadChannel,
|
leadChannel: crmCustomers.leadChannel,
|
||||||
customerGroup: crmCustomers.customerGroup,
|
customerGroup: crmCustomers.customerGroup,
|
||||||
notes: crmCustomers.notes,
|
notes: crmCustomers.notes,
|
||||||
|
ownerUserId: crmCustomers.ownerUserId,
|
||||||
|
ownerAssignedAt: crmCustomers.ownerAssignedAt,
|
||||||
|
ownerAssignedBy: crmCustomers.ownerAssignedBy,
|
||||||
isActive: crmCustomers.isActive,
|
isActive: crmCustomers.isActive,
|
||||||
createdAt: crmCustomers.createdAt,
|
createdAt: crmCustomers.createdAt,
|
||||||
updatedAt: crmCustomers.updatedAt,
|
updatedAt: crmCustomers.updatedAt,
|
||||||
@@ -165,7 +226,8 @@ async function hasCustomerSubGroupColumn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapCustomerContactRecord(
|
function mapCustomerContactRecord(
|
||||||
row: typeof crmCustomerContacts.$inferSelect
|
row: typeof crmCustomerContacts.$inferSelect,
|
||||||
|
shares: CustomerContactRecord['shares'] = []
|
||||||
): CustomerContactRecord {
|
): CustomerContactRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -184,7 +246,8 @@ function mapCustomerContactRecord(
|
|||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy
|
updatedBy: row.updatedBy,
|
||||||
|
shares
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +336,93 @@ async function assertCustomerBelongsToOrganization(id: string, organizationId: s
|
|||||||
return customer;
|
return customer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listCrmUsersForOrganization(organizationId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
userId: memberships.userId,
|
||||||
|
name: users.name,
|
||||||
|
membershipRole: memberships.role,
|
||||||
|
businessRole: memberships.businessRole
|
||||||
|
})
|
||||||
|
.from(memberships)
|
||||||
|
.innerJoin(users, eq(memberships.userId, users.id))
|
||||||
|
.where(eq(memberships.organizationId, organizationId))
|
||||||
|
.orderBy(asc(users.name));
|
||||||
|
|
||||||
|
return [...new Map(rows.map((row) => [row.userId, row])).values()].map(mapCustomerUserLookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertOwnerUserBelongsToOrganization(ownerUserId: string, organizationId: string) {
|
||||||
|
const [membership] = await db
|
||||||
|
.select({
|
||||||
|
userId: memberships.userId
|
||||||
|
})
|
||||||
|
.from(memberships)
|
||||||
|
.where(and(eq(memberships.userId, ownerUserId), eq(memberships.organizationId, organizationId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new AuthError('Owner user not found in organization', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listContactSharesMap(contactIds: string[], organizationId: string) {
|
||||||
|
if (!contactIds.length) {
|
||||||
|
return new Map<string, CustomerContactRecord['shares']>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareRows = await db
|
||||||
|
.select({
|
||||||
|
id: crmContactShares.id,
|
||||||
|
contactId: crmContactShares.contactId,
|
||||||
|
sharedToUserId: crmContactShares.sharedToUserId,
|
||||||
|
sharedToName: users.name,
|
||||||
|
sharedByUserId: crmContactShares.sharedByUserId,
|
||||||
|
sharedAt: crmContactShares.sharedAt,
|
||||||
|
isActive: crmContactShares.isActive,
|
||||||
|
remark: crmContactShares.remark
|
||||||
|
})
|
||||||
|
.from(crmContactShares)
|
||||||
|
.leftJoin(users, eq(users.id, crmContactShares.sharedToUserId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmContactShares.organizationId, organizationId),
|
||||||
|
inArray(crmContactShares.contactId, contactIds),
|
||||||
|
eq(crmContactShares.isActive, true),
|
||||||
|
isNull(crmContactShares.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(crmContactShares.sharedAt));
|
||||||
|
|
||||||
|
const sharedByIds = [...new Set(shareRows.map((row) => row.sharedByUserId))];
|
||||||
|
const sharedByRows = sharedByIds.length
|
||||||
|
? await db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, sharedByIds))
|
||||||
|
: [];
|
||||||
|
const sharedByMap = new Map(sharedByRows.map((row) => [row.id, row.name]));
|
||||||
|
const shareMap = new Map<string, CustomerContactRecord['shares']>();
|
||||||
|
|
||||||
|
for (const row of shareRows) {
|
||||||
|
const items = shareMap.get(row.contactId) ?? [];
|
||||||
|
items.push({
|
||||||
|
id: row.id,
|
||||||
|
contactId: row.contactId,
|
||||||
|
sharedToUserId: row.sharedToUserId,
|
||||||
|
sharedToName: row.sharedToName ?? null,
|
||||||
|
sharedByUserId: row.sharedByUserId,
|
||||||
|
sharedByName: sharedByMap.get(row.sharedByUserId) ?? null,
|
||||||
|
sharedAt: row.sharedAt.toISOString(),
|
||||||
|
isActive: row.isActive,
|
||||||
|
remark: row.remark ?? null
|
||||||
|
});
|
||||||
|
shareMap.set(row.contactId, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
return shareMap;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveAccessibleCustomerRelationSets(
|
async function resolveAccessibleCustomerRelationSets(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
accessContext: CustomerAccessContext
|
accessContext: CustomerAccessContext
|
||||||
@@ -353,8 +503,19 @@ async function resolveAccessibleCustomerRelationSets(
|
|||||||
|
|
||||||
async function canAccessCustomerRow(
|
async function canAccessCustomerRow(
|
||||||
row: CustomerRecordSource,
|
row: CustomerRecordSource,
|
||||||
accessContext: CustomerAccessContext
|
accessContext: CustomerAccessContext,
|
||||||
|
relationSets?: Awaited<ReturnType<typeof resolveAccessibleCustomerRelationSets>>
|
||||||
) {
|
) {
|
||||||
|
if (!canAccessCustomer(accessContext, row)) {
|
||||||
|
if (
|
||||||
|
accessContext.membershipRole === 'admin' ||
|
||||||
|
accessContext.ownershipScope === 'organization' ||
|
||||||
|
accessContext.ownershipScope === 'team'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
|
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -367,10 +528,9 @@ async function canAccessCustomerRow(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { enquiryCustomerIds, quotationCustomerIds } = await resolveAccessibleCustomerRelationSets(
|
const { enquiryCustomerIds, quotationCustomerIds } =
|
||||||
row.organizationId,
|
relationSets ??
|
||||||
accessContext
|
(await resolveAccessibleCustomerRelationSets(row.organizationId, accessContext));
|
||||||
);
|
|
||||||
|
|
||||||
if (accessContext.ownershipScope === 'monitor') {
|
if (accessContext.ownershipScope === 'monitor') {
|
||||||
return enquiryCustomerIds.has(row.id);
|
return enquiryCustomerIds.has(row.id);
|
||||||
@@ -378,6 +538,7 @@ async function canAccessCustomerRow(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
row.createdBy === accessContext.userId ||
|
row.createdBy === accessContext.userId ||
|
||||||
|
row.ownerUserId === accessContext.userId ||
|
||||||
enquiryCustomerIds.has(row.id) ||
|
enquiryCustomerIds.has(row.id) ||
|
||||||
quotationCustomerIds.has(row.id)
|
quotationCustomerIds.has(row.id)
|
||||||
);
|
);
|
||||||
@@ -397,6 +558,41 @@ async function assertCustomerAccess(
|
|||||||
return customer;
|
return customer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function canAccessContactRow(
|
||||||
|
contact: typeof crmCustomerContacts.$inferSelect,
|
||||||
|
customer: CustomerRecordSource,
|
||||||
|
accessContext: CustomerAccessContext,
|
||||||
|
shares?: CustomerContactRecord['shares']
|
||||||
|
) {
|
||||||
|
return canAccessContact(accessContext, {
|
||||||
|
branchId: customer.branchId,
|
||||||
|
createdBy: contact.createdBy,
|
||||||
|
ownerUserId: customer.ownerUserId,
|
||||||
|
sharedToUserIds: (shares ?? []).map((share) => share.sharedToUserId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertContactAccess(
|
||||||
|
customerId: string,
|
||||||
|
contactId: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: CustomerAccessContext
|
||||||
|
) {
|
||||||
|
const customer = await assertCustomerBelongsToOrganization(customerId, organizationId);
|
||||||
|
const contact = await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
||||||
|
|
||||||
|
if (accessContext) {
|
||||||
|
const shares = (await listContactSharesMap([contact.id], organizationId)).get(contact.id) ?? [];
|
||||||
|
const visible = await canAccessContactRow(contact, customer, accessContext, shares);
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { customer, contact };
|
||||||
|
}
|
||||||
|
|
||||||
async function assertContactBelongsToCustomer(
|
async function assertContactBelongsToCustomer(
|
||||||
contactId: string,
|
contactId: string,
|
||||||
customerId: string,
|
customerId: string,
|
||||||
@@ -477,6 +673,7 @@ function buildCustomerFilters(organizationId: string, filters: CustomerFilters):
|
|||||||
const statuses = splitFilterValue(filters.customerStatus);
|
const statuses = splitFilterValue(filters.customerStatus);
|
||||||
const customerTypes = splitFilterValue(filters.customerType);
|
const customerTypes = splitFilterValue(filters.customerType);
|
||||||
const branches = splitFilterValue(filters.branch);
|
const branches = splitFilterValue(filters.branch);
|
||||||
|
const owners = splitFilterValue(filters.ownerUserId);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
eq(crmCustomers.organizationId, organizationId),
|
eq(crmCustomers.organizationId, organizationId),
|
||||||
@@ -484,6 +681,7 @@ function buildCustomerFilters(organizationId: string, filters: CustomerFilters):
|
|||||||
...(statuses.length ? [inArray(crmCustomers.customerStatus, statuses)] : []),
|
...(statuses.length ? [inArray(crmCustomers.customerStatus, statuses)] : []),
|
||||||
...(customerTypes.length ? [inArray(crmCustomers.customerType, customerTypes)] : []),
|
...(customerTypes.length ? [inArray(crmCustomers.customerType, customerTypes)] : []),
|
||||||
...(branches.length ? [inArray(crmCustomers.branchId, branches)] : []),
|
...(branches.length ? [inArray(crmCustomers.branchId, branches)] : []),
|
||||||
|
...(owners.length ? [inArray(crmCustomers.ownerUserId, owners)] : []),
|
||||||
...(filters.search
|
...(filters.search
|
||||||
? [
|
? [
|
||||||
or(
|
or(
|
||||||
@@ -501,7 +699,15 @@ 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, customerSubGroups] =
|
const [
|
||||||
|
branches,
|
||||||
|
customerTypes,
|
||||||
|
customerStatuses,
|
||||||
|
leadChannels,
|
||||||
|
customerGroups,
|
||||||
|
customerSubGroups,
|
||||||
|
crmUsers
|
||||||
|
] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getUserBranches(),
|
getUserBranches(),
|
||||||
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerType, { organizationId }),
|
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerType, { organizationId }),
|
||||||
@@ -520,6 +726,8 @@ export async function getCustomerReferenceData(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(asc(msOptions.sortOrder), asc(msOptions.label))
|
.orderBy(asc(msOptions.sortOrder), asc(msOptions.label))
|
||||||
|
,
|
||||||
|
listCrmUsersForOrganization(organizationId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -528,7 +736,8 @@ export async function getCustomerReferenceData(
|
|||||||
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)
|
customerSubGroups: customerSubGroups.map(mapCustomerOptionRow),
|
||||||
|
crmUsers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,19 +750,64 @@ export async function listCustomers(
|
|||||||
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);
|
||||||
|
if (filters.myCustomers === 'true' && accessContext) {
|
||||||
|
whereFilters.push(eq(crmCustomers.ownerUserId, accessContext.userId));
|
||||||
|
}
|
||||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db.execute<CustomerRecordSource>(sql`
|
||||||
.select(getCustomerRecordSelection(includeCustomerSubGroup))
|
select
|
||||||
.from(crmCustomers)
|
c.id,
|
||||||
.where(where)
|
c.organization_id as "organizationId",
|
||||||
.orderBy(parseSort(filters.sort));
|
c.branch_id as "branchId",
|
||||||
|
c.code,
|
||||||
|
c.name,
|
||||||
|
c.abbr,
|
||||||
|
c.tax_id as "taxId",
|
||||||
|
c.customer_type as "customerType",
|
||||||
|
c.customer_status as "customerStatus",
|
||||||
|
c.address,
|
||||||
|
c.province,
|
||||||
|
c.district,
|
||||||
|
c.sub_district as "subDistrict",
|
||||||
|
c.postal_code as "postalCode",
|
||||||
|
c.country,
|
||||||
|
c.phone,
|
||||||
|
c.fax,
|
||||||
|
c.email,
|
||||||
|
c.website,
|
||||||
|
c.lead_channel as "leadChannel",
|
||||||
|
c.customer_group as "customerGroup",
|
||||||
|
${includeCustomerSubGroup ? sql`c.customer_sub_group` : sql`null`} as "customerSubGroup",
|
||||||
|
c.owner_user_id as "ownerUserId",
|
||||||
|
c.owner_assigned_at as "ownerAssignedAt",
|
||||||
|
c.owner_assigned_by as "ownerAssignedBy",
|
||||||
|
c.notes,
|
||||||
|
c.is_active as "isActive",
|
||||||
|
c.created_at as "createdAt",
|
||||||
|
c.updated_at as "updatedAt",
|
||||||
|
c.deleted_at as "deletedAt",
|
||||||
|
c.created_by as "createdBy",
|
||||||
|
c.updated_by as "updatedBy",
|
||||||
|
owner_u.name as "ownerName",
|
||||||
|
assigned_by_u.name as "ownerAssignedByName"
|
||||||
|
from crm_customers c
|
||||||
|
left join users owner_u on owner_u.id = c.owner_user_id
|
||||||
|
left join users assigned_by_u on assigned_by_u.id = c.owner_assigned_by
|
||||||
|
where ${where}
|
||||||
|
order by ${parseSort(filters.sort)}
|
||||||
|
`);
|
||||||
|
const relationSets = accessContext
|
||||||
|
? await resolveAccessibleCustomerRelationSets(organizationId, accessContext)
|
||||||
|
: null;
|
||||||
|
|
||||||
const scopedRows = accessContext
|
const scopedRows = accessContext
|
||||||
? (
|
? (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
rows.map(async (row) => ((await canAccessCustomerRow(row, accessContext)) ? row : null))
|
rows.map(async (row) =>
|
||||||
|
(await canAccessCustomerRow(row, accessContext, relationSets ?? undefined)) ? row : null
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).filter((row): row is CustomerRecordSource => row !== null)
|
).filter((row): row is CustomerRecordSource => row !== null)
|
||||||
: rows;
|
: rows;
|
||||||
@@ -593,7 +847,22 @@ export async function getCustomerDetail(
|
|||||||
accessContext?: CustomerAccessContext
|
accessContext?: CustomerAccessContext
|
||||||
): Promise<CustomerRecord> {
|
): Promise<CustomerRecord> {
|
||||||
const customer = await assertCustomerAccess(id, organizationId, accessContext);
|
const customer = await assertCustomerAccess(id, organizationId, accessContext);
|
||||||
return mapCustomerRecord(customer);
|
const relatedUserIds = [customer.ownerUserId, customer.ownerAssignedBy].filter(Boolean) as string[];
|
||||||
|
const userRows = relatedUserIds.length
|
||||||
|
? await db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, relatedUserIds))
|
||||||
|
: [];
|
||||||
|
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
||||||
|
|
||||||
|
return mapCustomerRecord({
|
||||||
|
...customer,
|
||||||
|
ownerName: customer.ownerUserId ? (userMap.get(customer.ownerUserId) ?? null) : null,
|
||||||
|
ownerAssignedByName: customer.ownerAssignedBy
|
||||||
|
? (userMap.get(customer.ownerAssignedBy) ?? null)
|
||||||
|
: null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCustomerActivity(
|
export async function getCustomerActivity(
|
||||||
@@ -626,6 +895,39 @@ export async function getCustomerActivity(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCustomerOwnerHistory(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: CustomerAccessContext
|
||||||
|
): Promise<CustomerOwnerHistoryRecord[]> {
|
||||||
|
await assertCustomerAccess(id, organizationId, accessContext);
|
||||||
|
const rows = await db.query.crmCustomerOwnerHistory.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(crmCustomerOwnerHistory.customerId, id),
|
||||||
|
eq(crmCustomerOwnerHistory.organizationId, organizationId),
|
||||||
|
isNull(crmCustomerOwnerHistory.deletedAt)
|
||||||
|
),
|
||||||
|
orderBy: [desc(crmCustomerOwnerHistory.changedAt)]
|
||||||
|
});
|
||||||
|
const userIds = [...new Set(rows.flatMap((row) => [row.oldOwnerUserId, row.newOwnerUserId, row.changedBy]).filter(Boolean) as string[])];
|
||||||
|
const userRows = userIds.length
|
||||||
|
? await db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, userIds))
|
||||||
|
: [];
|
||||||
|
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
||||||
|
|
||||||
|
return rows.map((row) =>
|
||||||
|
mapOwnerHistoryRecord({
|
||||||
|
...row,
|
||||||
|
oldOwnerName: row.oldOwnerUserId ? (userMap.get(row.oldOwnerUserId) ?? null) : null,
|
||||||
|
newOwnerName: row.newOwnerUserId ? (userMap.get(row.newOwnerUserId) ?? null) : null,
|
||||||
|
changedByName: userMap.get(row.changedBy) ?? null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createCustomer(
|
export async function createCustomer(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -674,6 +976,105 @@ export async function createCustomer(
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function recordCustomerOwnerHistory(input: {
|
||||||
|
organizationId: string;
|
||||||
|
customerId: string;
|
||||||
|
oldOwnerUserId?: string | null;
|
||||||
|
newOwnerUserId?: string | null;
|
||||||
|
changedBy: string;
|
||||||
|
remark?: string | null;
|
||||||
|
}) {
|
||||||
|
await db.insert(crmCustomerOwnerHistory).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
customerId: input.customerId,
|
||||||
|
oldOwnerUserId: input.oldOwnerUserId ?? null,
|
||||||
|
newOwnerUserId: input.newOwnerUserId ?? null,
|
||||||
|
changedBy: input.changedBy,
|
||||||
|
changedAt: new Date(),
|
||||||
|
remark: input.remark?.trim() || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignCustomerOwner(
|
||||||
|
customerId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: CustomerOwnerMutationPayload,
|
||||||
|
accessContext?: CustomerAccessContext
|
||||||
|
) {
|
||||||
|
if (!payload.ownerUserId) {
|
||||||
|
throw new AuthError('Owner user is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await assertCustomerAccess(customerId, organizationId, accessContext);
|
||||||
|
await assertOwnerUserBelongsToOrganization(payload.ownerUserId, organizationId);
|
||||||
|
|
||||||
|
if (current.ownerUserId === payload.ownerUserId) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmCustomers)
|
||||||
|
.set({
|
||||||
|
ownerUserId: payload.ownerUserId,
|
||||||
|
ownerAssignedAt: new Date(),
|
||||||
|
ownerAssignedBy: userId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.where(eq(crmCustomers.id, customerId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await recordCustomerOwnerHistory({
|
||||||
|
organizationId,
|
||||||
|
customerId,
|
||||||
|
oldOwnerUserId: current.ownerUserId,
|
||||||
|
newOwnerUserId: payload.ownerUserId,
|
||||||
|
changedBy: userId,
|
||||||
|
remark: payload.remark
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCustomerOwner(
|
||||||
|
customerId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload?: Pick<CustomerOwnerMutationPayload, 'remark'>,
|
||||||
|
accessContext?: CustomerAccessContext
|
||||||
|
) {
|
||||||
|
const current = await assertCustomerAccess(customerId, organizationId, accessContext);
|
||||||
|
|
||||||
|
if (!current.ownerUserId) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmCustomers)
|
||||||
|
.set({
|
||||||
|
ownerUserId: null,
|
||||||
|
ownerAssignedAt: null,
|
||||||
|
ownerAssignedBy: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.where(eq(crmCustomers.id, customerId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await recordCustomerOwnerHistory({
|
||||||
|
organizationId,
|
||||||
|
customerId,
|
||||||
|
oldOwnerUserId: current.ownerUserId,
|
||||||
|
newOwnerUserId: null,
|
||||||
|
changedBy: userId,
|
||||||
|
remark: payload?.remark
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateCustomer(
|
export async function updateCustomer(
|
||||||
id: string,
|
id: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
@@ -761,7 +1162,7 @@ export async function listCustomerContacts(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
accessContext?: CustomerAccessContext
|
accessContext?: CustomerAccessContext
|
||||||
) {
|
) {
|
||||||
await assertCustomerAccess(id, organizationId, accessContext);
|
const customer = await assertCustomerBelongsToOrganization(id, organizationId);
|
||||||
const rows = await db.query.crmCustomerContacts.findMany({
|
const rows = await db.query.crmCustomerContacts.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(crmCustomerContacts.customerId, id),
|
eq(crmCustomerContacts.customerId, id),
|
||||||
@@ -770,17 +1171,31 @@ export async function listCustomerContacts(
|
|||||||
),
|
),
|
||||||
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
|
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
|
||||||
});
|
});
|
||||||
|
const shareMap = await listContactSharesMap(
|
||||||
return rows
|
rows.map((row) => row.id),
|
||||||
.filter(
|
organizationId
|
||||||
(row) =>
|
);
|
||||||
!accessContext ||
|
const scopedRows = accessContext
|
||||||
row.createdBy === accessContext.userId ||
|
? (
|
||||||
accessContext.membershipRole === 'admin' ||
|
await Promise.all(
|
||||||
accessContext.ownershipScope === 'organization' ||
|
rows.map(async (row) =>
|
||||||
accessContext.ownershipScope === 'team'
|
(await canAccessContactRow(row, customer, accessContext, shareMap.get(row.id) ?? []))
|
||||||
|
? row
|
||||||
|
: null
|
||||||
)
|
)
|
||||||
.map(mapCustomerContactRecord);
|
)
|
||||||
|
).filter((row): row is typeof crmCustomerContacts.$inferSelect => row !== null)
|
||||||
|
: rows;
|
||||||
|
|
||||||
|
if (accessContext && !scopedRows.length) {
|
||||||
|
const canViewCustomer = await canAccessCustomerRow(customer, accessContext);
|
||||||
|
|
||||||
|
if (!canViewCustomer) {
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scopedRows.map((row) => mapCustomerContactRecord(row, shareMap.get(row.id) ?? []));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCustomerContact(
|
export async function createCustomerContact(
|
||||||
@@ -842,8 +1257,7 @@ export async function updateCustomerContact(
|
|||||||
payload: CustomerContactMutationPayload,
|
payload: CustomerContactMutationPayload,
|
||||||
accessContext?: CustomerAccessContext
|
accessContext?: CustomerAccessContext
|
||||||
) {
|
) {
|
||||||
await assertCustomerAccess(customerId, organizationId, accessContext);
|
await assertContactAccess(customerId, contactId, organizationId, accessContext);
|
||||||
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
if (payload.isPrimary) {
|
if (payload.isPrimary) {
|
||||||
@@ -892,8 +1306,7 @@ export async function softDeleteCustomerContact(
|
|||||||
userId: string,
|
userId: string,
|
||||||
accessContext?: CustomerAccessContext
|
accessContext?: CustomerAccessContext
|
||||||
) {
|
) {
|
||||||
await assertCustomerAccess(customerId, organizationId, accessContext);
|
await assertContactAccess(customerId, contactId, organizationId, accessContext);
|
||||||
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(crmCustomerContacts)
|
.update(crmCustomerContacts)
|
||||||
@@ -910,6 +1323,103 @@ export async function softDeleteCustomerContact(
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function shareCustomerContact(
|
||||||
|
customerId: string,
|
||||||
|
contactId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: { sharedToUserId: string; remark?: string | null },
|
||||||
|
accessContext?: CustomerAccessContext
|
||||||
|
) {
|
||||||
|
await assertCustomerAccess(customerId, organizationId, accessContext);
|
||||||
|
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
||||||
|
await assertOwnerUserBelongsToOrganization(payload.sharedToUserId, organizationId);
|
||||||
|
|
||||||
|
const existing = await db.query.crmContactShares.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(crmContactShares.organizationId, organizationId),
|
||||||
|
eq(crmContactShares.contactId, contactId),
|
||||||
|
eq(crmContactShares.sharedToUserId, payload.sharedToUserId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmContactShares)
|
||||||
|
.set({
|
||||||
|
sharedByUserId: userId,
|
||||||
|
sharedAt: new Date(),
|
||||||
|
isActive: true,
|
||||||
|
remark: payload.remark?.trim() || null,
|
||||||
|
deletedAt: null,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmContactShares.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(crmContactShares)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
contactId,
|
||||||
|
sharedToUserId: payload.sharedToUserId,
|
||||||
|
sharedByUserId: userId,
|
||||||
|
sharedAt: new Date(),
|
||||||
|
isActive: true,
|
||||||
|
remark: payload.remark?.trim() || null
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCustomerContactShares(
|
||||||
|
customerId: string,
|
||||||
|
contactId: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: CustomerAccessContext
|
||||||
|
) {
|
||||||
|
await assertContactAccess(customerId, contactId, organizationId, accessContext);
|
||||||
|
return (await listContactSharesMap([contactId], organizationId)).get(contactId) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unshareCustomerContact(
|
||||||
|
customerId: string,
|
||||||
|
contactId: string,
|
||||||
|
shareId: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: CustomerAccessContext
|
||||||
|
) {
|
||||||
|
await assertCustomerAccess(customerId, organizationId, accessContext);
|
||||||
|
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmContactShares)
|
||||||
|
.set({
|
||||||
|
isActive: false,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmContactShares.id, shareId),
|
||||||
|
eq(crmContactShares.organizationId, organizationId),
|
||||||
|
eq(crmContactShares.contactId, contactId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new AuthError('Share not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveCustomerOptionLabel(
|
export async function resolveCustomerOptionLabel(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
category: string,
|
category: string,
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface EnquiryCustomerLookup {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
branchId: string | null;
|
branchId: string | null;
|
||||||
|
ownerUserId: string | null;
|
||||||
|
ownerName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryContactLookup {
|
export interface EnquiryContactLookup {
|
||||||
@@ -198,6 +200,7 @@ export interface EnquiryProjectPartyMutationPayload {
|
|||||||
export interface EnquiryMutationPayload {
|
export interface EnquiryMutationPayload {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
contactId?: string | null;
|
contactId?: string | null;
|
||||||
|
assignedToUserId?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
requirement?: string;
|
requirement?: string;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function toDefaultValues(
|
|||||||
return {
|
return {
|
||||||
customerId: enquiry?.customerId ?? referenceData.customers[0]?.id ?? '',
|
customerId: enquiry?.customerId ?? referenceData.customers[0]?.id ?? '',
|
||||||
contactId: enquiry?.contactId ?? undefined,
|
contactId: enquiry?.contactId ?? undefined,
|
||||||
|
assignedToUserId: enquiry?.assignedToUserId ?? undefined,
|
||||||
title: enquiry?.title ?? '',
|
title: enquiry?.title ?? '',
|
||||||
description: enquiry?.description ?? '',
|
description: enquiry?.description ?? '',
|
||||||
requirement: enquiry?.requirement ?? '',
|
requirement: enquiry?.requirement ?? '',
|
||||||
@@ -110,6 +111,7 @@ export function EnquiryFormSheet({
|
|||||||
const contactsForCustomer = referenceData.contacts.filter(
|
const contactsForCustomer = referenceData.contacts.filter(
|
||||||
(contact) => contact.customerId === selectedCustomerId
|
(contact) => contact.customerId === selectedCustomerId
|
||||||
);
|
);
|
||||||
|
const selectedCustomer = referenceData.customers.find((customer) => customer.id === selectedCustomerId);
|
||||||
|
|
||||||
const form = useAppForm({
|
const form = useAppForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
@@ -120,6 +122,7 @@ export function EnquiryFormSheet({
|
|||||||
const payload: EnquiryMutationPayload = {
|
const payload: EnquiryMutationPayload = {
|
||||||
customerId: value.customerId,
|
customerId: value.customerId,
|
||||||
contactId: value.contactId || null,
|
contactId: value.contactId || null,
|
||||||
|
assignedToUserId: value.assignedToUserId || null,
|
||||||
title: value.title,
|
title: value.title,
|
||||||
description: value.description,
|
description: value.description,
|
||||||
requirement: value.requirement,
|
requirement: value.requirement,
|
||||||
@@ -177,6 +180,17 @@ export function EnquiryFormSheet({
|
|||||||
);
|
);
|
||||||
}, [defaultValues, enquiry, form, open, projectPartiesQuery.data?.items]);
|
}, [defaultValues, enquiry, form, open, projectPartiesQuery.data?.items]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || isEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerUserId =
|
||||||
|
referenceData.customers.find((customer) => customer.id === selectedCustomerId)?.ownerUserId ?? '';
|
||||||
|
|
||||||
|
form.setFieldValue('assignedToUserId', ownerUserId);
|
||||||
|
}, [form, isEdit, open, referenceData.customers, selectedCustomerId]);
|
||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
const recordLabel = workspace === 'lead' ? 'ลีด' : 'โอกาสขาย';
|
const recordLabel = workspace === 'lead' ? 'ลีด' : 'โอกาสขาย';
|
||||||
@@ -262,6 +276,19 @@ export function EnquiryFormSheet({
|
|||||||
</field.FieldSet>
|
</field.FieldSet>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='assignedToUserId'
|
||||||
|
label='Suggested Sales Owner'
|
||||||
|
description={
|
||||||
|
selectedCustomer?.ownerName
|
||||||
|
? `Suggested from customer owner: ${selectedCustomer.ownerName}`
|
||||||
|
: 'Optional sales owner suggestion for this lead or enquiry'
|
||||||
|
}
|
||||||
|
options={referenceData.assignableUsers.map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: user.name
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormTextField
|
<FormTextField
|
||||||
name='title'
|
name='title'
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function coerceOptionalNumber(message: string) {
|
|||||||
export const enquirySchema = z.object({
|
export const enquirySchema = z.object({
|
||||||
customerId: z.string().min(1, 'Please select a customer'),
|
customerId: z.string().min(1, 'Please select a customer'),
|
||||||
contactId: z.string().optional().nullable(),
|
contactId: z.string().optional().nullable(),
|
||||||
|
assignedToUserId: z.string().optional().nullable(),
|
||||||
title: z.string().min(2, 'Title must be at least 2 characters'),
|
title: z.string().min(2, 'Title must be at least 2 characters'),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
requirement: z.string().optional(),
|
requirement: z.string().optional(),
|
||||||
|
|||||||
@@ -488,6 +488,10 @@ async function validateEnquiryPayload(
|
|||||||
throw new AuthError('Forbidden product scope', 403);
|
throw new AuthError('Forbidden product scope', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.assignedToUserId) {
|
||||||
|
await assertAssignableUserBelongsToOrganization(payload.assignedToUserId, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
for (const projectParty of payload.projectParties ?? []) {
|
for (const projectParty of payload.projectParties ?? []) {
|
||||||
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
|
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
|
||||||
await assertMasterOptionValue(
|
await assertMasterOptionValue(
|
||||||
@@ -623,7 +627,13 @@ export async function getEnquiryReferenceData(
|
|||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
|
||||||
db
|
db
|
||||||
.select()
|
.select({
|
||||||
|
id: crmCustomers.id,
|
||||||
|
code: crmCustomers.code,
|
||||||
|
name: crmCustomers.name,
|
||||||
|
branchId: crmCustomers.branchId,
|
||||||
|
ownerUserId: crmCustomers.ownerUserId
|
||||||
|
})
|
||||||
.from(crmCustomers)
|
.from(crmCustomers)
|
||||||
.where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)))
|
.where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)))
|
||||||
.orderBy(asc(crmCustomers.name)),
|
.orderBy(asc(crmCustomers.name)),
|
||||||
@@ -639,6 +649,14 @@ export async function getEnquiryReferenceData(
|
|||||||
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)),
|
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)),
|
||||||
listAssignableUsers(organizationId)
|
listAssignableUsers(organizationId)
|
||||||
]);
|
]);
|
||||||
|
const ownerIds = [...new Set(customers.map((customer) => customer.ownerUserId).filter(Boolean) as string[])];
|
||||||
|
const ownerRows = ownerIds.length
|
||||||
|
? await db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, ownerIds))
|
||||||
|
: [];
|
||||||
|
const ownerMap = new Map(ownerRows.map((row) => [row.id, row.name]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
branches: branches.map(mapBranch),
|
branches: branches.map(mapBranch),
|
||||||
@@ -652,7 +670,9 @@ export async function getEnquiryReferenceData(
|
|||||||
id: customer.id,
|
id: customer.id,
|
||||||
code: customer.code,
|
code: customer.code,
|
||||||
name: customer.name,
|
name: customer.name,
|
||||||
branchId: customer.branchId
|
branchId: customer.branchId,
|
||||||
|
ownerUserId: customer.ownerUserId ?? null,
|
||||||
|
ownerName: customer.ownerUserId ? (ownerMap.get(customer.ownerUserId) ?? null) : null
|
||||||
})),
|
})),
|
||||||
contacts: contacts.map((contact) => ({
|
contacts: contacts.map((contact) => ({
|
||||||
id: contact.id,
|
id: contact.id,
|
||||||
@@ -1063,6 +1083,10 @@ export async function createEnquiry(
|
|||||||
isHotProject: payload.isHotProject ?? false,
|
isHotProject: payload.isHotProject ?? false,
|
||||||
isActive: payload.isActive ?? true,
|
isActive: payload.isActive ?? true,
|
||||||
pipelineStage,
|
pipelineStage,
|
||||||
|
assignedToUserId: payload.assignedToUserId ?? null,
|
||||||
|
assignedAt: payload.assignedToUserId ? new Date() : null,
|
||||||
|
assignedBy: payload.assignedToUserId ? userId : null,
|
||||||
|
assignmentRemark: null,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
updatedBy: userId
|
updatedBy: userId
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,63 @@ export function canAccessScopedCrmRecord(
|
|||||||
return row.createdBy === context.userId || row.ownerUserId === context.userId;
|
return row.createdBy === context.userId || row.ownerUserId === context.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canAccessCustomer(
|
||||||
|
context: Pick<
|
||||||
|
CrmSecurityContext,
|
||||||
|
| 'membershipRole'
|
||||||
|
| 'userId'
|
||||||
|
| 'ownershipScope'
|
||||||
|
| 'branchScopeMode'
|
||||||
|
| 'branchScopeIds'
|
||||||
|
| 'productScopeMode'
|
||||||
|
| 'productTypeScopeIds'
|
||||||
|
>,
|
||||||
|
row: {
|
||||||
|
branchId?: string | null;
|
||||||
|
createdBy?: string | null;
|
||||||
|
ownerUserId?: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return canAccessScopedCrmRecord(context, {
|
||||||
|
branchId: row.branchId,
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
ownerUserId: row.ownerUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessContact(
|
||||||
|
context: Pick<
|
||||||
|
CrmSecurityContext,
|
||||||
|
| 'membershipRole'
|
||||||
|
| 'userId'
|
||||||
|
| 'ownershipScope'
|
||||||
|
| 'branchScopeMode'
|
||||||
|
| 'branchScopeIds'
|
||||||
|
| 'productScopeMode'
|
||||||
|
| 'productTypeScopeIds'
|
||||||
|
>,
|
||||||
|
row: {
|
||||||
|
branchId?: string | null;
|
||||||
|
createdBy?: string | null;
|
||||||
|
ownerUserId?: string | null;
|
||||||
|
sharedToUserIds?: string[] | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!hasBranchScopeAccess(context, row.branchId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.membershipRole === 'admin' || context.ownershipScope === 'organization' || context.ownershipScope === 'team') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
row.createdBy === context.userId ||
|
||||||
|
row.ownerUserId === context.userId ||
|
||||||
|
(row.sharedToUserIds ?? []).includes(context.userId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function auditCrmSecurityEvent(input: {
|
export async function auditCrmSecurityEvent(input: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -38,10 +38,16 @@ export const PERMISSIONS = {
|
|||||||
crmCustomerCreate: 'crm.customer.create',
|
crmCustomerCreate: 'crm.customer.create',
|
||||||
crmCustomerUpdate: 'crm.customer.update',
|
crmCustomerUpdate: 'crm.customer.update',
|
||||||
crmCustomerDelete: 'crm.customer.delete',
|
crmCustomerDelete: 'crm.customer.delete',
|
||||||
|
crmCustomerOwnerRead: 'crm.customer.owner.read',
|
||||||
|
crmCustomerOwnerManage: 'crm.customer.owner.manage',
|
||||||
crmContactRead: 'crm.contact.read',
|
crmContactRead: 'crm.contact.read',
|
||||||
crmContactCreate: 'crm.contact.create',
|
crmContactCreate: 'crm.contact.create',
|
||||||
crmContactUpdate: 'crm.contact.update',
|
crmContactUpdate: 'crm.contact.update',
|
||||||
crmContactDelete: 'crm.contact.delete',
|
crmContactDelete: 'crm.contact.delete',
|
||||||
|
crmContactShareRead: 'crm.contact.share.read',
|
||||||
|
crmContactShareCreate: 'crm.contact.share.create',
|
||||||
|
crmContactShareDelete: 'crm.contact.share.delete',
|
||||||
|
crmContactShareManage: 'crm.contact.share.manage',
|
||||||
crmEnquiryRead: 'crm.enquiry.read',
|
crmEnquiryRead: 'crm.enquiry.read',
|
||||||
crmEnquiryCreate: 'crm.enquiry.create',
|
crmEnquiryCreate: 'crm.enquiry.create',
|
||||||
crmEnquiryUpdate: 'crm.enquiry.update',
|
crmEnquiryUpdate: 'crm.enquiry.update',
|
||||||
@@ -156,6 +162,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
description: 'Own enquiries, quotations, and follow-ups within assigned branch and product scope.',
|
description: 'Own enquiries, quotations, and follow-ups within assigned branch and product scope.',
|
||||||
permissions: [
|
permissions: [
|
||||||
PERMISSIONS.crmCustomerRead,
|
PERMISSIONS.crmCustomerRead,
|
||||||
|
PERMISSIONS.crmCustomerOwnerRead,
|
||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryCreate,
|
PERMISSIONS.crmEnquiryCreate,
|
||||||
@@ -193,6 +200,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
description: 'Quotation drafting and operational support without approval authority.',
|
description: 'Quotation drafting and operational support without approval authority.',
|
||||||
permissions: [
|
permissions: [
|
||||||
PERMISSIONS.crmCustomerRead,
|
PERMISSIONS.crmCustomerRead,
|
||||||
|
PERMISSIONS.crmCustomerOwnerRead,
|
||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryUpdate,
|
PERMISSIONS.crmEnquiryUpdate,
|
||||||
@@ -234,9 +242,15 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmCustomerRead,
|
PERMISSIONS.crmCustomerRead,
|
||||||
PERMISSIONS.crmCustomerCreate,
|
PERMISSIONS.crmCustomerCreate,
|
||||||
PERMISSIONS.crmCustomerUpdate,
|
PERMISSIONS.crmCustomerUpdate,
|
||||||
|
PERMISSIONS.crmCustomerOwnerRead,
|
||||||
|
PERMISSIONS.crmCustomerOwnerManage,
|
||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
PERMISSIONS.crmContactCreate,
|
PERMISSIONS.crmContactCreate,
|
||||||
PERMISSIONS.crmContactUpdate,
|
PERMISSIONS.crmContactUpdate,
|
||||||
|
PERMISSIONS.crmContactShareRead,
|
||||||
|
PERMISSIONS.crmContactShareCreate,
|
||||||
|
PERMISSIONS.crmContactShareDelete,
|
||||||
|
PERMISSIONS.crmContactShareManage,
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryCreate,
|
PERMISSIONS.crmEnquiryCreate,
|
||||||
PERMISSIONS.crmEnquiryUpdate,
|
PERMISSIONS.crmEnquiryUpdate,
|
||||||
@@ -282,7 +296,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
permissions: [
|
permissions: [
|
||||||
PERMISSIONS.crmLeadRead,
|
PERMISSIONS.crmLeadRead,
|
||||||
PERMISSIONS.crmCustomerRead,
|
PERMISSIONS.crmCustomerRead,
|
||||||
|
PERMISSIONS.crmCustomerOwnerRead,
|
||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
|
PERMISSIONS.crmContactShareRead,
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryAssign,
|
PERMISSIONS.crmEnquiryAssign,
|
||||||
PERMISSIONS.crmEnquiryReassign,
|
PERMISSIONS.crmEnquiryReassign,
|
||||||
@@ -315,7 +331,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
permissions: [
|
permissions: [
|
||||||
PERMISSIONS.crmLeadRead,
|
PERMISSIONS.crmLeadRead,
|
||||||
PERMISSIONS.crmCustomerRead,
|
PERMISSIONS.crmCustomerRead,
|
||||||
|
PERMISSIONS.crmCustomerOwnerRead,
|
||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
|
PERMISSIONS.crmContactShareRead,
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryAssign,
|
PERMISSIONS.crmEnquiryAssign,
|
||||||
PERMISSIONS.crmEnquiryReassign,
|
PERMISSIONS.crmEnquiryReassign,
|
||||||
@@ -347,6 +365,12 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
description: 'CRM configuration authority for templates, workflows, sequences, and permissions.',
|
description: 'CRM configuration authority for templates, workflows, sequences, and permissions.',
|
||||||
permissions: [
|
permissions: [
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
|
PERMISSIONS.crmCustomerOwnerRead,
|
||||||
|
PERMISSIONS.crmCustomerOwnerManage,
|
||||||
|
PERMISSIONS.crmContactShareRead,
|
||||||
|
PERMISSIONS.crmContactShareCreate,
|
||||||
|
PERMISSIONS.crmContactShareDelete,
|
||||||
|
PERMISSIONS.crmContactShareManage,
|
||||||
PERMISSIONS.crmRoleRead,
|
PERMISSIONS.crmRoleRead,
|
||||||
PERMISSIONS.crmRoleCreate,
|
PERMISSIONS.crmRoleCreate,
|
||||||
PERMISSIONS.crmRoleUpdate,
|
PERMISSIONS.crmRoleUpdate,
|
||||||
@@ -406,6 +430,32 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ key: PERMISSIONS.crmLeadDelete, label: 'Delete leads' }
|
{ key: PERMISSIONS.crmLeadDelete, label: 'Delete leads' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'customer',
|
||||||
|
label: 'Customers',
|
||||||
|
permissions: [
|
||||||
|
{ key: PERMISSIONS.crmCustomerRead, label: 'Read customers' },
|
||||||
|
{ key: PERMISSIONS.crmCustomerCreate, label: 'Create customers' },
|
||||||
|
{ key: PERMISSIONS.crmCustomerUpdate, label: 'Update customers' },
|
||||||
|
{ key: PERMISSIONS.crmCustomerDelete, label: 'Delete customers' },
|
||||||
|
{ key: PERMISSIONS.crmCustomerOwnerRead, label: 'Read customer owner' },
|
||||||
|
{ key: PERMISSIONS.crmCustomerOwnerManage, label: 'Manage customer owner' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contact',
|
||||||
|
label: 'Contacts',
|
||||||
|
permissions: [
|
||||||
|
{ key: PERMISSIONS.crmContactRead, label: 'Read contacts' },
|
||||||
|
{ key: PERMISSIONS.crmContactCreate, label: 'Create contacts' },
|
||||||
|
{ key: PERMISSIONS.crmContactUpdate, label: 'Update contacts' },
|
||||||
|
{ key: PERMISSIONS.crmContactDelete, label: 'Delete contacts' },
|
||||||
|
{ key: PERMISSIONS.crmContactShareRead, label: 'Read contact sharing' },
|
||||||
|
{ key: PERMISSIONS.crmContactShareCreate, label: 'Create contact sharing' },
|
||||||
|
{ key: PERMISSIONS.crmContactShareDelete, label: 'Delete contact sharing' },
|
||||||
|
{ key: PERMISSIONS.crmContactShareManage, label: 'Manage contact sharing' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'enquiry',
|
key: 'enquiry',
|
||||||
label: 'Enquiries',
|
label: 'Enquiries',
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const searchParams = {
|
|||||||
customerType: parseAsString,
|
customerType: parseAsString,
|
||||||
customerStatus: parseAsString,
|
customerStatus: parseAsString,
|
||||||
customer: parseAsString,
|
customer: parseAsString,
|
||||||
|
ownerUserId: parseAsString,
|
||||||
|
myCustomers: parseAsString,
|
||||||
enquiry: parseAsString,
|
enquiry: parseAsString,
|
||||||
branch: parseAsString,
|
branch: parseAsString,
|
||||||
productType: parseAsString,
|
productType: parseAsString,
|
||||||
|
|||||||
Reference in New Issue
Block a user