task h.1 adn fix permission

This commit is contained in:
phaichayon
2026-06-16 15:13:14 +07:00
parent 9ae41e4f2c
commit 90ee59d388
29 changed files with 3807 additions and 119 deletions

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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