From 3f28fde39fcdfe4e676ccaf45a461371ef604961 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Mon, 22 Jun 2026 15:49:31 +0700 Subject: [PATCH] task-c.1 --- ...0015-customer-ownership-contact-sharing.md | 78 +++ ...er-ownership-contact-sharing-governance.md | 51 ++ docs/implementation/technical-debt.md | 7 +- .../crm-access-enforcement-inventory.md | 4 +- docs/security/crm-authorization-boundaries.md | 2 +- docs/security/team-scope-limitations.md | 10 +- ...0015_customer_ownership_contact_shares.sql | 35 + drizzle/meta/_journal.json | 9 +- plans/task-c.1.md | 653 ++++++++++++++++++ .../[contactId]/shares/[shareId]/route.ts | 59 ++ .../[id]/contacts/[contactId]/shares/route.ts | 111 +++ src/app/api/crm/customers/[id]/owner/route.ts | 146 ++++ src/app/api/crm/customers/[id]/route.ts | 9 +- src/app/api/crm/customers/route.ts | 4 + src/app/api/crm/enquiries/route.ts | 24 +- src/app/dashboard/crm/customers/[id]/page.tsx | 19 + src/db/schema.ts | 41 ++ src/features/crm/customers/api/mutations.ts | 77 ++- src/features/crm/customers/api/queries.ts | 18 +- src/features/crm/customers/api/service.ts | 55 ++ src/features/crm/customers/api/types.ts | 58 ++ .../components/contact-share-sheet.tsx | 173 +++++ .../customers/components/customer-columns.tsx | 26 + .../components/customer-contacts-tab.tsx | 39 +- .../customers/components/customer-detail.tsx | 15 + .../customers/components/customer-listing.tsx | 2 + .../components/customer-owner-card.tsx | 187 +++++ .../customers/components/customers-table.tsx | 2 + src/features/crm/customers/server/service.ts | 576 ++++++++++++++- src/features/crm/enquiries/api/types.ts | 3 + .../components/enquiry-form-sheet.tsx | 27 + .../crm/enquiries/schemas/enquiry.schema.ts | 1 + src/features/crm/enquiries/server/service.ts | 28 +- src/features/crm/security/server/service.ts | 57 ++ src/lib/auth/rbac.ts | 50 ++ src/lib/searchparams.ts | 2 + 36 files changed, 2602 insertions(+), 56 deletions(-) create mode 100644 docs/adr/0015-customer-ownership-contact-sharing.md create mode 100644 docs/implementation/task-c1-customer-ownership-contact-sharing-governance.md create mode 100644 drizzle/0015_customer_ownership_contact_shares.sql create mode 100644 plans/task-c.1.md create mode 100644 src/app/api/crm/customers/[id]/contacts/[contactId]/shares/[shareId]/route.ts create mode 100644 src/app/api/crm/customers/[id]/contacts/[contactId]/shares/route.ts create mode 100644 src/app/api/crm/customers/[id]/owner/route.ts create mode 100644 src/features/crm/customers/components/contact-share-sheet.tsx create mode 100644 src/features/crm/customers/components/customer-owner-card.tsx diff --git a/docs/adr/0015-customer-ownership-contact-sharing.md b/docs/adr/0015-customer-ownership-contact-sharing.md new file mode 100644 index 0000000..e1be319 --- /dev/null +++ b/docs/adr/0015-customer-ownership-contact-sharing.md @@ -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. diff --git a/docs/implementation/task-c1-customer-ownership-contact-sharing-governance.md b/docs/implementation/task-c1-customer-ownership-contact-sharing-governance.md new file mode 100644 index 0000000..18295b9 --- /dev/null +++ b/docs/implementation/task-c1-customer-ownership-contact-sharing-governance.md @@ -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. diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index b480ec1..aa2d23b 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -434,9 +434,8 @@ Future: ### Contact-sharing persistence gap 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: - -- add a first-class contact-sharing table and audit trail if the business still needs shared-contact workflows -- extend security verification from creator/customer/admin visibility to explicit share-based visibility once persistence exists +- add deeper automated regression coverage for owner/share visibility across customer, lead, enquiry, and quotation flows +- introduce a first-class CRM team hierarchy if the business still needs inherited team visibility diff --git a/docs/security/crm-access-enforcement-inventory.md b/docs/security/crm-access-enforcement-inventory.md index 449f434..df48305 100644 --- a/docs/security/crm-access-enforcement-inventory.md +++ b/docs/security/crm-access-enforcement-inventory.md @@ -29,11 +29,11 @@ ## High-Risk Findings 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. ## Follow-up Focus - 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. -- 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. diff --git a/docs/security/crm-authorization-boundaries.md b/docs/security/crm-authorization-boundaries.md index 0588315..0a65f24 100644 --- a/docs/security/crm-authorization-boundaries.md +++ b/docs/security/crm-authorization-boundaries.md @@ -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 - contact creators retain access to their own contacts - 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 diff --git a/docs/security/team-scope-limitations.md b/docs/security/team-scope-limitations.md index ae6b1be..d19e480 100644 --- a/docs/security/team-scope-limitations.md +++ b/docs/security/team-scope-limitations.md @@ -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. -## 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: -- contact visibility is enforced through customer ownership plus contact creator visibility -- mock/demo shared-contact behavior from legacy demo data is not part of the production boundary yet +- contact visibility is enforced through customer ownership, contact creator visibility, and explicit contact-share grants +- cross-owner contact access can now be granted and revoked without relying on demo/mock behavior ## Future Team Hierarchy Model @@ -37,5 +37,5 @@ Recommended future direction: 1. add a first-class manager/team relationship model 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 diff --git a/drizzle/0015_customer_ownership_contact_shares.sql b/drizzle/0015_customer_ownership_contact_shares.sql new file mode 100644 index 0000000..5bc5283 --- /dev/null +++ b/drizzle/0015_customer_ownership_contact_shares.sql @@ -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"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e77c8f9..78bc111 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1782099743528, "tag": "0014_bored_valkyrie", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1782549600000, + "tag": "0015_customer_ownership_contact_shares", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/plans/task-c.1.md b/plans/task-c.1.md new file mode 100644 index 0000000..7281d7d --- /dev/null +++ b/plans/task-c.1.md @@ -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 +``` diff --git a/src/app/api/crm/customers/[id]/contacts/[contactId]/shares/[shareId]/route.ts b/src/app/api/crm/customers/[id]/contacts/[contactId]/shares/[shareId]/route.ts new file mode 100644 index 0000000..6d89964 --- /dev/null +++ b/src/app/api/crm/customers/[id]/contacts/[contactId]/shares/[shareId]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/customers/[id]/contacts/[contactId]/shares/route.ts b/src/app/api/crm/customers/[id]/contacts/[contactId]/shares/route.ts new file mode 100644 index 0000000..0d975c3 --- /dev/null +++ b/src/app/api/crm/customers/[id]/contacts/[contactId]/shares/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/customers/[id]/owner/route.ts b/src/app/api/crm/customers/[id]/owner/route.ts new file mode 100644 index 0000000..806543d --- /dev/null +++ b/src/app/api/crm/customers/[id]/owner/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/customers/[id]/route.ts b/src/app/api/crm/customers/[id]/route.ts index e83888b..7ab53c4 100644 --- a/src/app/api/crm/customers/[id]/route.ts +++ b/src/app/api/crm/customers/[id]/route.ts @@ -8,6 +8,7 @@ import { import { getCustomerActivity, getCustomerDetail, + getCustomerOwnerHistory, softDeleteCustomer, updateCustomer } from '@/features/crm/customers/server/service'; @@ -37,9 +38,10 @@ export async function GET(_request: NextRequest, { params }: Params) { userId: session.user.id, membership }); - const [customer, activity] = await Promise.all([ + const [customer, activity, ownerHistory] = await Promise.all([ getCustomerDetail(id, organization.id, accessContext), - getCustomerActivity(id, organization.id) + getCustomerActivity(id, organization.id), + getCustomerOwnerHistory(id, organization.id, accessContext) ]); return NextResponse.json({ @@ -47,7 +49,8 @@ export async function GET(_request: NextRequest, { params }: Params) { time: new Date().toISOString(), message: 'Customer loaded successfully', customer, - activity + activity, + ownerHistory }); } catch (error) { if (error instanceof AuthError) { diff --git a/src/app/api/crm/customers/route.ts b/src/app/api/crm/customers/route.ts index c238f8e..cf6727e 100644 --- a/src/app/api/crm/customers/route.ts +++ b/src/app/api/crm/customers/route.ts @@ -29,6 +29,8 @@ export async function GET(request: NextRequest) { const customerStatus = searchParams.get('customerStatus') ?? undefined; const customerType = searchParams.get('customerType') ?? 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 accessContext = buildCrmSecurityContext({ organizationId: organization.id, @@ -42,6 +44,8 @@ export async function GET(request: NextRequest) { customerStatus, customerType, branch, + ownerUserId, + myCustomers, sort }, accessContext); const offset = (page - 1) * limit; diff --git a/src/app/api/crm/enquiries/route.ts b/src/app/api/crm/enquiries/route.ts index 2a99457..b610bea 100644 --- a/src/app/api/crm/enquiries/route.ts +++ b/src/app/api/crm/enquiries/route.ts @@ -1,13 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; 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 { getCustomerDetail } from '@/features/crm/customers/server/service'; import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; const enquiryRequestSchema = enquirySchema.extend({ contactId: z.string().nullable().optional(), + assignedToUserId: z.string().nullable().optional(), branchId: z.string().nullable().optional(), leadChannel: z.string().nullable().optional(), expectedCloseDate: z.string().nullable().optional() @@ -102,6 +104,7 @@ export async function POST(request: NextRequest) { accessContext, payload ); + const customer = await getCustomerDetail(payload.customerId, organization.id); await auditCreate({ organizationId: organization.id, @@ -112,6 +115,25 @@ export async function POST(request: NextRequest) { 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( { success: true, diff --git a/src/app/dashboard/crm/customers/[id]/page.tsx b/src/app/dashboard/crm/customers/[id]/page.tsx index 399affb..86ff35d 100644 --- a/src/app/dashboard/crm/customers/[id]/page.tsx +++ b/src/app/dashboard/crm/customers/[id]/page.tsx @@ -49,6 +49,22 @@ export default async function CustomerDetailRoute({ params }: PageProps) { (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || 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 = session?.user?.activeOrganizationId && session?.user?.id ? { @@ -116,6 +132,9 @@ export default async function CustomerDetailRoute({ params }: PageProps) { update: canContactUpdate, delete: canContactDelete }} + canReadContactShares={canContactShareRead} + canManageContactShares={canContactShareManage} + canManageOwner={canOwnerManage} relatedEnquiries={relatedEnquiries} relatedQuotations={relatedQuotations} /> diff --git a/src/db/schema.ts b/src/db/schema.ts index 925098b..29a9e1f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -213,6 +213,9 @@ export const crmCustomers = pgTable( leadChannel: text('lead_channel'), customerGroup: text('customer_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'), isActive: boolean('is_active').default(true).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', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), @@ -249,6 +266,30 @@ export const crmCustomerContacts = pgTable('crm_customer_contacts', { 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( 'crm_enquiries', { diff --git a/src/features/crm/customers/api/mutations.ts b/src/features/crm/customers/api/mutations.ts index 689e328..7ce9347 100644 --- a/src/features/crm/customers/api/mutations.ts +++ b/src/features/crm/customers/api/mutations.ts @@ -1,15 +1,24 @@ import { mutationOptions } from '@tanstack/react-query'; import { getQueryClient } from '@/lib/query-client'; import { + assignCustomerOwner, + clearCustomerOwner, createCustomer, createCustomerContact, deleteCustomer, deleteCustomerContact, + shareCustomerContact, + unshareCustomerContact, updateCustomer, updateCustomerContact } from './service'; import { customerKeys } from './queries'; -import type { CustomerContactMutationPayload, CustomerMutationPayload } from './types'; +import type { + ContactShareMutationPayload, + CustomerContactMutationPayload, + CustomerMutationPayload, + CustomerOwnerMutationPayload +} from './types'; async function invalidateCustomerLists() { await getQueryClient().invalidateQueries({ queryKey: customerKeys.lists() }); @@ -23,6 +32,12 @@ async function invalidateCustomerContacts(id: string) { 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) { 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 }) => + clearCustomerOwner(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateCustomerMutationQueries(variables.id); + } + } +}); + export const createCustomerContactMutation = mutationOptions({ mutationFn: ({ 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) + ]); + } + } +}); diff --git a/src/features/crm/customers/api/queries.ts b/src/features/crm/customers/api/queries.ts index 7e9d0bd..cfe4047 100644 --- a/src/features/crm/customers/api/queries.ts +++ b/src/features/crm/customers/api/queries.ts @@ -1,5 +1,10 @@ import { queryOptions } from '@tanstack/react-query'; -import { getCustomerById, getCustomerContacts, getCustomers } from './service'; +import { + getCustomerById, + getCustomerContactShares, + getCustomerContacts, + getCustomers +} from './service'; import type { CustomerFilters } from './types'; export const customerKeys = { @@ -9,7 +14,10 @@ export const customerKeys = { details: () => [...customerKeys.all, 'detail'] as const, detail: (id: string) => [...customerKeys.details(), id] 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) => @@ -29,3 +37,9 @@ export const customerContactsOptions = (id: string) => queryKey: customerKeys.contacts(id), queryFn: () => getCustomerContacts(id) }); + +export const customerContactSharesOptions = (customerId: string, contactId: string) => + queryOptions({ + queryKey: customerKeys.contactShares(customerId, contactId), + queryFn: () => getCustomerContactShares(customerId, contactId) + }); diff --git a/src/features/crm/customers/api/service.ts b/src/features/crm/customers/api/service.ts index 116dd13..bf908ad 100644 --- a/src/features/crm/customers/api/service.ts +++ b/src/features/crm/customers/api/service.ts @@ -1,11 +1,14 @@ import { apiClient } from '@/lib/api-client'; import type { + ContactShareMutationPayload, CustomerContactMutationPayload, + CustomerContactSharesResponse, CustomerContactsResponse, CustomerDetailResponse, CustomerFilters, CustomerListResponse, CustomerMutationPayload, + CustomerOwnerMutationPayload, MutationSuccessResponse } from './types'; @@ -18,6 +21,8 @@ export async function getCustomers(filters: CustomerFilters): Promise(`/crm/customers/${id}/owner`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function clearCustomerOwner(id: string, data?: Pick) { + return apiClient(`/crm/customers/${id}/owner`, { + method: 'DELETE', + body: JSON.stringify(data ?? {}) + }); +} + export async function getCustomerContacts(id: string): Promise { return apiClient(`/crm/customers/${id}/contacts`); } @@ -79,3 +98,39 @@ export async function deleteCustomerContact(customerId: string, contactId: strin method: 'DELETE' }); } + +export async function getCustomerContactShares( + customerId: string, + contactId: string +): Promise { + return apiClient( + `/crm/customers/${customerId}/contacts/${contactId}/shares` + ); +} + +export async function shareCustomerContact( + customerId: string, + contactId: string, + data: ContactShareMutationPayload +) { + return apiClient( + `/crm/customers/${customerId}/contacts/${contactId}/shares`, + { + method: 'POST', + body: JSON.stringify(data) + } + ); +} + +export async function unshareCustomerContact( + customerId: string, + contactId: string, + shareId: string +) { + return apiClient( + `/crm/customers/${customerId}/contacts/${contactId}/shares/${shareId}`, + { + method: 'DELETE' + } + ); +} diff --git a/src/features/crm/customers/api/types.ts b/src/features/crm/customers/api/types.ts index 046e019..c95c8d5 100644 --- a/src/features/crm/customers/api/types.ts +++ b/src/features/crm/customers/api/types.ts @@ -36,6 +36,11 @@ export interface CustomerRecord { leadChannel: string | null; customerGroup: string | null; customerSubGroup: string | null; + ownerUserId: string | null; + ownerAssignedAt: string | null; + ownerAssignedBy: string | null; + ownerName: string | null; + ownerAssignedByName: string | null; notes: string | null; isActive: boolean; createdAt: string; @@ -49,6 +54,38 @@ export interface CustomerListItem extends CustomerRecord { 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 { id: string; organizationId: string; @@ -67,6 +104,7 @@ export interface CustomerContactRecord { deletedAt: string | null; createdBy: string; updatedBy: string; + shares: ContactShareRecord[]; } export interface CustomerActivityRecord { @@ -95,6 +133,7 @@ export interface CustomerReferenceData { leadChannels: CustomerOption[]; customerGroups: CustomerOption[]; customerSubGroups: CustomerOption[]; + crmUsers: CustomerUserLookup[]; } export interface CustomerFilters { @@ -104,6 +143,8 @@ export interface CustomerFilters { customerStatus?: string; customerType?: string; branch?: string; + ownerUserId?: string; + myCustomers?: string; sort?: string; } @@ -123,6 +164,7 @@ export interface CustomerDetailResponse { message: string; customer: CustomerRecord; activity: CustomerActivityRecord[]; + ownerHistory: CustomerOwnerHistoryRecord[]; } export interface CustomerContactsResponse { @@ -132,6 +174,12 @@ export interface CustomerContactsResponse { items: CustomerContactRecord[]; } +export interface CustomerContactSharesResponse { + success: boolean; + message: string; + items: ContactShareRecord[]; +} + export interface CustomerMutationPayload { name: string; abbr?: string; @@ -168,6 +216,16 @@ export interface CustomerContactMutationPayload { isActive?: boolean; } +export interface CustomerOwnerMutationPayload { + ownerUserId?: string | null; + remark?: string | null; +} + +export interface ContactShareMutationPayload { + sharedToUserId: string; + remark?: string | null; +} + export interface MutationSuccessResponse { success: boolean; message: string; diff --git a/src/features/crm/customers/components/contact-share-sheet.tsx b/src/features/crm/customers/components/contact-share-sheet.tsx new file mode 100644 index 0000000..a38bdfd --- /dev/null +++ b/src/features/crm/customers/components/contact-share-sheet.tsx @@ -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 ( + + + + Shared With + จัดการผู้ใช้ที่เข้าถึง contact นี้ได้ + +
+ {!contact?.shares.length ? ( +
ยังไม่มีการแชร์ contact นี้
+ ) : ( +
+ {contact.shares.map((share) => ( +
+
+
{share.sharedToName ?? share.sharedToUserId}
+
+ {formatDateTime(share.sharedAt)} โดย {share.sharedByName ?? share.sharedByUserId} +
+ {share.remark ?
{share.remark}
: null} +
+ {canManage ? ( + + ) : null} +
+ ))} +
+ )} + {canManage ? ( +
+ +