task-c.1
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { unshareCustomerContact } from '@/features/crm/customers/server/service';
|
||||
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string; contactId: string; shareId: string }>;
|
||||
};
|
||||
|
||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id, contactId, shareId } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmContactShareDelete
|
||||
});
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const removed = await unshareCustomerContact(
|
||||
id,
|
||||
contactId,
|
||||
shareId,
|
||||
organization.id,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_contact_share',
|
||||
entityId: shareId,
|
||||
action: 'unshare',
|
||||
beforeData: {
|
||||
customerId: id,
|
||||
contactId,
|
||||
sharedUser: removed.sharedToUserId
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Contact share removed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
console.error(
|
||||
'DELETE /api/crm/customers/[id]/contacts/[contactId]/shares/[shareId] failed',
|
||||
error
|
||||
);
|
||||
return NextResponse.json({ message: 'Unable to remove contact share' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
listCustomerContactShares,
|
||||
shareCustomerContact
|
||||
} from '@/features/crm/customers/server/service';
|
||||
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string; contactId: string }>;
|
||||
};
|
||||
|
||||
const shareSchema = z.object({
|
||||
sharedToUserId: z.string().min(1, 'Shared user is required'),
|
||||
remark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id, contactId } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmContactShareRead
|
||||
});
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const items = await listCustomerContactShares(
|
||||
id,
|
||||
contactId,
|
||||
organization.id,
|
||||
accessContext
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Contact shares loaded successfully',
|
||||
items
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
console.error('GET /api/crm/customers/[id]/contacts/[contactId]/shares failed', error);
|
||||
return NextResponse.json({ message: 'Unable to load contact shares' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id, contactId } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmContactShareCreate
|
||||
});
|
||||
const payload = shareSchema.parse(await request.json());
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const created = await shareCustomerContact(
|
||||
id,
|
||||
contactId,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_contact_share',
|
||||
entityId: created.id,
|
||||
action: 'share',
|
||||
afterData: {
|
||||
customerId: id,
|
||||
contactId,
|
||||
sharedUser: payload.sharedToUserId,
|
||||
remark: payload.remark ?? null
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Contact shared successfully'
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error('POST /api/crm/customers/[id]/contacts/[contactId]/shares failed', error);
|
||||
return NextResponse.json({ message: 'Unable to share contact' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
146
src/app/api/crm/customers/[id]/owner/route.ts
Normal file
146
src/app/api/crm/customers/[id]/owner/route.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
assignCustomerOwner,
|
||||
clearCustomerOwner,
|
||||
getCustomerDetail
|
||||
} from '@/features/crm/customers/server/service';
|
||||
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
const ownerSchema = z.object({
|
||||
ownerUserId: z.string().min(1, 'Owner user is required'),
|
||||
remark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
const clearOwnerSchema = z.object({
|
||||
remark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmCustomerOwnerManage
|
||||
});
|
||||
const payload = ownerSchema.parse(await request.json());
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const before = await getCustomerDetail(id, organization.id, accessContext);
|
||||
const updated = await assignCustomerOwner(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
branchId: updated.branchId,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_customer_owner',
|
||||
entityId: id,
|
||||
action: before.ownerUserId ? 'change_owner' : 'assign_owner',
|
||||
beforeData: {
|
||||
customerId: id,
|
||||
oldOwnerUserId: before.ownerUserId
|
||||
},
|
||||
afterData: {
|
||||
customerId: id,
|
||||
newOwnerUserId: payload.ownerUserId,
|
||||
remark: payload.remark ?? null
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Customer owner updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error('PATCH /api/crm/customers/[id]/owner failed', error);
|
||||
return NextResponse.json({ message: 'Unable to update customer owner' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmCustomerOwnerManage
|
||||
});
|
||||
const payload = clearOwnerSchema.parse(
|
||||
request.method === 'DELETE' ? (await request.json().catch(() => ({}))) : {}
|
||||
);
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const before = await getCustomerDetail(id, organization.id, accessContext);
|
||||
const updated = await clearCustomerOwner(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
branchId: updated.branchId,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_customer_owner',
|
||||
entityId: id,
|
||||
action: 'clear_owner',
|
||||
beforeData: {
|
||||
customerId: id,
|
||||
oldOwnerUserId: before.ownerUserId
|
||||
},
|
||||
afterData: {
|
||||
customerId: id,
|
||||
newOwnerUserId: null,
|
||||
remark: payload.remark ?? null
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Customer owner cleared successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error('DELETE /api/crm/customers/[id]/owner failed', error);
|
||||
return NextResponse.json({ message: 'Unable to clear customer owner' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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<CustomerOwnerMutationPayload, 'remark'> }) =>
|
||||
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)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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<CustomerLi
|
||||
if (filters.customerStatus) searchParams.set('customerStatus', filters.customerStatus);
|
||||
if (filters.customerType) searchParams.set('customerType', filters.customerType);
|
||||
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);
|
||||
|
||||
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> {
|
||||
return apiClient<CustomerContactsResponse>(`/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<CustomerContactSharesResponse> {
|
||||
return apiClient<CustomerContactSharesResponse>(
|
||||
`/crm/customers/${customerId}/contacts/${contactId}/shares`
|
||||
);
|
||||
}
|
||||
|
||||
export async function shareCustomerContact(
|
||||
customerId: string,
|
||||
contactId: string,
|
||||
data: ContactShareMutationPayload
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(
|
||||
`/crm/customers/${customerId}/contacts/${contactId}/shares`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function unshareCustomerContact(
|
||||
customerId: string,
|
||||
contactId: string,
|
||||
shareId: string
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(
|
||||
`/crm/customers/${customerId}/contacts/${contactId}/shares/${shareId}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ export interface CustomerRecord {
|
||||
leadChannel: string | null;
|
||||
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;
|
||||
|
||||
173
src/features/crm/customers/components/contact-share-sheet.tsx
Normal file
173
src/features/crm/customers/components/contact-share-sheet.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
shareCustomerContactMutation,
|
||||
unshareCustomerContactMutation
|
||||
} from '../api/mutations';
|
||||
import type { CustomerContactRecord, CustomerReferenceData } from '../api/types';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
export function ContactShareSheet({
|
||||
customerId,
|
||||
contact,
|
||||
referenceData,
|
||||
open,
|
||||
onOpenChange,
|
||||
canManage
|
||||
}: {
|
||||
customerId: string;
|
||||
contact: CustomerContactRecord | null;
|
||||
referenceData: CustomerReferenceData;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const [sharedToUserId, setSharedToUserId] = useState('');
|
||||
const [remark, setRemark] = useState('');
|
||||
const existingSharedIds = useMemo(
|
||||
() => new Set(contact?.shares.map((item) => item.sharedToUserId) ?? []),
|
||||
[contact]
|
||||
);
|
||||
const candidates = useMemo(
|
||||
() => referenceData.crmUsers.filter((user) => !existingSharedIds.has(user.id)),
|
||||
[existingSharedIds, referenceData.crmUsers]
|
||||
);
|
||||
|
||||
const shareMutation = useMutation({
|
||||
...shareCustomerContactMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('แชร์ contact เรียบร้อย');
|
||||
setSharedToUserId('');
|
||||
setRemark('');
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถแชร์ contact ได้')
|
||||
});
|
||||
const unshareMutation = useMutation({
|
||||
...unshareCustomerContactMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('ยกเลิกการแชร์เรียบร้อย');
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถยกเลิกการแชร์ได้')
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Shared With</DialogTitle>
|
||||
<DialogDescription>จัดการผู้ใช้ที่เข้าถึง contact นี้ได้</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
{!contact?.shares.length ? (
|
||||
<div className='text-muted-foreground text-sm'>ยังไม่มีการแชร์ contact นี้</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
{contact.shares.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className='flex items-start justify-between gap-3 rounded-lg border p-3'
|
||||
>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<div className='font-medium'>{share.sharedToName ?? share.sharedToUserId}</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{formatDateTime(share.sharedAt)} โดย {share.sharedByName ?? share.sharedByUserId}
|
||||
</div>
|
||||
{share.remark ? <div className='text-xs'>{share.remark}</div> : null}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
unshareMutation.mutate({
|
||||
customerId,
|
||||
contactId: contact.id,
|
||||
shareId: share.id
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icons.trash className='mr-2 h-4 w-4' />
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{canManage ? (
|
||||
<div className='space-y-3 border-t pt-4'>
|
||||
<Select value={sharedToUserId} onValueChange={setSharedToUserId}>
|
||||
<SelectTrigger aria-label='Shared user'>
|
||||
<SelectValue placeholder='Select user' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidates.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
value={remark}
|
||||
onChange={(event) => setRemark(event.target.value)}
|
||||
placeholder='Remark'
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
ปิด
|
||||
</Button>
|
||||
{canManage ? (
|
||||
<Button
|
||||
isLoading={shareMutation.isPending}
|
||||
disabled={!contact || !sharedToUserId}
|
||||
onClick={() => {
|
||||
if (!contact) {
|
||||
return;
|
||||
}
|
||||
|
||||
shareMutation.mutate({
|
||||
customerId,
|
||||
contactId: contact.id,
|
||||
values: {
|
||||
sharedToUserId,
|
||||
remark
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
Share
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export function getCustomerColumns({
|
||||
const statusMap = new Map(referenceData.customerStatuses.map((item) => [item.id, item]));
|
||||
const groupMap = new Map(referenceData.customerGroups.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 [
|
||||
{
|
||||
@@ -133,6 +134,31 @@ export function getCustomerColumns({
|
||||
),
|
||||
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',
|
||||
accessorKey: 'contactCount',
|
||||
|
||||
@@ -11,22 +11,31 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { deleteCustomerContactMutation } from '../api/mutations';
|
||||
import { customerContactsOptions } from '../api/queries';
|
||||
import { ContactFormSheet } from './contact-form-sheet';
|
||||
import { ContactShareSheet } from './contact-share-sheet';
|
||||
import type { CustomerReferenceData } from '../api/types';
|
||||
|
||||
export function CustomerContactsTab({
|
||||
customerId,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canDelete
|
||||
canDelete,
|
||||
canShareRead,
|
||||
canShareManage,
|
||||
referenceData
|
||||
}: {
|
||||
customerId: string;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canShareRead: boolean;
|
||||
canShareManage: boolean;
|
||||
referenceData: CustomerReferenceData;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(customerContactsOptions(customerId));
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const selectedContact = data.items.find((contact) => contact.id === selectedId);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -84,6 +93,14 @@ export function CustomerContactsTab({
|
||||
{[contact.phone, contact.mobile, contact.email].filter(Boolean).join(' • ') ||
|
||||
'ยังไม่มีช่องทางติดต่อ'}
|
||||
</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}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
@@ -98,6 +115,18 @@ export function CustomerContactsTab({
|
||||
>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> แก้ไข
|
||||
</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
|
||||
variant='outline'
|
||||
size='sm'
|
||||
@@ -121,6 +150,14 @@ export function CustomerContactsTab({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
<ContactShareSheet
|
||||
customerId={customerId}
|
||||
contact={selectedContact ?? null}
|
||||
referenceData={referenceData}
|
||||
open={shareOpen}
|
||||
onOpenChange={setShareOpen}
|
||||
canManage={canShareManage}
|
||||
/>
|
||||
<AlertModal
|
||||
isOpen={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { customerByIdOptions } from '../api/queries';
|
||||
import type { CustomerReferenceData } from '../api/types';
|
||||
import { CustomerFormSheet } from './customer-form-sheet';
|
||||
import { CustomerContactsTab } from './customer-contacts-tab';
|
||||
import { CustomerOwnerCard } from './customer-owner-card';
|
||||
import { CustomerStatusBadge } from './customer-status-badge';
|
||||
import type { CustomerRelatedEnquiryItem } from '../api/types';
|
||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||
@@ -33,6 +34,9 @@ export function CustomerDetail({
|
||||
canUpdate,
|
||||
canReadContacts,
|
||||
canManageContacts,
|
||||
canReadContactShares,
|
||||
canManageContactShares,
|
||||
canManageOwner,
|
||||
relatedEnquiries,
|
||||
relatedQuotations
|
||||
}: {
|
||||
@@ -45,6 +49,9 @@ export function CustomerDetail({
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
canReadContactShares: boolean;
|
||||
canManageContactShares: boolean;
|
||||
canManageOwner: boolean;
|
||||
relatedEnquiries: CustomerRelatedEnquiryItem[];
|
||||
relatedQuotations: QuotationRelationItem[];
|
||||
}) {
|
||||
@@ -182,6 +189,9 @@ export function CustomerDetail({
|
||||
canCreate={canManageContacts.create}
|
||||
canUpdate={canManageContacts.update}
|
||||
canDelete={canManageContacts.delete}
|
||||
canShareRead={canReadContactShares}
|
||||
canShareManage={canManageContactShares}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
@@ -280,6 +290,11 @@ export function CustomerDetail({
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<CustomerOwnerCard
|
||||
customerId={customerId}
|
||||
referenceData={referenceData}
|
||||
canManage={canManageOwner}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function CustomerListing({
|
||||
const customerStatus = searchParamsCache.get('customerStatus');
|
||||
const customerType = searchParamsCache.get('customerType');
|
||||
const branch = searchParamsCache.get('branch');
|
||||
const ownerUserId = searchParamsCache.get('ownerUserId');
|
||||
const sort = searchParamsCache.get('sort');
|
||||
const filters = {
|
||||
page,
|
||||
@@ -28,6 +29,7 @@ export default function CustomerListing({
|
||||
...(customerStatus && { customerStatus }),
|
||||
...(customerType && { customerType }),
|
||||
...(branch && { branch }),
|
||||
...(ownerUserId && { ownerUserId }),
|
||||
...(sort && { sort })
|
||||
};
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
187
src/features/crm/customers/components/customer-owner-card.tsx
Normal file
187
src/features/crm/customers/components/customer-owner-card.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { assignCustomerOwnerMutation, clearCustomerOwnerMutation } from '../api/mutations';
|
||||
import { customerByIdOptions } from '../api/queries';
|
||||
import type { CustomerReferenceData } from '../api/types';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
export function CustomerOwnerCard({
|
||||
customerId,
|
||||
referenceData,
|
||||
canManage
|
||||
}: {
|
||||
customerId: string;
|
||||
referenceData: CustomerReferenceData;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedOwnerUserId, setSelectedOwnerUserId] = useState('');
|
||||
const [remark, setRemark] = useState('');
|
||||
const customer = data.customer;
|
||||
const ownerCandidates = useMemo(
|
||||
() =>
|
||||
referenceData.crmUsers.filter((user) =>
|
||||
['sales', 'sales_manager', 'crm_admin'].includes(user.businessRole)
|
||||
),
|
||||
[referenceData.crmUsers]
|
||||
);
|
||||
|
||||
const assignMutation = useMutation({
|
||||
...assignCustomerOwnerMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('บันทึกเจ้าของลูกค้าเรียบร้อย');
|
||||
setOpen(false);
|
||||
setRemark('');
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถบันทึกเจ้าของลูกค้าได้')
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
...clearCustomerOwnerMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('ล้างเจ้าของลูกค้าเรียบร้อย');
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถล้างเจ้าของลูกค้าได้')
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Customer Owner</CardTitle>
|
||||
<CardDescription>ผู้ดูแลความสัมพันธ์หลักของลูกค้ารายนี้</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>Owner</div>
|
||||
<div className='text-sm font-medium'>{customer.ownerName ?? '-'}</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>Assigned At</div>
|
||||
<div className='text-sm'>
|
||||
{customer.ownerAssignedAt ? formatDateTime(customer.ownerAssignedAt) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>Assigned By</div>
|
||||
<div className='text-sm'>{customer.ownerAssignedByName ?? '-'}</div>
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setSelectedOwnerUserId(customer.ownerUserId ?? ownerCandidates[0]?.id ?? '');
|
||||
setRemark('');
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.edit className='mr-2 h-4 w-4' />
|
||||
{customer.ownerUserId ? 'Change Owner' : 'Assign Owner'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={!customer.ownerUserId || clearMutation.isPending}
|
||||
onClick={() => clearMutation.mutate({ id: customerId })}
|
||||
>
|
||||
<Icons.trash className='mr-2 h-4 w-4' />
|
||||
Clear Owner
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className='space-y-3 border-t pt-4'>
|
||||
<div className='text-sm font-medium'>Owner History</div>
|
||||
{!data.ownerHistory.length ? (
|
||||
<div className='text-muted-foreground text-sm'>ยังไม่มีประวัติการเปลี่ยน owner</div>
|
||||
) : (
|
||||
data.ownerHistory.slice(0, 5).map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-3 text-sm'>
|
||||
<div>
|
||||
{item.oldOwnerName ?? '-'} {'->'} {item.newOwnerName ?? '-'}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{formatDateTime(item.changedAt)} โดย {item.changedByName ?? item.changedBy}
|
||||
</div>
|
||||
{item.remark ? <div className='mt-1 text-xs'>{item.remark}</div> : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{customer.ownerUserId ? 'Change Owner' : 'Assign Owner'}</DialogTitle>
|
||||
<DialogDescription>เลือกผู้รับผิดชอบหลักของลูกค้ารายนี้</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
<Select value={selectedOwnerUserId} onValueChange={setSelectedOwnerUserId}>
|
||||
<SelectTrigger aria-label='Customer owner'>
|
||||
<SelectValue placeholder='Select owner' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ownerCandidates.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
value={remark}
|
||||
onChange={(event) => setRemark(event.target.value)}
|
||||
placeholder='Remark'
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => setOpen(false)}>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={assignMutation.isPending}
|
||||
disabled={!selectedOwnerUserId}
|
||||
onClick={() =>
|
||||
assignMutation.mutate({
|
||||
id: customerId,
|
||||
values: { ownerUserId: selectedOwnerUserId, remark }
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export function CustomersTable({
|
||||
customerStatus: parseAsString,
|
||||
customerType: parseAsString,
|
||||
branch: parseAsString,
|
||||
ownerUserId: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
@@ -42,6 +43,7 @@ export function CustomersTable({
|
||||
...(params.customerStatus && { customerStatus: params.customerStatus }),
|
||||
...(params.customerType && { customerType: params.customerType }),
|
||||
...(params.branch && { branch: params.branch }),
|
||||
...(params.ownerUserId && { ownerUserId: params.ownerUserId }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import { 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 { AuthError } from '@/lib/auth/session';
|
||||
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 { generateNextDocumentCode } from '@/features/foundation/document-sequence/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 {
|
||||
BranchOption,
|
||||
CustomerActivityRecord,
|
||||
@@ -17,12 +31,16 @@ import type {
|
||||
CustomerListItem,
|
||||
CustomerMutationPayload,
|
||||
CustomerOption,
|
||||
CustomerOwnerHistoryRecord,
|
||||
CustomerOwnerMutationPayload,
|
||||
CustomerRecord,
|
||||
CustomerReferenceData
|
||||
} from '../api/types';
|
||||
|
||||
type CustomerRecordSource = Omit<typeof crmCustomers.$inferSelect, 'customerSubGroup'> & {
|
||||
customerSubGroup?: string | null;
|
||||
ownerName?: string | null;
|
||||
ownerAssignedByName?: string | null;
|
||||
};
|
||||
|
||||
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 {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -94,6 +147,11 @@ function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord {
|
||||
leadChannel: row.leadChannel,
|
||||
customerGroup: row.customerGroup,
|
||||
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,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
@@ -127,6 +185,9 @@ const baseCustomerRecordSelection = {
|
||||
leadChannel: crmCustomers.leadChannel,
|
||||
customerGroup: crmCustomers.customerGroup,
|
||||
notes: crmCustomers.notes,
|
||||
ownerUserId: crmCustomers.ownerUserId,
|
||||
ownerAssignedAt: crmCustomers.ownerAssignedAt,
|
||||
ownerAssignedBy: crmCustomers.ownerAssignedBy,
|
||||
isActive: crmCustomers.isActive,
|
||||
createdAt: crmCustomers.createdAt,
|
||||
updatedAt: crmCustomers.updatedAt,
|
||||
@@ -165,7 +226,8 @@ async function hasCustomerSubGroupColumn() {
|
||||
}
|
||||
|
||||
function mapCustomerContactRecord(
|
||||
row: typeof crmCustomerContacts.$inferSelect
|
||||
row: typeof crmCustomerContacts.$inferSelect,
|
||||
shares: CustomerContactRecord['shares'] = []
|
||||
): CustomerContactRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -184,7 +246,8 @@ function mapCustomerContactRecord(
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy
|
||||
updatedBy: row.updatedBy,
|
||||
shares
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,6 +336,93 @@ async function assertCustomerBelongsToOrganization(id: string, organizationId: s
|
||||
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(
|
||||
organizationId: string,
|
||||
accessContext: CustomerAccessContext
|
||||
@@ -353,8 +503,19 @@ async function resolveAccessibleCustomerRelationSets(
|
||||
|
||||
async function canAccessCustomerRow(
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
@@ -367,10 +528,9 @@ async function canAccessCustomerRow(
|
||||
return true;
|
||||
}
|
||||
|
||||
const { enquiryCustomerIds, quotationCustomerIds } = await resolveAccessibleCustomerRelationSets(
|
||||
row.organizationId,
|
||||
accessContext
|
||||
);
|
||||
const { enquiryCustomerIds, quotationCustomerIds } =
|
||||
relationSets ??
|
||||
(await resolveAccessibleCustomerRelationSets(row.organizationId, accessContext));
|
||||
|
||||
if (accessContext.ownershipScope === 'monitor') {
|
||||
return enquiryCustomerIds.has(row.id);
|
||||
@@ -378,6 +538,7 @@ async function canAccessCustomerRow(
|
||||
|
||||
return (
|
||||
row.createdBy === accessContext.userId ||
|
||||
row.ownerUserId === accessContext.userId ||
|
||||
enquiryCustomerIds.has(row.id) ||
|
||||
quotationCustomerIds.has(row.id)
|
||||
);
|
||||
@@ -397,6 +558,41 @@ async function assertCustomerAccess(
|
||||
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(
|
||||
contactId: string,
|
||||
customerId: string,
|
||||
@@ -477,6 +673,7 @@ function buildCustomerFilters(organizationId: string, filters: CustomerFilters):
|
||||
const statuses = splitFilterValue(filters.customerStatus);
|
||||
const customerTypes = splitFilterValue(filters.customerType);
|
||||
const branches = splitFilterValue(filters.branch);
|
||||
const owners = splitFilterValue(filters.ownerUserId);
|
||||
|
||||
return [
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
@@ -484,6 +681,7 @@ function buildCustomerFilters(organizationId: string, filters: CustomerFilters):
|
||||
...(statuses.length ? [inArray(crmCustomers.customerStatus, statuses)] : []),
|
||||
...(customerTypes.length ? [inArray(crmCustomers.customerType, customerTypes)] : []),
|
||||
...(branches.length ? [inArray(crmCustomers.branchId, branches)] : []),
|
||||
...(owners.length ? [inArray(crmCustomers.ownerUserId, owners)] : []),
|
||||
...(filters.search
|
||||
? [
|
||||
or(
|
||||
@@ -501,7 +699,15 @@ function buildCustomerFilters(organizationId: string, filters: CustomerFilters):
|
||||
export async function getCustomerReferenceData(
|
||||
organizationId: string
|
||||
): Promise<CustomerReferenceData> {
|
||||
const [branches, customerTypes, customerStatuses, leadChannels, customerGroups, customerSubGroups] =
|
||||
const [
|
||||
branches,
|
||||
customerTypes,
|
||||
customerStatuses,
|
||||
leadChannels,
|
||||
customerGroups,
|
||||
customerSubGroups,
|
||||
crmUsers
|
||||
] =
|
||||
await Promise.all([
|
||||
getUserBranches(),
|
||||
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerType, { organizationId }),
|
||||
@@ -520,6 +726,8 @@ export async function getCustomerReferenceData(
|
||||
)
|
||||
)
|
||||
.orderBy(asc(msOptions.sortOrder), asc(msOptions.label))
|
||||
,
|
||||
listCrmUsersForOrganization(organizationId)
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -528,7 +736,8 @@ export async function getCustomerReferenceData(
|
||||
customerStatuses: customerStatuses.map(mapCustomerOption),
|
||||
leadChannels: leadChannels.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 limit = filters.limit ?? 10;
|
||||
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 offset = (page - 1) * limit;
|
||||
|
||||
const rows = await db
|
||||
.select(getCustomerRecordSelection(includeCustomerSubGroup))
|
||||
.from(crmCustomers)
|
||||
.where(where)
|
||||
.orderBy(parseSort(filters.sort));
|
||||
const rows = await db.execute<CustomerRecordSource>(sql`
|
||||
select
|
||||
c.id,
|
||||
c.organization_id as "organizationId",
|
||||
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
|
||||
? (
|
||||
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)
|
||||
: rows;
|
||||
@@ -593,7 +847,22 @@ export async function getCustomerDetail(
|
||||
accessContext?: CustomerAccessContext
|
||||
): Promise<CustomerRecord> {
|
||||
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(
|
||||
@@ -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(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
@@ -674,6 +976,105 @@ export async function createCustomer(
|
||||
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(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
@@ -761,7 +1162,7 @@ export async function listCustomerContacts(
|
||||
organizationId: string,
|
||||
accessContext?: CustomerAccessContext
|
||||
) {
|
||||
await assertCustomerAccess(id, organizationId, accessContext);
|
||||
const customer = await assertCustomerBelongsToOrganization(id, organizationId);
|
||||
const rows = await db.query.crmCustomerContacts.findMany({
|
||||
where: and(
|
||||
eq(crmCustomerContacts.customerId, id),
|
||||
@@ -770,17 +1171,31 @@ export async function listCustomerContacts(
|
||||
),
|
||||
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
|
||||
.filter(
|
||||
(row) =>
|
||||
!accessContext ||
|
||||
row.createdBy === accessContext.userId ||
|
||||
accessContext.membershipRole === 'admin' ||
|
||||
accessContext.ownershipScope === 'organization' ||
|
||||
accessContext.ownershipScope === 'team'
|
||||
)
|
||||
.map(mapCustomerContactRecord);
|
||||
if (accessContext && !scopedRows.length) {
|
||||
const canViewCustomer = await canAccessCustomerRow(customer, accessContext);
|
||||
|
||||
if (!canViewCustomer) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
}
|
||||
|
||||
return scopedRows.map((row) => mapCustomerContactRecord(row, shareMap.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
export async function createCustomerContact(
|
||||
@@ -842,8 +1257,7 @@ export async function updateCustomerContact(
|
||||
payload: CustomerContactMutationPayload,
|
||||
accessContext?: CustomerAccessContext
|
||||
) {
|
||||
await assertCustomerAccess(customerId, organizationId, accessContext);
|
||||
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
||||
await assertContactAccess(customerId, contactId, organizationId, accessContext);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
if (payload.isPrimary) {
|
||||
@@ -892,8 +1306,7 @@ export async function softDeleteCustomerContact(
|
||||
userId: string,
|
||||
accessContext?: CustomerAccessContext
|
||||
) {
|
||||
await assertCustomerAccess(customerId, organizationId, accessContext);
|
||||
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
||||
await assertContactAccess(customerId, contactId, organizationId, accessContext);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmCustomerContacts)
|
||||
@@ -910,6 +1323,103 @@ export async function softDeleteCustomerContact(
|
||||
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(
|
||||
organizationId: string,
|
||||
category: string,
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface EnquiryCustomerLookup {
|
||||
code: string;
|
||||
name: string;
|
||||
branchId: string | null;
|
||||
ownerUserId: string | null;
|
||||
ownerName: string | null;
|
||||
}
|
||||
|
||||
export interface EnquiryContactLookup {
|
||||
@@ -198,6 +200,7 @@ export interface EnquiryProjectPartyMutationPayload {
|
||||
export interface EnquiryMutationPayload {
|
||||
customerId: string;
|
||||
contactId?: string | null;
|
||||
assignedToUserId?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
requirement?: string;
|
||||
|
||||
@@ -38,6 +38,7 @@ function toDefaultValues(
|
||||
return {
|
||||
customerId: enquiry?.customerId ?? referenceData.customers[0]?.id ?? '',
|
||||
contactId: enquiry?.contactId ?? undefined,
|
||||
assignedToUserId: enquiry?.assignedToUserId ?? undefined,
|
||||
title: enquiry?.title ?? '',
|
||||
description: enquiry?.description ?? '',
|
||||
requirement: enquiry?.requirement ?? '',
|
||||
@@ -110,6 +111,7 @@ export function EnquiryFormSheet({
|
||||
const contactsForCustomer = referenceData.contacts.filter(
|
||||
(contact) => contact.customerId === selectedCustomerId
|
||||
);
|
||||
const selectedCustomer = referenceData.customers.find((customer) => customer.id === selectedCustomerId);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
@@ -120,6 +122,7 @@ export function EnquiryFormSheet({
|
||||
const payload: EnquiryMutationPayload = {
|
||||
customerId: value.customerId,
|
||||
contactId: value.contactId || null,
|
||||
assignedToUserId: value.assignedToUserId || null,
|
||||
title: value.title,
|
||||
description: value.description,
|
||||
requirement: value.requirement,
|
||||
@@ -177,6 +180,17 @@ export function EnquiryFormSheet({
|
||||
);
|
||||
}, [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 recordLabel = workspace === 'lead' ? 'ลีด' : 'โอกาสขาย';
|
||||
@@ -262,6 +276,19 @@ export function EnquiryFormSheet({
|
||||
</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
|
||||
name='title'
|
||||
|
||||
@@ -27,6 +27,7 @@ function coerceOptionalNumber(message: string) {
|
||||
export const enquirySchema = z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
assignedToUserId: z.string().optional().nullable(),
|
||||
title: z.string().min(2, 'Title must be at least 2 characters'),
|
||||
description: z.string().optional(),
|
||||
requirement: z.string().optional(),
|
||||
|
||||
@@ -488,6 +488,10 @@ async function validateEnquiryPayload(
|
||||
throw new AuthError('Forbidden product scope', 403);
|
||||
}
|
||||
|
||||
if (payload.assignedToUserId) {
|
||||
await assertAssignableUserBelongsToOrganization(payload.assignedToUserId, organizationId);
|
||||
}
|
||||
|
||||
for (const projectParty of payload.projectParties ?? []) {
|
||||
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
|
||||
await assertMasterOptionValue(
|
||||
@@ -623,7 +627,13 @@ export async function getEnquiryReferenceData(
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
|
||||
db
|
||||
.select()
|
||||
.select({
|
||||
id: crmCustomers.id,
|
||||
code: crmCustomers.code,
|
||||
name: crmCustomers.name,
|
||||
branchId: crmCustomers.branchId,
|
||||
ownerUserId: crmCustomers.ownerUserId
|
||||
})
|
||||
.from(crmCustomers)
|
||||
.where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)))
|
||||
.orderBy(asc(crmCustomers.name)),
|
||||
@@ -639,6 +649,14 @@ export async function getEnquiryReferenceData(
|
||||
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)),
|
||||
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 {
|
||||
branches: branches.map(mapBranch),
|
||||
@@ -652,7 +670,9 @@ export async function getEnquiryReferenceData(
|
||||
id: customer.id,
|
||||
code: customer.code,
|
||||
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) => ({
|
||||
id: contact.id,
|
||||
@@ -1063,6 +1083,10 @@ export async function createEnquiry(
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isActive: payload.isActive ?? true,
|
||||
pipelineStage,
|
||||
assignedToUserId: payload.assignedToUserId ?? null,
|
||||
assignedAt: payload.assignedToUserId ? new Date() : null,
|
||||
assignedBy: payload.assignedToUserId ? userId : null,
|
||||
assignmentRemark: null,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
|
||||
@@ -86,6 +86,63 @@ export function canAccessScopedCrmRecord(
|
||||
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: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
|
||||
@@ -38,10 +38,16 @@ export const PERMISSIONS = {
|
||||
crmCustomerCreate: 'crm.customer.create',
|
||||
crmCustomerUpdate: 'crm.customer.update',
|
||||
crmCustomerDelete: 'crm.customer.delete',
|
||||
crmCustomerOwnerRead: 'crm.customer.owner.read',
|
||||
crmCustomerOwnerManage: 'crm.customer.owner.manage',
|
||||
crmContactRead: 'crm.contact.read',
|
||||
crmContactCreate: 'crm.contact.create',
|
||||
crmContactUpdate: 'crm.contact.update',
|
||||
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',
|
||||
crmEnquiryCreate: 'crm.enquiry.create',
|
||||
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.',
|
||||
permissions: [
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmCustomerOwnerRead,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryCreate,
|
||||
@@ -193,6 +200,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
description: 'Quotation drafting and operational support without approval authority.',
|
||||
permissions: [
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmCustomerOwnerRead,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
@@ -234,9 +242,15 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmCustomerCreate,
|
||||
PERMISSIONS.crmCustomerUpdate,
|
||||
PERMISSIONS.crmCustomerOwnerRead,
|
||||
PERMISSIONS.crmCustomerOwnerManage,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmContactCreate,
|
||||
PERMISSIONS.crmContactUpdate,
|
||||
PERMISSIONS.crmContactShareRead,
|
||||
PERMISSIONS.crmContactShareCreate,
|
||||
PERMISSIONS.crmContactShareDelete,
|
||||
PERMISSIONS.crmContactShareManage,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryCreate,
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
@@ -282,7 +296,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
permissions: [
|
||||
PERMISSIONS.crmLeadRead,
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmCustomerOwnerRead,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmContactShareRead,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryAssign,
|
||||
PERMISSIONS.crmEnquiryReassign,
|
||||
@@ -315,7 +331,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
permissions: [
|
||||
PERMISSIONS.crmLeadRead,
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmCustomerOwnerRead,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmContactShareRead,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryAssign,
|
||||
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.',
|
||||
permissions: [
|
||||
PERMISSIONS.crmDashboardRead,
|
||||
PERMISSIONS.crmCustomerOwnerRead,
|
||||
PERMISSIONS.crmCustomerOwnerManage,
|
||||
PERMISSIONS.crmContactShareRead,
|
||||
PERMISSIONS.crmContactShareCreate,
|
||||
PERMISSIONS.crmContactShareDelete,
|
||||
PERMISSIONS.crmContactShareManage,
|
||||
PERMISSIONS.crmRoleRead,
|
||||
PERMISSIONS.crmRoleCreate,
|
||||
PERMISSIONS.crmRoleUpdate,
|
||||
@@ -406,6 +430,32 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{ 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',
|
||||
label: 'Enquiries',
|
||||
|
||||
@@ -14,6 +14,8 @@ export const searchParams = {
|
||||
customerType: parseAsString,
|
||||
customerStatus: parseAsString,
|
||||
customer: parseAsString,
|
||||
ownerUserId: parseAsString,
|
||||
myCustomers: parseAsString,
|
||||
enquiry: parseAsString,
|
||||
branch: parseAsString,
|
||||
productType: parseAsString,
|
||||
|
||||
Reference in New Issue
Block a user