task h.1 adn fix permission
This commit is contained in:
@@ -3,7 +3,7 @@ CREATE TABLE "memberships" (
|
||||
"user_id" text NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"role" text DEFAULT 'user' NOT NULL,
|
||||
"business_role" text DEFAULT 'viewer' NOT NULL,
|
||||
"business_role" text DEFAULT 'sales_support' NOT NULL,
|
||||
"permissions" text[] DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
@@ -45,4 +45,4 @@ CREATE TABLE "users" (
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "organizations_slug_idx" ON "organizations" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||
|
||||
5
drizzle/0008_clean_justin_hammer.sql
Normal file
5
drizzle/0008_clean_justin_hammer.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "crm_enquiries" ADD COLUMN "assigned_to_user_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "crm_enquiries" ADD COLUMN "assigned_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "crm_enquiries" ADD COLUMN "assigned_by" text;--> statement-breakpoint
|
||||
ALTER TABLE "crm_enquiries" ADD COLUMN "assignment_remark" text;--> statement-breakpoint
|
||||
ALTER TABLE "memberships" ALTER COLUMN "business_role" SET DEFAULT 'sales_support';
|
||||
3019
drizzle/meta/0008_snapshot.json
Normal file
3019
drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
||||
"when": 1781589458455,
|
||||
"tag": "0007_luxuriant_malice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1781595962545,
|
||||
"tag": "0008_clean_justin_hammer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
6
src/app/api/crm/enquiries/[id]/_schemas.ts
Normal file
6
src/app/api/crm/enquiries/[id]/_schemas.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const enquiryAssignmentRequestSchema = z.object({
|
||||
assignedToUserId: z.string().min(1, 'Sales user is required'),
|
||||
remark: z.string().trim().max(1000).optional()
|
||||
});
|
||||
62
src/app/api/crm/enquiries/[id]/assign/route.ts
Normal file
62
src/app/api/crm/enquiries/[id]/assign/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { assignEnquiryToUser, getEnquiryDetail } from '@/features/crm/enquiries/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
import { enquiryAssignmentRequestSchema } from '../_schemas';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryAssign
|
||||
});
|
||||
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
|
||||
const before = await getEnquiryDetail(id, organization.id);
|
||||
const updated = await assignEnquiryToUser(id, organization.id, session.user.id, payload);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
branchId: updated.branchId,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_enquiry',
|
||||
entityId: id,
|
||||
action: 'assign',
|
||||
beforeData: {
|
||||
oldAssignedToUserId: before.assignedToUserId
|
||||
},
|
||||
afterData: {
|
||||
newAssignedToUserId: updated.assignedToUserId,
|
||||
remark: updated.assignmentRemark
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Enquiry assigned successfully',
|
||||
enquiry: updated
|
||||
});
|
||||
} 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 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to assign enquiry' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
62
src/app/api/crm/enquiries/[id]/reassign/route.ts
Normal file
62
src/app/api/crm/enquiries/[id]/reassign/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { getEnquiryDetail, reassignEnquiryToUser } from '@/features/crm/enquiries/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
import { enquiryAssignmentRequestSchema } from '../_schemas';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryReassign
|
||||
});
|
||||
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
|
||||
const before = await getEnquiryDetail(id, organization.id);
|
||||
const updated = await reassignEnquiryToUser(id, organization.id, session.user.id, payload);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
branchId: updated.branchId,
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_enquiry',
|
||||
entityId: id,
|
||||
action: 'reassign',
|
||||
beforeData: {
|
||||
oldAssignedToUserId: before.assignedToUserId
|
||||
},
|
||||
afterData: {
|
||||
newAssignedToUserId: updated.assignedToUserId,
|
||||
remark: updated.assignmentRemark
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Enquiry reassigned successfully',
|
||||
enquiry: updated
|
||||
});
|
||||
} 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 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to reassign enquiry' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export async function GET(request: NextRequest) {
|
||||
plan: organization.plan,
|
||||
imageUrl: organization.imageUrl,
|
||||
role: 'admin',
|
||||
businessRole: 'it_admin',
|
||||
businessRole: 'sales_manager',
|
||||
canManageUsers: true
|
||||
}))
|
||||
});
|
||||
@@ -51,7 +51,12 @@ export async function GET(request: NextRequest) {
|
||||
const organizationRows = await db
|
||||
.select()
|
||||
.from(organizations)
|
||||
.where(inArray(organizations.id, filteredMemberships.map((membership) => membership.organizationId)));
|
||||
.where(
|
||||
inArray(
|
||||
organizations.id,
|
||||
filteredMemberships.map((membership) => membership.organizationId)
|
||||
)
|
||||
);
|
||||
|
||||
const membershipMap = new Map(
|
||||
filteredMemberships.map((membership) => [membership.organizationId, membership])
|
||||
@@ -68,7 +73,7 @@ export async function GET(request: NextRequest) {
|
||||
plan: organization.plan,
|
||||
imageUrl: organization.imageUrl,
|
||||
role: membership?.role ?? 'user',
|
||||
businessRole: membership?.businessRole ?? 'viewer',
|
||||
businessRole: membership?.businessRole ?? 'sales_support',
|
||||
canManageUsers: membership?.role === 'admin'
|
||||
};
|
||||
})
|
||||
@@ -109,8 +114,8 @@ export async function POST(request: NextRequest) {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
role: 'admin',
|
||||
businessRole: 'it_admin',
|
||||
permissions: getDefaultPermissions('admin', 'it_admin')
|
||||
businessRole: 'sales_manager',
|
||||
permissions: getDefaultPermissions('admin', 'sales_manager')
|
||||
});
|
||||
|
||||
await db
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { memberships, organizations, users } from '@/db/schema';
|
||||
import {
|
||||
type BusinessRole,
|
||||
getDefaultBusinessRole,
|
||||
getDefaultPermissions,
|
||||
isBusinessRole,
|
||||
@@ -13,7 +14,7 @@ import { db } from '@/lib/db';
|
||||
export type UserMembershipInput = {
|
||||
organizationId: string;
|
||||
membershipRole: 'admin' | 'user';
|
||||
businessRole: 'it_admin' | 'helpdesk' | 'infrastructure' | 'application' | 'auditor' | 'viewer';
|
||||
businessRole: BusinessRole;
|
||||
};
|
||||
|
||||
export type UserMutationInput = {
|
||||
@@ -81,8 +82,7 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
|
||||
const value = membership as Record<string, unknown>;
|
||||
const organizationId =
|
||||
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
|
||||
const membershipRole =
|
||||
typeof value.membershipRole === 'string' ? value.membershipRole : '';
|
||||
const membershipRole = typeof value.membershipRole === 'string' ? value.membershipRole : '';
|
||||
const businessRole =
|
||||
typeof value.businessRole === 'string'
|
||||
? value.businessRole
|
||||
@@ -102,7 +102,9 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
|
||||
})
|
||||
.filter((membership): membership is UserMembershipInput => membership !== null);
|
||||
|
||||
const uniqueMemberships = [...new Map(memberships.map((membership) => [membership.organizationId, membership])).values()];
|
||||
const uniqueMemberships = [
|
||||
...new Map(memberships.map((membership) => [membership.organizationId, membership])).values()
|
||||
];
|
||||
|
||||
if (!uniqueMemberships.length) {
|
||||
throw new Error('Select at least one organization');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { asc, eq } from 'drizzle-orm';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { memberships, organizations, users } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { isBusinessRole } from '@/lib/auth/rbac';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import {
|
||||
buildMembershipValues,
|
||||
@@ -38,13 +39,7 @@ function formatUsers(rows: UserRow[]) {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
membershipRole: 'admin' | 'user';
|
||||
businessRole:
|
||||
| 'it_admin'
|
||||
| 'helpdesk'
|
||||
| 'infrastructure'
|
||||
| 'application'
|
||||
| 'auditor'
|
||||
| 'viewer';
|
||||
businessRole: string;
|
||||
}>;
|
||||
}
|
||||
>();
|
||||
@@ -60,14 +55,7 @@ function formatUsers(rows: UserRow[]) {
|
||||
organizationId: row.organizationId,
|
||||
organizationName: row.organizationName,
|
||||
membershipRole: organization.role,
|
||||
businessRole:
|
||||
row.businessRole === 'it_admin' ||
|
||||
row.businessRole === 'helpdesk' ||
|
||||
row.businessRole === 'infrastructure' ||
|
||||
row.businessRole === 'application' ||
|
||||
row.businessRole === 'auditor'
|
||||
? row.businessRole
|
||||
: 'viewer'
|
||||
businessRole: isBusinessRole(row.businessRole) ? row.businessRole : 'sales_support'
|
||||
} as const;
|
||||
|
||||
if (existing) {
|
||||
@@ -146,13 +134,13 @@ function sortUsers(usersList: ReturnType<typeof formatUsers>, sort?: string) {
|
||||
? `${a.systemRole}:${a.activeMembershipRole ?? ''}`
|
||||
: primarySort.id === 'organizations'
|
||||
? a.memberships.map((membership) => membership.organizationName).join(', ')
|
||||
: a[primarySort.id as 'name' | 'email'] ?? a.name;
|
||||
: (a[primarySort.id as 'name' | 'email'] ?? a.name);
|
||||
const right =
|
||||
primarySort.id === 'role'
|
||||
? `${b.systemRole}:${b.activeMembershipRole ?? ''}`
|
||||
: primarySort.id === 'organizations'
|
||||
? b.memberships.map((membership) => membership.organizationName).join(', ')
|
||||
: b[primarySort.id as 'name' | 'email'] ?? b.name;
|
||||
: (b[primarySort.id as 'name' | 'email'] ?? b.name);
|
||||
|
||||
return String(left).localeCompare(String(right));
|
||||
});
|
||||
@@ -244,7 +232,11 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
await validateManagedOrganizations(payload.memberships, manageableOrganizationIds, isSuperAdmin);
|
||||
await validateManagedOrganizations(
|
||||
payload.memberships,
|
||||
manageableOrganizationIds,
|
||||
isSuperAdmin
|
||||
);
|
||||
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.email, payload.email)
|
||||
|
||||
@@ -40,6 +40,16 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupDelete)));
|
||||
const canAssign =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
|
||||
const canReassign =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (canRead) {
|
||||
@@ -74,6 +84,8 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
referenceData={referenceData}
|
||||
relatedQuotations={relatedQuotations}
|
||||
canUpdate={canUpdate}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
canManageFollowups={{
|
||||
create: canFollowupCreate,
|
||||
update: canFollowupUpdate,
|
||||
|
||||
@@ -38,6 +38,16 @@ export default async function EnquiriesRoute(props: PageProps) {
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
|
||||
const canAssign =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
|
||||
const canReassign =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
|
||||
|
||||
searchParamsCache.parse(searchParams);
|
||||
|
||||
@@ -63,7 +73,13 @@ export default async function EnquiriesRoute(props: PageProps) {
|
||||
}
|
||||
>
|
||||
{referenceData ? (
|
||||
<EnquiryListing referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
||||
<EnquiryListing
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ export const memberships = pgTable('memberships', {
|
||||
userId: text('user_id').notNull(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
role: text('role').default('user').notNull(),
|
||||
businessRole: text('business_role').default('viewer').notNull(),
|
||||
businessRole: text('business_role').default('sales_support').notNull(),
|
||||
permissions: text('permissions').array().default([]).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
@@ -215,6 +215,10 @@ export const crmEnquiries = pgTable(
|
||||
notes: text('notes'),
|
||||
isHotProject: boolean('is_hot_project').default(false).notNull(),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
assignedToUserId: text('assigned_to_user_id'),
|
||||
assignedAt: timestamp('assigned_at', { withTimezone: true }),
|
||||
assignedBy: text('assigned_by'),
|
||||
assignmentRemark: text('assignment_remark'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
@@ -541,10 +545,9 @@ export const crmDocumentTemplateMappings = pgTable(
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||
},
|
||||
(table) => ({
|
||||
templatePlaceholderIdx: uniqueIndex('crm_document_template_mappings_version_placeholder_idx').on(
|
||||
table.templateVersionId,
|
||||
table.placeholderKey
|
||||
)
|
||||
templatePlaceholderIdx: uniqueIndex(
|
||||
'crm_document_template_mappings_version_placeholder_idx'
|
||||
).on(table.templateVersionId, table.placeholderKey)
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
assignEnquiry,
|
||||
createEnquiry,
|
||||
createEnquiryFollowup,
|
||||
deleteEnquiry,
|
||||
deleteEnquiryFollowup,
|
||||
reassignEnquiry,
|
||||
updateEnquiry,
|
||||
updateEnquiryFollowup
|
||||
} from './service';
|
||||
import { enquiryKeys } from './queries';
|
||||
import type { EnquiryFollowupMutationPayload, EnquiryMutationPayload } from './types';
|
||||
import type {
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryMutationPayload
|
||||
} from './types';
|
||||
|
||||
async function invalidateEnquiryLists() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
|
||||
@@ -65,6 +71,26 @@ export const deleteEnquiryMutation = mutationOptions({
|
||||
}
|
||||
});
|
||||
|
||||
export const assignEnquiryMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: EnquiryAssignmentMutationPayload }) =>
|
||||
assignEnquiry(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateEnquiryMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const reassignEnquiryMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: EnquiryAssignmentMutationPayload }) =>
|
||||
reassignEnquiry(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateEnquiryMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createEnquiryFollowupMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
enquiryId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryDetailResponse,
|
||||
EnquiryFilters,
|
||||
EnquiryFollowupMutationPayload,
|
||||
@@ -51,6 +52,20 @@ export async function deleteEnquiry(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function assignEnquiry(id: string, data: EnquiryAssignmentMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function reassignEnquiry(id: string, data: EnquiryAssignmentMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/reassign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsResponse> {
|
||||
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface EnquiryContactLookup {
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export interface EnquiryAssignableUserLookup {
|
||||
id: string;
|
||||
name: string;
|
||||
membershipRole: 'admin' | 'user';
|
||||
businessRole: string;
|
||||
}
|
||||
|
||||
export interface EnquiryRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -52,6 +59,12 @@ export interface EnquiryRecord {
|
||||
notes: string | null;
|
||||
isHotProject: boolean;
|
||||
isActive: boolean;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
assignedAt: string | null;
|
||||
assignedBy: string | null;
|
||||
assignedByName: string | null;
|
||||
assignmentRemark: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
@@ -102,6 +115,7 @@ export interface EnquiryReferenceData {
|
||||
followupTypes: EnquiryOption[];
|
||||
customers: EnquiryCustomerLookup[];
|
||||
contacts: EnquiryContactLookup[];
|
||||
assignableUsers: EnquiryAssignableUserLookup[];
|
||||
}
|
||||
|
||||
export interface EnquiryFilters {
|
||||
@@ -174,6 +188,11 @@ export interface EnquiryFollowupMutationPayload {
|
||||
nextAction?: string;
|
||||
}
|
||||
|
||||
export interface EnquiryAssignmentMutationPayload {
|
||||
assignedToUserId: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface EnquiryCustomerRelationItem {
|
||||
id: string;
|
||||
code: string;
|
||||
|
||||
@@ -14,15 +14,19 @@ import { getEnquiryColumns } from './enquiry-columns';
|
||||
export function EnquiriesTable({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}) {
|
||||
const columns = useMemo(
|
||||
() => getEnquiryColumns({ referenceData, canUpdate, canDelete }),
|
||||
[referenceData, canUpdate, canDelete]
|
||||
() => getEnquiryColumns({ referenceData, canUpdate, canDelete, canAssign, canReassign }),
|
||||
[referenceData, canUpdate, canDelete, canAssign, canReassign]
|
||||
);
|
||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||
const [params] = useQueryStates({
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { assignEnquiryMutation, reassignEnquiryMutation } from '../api/mutations';
|
||||
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||
|
||||
const enquiryAssignmentSchema = z.object({
|
||||
assignedToUserId: z.string().min(1, 'Sales user is required'),
|
||||
remark: z.string().optional()
|
||||
});
|
||||
|
||||
type EnquiryAssignmentFormValues = z.infer<typeof enquiryAssignmentSchema>;
|
||||
|
||||
function toDefaultValues(
|
||||
enquiry: EnquiryRecord,
|
||||
referenceData: EnquiryReferenceData
|
||||
): EnquiryAssignmentFormValues {
|
||||
return {
|
||||
assignedToUserId: enquiry.assignedToUserId ?? referenceData.assignableUsers[0]?.id ?? '',
|
||||
remark: enquiry.assignmentRemark ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export function EnquiryAssignmentDialog({
|
||||
enquiry,
|
||||
referenceData,
|
||||
mode,
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
enquiry: EnquiryRecord;
|
||||
referenceData: EnquiryReferenceData;
|
||||
mode: 'assign' | 'reassign';
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const defaultValues = useMemo(
|
||||
() => toDefaultValues(enquiry, referenceData),
|
||||
[enquiry, referenceData]
|
||||
);
|
||||
const { FormSelectField, FormTextareaField } = useFormFields<EnquiryAssignmentFormValues>();
|
||||
|
||||
const assignMutation = useMutation({
|
||||
...assignEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry assigned successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to assign enquiry')
|
||||
});
|
||||
|
||||
const reassignMutation = useMutation({
|
||||
...reassignEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry reassigned successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to reassign enquiry')
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: enquiryAssignmentSchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const values = {
|
||||
assignedToUserId: value.assignedToUserId,
|
||||
remark: value.remark?.trim() || undefined
|
||||
};
|
||||
|
||||
if (mode === 'assign') {
|
||||
await assignMutation.mutateAsync({ id: enquiry.id, values });
|
||||
return;
|
||||
}
|
||||
|
||||
await reassignMutation.mutateAsync({ id: enquiry.id, values });
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(defaultValues);
|
||||
}, [defaultValues, form, open]);
|
||||
|
||||
const isPending = assignMutation.isPending || reassignMutation.isPending;
|
||||
const noUsersAvailable = referenceData.assignableUsers.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'assign'
|
||||
? 'Select the sales owner who should take this enquiry forward.'
|
||||
: 'Move this enquiry to a different sales owner and keep the handoff note visible.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form.AppForm>
|
||||
<form.Form id='enquiry-assignment-form' className='px-0 md:px-0'>
|
||||
{noUsersAvailable ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||
No assignable sales users are available in this workspace yet.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FormSelectField
|
||||
name='assignedToUserId'
|
||||
label='Sales User'
|
||||
required
|
||||
options={referenceData.assignableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
label: `${user.name} (${user.businessRole.replaceAll('_', ' ')})`
|
||||
}))}
|
||||
placeholder='Select sales user'
|
||||
/>
|
||||
<FormTextareaField
|
||||
name='remark'
|
||||
label='Remark'
|
||||
placeholder='Optional handoff context or assignment note'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form='enquiry-assignment-form'
|
||||
isLoading={isPending}
|
||||
disabled={noUsersAvailable}
|
||||
>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -22,12 +22,16 @@ export function EnquiryCellAction({
|
||||
data,
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
canDelete,
|
||||
canAssign: _canAssign,
|
||||
canReassign: _canReassign
|
||||
}: {
|
||||
data: EnquiryListItem;
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
@@ -12,11 +12,15 @@ import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
export function getEnquiryColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}): ColumnDef<EnquiryListItem>[] {
|
||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item]));
|
||||
@@ -153,6 +157,20 @@ export function getEnquiryColumns({
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'assignedSales',
|
||||
header: 'Assigned Sales',
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<span>{row.original.assignedToName ?? 'Unassigned'}</span>
|
||||
{row.original.assignedAt ? (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{new Date(row.original.assignedAt).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'followupCount',
|
||||
accessorKey: 'followupCount',
|
||||
@@ -169,6 +187,8 @@ export function getEnquiryColumns({
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { enquiryByIdOptions } from '../api/queries';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
@@ -30,12 +31,16 @@ export function EnquiryDetail({
|
||||
referenceData,
|
||||
relatedQuotations,
|
||||
canUpdate,
|
||||
canAssign,
|
||||
canReassign,
|
||||
canManageFollowups
|
||||
}: {
|
||||
enquiryId: string;
|
||||
referenceData: EnquiryReferenceData;
|
||||
relatedQuotations: QuotationRelationItem[];
|
||||
canUpdate: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
canManageFollowups: {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
@@ -44,6 +49,7 @@ export function EnquiryDetail({
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||
const enquiry = data.enquiry;
|
||||
const branchMap = useMemo(
|
||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||
@@ -76,6 +82,8 @@ export function EnquiryDetail({
|
||||
const customer = customerMap.get(enquiry.customerId);
|
||||
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
|
||||
const status = statusMap.get(enquiry.status);
|
||||
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
||||
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
@@ -100,6 +108,12 @@ export function EnquiryDetail({
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{canManageAssignment ? (
|
||||
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{assignmentMode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canUpdate ? (
|
||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit Enquiry
|
||||
@@ -169,6 +183,15 @@ export function EnquiryDetail({
|
||||
/>
|
||||
<FieldItem label='Competitor' value={enquiry.competitor} />
|
||||
<FieldItem label='Source' value={enquiry.source} />
|
||||
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
|
||||
<FieldItem
|
||||
label='Assigned At'
|
||||
value={
|
||||
enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null
|
||||
}
|
||||
/>
|
||||
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
||||
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem
|
||||
label='Project'
|
||||
@@ -289,11 +312,19 @@ export function EnquiryDetail({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Record Snapshot</CardTitle>
|
||||
<CardDescription>Operational metadata for this enquiry row.</CardDescription>
|
||||
<CardDescription>
|
||||
Operational metadata and assignment state for this enquiry row.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
|
||||
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
||||
<FieldItem label='Created By' value={enquiry.createdBy} />
|
||||
<FieldItem label='Updated By' value={enquiry.updatedBy} />
|
||||
<FieldItem
|
||||
label='Assigned At'
|
||||
value={enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null}
|
||||
/>
|
||||
<FieldItem label='Created At' value={new Date(enquiry.createdAt).toLocaleString()} />
|
||||
<FieldItem label='Updated At' value={new Date(enquiry.updatedAt).toLocaleString()} />
|
||||
</CardContent>
|
||||
@@ -301,6 +332,13 @@ export function EnquiryDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnquiryAssignmentDialog
|
||||
enquiry={enquiry}
|
||||
referenceData={referenceData}
|
||||
mode={assignmentMode}
|
||||
open={assignmentOpen}
|
||||
onOpenChange={setAssignmentOpen}
|
||||
/>
|
||||
<EnquiryFormSheet
|
||||
enquiry={enquiry}
|
||||
open={editOpen}
|
||||
|
||||
@@ -8,11 +8,15 @@ import { EnquiriesTable } from './enquiries-table';
|
||||
export default function EnquiryListing({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}) {
|
||||
const page = searchParamsCache.get('page');
|
||||
const limit = searchParamsCache.get('perPage');
|
||||
@@ -40,7 +44,13 @@ export default function EnquiryListing({
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiriesTable referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
||||
<EnquiriesTable
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryFollowups,
|
||||
memberships,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
@@ -14,7 +15,9 @@ import { generateNextDocumentCode } from '@/features/foundation/document-sequenc
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import type {
|
||||
EnquiryActivityRecord,
|
||||
EnquiryAssignableUserLookup,
|
||||
EnquiryBranchOption,
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryCustomerRelationItem,
|
||||
EnquiryFilters,
|
||||
EnquiryFollowupMutationPayload,
|
||||
@@ -34,6 +37,8 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
||||
followupType: 'crm_followup_type'
|
||||
} as const;
|
||||
|
||||
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
|
||||
|
||||
function mapOption(
|
||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||
): EnquiryOption {
|
||||
@@ -56,7 +61,10 @@ function mapBranch(
|
||||
};
|
||||
}
|
||||
|
||||
function mapEnquiryRecord(row: typeof crmEnquiries.$inferSelect): EnquiryRecord {
|
||||
function mapEnquiryRecord(
|
||||
row: typeof crmEnquiries.$inferSelect,
|
||||
options?: { assignedToName?: string | null; assignedByName?: string | null }
|
||||
): EnquiryRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
@@ -81,6 +89,12 @@ function mapEnquiryRecord(row: typeof crmEnquiries.$inferSelect): EnquiryRecord
|
||||
notes: row.notes,
|
||||
isHotProject: row.isHotProject,
|
||||
isActive: row.isActive,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
assignedToName: options?.assignedToName ?? null,
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
assignedBy: row.assignedBy,
|
||||
assignedByName: options?.assignedByName ?? null,
|
||||
assignmentRemark: row.assignmentRemark,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||
@@ -220,6 +234,52 @@ export async function assertContactBelongsToOrganization(
|
||||
return contact;
|
||||
}
|
||||
|
||||
async function listAssignableUsers(organizationId: string): Promise<EnquiryAssignableUserLookup[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: memberships.userId,
|
||||
membershipRole: memberships.role,
|
||||
businessRole: memberships.businessRole,
|
||||
name: users.name
|
||||
})
|
||||
.from(memberships)
|
||||
.innerJoin(users, eq(memberships.userId, users.id))
|
||||
.where(eq(memberships.organizationId, organizationId))
|
||||
.orderBy(asc(users.name));
|
||||
|
||||
const uniqueRows = [...new Map(rows.map((row) => [row.userId, row])).values()];
|
||||
|
||||
return uniqueRows
|
||||
.filter(
|
||||
(row) => row.membershipRole === 'admin' || ASSIGNABLE_BUSINESS_ROLES.has(row.businessRole)
|
||||
)
|
||||
.map<EnquiryAssignableUserLookup>((row) => ({
|
||||
id: row.userId,
|
||||
name: row.name,
|
||||
membershipRole: row.membershipRole === 'admin' ? 'admin' : 'user',
|
||||
businessRole: row.businessRole
|
||||
}));
|
||||
}
|
||||
|
||||
export async function assertAssignableUserBelongsToOrganization(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
) {
|
||||
const [membership] = await db
|
||||
.select({
|
||||
userId: memberships.userId
|
||||
})
|
||||
.from(memberships)
|
||||
.where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
throw new AuthError('Assignee not found in organization', 404);
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
||||
const [enquiry] = await db
|
||||
.select()
|
||||
@@ -351,7 +411,8 @@ export async function getEnquiryReferenceData(
|
||||
leadChannels,
|
||||
followupTypes,
|
||||
customers,
|
||||
contacts
|
||||
contacts,
|
||||
assignableUsers
|
||||
] = await Promise.all([
|
||||
getUserBranches(),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
||||
@@ -373,7 +434,8 @@ export async function getEnquiryReferenceData(
|
||||
isNull(crmCustomerContacts.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name))
|
||||
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)),
|
||||
listAssignableUsers(organizationId)
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -396,7 +458,8 @@ export async function getEnquiryReferenceData(
|
||||
email: contact.email,
|
||||
mobile: contact.mobile,
|
||||
isPrimary: contact.isPrimary
|
||||
}))
|
||||
})),
|
||||
assignableUsers
|
||||
};
|
||||
}
|
||||
|
||||
@@ -421,9 +484,12 @@ export async function listEnquiries(
|
||||
|
||||
const customerIds = [...new Set(rows.map((row) => row.customerId))];
|
||||
const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean) as string[])];
|
||||
const assignedUserIds = [
|
||||
...new Set(rows.map((row) => row.assignedToUserId).filter(Boolean) as string[])
|
||||
];
|
||||
const enquiryIds = rows.map((row) => row.id);
|
||||
|
||||
const [customers, contacts, followupCounts] = await Promise.all([
|
||||
const [customers, contacts, assignedUsers, followupCounts] = await Promise.all([
|
||||
customerIds.length
|
||||
? db
|
||||
.select()
|
||||
@@ -446,6 +512,12 @@ export async function listEnquiries(
|
||||
)
|
||||
)
|
||||
: [],
|
||||
assignedUserIds.length
|
||||
? db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, assignedUserIds))
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
@@ -466,11 +538,16 @@ export async function listEnquiries(
|
||||
|
||||
const customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
|
||||
const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name]));
|
||||
const assignedUserMap = new Map(assignedUsers.map((user) => [user.id, user.name]));
|
||||
const followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value]));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
...mapEnquiryRecord(row),
|
||||
...mapEnquiryRecord(row, {
|
||||
assignedToName: row.assignedToUserId
|
||||
? (assignedUserMap.get(row.assignedToUserId) ?? null)
|
||||
: null
|
||||
}),
|
||||
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
||||
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
|
||||
followupCount: followupCountMap.get(row.id) ?? 0
|
||||
@@ -481,7 +558,21 @@ export async function listEnquiries(
|
||||
|
||||
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
return mapEnquiryRecord(enquiry);
|
||||
const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].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 mapEnquiryRecord(enquiry, {
|
||||
assignedToName: enquiry.assignedToUserId
|
||||
? (userMap.get(enquiry.assignedToUserId) ?? null)
|
||||
: null,
|
||||
assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEnquiryActivity(
|
||||
@@ -640,6 +731,59 @@ export async function softDeleteEnquiry(id: string, organizationId: string, user
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function updateEnquiryAssignment(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryAssignmentMutationPayload,
|
||||
mode: 'assign' | 'reassign'
|
||||
) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
|
||||
if (mode === 'assign' && enquiry.assignedToUserId) {
|
||||
throw new AuthError('Enquiry is already assigned. Use reassign instead.', 400);
|
||||
}
|
||||
|
||||
if (mode === 'reassign' && !enquiry.assignedToUserId) {
|
||||
throw new AuthError('Enquiry is not assigned yet. Use assign instead.', 400);
|
||||
}
|
||||
|
||||
await assertAssignableUserBelongsToOrganization(payload.assignedToUserId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
assignedToUserId: payload.assignedToUserId,
|
||||
assignedAt: new Date(),
|
||||
assignedBy: userId,
|
||||
assignmentRemark: payload.remark?.trim() || null,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmEnquiries.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function assignEnquiryToUser(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryAssignmentMutationPayload
|
||||
) {
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign');
|
||||
}
|
||||
|
||||
export async function reassignEnquiryToUser(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryAssignmentMutationPayload
|
||||
) {
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
|
||||
}
|
||||
|
||||
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
const rows = await db
|
||||
|
||||
@@ -13,7 +13,8 @@ export const exampleProducts: Product[] = [
|
||||
organization_id: 'example-org',
|
||||
photo_url: imageFor('Asset Portal', '1b4332'),
|
||||
name: 'Asset Portal',
|
||||
description: 'Starter module showing a route-handler-backed listing with filters and pagination.',
|
||||
description:
|
||||
'Starter module showing a route-handler-backed listing with filters and pagination.',
|
||||
created_at: now,
|
||||
price: 1290,
|
||||
category: 'Electronics',
|
||||
@@ -159,13 +160,13 @@ export const exampleUsers: User[] = [
|
||||
organizationId: 'org-atlas',
|
||||
organizationName: 'Atlas Labs',
|
||||
membershipRole: 'admin',
|
||||
businessRole: 'it_admin'
|
||||
businessRole: 'sales_manager'
|
||||
},
|
||||
{
|
||||
organizationId: 'org-nova',
|
||||
organizationName: 'Nova Retail',
|
||||
membershipRole: 'admin',
|
||||
businessRole: 'auditor'
|
||||
businessRole: 'department_manager'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -182,7 +183,7 @@ export const exampleUsers: User[] = [
|
||||
organizationId: 'org-atlas',
|
||||
organizationName: 'Atlas Labs',
|
||||
membershipRole: 'admin',
|
||||
businessRole: 'helpdesk'
|
||||
businessRole: 'sales_manager'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -199,7 +200,7 @@ export const exampleUsers: User[] = [
|
||||
organizationId: 'org-atlas',
|
||||
organizationName: 'Atlas Labs',
|
||||
membershipRole: 'user',
|
||||
businessRole: 'application'
|
||||
businessRole: 'sales'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -216,7 +217,7 @@ export const exampleUsers: User[] = [
|
||||
organizationId: 'org-nova',
|
||||
organizationName: 'Nova Retail',
|
||||
membershipRole: 'admin',
|
||||
businessRole: 'infrastructure'
|
||||
businessRole: 'department_manager'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -233,7 +234,7 @@ export const exampleUsers: User[] = [
|
||||
organizationId: 'org-nova',
|
||||
organizationName: 'Nova Retail',
|
||||
membershipRole: 'user',
|
||||
businessRole: 'viewer'
|
||||
businessRole: 'sales_support'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -250,7 +251,7 @@ export const exampleUsers: User[] = [
|
||||
organizationId: 'org-orbit',
|
||||
organizationName: 'Orbit Health',
|
||||
membershipRole: 'user',
|
||||
businessRole: 'auditor'
|
||||
businessRole: 'top_manager'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -267,7 +268,7 @@ export const exampleUsers: User[] = [
|
||||
organizationId: 'org-orbit',
|
||||
organizationName: 'Orbit Health',
|
||||
membershipRole: 'admin',
|
||||
businessRole: 'it_admin'
|
||||
businessRole: 'sales_manager'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -287,13 +288,13 @@ export const exampleUsers: User[] = [
|
||||
organizationId: 'org-atlas',
|
||||
organizationName: 'Atlas Labs',
|
||||
membershipRole: 'user',
|
||||
businessRole: 'helpdesk'
|
||||
businessRole: 'sales_support'
|
||||
},
|
||||
{
|
||||
organizationId: 'org-orbit',
|
||||
organizationName: 'Orbit Health',
|
||||
membershipRole: 'user',
|
||||
businessRole: 'viewer'
|
||||
businessRole: 'sales'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function getCurrentUserContext(): Promise<CurrentUserContext | null
|
||||
userId: user.id,
|
||||
organizationId: activeOrganization.id,
|
||||
role: user.activeMembershipRole ?? 'user',
|
||||
businessRole: user.activeBusinessRole ?? 'viewer',
|
||||
businessRole: user.activeBusinessRole ?? 'sales_support',
|
||||
permissions: user.activePermissions
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { BusinessRole } from '@/lib/auth/rbac';
|
||||
|
||||
export interface UserOrganization {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,13 +10,7 @@ export interface UserMembership {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
membershipRole: 'admin' | 'user';
|
||||
businessRole:
|
||||
| 'it_admin'
|
||||
| 'helpdesk'
|
||||
| 'infrastructure'
|
||||
| 'application'
|
||||
| 'auditor'
|
||||
| 'viewer';
|
||||
businessRole: BusinessRole;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
@@ -54,12 +50,6 @@ export type UserMutationPayload = {
|
||||
memberships: Array<{
|
||||
organizationId: string;
|
||||
membershipRole: 'admin' | 'user';
|
||||
businessRole:
|
||||
| 'it_admin'
|
||||
| 'helpdesk'
|
||||
| 'infrastructure'
|
||||
| 'application'
|
||||
| 'auditor'
|
||||
| 'viewer';
|
||||
businessRole: BusinessRole;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import { createUserMutation, updateUserMutation } from '../api/mutations';
|
||||
import type { User } from '../api/types';
|
||||
import { toast } from 'sonner';
|
||||
import { type UserFormValues, userSchema } from '../schemas/user';
|
||||
import type { BusinessRole } from '@/lib/auth/rbac';
|
||||
|
||||
interface UserFormSheetProps {
|
||||
user?: User;
|
||||
@@ -46,12 +47,11 @@ const membershipRoleOptions = [
|
||||
] as const;
|
||||
|
||||
const businessRoleOptions = [
|
||||
{ value: 'it_admin', label: 'IT Admin' },
|
||||
{ value: 'helpdesk', label: 'Helpdesk' },
|
||||
{ value: 'infrastructure', label: 'Infrastructure' },
|
||||
{ value: 'application', label: 'Application' },
|
||||
{ value: 'auditor', label: 'Auditor' },
|
||||
{ value: 'viewer', label: 'Viewer' }
|
||||
{ value: 'sales_manager', label: 'Sales Manager' },
|
||||
{ value: 'sales', label: 'Sales' },
|
||||
{ value: 'sales_support', label: 'Sales Support' },
|
||||
{ value: 'department_manager', label: 'Department Manager' },
|
||||
{ value: 'top_manager', label: 'Top Manager' }
|
||||
] as const;
|
||||
|
||||
export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) {
|
||||
@@ -194,7 +194,9 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
||||
label={isEdit ? 'Password (optional)' : 'Password'}
|
||||
required={!isEdit}
|
||||
type='password'
|
||||
placeholder={isEdit ? 'Leave blank to keep current password' : 'Minimum 8 characters'}
|
||||
placeholder={
|
||||
isEdit ? 'Leave blank to keep current password' : 'Minimum 8 characters'
|
||||
}
|
||||
/>
|
||||
|
||||
{isEdit && user?.systemRole === 'super_admin' && (
|
||||
@@ -254,7 +256,7 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
||||
{
|
||||
organizationId: organization.id,
|
||||
membershipRole: 'user',
|
||||
businessRole: 'viewer'
|
||||
businessRole: 'sales_support'
|
||||
}
|
||||
]);
|
||||
field.handleBlur();
|
||||
@@ -298,7 +300,7 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={membership?.businessRole ?? 'viewer'}
|
||||
value={membership?.businessRole ?? 'sales_support'}
|
||||
disabled={!checked}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(
|
||||
@@ -306,13 +308,7 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
||||
item.organizationId === organization.id
|
||||
? {
|
||||
...item,
|
||||
businessRole: value as
|
||||
| 'it_admin'
|
||||
| 'helpdesk'
|
||||
| 'infrastructure'
|
||||
| 'application'
|
||||
| 'auditor'
|
||||
| 'viewer'
|
||||
businessRole: value as BusinessRole
|
||||
}
|
||||
: item
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as z from 'zod';
|
||||
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
|
||||
|
||||
export const userSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
@@ -10,14 +11,7 @@ export const userSchema = z.object({
|
||||
z.object({
|
||||
organizationId: z.string().min(1),
|
||||
membershipRole: z.enum(['admin', 'user']),
|
||||
businessRole: z.enum([
|
||||
'it_admin',
|
||||
'helpdesk',
|
||||
'infrastructure',
|
||||
'application',
|
||||
'auditor',
|
||||
'viewer'
|
||||
])
|
||||
businessRole: z.enum(BUSINESS_ROLES)
|
||||
})
|
||||
)
|
||||
.min(1, 'Select at least one organization')
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
export const SYSTEM_ROLES = ['super_admin', 'user'] as const;
|
||||
export const MEMBERSHIP_ROLES = ['admin', 'user'] as const;
|
||||
export const BUSINESS_ROLES = [
|
||||
'it_admin',
|
||||
'helpdesk',
|
||||
'infrastructure',
|
||||
'application',
|
||||
'auditor',
|
||||
'viewer',
|
||||
'sales',
|
||||
'sales_support',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
@@ -34,6 +30,8 @@ export const PERMISSIONS = {
|
||||
crmEnquiryCreate: 'crm.enquiry.create',
|
||||
crmEnquiryUpdate: 'crm.enquiry.update',
|
||||
crmEnquiryDelete: 'crm.enquiry.delete',
|
||||
crmEnquiryAssign: 'crm.enquiry.assign',
|
||||
crmEnquiryReassign: 'crm.enquiry.reassign',
|
||||
crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
|
||||
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
|
||||
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
|
||||
@@ -84,6 +82,8 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
PERMISSIONS.crmEnquiryCreate,
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
PERMISSIONS.crmEnquiryDelete,
|
||||
PERMISSIONS.crmEnquiryAssign,
|
||||
PERMISSIONS.crmEnquiryReassign,
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||
@@ -131,18 +131,89 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
|
||||
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||
switch (role) {
|
||||
case 'it_admin':
|
||||
return [PERMISSIONS.reportRead];
|
||||
case 'helpdesk':
|
||||
return [];
|
||||
case 'infrastructure':
|
||||
return [PERMISSIONS.reportRead];
|
||||
case 'application':
|
||||
return [PERMISSIONS.reportRead];
|
||||
case 'auditor':
|
||||
return [PERMISSIONS.reportRead];
|
||||
case 'viewer':
|
||||
case 'sales_manager':
|
||||
return [
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmCustomerCreate,
|
||||
PERMISSIONS.crmCustomerUpdate,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmContactCreate,
|
||||
PERMISSIONS.crmContactUpdate,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryCreate,
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
PERMISSIONS.crmEnquiryAssign,
|
||||
PERMISSIONS.crmEnquiryReassign,
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||
PERMISSIONS.crmQuotationRead,
|
||||
PERMISSIONS.crmQuotationCreate,
|
||||
PERMISSIONS.crmQuotationUpdate,
|
||||
PERMISSIONS.crmQuotationItemManage,
|
||||
PERMISSIONS.crmQuotationCustomerManage,
|
||||
PERMISSIONS.crmQuotationTopicManage,
|
||||
PERMISSIONS.crmQuotationFollowupManage,
|
||||
PERMISSIONS.crmQuotationAttachmentManage,
|
||||
PERMISSIONS.crmQuotationRevisionCreate,
|
||||
PERMISSIONS.crmApprovalRead,
|
||||
PERMISSIONS.crmApprovalSubmit,
|
||||
PERMISSIONS.crmDocumentTemplateRead,
|
||||
PERMISSIONS.crmQuotationDocumentPreview,
|
||||
PERMISSIONS.crmQuotationPdfPreview,
|
||||
PERMISSIONS.crmQuotationPdfDownload
|
||||
];
|
||||
case 'sales':
|
||||
return [
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryCreate,
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||
PERMISSIONS.crmQuotationRead,
|
||||
PERMISSIONS.crmQuotationCreate,
|
||||
PERMISSIONS.crmQuotationUpdate,
|
||||
PERMISSIONS.crmQuotationItemManage,
|
||||
PERMISSIONS.crmQuotationCustomerManage,
|
||||
PERMISSIONS.crmQuotationTopicManage,
|
||||
PERMISSIONS.crmQuotationFollowupManage,
|
||||
PERMISSIONS.crmQuotationAttachmentManage,
|
||||
PERMISSIONS.crmQuotationRevisionCreate,
|
||||
PERMISSIONS.crmApprovalRead,
|
||||
PERMISSIONS.crmApprovalSubmit,
|
||||
PERMISSIONS.crmDocumentTemplateRead,
|
||||
PERMISSIONS.crmQuotationDocumentPreview,
|
||||
PERMISSIONS.crmQuotationPdfPreview,
|
||||
PERMISSIONS.crmQuotationPdfDownload
|
||||
];
|
||||
case 'sales_support':
|
||||
return [
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryCreate,
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||
PERMISSIONS.crmQuotationRead,
|
||||
PERMISSIONS.crmQuotationCreate,
|
||||
PERMISSIONS.crmQuotationUpdate,
|
||||
PERMISSIONS.crmQuotationItemManage,
|
||||
PERMISSIONS.crmQuotationCustomerManage,
|
||||
PERMISSIONS.crmQuotationTopicManage,
|
||||
PERMISSIONS.crmQuotationFollowupManage,
|
||||
PERMISSIONS.crmQuotationAttachmentManage,
|
||||
PERMISSIONS.crmQuotationRevisionCreate,
|
||||
PERMISSIONS.crmApprovalRead,
|
||||
PERMISSIONS.crmDocumentTemplateRead,
|
||||
PERMISSIONS.crmQuotationDocumentPreview,
|
||||
PERMISSIONS.crmQuotationPdfPreview,
|
||||
PERMISSIONS.crmQuotationPdfDownload
|
||||
];
|
||||
case 'department_manager':
|
||||
case 'top_manager':
|
||||
default:
|
||||
@@ -151,7 +222,7 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||
}
|
||||
|
||||
export function getDefaultBusinessRole(role: MembershipRole): BusinessRole {
|
||||
return role === 'admin' ? 'it_admin' : 'viewer';
|
||||
return role === 'admin' ? 'sales_manager' : 'sales_support';
|
||||
}
|
||||
|
||||
export function getDefaultPermissions(
|
||||
|
||||
Reference in New Issue
Block a user