This commit is contained in:
phaichayon
2026-06-22 15:49:31 +07:00
parent 1b901efc51
commit 3f28fde39f
36 changed files with 2602 additions and 56 deletions

View 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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View 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");

View File

@@ -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
View 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
```

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View 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 });
}
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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}
/> />

View File

@@ -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',
{ {

View File

@@ -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)
]);
}
}
});

View File

@@ -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)
});

View File

@@ -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'
}
);
}

View File

@@ -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;

View 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>
);
}

View File

@@ -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',

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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();

View 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>
</>
);
}

View File

@@ -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) })
}; };

View File

@@ -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(
rows.map((row) => row.id),
organizationId
);
const scopedRows = accessContext
? (
await Promise.all(
rows.map(async (row) =>
(await canAccessContactRow(row, customer, accessContext, shareMap.get(row.id) ?? []))
? row
: null
)
)
).filter((row): row is typeof crmCustomerContacts.$inferSelect => row !== null)
: rows;
return rows if (accessContext && !scopedRows.length) {
.filter( const canViewCustomer = await canAccessCustomerRow(customer, accessContext);
(row) =>
!accessContext || if (!canViewCustomer) {
row.createdBy === accessContext.userId || throw new AuthError('Forbidden', 403);
accessContext.membershipRole === 'admin' || }
accessContext.ownershipScope === 'organization' || }
accessContext.ownershipScope === 'team'
) return scopedRows.map((row) => mapCustomerContactRecord(row, shareMap.get(row.id) ?? []));
.map(mapCustomerContactRecord);
} }
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,

View File

@@ -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;

View File

@@ -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'

View File

@@ -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(),

View File

@@ -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
}) })

View File

@@ -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;

View File

@@ -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',

View File

@@ -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,