task h.1 adn fix permission
This commit is contained in:
@@ -3,7 +3,7 @@ CREATE TABLE "memberships" (
|
|||||||
"user_id" text NOT NULL,
|
"user_id" text NOT NULL,
|
||||||
"organization_id" text NOT NULL,
|
"organization_id" text NOT NULL,
|
||||||
"role" text DEFAULT 'user' 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,
|
"permissions" text[] DEFAULT '{}' NOT NULL,
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
|||||||
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,
|
"when": 1781589458455,
|
||||||
"tag": "0007_luxuriant_malice",
|
"tag": "0007_luxuriant_malice",
|
||||||
"breakpoints": true
|
"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,
|
plan: organization.plan,
|
||||||
imageUrl: organization.imageUrl,
|
imageUrl: organization.imageUrl,
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
businessRole: 'it_admin',
|
businessRole: 'sales_manager',
|
||||||
canManageUsers: true
|
canManageUsers: true
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
@@ -51,7 +51,12 @@ export async function GET(request: NextRequest) {
|
|||||||
const organizationRows = await db
|
const organizationRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(organizations)
|
.from(organizations)
|
||||||
.where(inArray(organizations.id, filteredMemberships.map((membership) => membership.organizationId)));
|
.where(
|
||||||
|
inArray(
|
||||||
|
organizations.id,
|
||||||
|
filteredMemberships.map((membership) => membership.organizationId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const membershipMap = new Map(
|
const membershipMap = new Map(
|
||||||
filteredMemberships.map((membership) => [membership.organizationId, membership])
|
filteredMemberships.map((membership) => [membership.organizationId, membership])
|
||||||
@@ -68,7 +73,7 @@ export async function GET(request: NextRequest) {
|
|||||||
plan: organization.plan,
|
plan: organization.plan,
|
||||||
imageUrl: organization.imageUrl,
|
imageUrl: organization.imageUrl,
|
||||||
role: membership?.role ?? 'user',
|
role: membership?.role ?? 'user',
|
||||||
businessRole: membership?.businessRole ?? 'viewer',
|
businessRole: membership?.businessRole ?? 'sales_support',
|
||||||
canManageUsers: membership?.role === 'admin'
|
canManageUsers: membership?.role === 'admin'
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -109,8 +114,8 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
organizationId,
|
organizationId,
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
businessRole: 'it_admin',
|
businessRole: 'sales_manager',
|
||||||
permissions: getDefaultPermissions('admin', 'it_admin')
|
permissions: getDefaultPermissions('admin', 'sales_manager')
|
||||||
});
|
});
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { memberships, organizations, users } from '@/db/schema';
|
import { memberships, organizations, users } from '@/db/schema';
|
||||||
import {
|
import {
|
||||||
|
type BusinessRole,
|
||||||
getDefaultBusinessRole,
|
getDefaultBusinessRole,
|
||||||
getDefaultPermissions,
|
getDefaultPermissions,
|
||||||
isBusinessRole,
|
isBusinessRole,
|
||||||
@@ -13,7 +14,7 @@ import { db } from '@/lib/db';
|
|||||||
export type UserMembershipInput = {
|
export type UserMembershipInput = {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
membershipRole: 'admin' | 'user';
|
membershipRole: 'admin' | 'user';
|
||||||
businessRole: 'it_admin' | 'helpdesk' | 'infrastructure' | 'application' | 'auditor' | 'viewer';
|
businessRole: BusinessRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserMutationInput = {
|
export type UserMutationInput = {
|
||||||
@@ -81,8 +82,7 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
|
|||||||
const value = membership as Record<string, unknown>;
|
const value = membership as Record<string, unknown>;
|
||||||
const organizationId =
|
const organizationId =
|
||||||
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
|
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
|
||||||
const membershipRole =
|
const membershipRole = typeof value.membershipRole === 'string' ? value.membershipRole : '';
|
||||||
typeof value.membershipRole === 'string' ? value.membershipRole : '';
|
|
||||||
const businessRole =
|
const businessRole =
|
||||||
typeof value.businessRole === 'string'
|
typeof value.businessRole === 'string'
|
||||||
? value.businessRole
|
? value.businessRole
|
||||||
@@ -102,7 +102,9 @@ export function parseUserMutationPayload(body: unknown): UserMutationInput {
|
|||||||
})
|
})
|
||||||
.filter((membership): membership is UserMembershipInput => membership !== null);
|
.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) {
|
if (!uniqueMemberships.length) {
|
||||||
throw new Error('Select at least one organization');
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { memberships, organizations, users } from '@/db/schema';
|
import { memberships, organizations, users } from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { isBusinessRole } from '@/lib/auth/rbac';
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
import {
|
import {
|
||||||
buildMembershipValues,
|
buildMembershipValues,
|
||||||
@@ -38,13 +39,7 @@ function formatUsers(rows: UserRow[]) {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
membershipRole: 'admin' | 'user';
|
membershipRole: 'admin' | 'user';
|
||||||
businessRole:
|
businessRole: string;
|
||||||
| 'it_admin'
|
|
||||||
| 'helpdesk'
|
|
||||||
| 'infrastructure'
|
|
||||||
| 'application'
|
|
||||||
| 'auditor'
|
|
||||||
| 'viewer';
|
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
@@ -60,14 +55,7 @@ function formatUsers(rows: UserRow[]) {
|
|||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
organizationName: row.organizationName,
|
organizationName: row.organizationName,
|
||||||
membershipRole: organization.role,
|
membershipRole: organization.role,
|
||||||
businessRole:
|
businessRole: isBusinessRole(row.businessRole) ? row.businessRole : 'sales_support'
|
||||||
row.businessRole === 'it_admin' ||
|
|
||||||
row.businessRole === 'helpdesk' ||
|
|
||||||
row.businessRole === 'infrastructure' ||
|
|
||||||
row.businessRole === 'application' ||
|
|
||||||
row.businessRole === 'auditor'
|
|
||||||
? row.businessRole
|
|
||||||
: 'viewer'
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -146,13 +134,13 @@ function sortUsers(usersList: ReturnType<typeof formatUsers>, sort?: string) {
|
|||||||
? `${a.systemRole}:${a.activeMembershipRole ?? ''}`
|
? `${a.systemRole}:${a.activeMembershipRole ?? ''}`
|
||||||
: primarySort.id === 'organizations'
|
: primarySort.id === 'organizations'
|
||||||
? a.memberships.map((membership) => membership.organizationName).join(', ')
|
? 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 =
|
const right =
|
||||||
primarySort.id === 'role'
|
primarySort.id === 'role'
|
||||||
? `${b.systemRole}:${b.activeMembershipRole ?? ''}`
|
? `${b.systemRole}:${b.activeMembershipRole ?? ''}`
|
||||||
: primarySort.id === 'organizations'
|
: primarySort.id === 'organizations'
|
||||||
? b.memberships.map((membership) => membership.organizationName).join(', ')
|
? 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));
|
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({
|
const existingUser = await db.query.users.findFirst({
|
||||||
where: eq(users.email, payload.email)
|
where: eq(users.email, payload.email)
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupDelete)));
|
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();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
if (canRead) {
|
if (canRead) {
|
||||||
@@ -74,6 +84,8 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
relatedQuotations={relatedQuotations}
|
relatedQuotations={relatedQuotations}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
|
canAssign={canAssign}
|
||||||
|
canReassign={canReassign}
|
||||||
canManageFollowups={{
|
canManageFollowups={{
|
||||||
create: canFollowupCreate,
|
create: canFollowupCreate,
|
||||||
update: canFollowupUpdate,
|
update: canFollowupUpdate,
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ export default async function EnquiriesRoute(props: PageProps) {
|
|||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
|
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);
|
searchParamsCache.parse(searchParams);
|
||||||
|
|
||||||
@@ -63,7 +73,13 @@ export default async function EnquiriesRoute(props: PageProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{referenceData ? (
|
{referenceData ? (
|
||||||
<EnquiryListing referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
<EnquiryListing
|
||||||
|
referenceData={referenceData}
|
||||||
|
canUpdate={canUpdate}
|
||||||
|
canDelete={canDelete}
|
||||||
|
canAssign={canAssign}
|
||||||
|
canReassign={canReassign}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const memberships = pgTable('memberships', {
|
|||||||
userId: text('user_id').notNull(),
|
userId: text('user_id').notNull(),
|
||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
role: text('role').default('user').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(),
|
permissions: text('permissions').array().default([]).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
@@ -215,6 +215,10 @@ export const crmEnquiries = pgTable(
|
|||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
isHotProject: boolean('is_hot_project').default(false).notNull(),
|
isHotProject: boolean('is_hot_project').default(false).notNull(),
|
||||||
isActive: boolean('is_active').default(true).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(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
@@ -541,10 +545,9 @@ export const crmDocumentTemplateMappings = pgTable(
|
|||||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
templatePlaceholderIdx: uniqueIndex('crm_document_template_mappings_version_placeholder_idx').on(
|
templatePlaceholderIdx: uniqueIndex(
|
||||||
table.templateVersionId,
|
'crm_document_template_mappings_version_placeholder_idx'
|
||||||
table.placeholderKey
|
).on(table.templateVersionId, table.placeholderKey)
|
||||||
)
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import { mutationOptions } from '@tanstack/react-query';
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
import {
|
import {
|
||||||
|
assignEnquiry,
|
||||||
createEnquiry,
|
createEnquiry,
|
||||||
createEnquiryFollowup,
|
createEnquiryFollowup,
|
||||||
deleteEnquiry,
|
deleteEnquiry,
|
||||||
deleteEnquiryFollowup,
|
deleteEnquiryFollowup,
|
||||||
|
reassignEnquiry,
|
||||||
updateEnquiry,
|
updateEnquiry,
|
||||||
updateEnquiryFollowup
|
updateEnquiryFollowup
|
||||||
} from './service';
|
} from './service';
|
||||||
import { enquiryKeys } from './queries';
|
import { enquiryKeys } from './queries';
|
||||||
import type { EnquiryFollowupMutationPayload, EnquiryMutationPayload } from './types';
|
import type {
|
||||||
|
EnquiryAssignmentMutationPayload,
|
||||||
|
EnquiryFollowupMutationPayload,
|
||||||
|
EnquiryMutationPayload
|
||||||
|
} from './types';
|
||||||
|
|
||||||
async function invalidateEnquiryLists() {
|
async function invalidateEnquiryLists() {
|
||||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
|
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({
|
export const createEnquiryFollowupMutation = mutationOptions({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
enquiryId,
|
enquiryId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import type {
|
import type {
|
||||||
|
EnquiryAssignmentMutationPayload,
|
||||||
EnquiryDetailResponse,
|
EnquiryDetailResponse,
|
||||||
EnquiryFilters,
|
EnquiryFilters,
|
||||||
EnquiryFollowupMutationPayload,
|
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> {
|
export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsResponse> {
|
||||||
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
|
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export interface EnquiryContactLookup {
|
|||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnquiryAssignableUserLookup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
membershipRole: 'admin' | 'user';
|
||||||
|
businessRole: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnquiryRecord {
|
export interface EnquiryRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -52,6 +59,12 @@ export interface EnquiryRecord {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
isHotProject: boolean;
|
isHotProject: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
assignedToUserId: string | null;
|
||||||
|
assignedToName: string | null;
|
||||||
|
assignedAt: string | null;
|
||||||
|
assignedBy: string | null;
|
||||||
|
assignedByName: string | null;
|
||||||
|
assignmentRemark: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
@@ -102,6 +115,7 @@ export interface EnquiryReferenceData {
|
|||||||
followupTypes: EnquiryOption[];
|
followupTypes: EnquiryOption[];
|
||||||
customers: EnquiryCustomerLookup[];
|
customers: EnquiryCustomerLookup[];
|
||||||
contacts: EnquiryContactLookup[];
|
contacts: EnquiryContactLookup[];
|
||||||
|
assignableUsers: EnquiryAssignableUserLookup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryFilters {
|
export interface EnquiryFilters {
|
||||||
@@ -174,6 +188,11 @@ export interface EnquiryFollowupMutationPayload {
|
|||||||
nextAction?: string;
|
nextAction?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnquiryAssignmentMutationPayload {
|
||||||
|
assignedToUserId: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnquiryCustomerRelationItem {
|
export interface EnquiryCustomerRelationItem {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
@@ -14,15 +14,19 @@ import { getEnquiryColumns } from './enquiry-columns';
|
|||||||
export function EnquiriesTable({
|
export function EnquiriesTable({
|
||||||
referenceData,
|
referenceData,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete
|
canDelete,
|
||||||
|
canAssign,
|
||||||
|
canReassign
|
||||||
}: {
|
}: {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
canAssign: boolean;
|
||||||
|
canReassign: boolean;
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => getEnquiryColumns({ referenceData, canUpdate, canDelete }),
|
() => getEnquiryColumns({ referenceData, canUpdate, canDelete, canAssign, canReassign }),
|
||||||
[referenceData, canUpdate, canDelete]
|
[referenceData, canUpdate, canDelete, canAssign, canReassign]
|
||||||
);
|
);
|
||||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||||
const [params] = useQueryStates({
|
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,
|
data,
|
||||||
referenceData,
|
referenceData,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete
|
canDelete,
|
||||||
|
canAssign: _canAssign,
|
||||||
|
canReassign: _canReassign
|
||||||
}: {
|
}: {
|
||||||
data: EnquiryListItem;
|
data: EnquiryListItem;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
canAssign: boolean;
|
||||||
|
canReassign: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ import { EnquiryStatusBadge } from './enquiry-status-badge';
|
|||||||
export function getEnquiryColumns({
|
export function getEnquiryColumns({
|
||||||
referenceData,
|
referenceData,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete
|
canDelete,
|
||||||
|
canAssign,
|
||||||
|
canReassign
|
||||||
}: {
|
}: {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
canAssign: boolean;
|
||||||
|
canReassign: boolean;
|
||||||
}): ColumnDef<EnquiryListItem>[] {
|
}): ColumnDef<EnquiryListItem>[] {
|
||||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||||
const productTypeMap = new Map(referenceData.productTypes.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
|
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',
|
id: 'followupCount',
|
||||||
accessorKey: 'followupCount',
|
accessorKey: 'followupCount',
|
||||||
@@ -169,6 +187,8 @@ export function getEnquiryColumns({
|
|||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
canDelete={canDelete}
|
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 { enquiryByIdOptions } from '../api/queries';
|
||||||
import type { EnquiryReferenceData } from '../api/types';
|
import type { EnquiryReferenceData } from '../api/types';
|
||||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||||
|
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||||
@@ -30,12 +31,16 @@ export function EnquiryDetail({
|
|||||||
referenceData,
|
referenceData,
|
||||||
relatedQuotations,
|
relatedQuotations,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
|
canAssign,
|
||||||
|
canReassign,
|
||||||
canManageFollowups
|
canManageFollowups
|
||||||
}: {
|
}: {
|
||||||
enquiryId: string;
|
enquiryId: string;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
relatedQuotations: QuotationRelationItem[];
|
relatedQuotations: QuotationRelationItem[];
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
|
canAssign: boolean;
|
||||||
|
canReassign: boolean;
|
||||||
canManageFollowups: {
|
canManageFollowups: {
|
||||||
create: boolean;
|
create: boolean;
|
||||||
update: boolean;
|
update: boolean;
|
||||||
@@ -44,6 +49,7 @@ export function EnquiryDetail({
|
|||||||
}) {
|
}) {
|
||||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||||
const enquiry = data.enquiry;
|
const enquiry = data.enquiry;
|
||||||
const branchMap = useMemo(
|
const branchMap = useMemo(
|
||||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||||
@@ -76,6 +82,8 @@ export function EnquiryDetail({
|
|||||||
const customer = customerMap.get(enquiry.customerId);
|
const customer = customerMap.get(enquiry.customerId);
|
||||||
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
|
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
|
||||||
const status = statusMap.get(enquiry.status);
|
const status = statusMap.get(enquiry.status);
|
||||||
|
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
||||||
|
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
@@ -100,6 +108,12 @@ export function EnquiryDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<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 ? (
|
{canUpdate ? (
|
||||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit Enquiry
|
<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='Competitor' value={enquiry.competitor} />
|
||||||
<FieldItem label='Source' value={enquiry.source} />
|
<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'>
|
<div className='md:col-span-2 xl:col-span-4'>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Project'
|
label='Project'
|
||||||
@@ -289,11 +312,19 @@ export function EnquiryDetail({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Record Snapshot</CardTitle>
|
<CardTitle>Record Snapshot</CardTitle>
|
||||||
<CardDescription>Operational metadata for this enquiry row.</CardDescription>
|
<CardDescription>
|
||||||
|
Operational metadata and assignment state for this enquiry row.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<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='Created By' value={enquiry.createdBy} />
|
||||||
<FieldItem label='Updated By' value={enquiry.updatedBy} />
|
<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='Created At' value={new Date(enquiry.createdAt).toLocaleString()} />
|
||||||
<FieldItem label='Updated At' value={new Date(enquiry.updatedAt).toLocaleString()} />
|
<FieldItem label='Updated At' value={new Date(enquiry.updatedAt).toLocaleString()} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -301,6 +332,13 @@ export function EnquiryDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EnquiryAssignmentDialog
|
||||||
|
enquiry={enquiry}
|
||||||
|
referenceData={referenceData}
|
||||||
|
mode={assignmentMode}
|
||||||
|
open={assignmentOpen}
|
||||||
|
onOpenChange={setAssignmentOpen}
|
||||||
|
/>
|
||||||
<EnquiryFormSheet
|
<EnquiryFormSheet
|
||||||
enquiry={enquiry}
|
enquiry={enquiry}
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import { EnquiriesTable } from './enquiries-table';
|
|||||||
export default function EnquiryListing({
|
export default function EnquiryListing({
|
||||||
referenceData,
|
referenceData,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete
|
canDelete,
|
||||||
|
canAssign,
|
||||||
|
canReassign
|
||||||
}: {
|
}: {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
canAssign: boolean;
|
||||||
|
canReassign: boolean;
|
||||||
}) {
|
}) {
|
||||||
const page = searchParamsCache.get('page');
|
const page = searchParamsCache.get('page');
|
||||||
const limit = searchParamsCache.get('perPage');
|
const limit = searchParamsCache.get('perPage');
|
||||||
@@ -40,7 +44,13 @@ export default function EnquiryListing({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<EnquiriesTable referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
<EnquiriesTable
|
||||||
|
referenceData={referenceData}
|
||||||
|
canUpdate={canUpdate}
|
||||||
|
canDelete={canDelete}
|
||||||
|
canAssign={canAssign}
|
||||||
|
canReassign={canReassign}
|
||||||
|
/>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
crmCustomers,
|
crmCustomers,
|
||||||
crmEnquiries,
|
crmEnquiries,
|
||||||
crmEnquiryFollowups,
|
crmEnquiryFollowups,
|
||||||
|
memberships,
|
||||||
users
|
users
|
||||||
} from '@/db/schema';
|
} from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
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 { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
import type {
|
import type {
|
||||||
EnquiryActivityRecord,
|
EnquiryActivityRecord,
|
||||||
|
EnquiryAssignableUserLookup,
|
||||||
EnquiryBranchOption,
|
EnquiryBranchOption,
|
||||||
|
EnquiryAssignmentMutationPayload,
|
||||||
EnquiryCustomerRelationItem,
|
EnquiryCustomerRelationItem,
|
||||||
EnquiryFilters,
|
EnquiryFilters,
|
||||||
EnquiryFollowupMutationPayload,
|
EnquiryFollowupMutationPayload,
|
||||||
@@ -34,6 +37,8 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
|||||||
followupType: 'crm_followup_type'
|
followupType: 'crm_followup_type'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
|
||||||
|
|
||||||
function mapOption(
|
function mapOption(
|
||||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||||
): EnquiryOption {
|
): 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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
@@ -81,6 +89,12 @@ function mapEnquiryRecord(row: typeof crmEnquiries.$inferSelect): EnquiryRecord
|
|||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
isHotProject: row.isHotProject,
|
isHotProject: row.isHotProject,
|
||||||
isActive: row.isActive,
|
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(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||||
@@ -220,6 +234,52 @@ export async function assertContactBelongsToOrganization(
|
|||||||
return contact;
|
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) {
|
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
||||||
const [enquiry] = await db
|
const [enquiry] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -351,7 +411,8 @@ export async function getEnquiryReferenceData(
|
|||||||
leadChannels,
|
leadChannels,
|
||||||
followupTypes,
|
followupTypes,
|
||||||
customers,
|
customers,
|
||||||
contacts
|
contacts,
|
||||||
|
assignableUsers
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getUserBranches(),
|
getUserBranches(),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
||||||
@@ -373,7 +434,8 @@ export async function getEnquiryReferenceData(
|
|||||||
isNull(crmCustomerContacts.deletedAt)
|
isNull(crmCustomerContacts.deletedAt)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name))
|
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)),
|
||||||
|
listAssignableUsers(organizationId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -396,7 +458,8 @@ export async function getEnquiryReferenceData(
|
|||||||
email: contact.email,
|
email: contact.email,
|
||||||
mobile: contact.mobile,
|
mobile: contact.mobile,
|
||||||
isPrimary: contact.isPrimary
|
isPrimary: contact.isPrimary
|
||||||
}))
|
})),
|
||||||
|
assignableUsers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,9 +484,12 @@ export async function listEnquiries(
|
|||||||
|
|
||||||
const customerIds = [...new Set(rows.map((row) => row.customerId))];
|
const customerIds = [...new Set(rows.map((row) => row.customerId))];
|
||||||
const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean) as string[])];
|
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 enquiryIds = rows.map((row) => row.id);
|
||||||
|
|
||||||
const [customers, contacts, followupCounts] = await Promise.all([
|
const [customers, contacts, assignedUsers, followupCounts] = await Promise.all([
|
||||||
customerIds.length
|
customerIds.length
|
||||||
? db
|
? db
|
||||||
.select()
|
.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
|
enquiryIds.length
|
||||||
? db
|
? db
|
||||||
.select({
|
.select({
|
||||||
@@ -466,11 +538,16 @@ export async function listEnquiries(
|
|||||||
|
|
||||||
const customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
|
const customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
|
||||||
const contactMap = new Map(contacts.map((contact) => [contact.id, contact.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]));
|
const followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: rows.map((row) => ({
|
items: rows.map((row) => ({
|
||||||
...mapEnquiryRecord(row),
|
...mapEnquiryRecord(row, {
|
||||||
|
assignedToName: row.assignedToUserId
|
||||||
|
? (assignedUserMap.get(row.assignedToUserId) ?? null)
|
||||||
|
: null
|
||||||
|
}),
|
||||||
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
||||||
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
|
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
|
||||||
followupCount: followupCountMap.get(row.id) ?? 0
|
followupCount: followupCountMap.get(row.id) ?? 0
|
||||||
@@ -481,7 +558,21 @@ export async function listEnquiries(
|
|||||||
|
|
||||||
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
|
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
|
||||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
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(
|
export async function getEnquiryActivity(
|
||||||
@@ -640,6 +731,59 @@ export async function softDeleteEnquiry(id: string, organizationId: string, user
|
|||||||
return updated;
|
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) {
|
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
||||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export const exampleProducts: Product[] = [
|
|||||||
organization_id: 'example-org',
|
organization_id: 'example-org',
|
||||||
photo_url: imageFor('Asset Portal', '1b4332'),
|
photo_url: imageFor('Asset Portal', '1b4332'),
|
||||||
name: 'Asset Portal',
|
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,
|
created_at: now,
|
||||||
price: 1290,
|
price: 1290,
|
||||||
category: 'Electronics',
|
category: 'Electronics',
|
||||||
@@ -159,13 +160,13 @@ export const exampleUsers: User[] = [
|
|||||||
organizationId: 'org-atlas',
|
organizationId: 'org-atlas',
|
||||||
organizationName: 'Atlas Labs',
|
organizationName: 'Atlas Labs',
|
||||||
membershipRole: 'admin',
|
membershipRole: 'admin',
|
||||||
businessRole: 'it_admin'
|
businessRole: 'sales_manager'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
organizationId: 'org-nova',
|
organizationId: 'org-nova',
|
||||||
organizationName: 'Nova Retail',
|
organizationName: 'Nova Retail',
|
||||||
membershipRole: 'admin',
|
membershipRole: 'admin',
|
||||||
businessRole: 'auditor'
|
businessRole: 'department_manager'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -182,7 +183,7 @@ export const exampleUsers: User[] = [
|
|||||||
organizationId: 'org-atlas',
|
organizationId: 'org-atlas',
|
||||||
organizationName: 'Atlas Labs',
|
organizationName: 'Atlas Labs',
|
||||||
membershipRole: 'admin',
|
membershipRole: 'admin',
|
||||||
businessRole: 'helpdesk'
|
businessRole: 'sales_manager'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -199,7 +200,7 @@ export const exampleUsers: User[] = [
|
|||||||
organizationId: 'org-atlas',
|
organizationId: 'org-atlas',
|
||||||
organizationName: 'Atlas Labs',
|
organizationName: 'Atlas Labs',
|
||||||
membershipRole: 'user',
|
membershipRole: 'user',
|
||||||
businessRole: 'application'
|
businessRole: 'sales'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -216,7 +217,7 @@ export const exampleUsers: User[] = [
|
|||||||
organizationId: 'org-nova',
|
organizationId: 'org-nova',
|
||||||
organizationName: 'Nova Retail',
|
organizationName: 'Nova Retail',
|
||||||
membershipRole: 'admin',
|
membershipRole: 'admin',
|
||||||
businessRole: 'infrastructure'
|
businessRole: 'department_manager'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -233,7 +234,7 @@ export const exampleUsers: User[] = [
|
|||||||
organizationId: 'org-nova',
|
organizationId: 'org-nova',
|
||||||
organizationName: 'Nova Retail',
|
organizationName: 'Nova Retail',
|
||||||
membershipRole: 'user',
|
membershipRole: 'user',
|
||||||
businessRole: 'viewer'
|
businessRole: 'sales_support'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -250,7 +251,7 @@ export const exampleUsers: User[] = [
|
|||||||
organizationId: 'org-orbit',
|
organizationId: 'org-orbit',
|
||||||
organizationName: 'Orbit Health',
|
organizationName: 'Orbit Health',
|
||||||
membershipRole: 'user',
|
membershipRole: 'user',
|
||||||
businessRole: 'auditor'
|
businessRole: 'top_manager'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -267,7 +268,7 @@ export const exampleUsers: User[] = [
|
|||||||
organizationId: 'org-orbit',
|
organizationId: 'org-orbit',
|
||||||
organizationName: 'Orbit Health',
|
organizationName: 'Orbit Health',
|
||||||
membershipRole: 'admin',
|
membershipRole: 'admin',
|
||||||
businessRole: 'it_admin'
|
businessRole: 'sales_manager'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -287,13 +288,13 @@ export const exampleUsers: User[] = [
|
|||||||
organizationId: 'org-atlas',
|
organizationId: 'org-atlas',
|
||||||
organizationName: 'Atlas Labs',
|
organizationName: 'Atlas Labs',
|
||||||
membershipRole: 'user',
|
membershipRole: 'user',
|
||||||
businessRole: 'helpdesk'
|
businessRole: 'sales_support'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
organizationId: 'org-orbit',
|
organizationId: 'org-orbit',
|
||||||
organizationName: 'Orbit Health',
|
organizationName: 'Orbit Health',
|
||||||
membershipRole: 'user',
|
membershipRole: 'user',
|
||||||
businessRole: 'viewer'
|
businessRole: 'sales'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export async function getCurrentUserContext(): Promise<CurrentUserContext | null
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: activeOrganization.id,
|
organizationId: activeOrganization.id,
|
||||||
role: user.activeMembershipRole ?? 'user',
|
role: user.activeMembershipRole ?? 'user',
|
||||||
businessRole: user.activeBusinessRole ?? 'viewer',
|
businessRole: user.activeBusinessRole ?? 'sales_support',
|
||||||
permissions: user.activePermissions
|
permissions: user.activePermissions
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { BusinessRole } from '@/lib/auth/rbac';
|
||||||
|
|
||||||
export interface UserOrganization {
|
export interface UserOrganization {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,13 +10,7 @@ export interface UserMembership {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
membershipRole: 'admin' | 'user';
|
membershipRole: 'admin' | 'user';
|
||||||
businessRole:
|
businessRole: BusinessRole;
|
||||||
| 'it_admin'
|
|
||||||
| 'helpdesk'
|
|
||||||
| 'infrastructure'
|
|
||||||
| 'application'
|
|
||||||
| 'auditor'
|
|
||||||
| 'viewer';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -54,12 +50,6 @@ export type UserMutationPayload = {
|
|||||||
memberships: Array<{
|
memberships: Array<{
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
membershipRole: 'admin' | 'user';
|
membershipRole: 'admin' | 'user';
|
||||||
businessRole:
|
businessRole: BusinessRole;
|
||||||
| 'it_admin'
|
|
||||||
| 'helpdesk'
|
|
||||||
| 'infrastructure'
|
|
||||||
| 'application'
|
|
||||||
| 'auditor'
|
|
||||||
| 'viewer';
|
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { createUserMutation, updateUserMutation } from '../api/mutations';
|
|||||||
import type { User } from '../api/types';
|
import type { User } from '../api/types';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { type UserFormValues, userSchema } from '../schemas/user';
|
import { type UserFormValues, userSchema } from '../schemas/user';
|
||||||
|
import type { BusinessRole } from '@/lib/auth/rbac';
|
||||||
|
|
||||||
interface UserFormSheetProps {
|
interface UserFormSheetProps {
|
||||||
user?: User;
|
user?: User;
|
||||||
@@ -46,12 +47,11 @@ const membershipRoleOptions = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const businessRoleOptions = [
|
const businessRoleOptions = [
|
||||||
{ value: 'it_admin', label: 'IT Admin' },
|
{ value: 'sales_manager', label: 'Sales Manager' },
|
||||||
{ value: 'helpdesk', label: 'Helpdesk' },
|
{ value: 'sales', label: 'Sales' },
|
||||||
{ value: 'infrastructure', label: 'Infrastructure' },
|
{ value: 'sales_support', label: 'Sales Support' },
|
||||||
{ value: 'application', label: 'Application' },
|
{ value: 'department_manager', label: 'Department Manager' },
|
||||||
{ value: 'auditor', label: 'Auditor' },
|
{ value: 'top_manager', label: 'Top Manager' }
|
||||||
{ value: 'viewer', label: 'Viewer' }
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) {
|
export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) {
|
||||||
@@ -194,7 +194,9 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
|||||||
label={isEdit ? 'Password (optional)' : 'Password'}
|
label={isEdit ? 'Password (optional)' : 'Password'}
|
||||||
required={!isEdit}
|
required={!isEdit}
|
||||||
type='password'
|
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' && (
|
{isEdit && user?.systemRole === 'super_admin' && (
|
||||||
@@ -254,7 +256,7 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
|||||||
{
|
{
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
membershipRole: 'user',
|
membershipRole: 'user',
|
||||||
businessRole: 'viewer'
|
businessRole: 'sales_support'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
field.handleBlur();
|
field.handleBlur();
|
||||||
@@ -298,7 +300,7 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={membership?.businessRole ?? 'viewer'}
|
value={membership?.businessRole ?? 'sales_support'}
|
||||||
disabled={!checked}
|
disabled={!checked}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
field.handleChange(
|
field.handleChange(
|
||||||
@@ -306,13 +308,7 @@ export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps)
|
|||||||
item.organizationId === organization.id
|
item.organizationId === organization.id
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
businessRole: value as
|
businessRole: value as BusinessRole
|
||||||
| 'it_admin'
|
|
||||||
| 'helpdesk'
|
|
||||||
| 'infrastructure'
|
|
||||||
| 'application'
|
|
||||||
| 'auditor'
|
|
||||||
| 'viewer'
|
|
||||||
}
|
}
|
||||||
: item
|
: item
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
|
||||||
|
|
||||||
export const userSchema = z.object({
|
export const userSchema = z.object({
|
||||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
@@ -10,14 +11,7 @@ export const userSchema = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string().min(1),
|
organizationId: z.string().min(1),
|
||||||
membershipRole: z.enum(['admin', 'user']),
|
membershipRole: z.enum(['admin', 'user']),
|
||||||
businessRole: z.enum([
|
businessRole: z.enum(BUSINESS_ROLES)
|
||||||
'it_admin',
|
|
||||||
'helpdesk',
|
|
||||||
'infrastructure',
|
|
||||||
'application',
|
|
||||||
'auditor',
|
|
||||||
'viewer'
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.min(1, 'Select at least one organization')
|
.min(1, 'Select at least one organization')
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
export const SYSTEM_ROLES = ['super_admin', 'user'] as const;
|
export const SYSTEM_ROLES = ['super_admin', 'user'] as const;
|
||||||
export const MEMBERSHIP_ROLES = ['admin', 'user'] as const;
|
export const MEMBERSHIP_ROLES = ['admin', 'user'] as const;
|
||||||
export const BUSINESS_ROLES = [
|
export const BUSINESS_ROLES = [
|
||||||
'it_admin',
|
'sales',
|
||||||
'helpdesk',
|
'sales_support',
|
||||||
'infrastructure',
|
|
||||||
'application',
|
|
||||||
'auditor',
|
|
||||||
'viewer',
|
|
||||||
'sales_manager',
|
'sales_manager',
|
||||||
'department_manager',
|
'department_manager',
|
||||||
'top_manager'
|
'top_manager'
|
||||||
@@ -34,6 +30,8 @@ export const PERMISSIONS = {
|
|||||||
crmEnquiryCreate: 'crm.enquiry.create',
|
crmEnquiryCreate: 'crm.enquiry.create',
|
||||||
crmEnquiryUpdate: 'crm.enquiry.update',
|
crmEnquiryUpdate: 'crm.enquiry.update',
|
||||||
crmEnquiryDelete: 'crm.enquiry.delete',
|
crmEnquiryDelete: 'crm.enquiry.delete',
|
||||||
|
crmEnquiryAssign: 'crm.enquiry.assign',
|
||||||
|
crmEnquiryReassign: 'crm.enquiry.reassign',
|
||||||
crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
|
crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
|
||||||
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
|
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
|
||||||
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
|
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
|
||||||
@@ -84,6 +82,8 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.crmEnquiryCreate,
|
PERMISSIONS.crmEnquiryCreate,
|
||||||
PERMISSIONS.crmEnquiryUpdate,
|
PERMISSIONS.crmEnquiryUpdate,
|
||||||
PERMISSIONS.crmEnquiryDelete,
|
PERMISSIONS.crmEnquiryDelete,
|
||||||
|
PERMISSIONS.crmEnquiryAssign,
|
||||||
|
PERMISSIONS.crmEnquiryReassign,
|
||||||
PERMISSIONS.crmEnquiryFollowupRead,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||||
@@ -131,18 +131,89 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
|
|
||||||
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||||
switch (role) {
|
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':
|
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 'department_manager':
|
||||||
case 'top_manager':
|
case 'top_manager':
|
||||||
default:
|
default:
|
||||||
@@ -151,7 +222,7 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultBusinessRole(role: MembershipRole): BusinessRole {
|
export function getDefaultBusinessRole(role: MembershipRole): BusinessRole {
|
||||||
return role === 'admin' ? 'it_admin' : 'viewer';
|
return role === 'admin' ? 'sales_manager' : 'sales_support';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultPermissions(
|
export function getDefaultPermissions(
|
||||||
|
|||||||
Reference in New Issue
Block a user