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

@@ -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
@@ -45,4 +45,4 @@ CREATE TABLE "users" (
); );
--> statement-breakpoint --> statement-breakpoint
CREATE UNIQUE INDEX "organizations_slug_idx" ON "organizations" USING btree ("slug");--> statement-breakpoint CREATE UNIQUE INDEX "organizations_slug_idx" ON "organizations" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email"); CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");

View 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';

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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