task-l
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
import { db } from '@/lib/db';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import {
|
||||
@@ -57,6 +58,11 @@ type DashboardContext = {
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
permissions: string[];
|
||||
branchScopeIds: string[];
|
||||
productTypeScopeIds: string[];
|
||||
ownershipScope: string;
|
||||
branchScopeMode: string;
|
||||
productScopeMode: string;
|
||||
};
|
||||
|
||||
type NormalizedFilters = {
|
||||
@@ -73,15 +79,6 @@ type StatusMaps = {
|
||||
quotationStatusById: Map<string, string>;
|
||||
};
|
||||
|
||||
const DASHBOARD_FULL_ACCESS_ROLES = new Set([
|
||||
'marketing',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
|
||||
const DASHBOARD_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
|
||||
|
||||
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
|
||||
const now = new Date();
|
||||
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||
@@ -128,20 +125,26 @@ function canViewApprovalData(context: DashboardContext) {
|
||||
}
|
||||
|
||||
function hasDashboardFullScope(context: DashboardContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || DASHBOARD_FULL_ACCESS_ROLES.has(context.businessRole)
|
||||
);
|
||||
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
|
||||
}
|
||||
|
||||
function canAccessDashboardEnquiry(
|
||||
row: typeof crmEnquiries.$inferSelect,
|
||||
context: DashboardContext
|
||||
) {
|
||||
if (!hasBranchScopeAccess(context, row.branchId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasProductScopeAccess(context, row.productType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDashboardFullScope(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
if (context.ownershipScope === 'own') {
|
||||
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
|
||||
}
|
||||
|
||||
@@ -152,11 +155,19 @@ function canAccessDashboardQuotation(
|
||||
row: typeof crmQuotations.$inferSelect,
|
||||
context: DashboardContext
|
||||
) {
|
||||
if (!hasBranchScopeAccess(context, row.branchId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasProductScopeAccess(context, row.quotationType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDashboardFullScope(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
if (context.ownershipScope === 'own') {
|
||||
return row.salesmanId === context.userId;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
@@ -50,13 +51,6 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
||||
} as const;
|
||||
|
||||
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
|
||||
const SALES_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
|
||||
const ENQUIRY_FULL_ACCESS_ROLES = new Set([
|
||||
'marketing',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
const SALES_CREATED_ENQUIRY_ROLES = new Set([
|
||||
'sales',
|
||||
'sales_support',
|
||||
@@ -73,6 +67,11 @@ export interface EnquiryAccessContext {
|
||||
userId: string;
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
branchScopeIds: string[];
|
||||
productTypeScopeIds: string[];
|
||||
ownershipScope: string;
|
||||
branchScopeMode: string;
|
||||
productScopeMode: string;
|
||||
}
|
||||
|
||||
function mapOption(
|
||||
@@ -141,20 +140,26 @@ function mapEnquiryRecord(
|
||||
}
|
||||
|
||||
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || ENQUIRY_FULL_ACCESS_ROLES.has(context.businessRole)
|
||||
);
|
||||
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
|
||||
}
|
||||
|
||||
function canAccessEnquiryRow(
|
||||
row: typeof crmEnquiries.$inferSelect,
|
||||
context: EnquiryAccessContext
|
||||
) {
|
||||
if (!hasBranchScopeAccess(context, row.branchId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasProductScopeAccess(context, row.productType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasEnquiryFullAccess(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
if (context.ownershipScope === 'own') {
|
||||
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
|
||||
}
|
||||
|
||||
@@ -162,17 +167,23 @@ function canAccessEnquiryRow(
|
||||
}
|
||||
|
||||
function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] {
|
||||
if (hasEnquiryFullAccess(context)) {
|
||||
return [];
|
||||
const filters: SQL[] = [];
|
||||
|
||||
if (context.branchScopeMode === 'assigned' && context.branchScopeIds.length > 0) {
|
||||
filters.push(inArray(crmEnquiries.branchId, context.branchScopeIds));
|
||||
}
|
||||
|
||||
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return [
|
||||
if (context.productScopeMode === 'assigned' && context.productTypeScopeIds.length > 0) {
|
||||
filters.push(inArray(crmEnquiries.productType, context.productTypeScopeIds));
|
||||
}
|
||||
|
||||
if (!hasEnquiryFullAccess(context) && context.ownershipScope === 'own') {
|
||||
filters.push(
|
||||
or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))!
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
return filters;
|
||||
}
|
||||
|
||||
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
|
||||
@@ -437,7 +448,11 @@ async function assertFollowupBelongsToEnquiry(
|
||||
return followup;
|
||||
}
|
||||
|
||||
async function validateEnquiryPayload(organizationId: string, payload: EnquiryMutationPayload) {
|
||||
async function validateEnquiryPayload(
|
||||
organizationId: string,
|
||||
payload: EnquiryMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
||||
|
||||
if (payload.contactId) {
|
||||
@@ -465,6 +480,14 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
|
||||
await validateBranchAccess(payload.branchId);
|
||||
}
|
||||
|
||||
if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) {
|
||||
throw new AuthError('Forbidden branch scope', 403);
|
||||
}
|
||||
|
||||
if (accessContext && !hasProductScopeAccess(accessContext, payload.productType)) {
|
||||
throw new AuthError('Forbidden product scope', 403);
|
||||
}
|
||||
|
||||
for (const projectParty of payload.projectParties ?? []) {
|
||||
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
|
||||
await assertMasterOptionValue(
|
||||
@@ -996,13 +1019,13 @@ export async function getEnquiryActivity(
|
||||
export async function createEnquiry(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
businessRole: string,
|
||||
accessContext: EnquiryAccessContext,
|
||||
payload: EnquiryMutationPayload
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload);
|
||||
await validateEnquiryPayload(organizationId, payload, accessContext);
|
||||
const pipelineStage = await resolvePipelineStageForCreate(
|
||||
organizationId,
|
||||
businessRole,
|
||||
accessContext.businessRole,
|
||||
payload.status
|
||||
);
|
||||
|
||||
@@ -1064,7 +1087,7 @@ export async function updateEnquiry(
|
||||
payload: EnquiryMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload);
|
||||
await validateEnquiryPayload(organizationId, payload, accessContext);
|
||||
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
|
||||
|
||||
@@ -1167,9 +1190,10 @@ async function updateEnquiryAssignment(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryAssignmentMutationPayload,
|
||||
mode: 'assign' | 'reassign'
|
||||
mode: 'assign' | 'reassign',
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
|
||||
if (mode === 'assign' && enquiry.assignedToUserId) {
|
||||
throw new AuthError('Enquiry is already assigned. Use reassign instead.', 400);
|
||||
@@ -1202,18 +1226,20 @@ export async function assignEnquiryToUser(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryAssignmentMutationPayload
|
||||
payload: EnquiryAssignmentMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign');
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign', accessContext);
|
||||
}
|
||||
|
||||
export async function reassignEnquiryToUser(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryAssignmentMutationPayload
|
||||
payload: EnquiryAssignmentMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign', accessContext);
|
||||
}
|
||||
|
||||
export async function listEnquiryFollowups(
|
||||
|
||||
30
src/features/foundation/role-management/api/mutations.ts
Normal file
30
src/features/foundation/role-management/api/mutations.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { cloneCrmRoleProfile, updateCrmRoleProfile } from './service';
|
||||
import { crmRoleQueryKeys } from './queries';
|
||||
|
||||
export const updateCrmRoleProfileMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
code,
|
||||
payload
|
||||
}: {
|
||||
code: string;
|
||||
payload: Parameters<typeof updateCrmRoleProfile>[1];
|
||||
}) => updateCrmRoleProfile(code, payload),
|
||||
onSettled: async () => {
|
||||
await getQueryClient().invalidateQueries({ queryKey: crmRoleQueryKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const cloneCrmRoleProfileMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
sourceCode,
|
||||
payload
|
||||
}: {
|
||||
sourceCode: string;
|
||||
payload: Parameters<typeof cloneCrmRoleProfile>[1];
|
||||
}) => cloneCrmRoleProfile(sourceCode, payload),
|
||||
onSettled: async () => {
|
||||
await getQueryClient().invalidateQueries({ queryKey: crmRoleQueryKeys.all });
|
||||
}
|
||||
});
|
||||
13
src/features/foundation/role-management/api/queries.ts
Normal file
13
src/features/foundation/role-management/api/queries.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getCrmRoleProfiles } from './service';
|
||||
|
||||
export const crmRoleQueryKeys = {
|
||||
all: ['crm-role-profiles'] as const
|
||||
};
|
||||
|
||||
export function crmRoleProfilesQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: crmRoleQueryKeys.all,
|
||||
queryFn: getCrmRoleProfiles
|
||||
});
|
||||
}
|
||||
28
src/features/foundation/role-management/api/service.ts
Normal file
28
src/features/foundation/role-management/api/service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
CloneCrmRoleProfilePayload,
|
||||
CrmRoleProfileRecord,
|
||||
CrmRoleProfilesResponse,
|
||||
UpdateCrmRoleProfilePayload
|
||||
} from './types';
|
||||
|
||||
export async function getCrmRoleProfiles() {
|
||||
return apiClient<CrmRoleProfilesResponse>('/crm/settings/roles');
|
||||
}
|
||||
|
||||
export async function updateCrmRoleProfile(code: string, payload: UpdateCrmRoleProfilePayload) {
|
||||
return apiClient<{ success: boolean; role: CrmRoleProfileRecord }>(`/crm/settings/roles/${code}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function cloneCrmRoleProfile(sourceCode: string, payload: CloneCrmRoleProfilePayload) {
|
||||
return apiClient<{ success: boolean; role: CrmRoleProfileRecord }>(
|
||||
`/crm/settings/roles/${sourceCode}/clone`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
57
src/features/foundation/role-management/api/types.ts
Normal file
57
src/features/foundation/role-management/api/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
CrmApprovalAuthority,
|
||||
CrmOwnershipScope,
|
||||
CrmScopeMode,
|
||||
Permission
|
||||
} from '@/lib/auth/rbac';
|
||||
|
||||
export interface CrmRoleProfileRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
permissions: Permission[];
|
||||
branchScopeMode: CrmScopeMode;
|
||||
productScopeMode: CrmScopeMode;
|
||||
ownershipScope: CrmOwnershipScope;
|
||||
approvalAuthority: CrmApprovalAuthority;
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PermissionGroupDto {
|
||||
key: string;
|
||||
label: string;
|
||||
permissions: Array<{
|
||||
key: Permission;
|
||||
label: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CrmRoleProfilesResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
roles: CrmRoleProfileRecord[];
|
||||
permissionGroups: PermissionGroupDto[];
|
||||
}
|
||||
|
||||
export interface UpdateCrmRoleProfilePayload {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
permissions: Permission[];
|
||||
branchScopeMode: CrmScopeMode;
|
||||
productScopeMode: CrmScopeMode;
|
||||
ownershipScope: CrmOwnershipScope;
|
||||
approvalAuthority: CrmApprovalAuthority;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CloneCrmRoleProfilePayload {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
APPROVAL_AUTHORITIES,
|
||||
OWNERSHIP_SCOPES,
|
||||
SCOPE_MODES,
|
||||
type Permission
|
||||
} from '@/lib/auth/rbac';
|
||||
import { cloneCrmRoleProfileMutation, updateCrmRoleProfileMutation } from '../api/mutations';
|
||||
import { crmRoleProfilesQueryOptions } from '../api/queries';
|
||||
|
||||
type RoleEditorState = {
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: Permission[];
|
||||
branchScopeMode: (typeof SCOPE_MODES)[number];
|
||||
productScopeMode: (typeof SCOPE_MODES)[number];
|
||||
ownershipScope: (typeof OWNERSHIP_SCOPES)[number];
|
||||
approvalAuthority: (typeof APPROVAL_AUTHORITIES)[number];
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export function RoleManagementPage() {
|
||||
const { data } = useSuspenseQuery(crmRoleProfilesQueryOptions());
|
||||
const [selectedCode, setSelectedCode] = useState<string | null>(data.roles[0]?.code ?? null);
|
||||
const [cloneCode, setCloneCode] = useState('');
|
||||
const [cloneName, setCloneName] = useState('');
|
||||
const [state, setState] = useState<RoleEditorState | null>(null);
|
||||
const selectedRole = useMemo(
|
||||
() => data.roles.find((role) => role.code === selectedCode) ?? null,
|
||||
[data.roles, selectedCode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRole) {
|
||||
setState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
name: selectedRole.name,
|
||||
description: selectedRole.description ?? '',
|
||||
permissions: selectedRole.permissions,
|
||||
branchScopeMode: selectedRole.branchScopeMode,
|
||||
productScopeMode: selectedRole.productScopeMode,
|
||||
ownershipScope: selectedRole.ownershipScope,
|
||||
approvalAuthority: selectedRole.approvalAuthority,
|
||||
isActive: selectedRole.isActive
|
||||
});
|
||||
}, [selectedRole]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateCrmRoleProfileMutation,
|
||||
onSuccess: () => toast.success('CRM role updated successfully'),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to update CRM role')
|
||||
});
|
||||
|
||||
const cloneMutation = useMutation({
|
||||
...cloneCrmRoleProfileMutation,
|
||||
onSuccess: (result) => {
|
||||
toast.success('CRM role cloned successfully');
|
||||
setSelectedCode(result.role.code);
|
||||
setCloneCode('');
|
||||
setCloneName('');
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to clone CRM role')
|
||||
});
|
||||
|
||||
if (!selectedRole || !state) {
|
||||
return (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
No CRM roles available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentRole = selectedRole;
|
||||
const editorState = state;
|
||||
|
||||
function togglePermission(permission: Permission, checked: boolean) {
|
||||
setState((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
permissions: checked
|
||||
? [...new Set([...current.permissions, permission])]
|
||||
: current.permissions.filter((item) => item !== permission)
|
||||
}
|
||||
: current
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await updateMutation.mutateAsync({
|
||||
code: currentRole.code,
|
||||
payload: {
|
||||
...editorState,
|
||||
description: editorState.description || null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleClone() {
|
||||
if (!cloneCode.trim() || !cloneName.trim()) {
|
||||
toast.error('Clone code and clone name are required');
|
||||
return;
|
||||
}
|
||||
|
||||
await cloneMutation.mutateAsync({
|
||||
sourceCode: currentRole.code,
|
||||
payload: {
|
||||
code: cloneCode,
|
||||
name: cloneName,
|
||||
description: editorState.description || null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Roles</CardTitle>
|
||||
<CardDescription>System and cloned CRM role profiles for this organization.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{data.roles.map((role) => (
|
||||
<button
|
||||
key={role.code}
|
||||
type='button'
|
||||
onClick={() => setSelectedCode(role.code)}
|
||||
className={`w-full rounded-lg border p-3 text-left transition ${
|
||||
role.code === selectedCode ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='font-medium'>{role.name}</div>
|
||||
<div className='flex gap-2'>
|
||||
{role.isSystem ? <Badge variant='secondary'>System</Badge> : null}
|
||||
{!role.isActive ? <Badge variant='outline'>Inactive</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs'>{role.code}</div>
|
||||
<div className='text-muted-foreground mt-2 line-clamp-2 text-sm'>
|
||||
{role.description || 'No description'}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{currentRole.name}</CardTitle>
|
||||
<CardDescription>
|
||||
Configure permissions, ownership visibility, branch scope mode, and approval authority.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='role-name'>Role Name</Label>
|
||||
<Input
|
||||
id='role-name'
|
||||
value={editorState.name}
|
||||
onChange={(event) => setState({ ...state, name: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>Role Code</Label>
|
||||
<Input value={currentRole.code} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='role-description'>Description</Label>
|
||||
<Textarea
|
||||
id='role-description'
|
||||
value={editorState.description}
|
||||
onChange={(event) => setState({ ...state, description: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label>Ownership Scope</Label>
|
||||
<Select
|
||||
value={editorState.ownershipScope}
|
||||
onValueChange={(value) =>
|
||||
setState({ ...state, ownershipScope: value as RoleEditorState['ownershipScope'] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OWNERSHIP_SCOPES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>Branch Scope</Label>
|
||||
<Select
|
||||
value={editorState.branchScopeMode}
|
||||
onValueChange={(value) =>
|
||||
setState({ ...state, branchScopeMode: value as RoleEditorState['branchScopeMode'] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_MODES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>Product Scope</Label>
|
||||
<Select
|
||||
value={editorState.productScopeMode}
|
||||
onValueChange={(value) =>
|
||||
setState({
|
||||
...state,
|
||||
productScopeMode: value as RoleEditorState['productScopeMode']
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_MODES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>Approval Authority</Label>
|
||||
<Select
|
||||
value={editorState.approvalAuthority}
|
||||
onValueChange={(value) =>
|
||||
setState({
|
||||
...state,
|
||||
approvalAuthority: value as RoleEditorState['approvalAuthority']
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{APPROVAL_AUTHORITIES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<Checkbox
|
||||
checked={editorState.isActive}
|
||||
onCheckedChange={(checked) => setState({ ...state, isActive: checked === true })}
|
||||
/>
|
||||
<div>
|
||||
<div className='font-medium'>Role active</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Inactive roles remain historical but should not be used for new assignments.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<div className='font-medium'>Permission Matrix</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Effective permissions are role permissions plus membership-level overrides.
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-4 xl:grid-cols-2'>
|
||||
{data.permissionGroups.map((group) => (
|
||||
<Card key={group.key}>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>{group.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{group.permissions.map((permission) => {
|
||||
const checked = editorState.permissions.includes(permission.key);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={permission.key}
|
||||
className='flex cursor-pointer items-start gap-3 rounded-md border p-3'
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(value) =>
|
||||
togglePermission(permission.key, value === true)
|
||||
}
|
||||
/>
|
||||
<div className='space-y-1'>
|
||||
<div className='font-medium'>{permission.label}</div>
|
||||
<div className='text-muted-foreground text-xs'>{permission.key}</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<Button onClick={() => void handleSave()} isLoading={updateMutation.isPending}>
|
||||
Save Role
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Clone Role</CardTitle>
|
||||
<CardDescription>
|
||||
Create a custom role profile by copying the current role and then tailoring its permissions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-[1fr_1fr_auto]'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='clone-code'>New Role Code</Label>
|
||||
<Input
|
||||
id='clone-code'
|
||||
value={cloneCode}
|
||||
onChange={(event) => setCloneCode(event.target.value)}
|
||||
placeholder='sales_crane_manager'
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='clone-name'>New Role Name</Label>
|
||||
<Input
|
||||
id='clone-name'
|
||||
value={cloneName}
|
||||
onChange={(event) => setCloneName(event.target.value)}
|
||||
placeholder='Sales Crane Manager'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-end'>
|
||||
<Button onClick={() => void handleClone()} isLoading={cloneMutation.isPending}>
|
||||
Clone
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
src/features/foundation/role-management/server/service.ts
Normal file
252
src/features/foundation/role-management/server/service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||
import { crmRoleProfiles } from '@/db/schema';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
CRM_PERMISSION_GROUPS,
|
||||
getDefaultRoleProfiles,
|
||||
isApprovalAuthority,
|
||||
isOwnershipScope,
|
||||
isScopeMode,
|
||||
type Permission
|
||||
} from '@/lib/auth/rbac';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import type {
|
||||
CloneCrmRoleProfilePayload,
|
||||
CrmRoleProfileRecord,
|
||||
UpdateCrmRoleProfilePayload
|
||||
} from '../api/types';
|
||||
|
||||
function mapRole(row: typeof crmRoleProfiles.$inferSelect): CrmRoleProfileRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
permissions: row.permissions as Permission[],
|
||||
branchScopeMode: isScopeMode(row.branchScopeMode) ? row.branchScopeMode : 'assigned',
|
||||
productScopeMode: isScopeMode(row.productScopeMode) ? row.productScopeMode : 'assigned',
|
||||
ownershipScope: isOwnershipScope(row.ownershipScope) ? row.ownershipScope : 'own',
|
||||
approvalAuthority: isApprovalAuthority(row.approvalAuthority)
|
||||
? row.approvalAuthority
|
||||
: 'none',
|
||||
isSystem: row.isSystem,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeCode(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
export async function ensureCrmRoleProfiles(
|
||||
organizationId: string,
|
||||
userId: string
|
||||
) {
|
||||
const existingRows = await db
|
||||
.select({ code: crmRoleProfiles.code })
|
||||
.from(crmRoleProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(crmRoleProfiles.organizationId, organizationId),
|
||||
isNull(crmRoleProfiles.deletedAt)
|
||||
)
|
||||
);
|
||||
const existingCodes = new Set(existingRows.map((row) => row.code));
|
||||
const missingProfiles = getDefaultRoleProfiles().filter(
|
||||
(profile) => !existingCodes.has(profile.code)
|
||||
);
|
||||
|
||||
if (!missingProfiles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(crmRoleProfiles).values(
|
||||
missingProfiles.map((profile) => ({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
code: profile.code,
|
||||
name: profile.name,
|
||||
description: profile.description,
|
||||
permissions: profile.permissions,
|
||||
branchScopeMode: profile.branchScopeMode,
|
||||
productScopeMode: profile.productScopeMode,
|
||||
ownershipScope: profile.ownershipScope,
|
||||
approvalAuthority: profile.approvalAuthority,
|
||||
isSystem: profile.isSystem,
|
||||
isActive: true,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCrmRoleProfiles(
|
||||
organizationId: string,
|
||||
userId: string
|
||||
) {
|
||||
await ensureCrmRoleProfiles(organizationId, userId);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmRoleProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(crmRoleProfiles.organizationId, organizationId),
|
||||
isNull(crmRoleProfiles.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmRoleProfiles.isSystem), asc(crmRoleProfiles.name));
|
||||
|
||||
return {
|
||||
roles: rows.map(mapRole),
|
||||
permissionGroups: CRM_PERMISSION_GROUPS
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCrmRoleProfileByCode(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
code: string
|
||||
) {
|
||||
await ensureCrmRoleProfiles(organizationId, userId);
|
||||
const row = await db.query.crmRoleProfiles.findFirst({
|
||||
where: and(
|
||||
eq(crmRoleProfiles.organizationId, organizationId),
|
||||
eq(crmRoleProfiles.code, code),
|
||||
isNull(crmRoleProfiles.deletedAt)
|
||||
)
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new AuthError('CRM role not found', 404);
|
||||
}
|
||||
|
||||
return mapRole(row);
|
||||
}
|
||||
|
||||
export async function updateCrmRoleProfile(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
code: string,
|
||||
payload: UpdateCrmRoleProfilePayload
|
||||
) {
|
||||
const current = await db.query.crmRoleProfiles.findFirst({
|
||||
where: and(
|
||||
eq(crmRoleProfiles.organizationId, organizationId),
|
||||
eq(crmRoleProfiles.code, code),
|
||||
isNull(crmRoleProfiles.deletedAt)
|
||||
)
|
||||
});
|
||||
|
||||
if (!current) {
|
||||
throw new AuthError('CRM role not found', 404);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmRoleProfiles)
|
||||
.set({
|
||||
name: payload.name.trim(),
|
||||
description: payload.description?.trim() || null,
|
||||
permissions: payload.permissions,
|
||||
branchScopeMode: payload.branchScopeMode,
|
||||
productScopeMode: payload.productScopeMode,
|
||||
ownershipScope: payload.ownershipScope,
|
||||
approvalAuthority: payload.approvalAuthority,
|
||||
isActive: payload.isActive,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmRoleProfiles.id, current.id))
|
||||
.returning();
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_role',
|
||||
entityId: updated.id,
|
||||
action: 'update',
|
||||
beforeData: mapRole(current),
|
||||
afterData: mapRole(updated)
|
||||
});
|
||||
|
||||
return mapRole(updated);
|
||||
}
|
||||
|
||||
export async function cloneCrmRoleProfile(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
sourceCode: string,
|
||||
payload: CloneCrmRoleProfilePayload
|
||||
) {
|
||||
const source = await db.query.crmRoleProfiles.findFirst({
|
||||
where: and(
|
||||
eq(crmRoleProfiles.organizationId, organizationId),
|
||||
eq(crmRoleProfiles.code, sourceCode),
|
||||
isNull(crmRoleProfiles.deletedAt)
|
||||
)
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
throw new AuthError('Source CRM role not found', 404);
|
||||
}
|
||||
|
||||
const targetCode = sanitizeCode(payload.code);
|
||||
|
||||
if (!targetCode) {
|
||||
throw new AuthError('Role code is required', 400);
|
||||
}
|
||||
|
||||
const duplicate = await db.query.crmRoleProfiles.findFirst({
|
||||
where: and(
|
||||
eq(crmRoleProfiles.organizationId, organizationId),
|
||||
eq(crmRoleProfiles.code, targetCode),
|
||||
isNull(crmRoleProfiles.deletedAt)
|
||||
)
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
throw new AuthError('Role code already exists', 409);
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmRoleProfiles)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
code: targetCode,
|
||||
name: payload.name.trim(),
|
||||
description: payload.description?.trim() || source.description,
|
||||
permissions: source.permissions,
|
||||
branchScopeMode: source.branchScopeMode,
|
||||
productScopeMode: source.productScopeMode,
|
||||
ownershipScope: source.ownershipScope,
|
||||
approvalAuthority: source.approvalAuthority,
|
||||
isSystem: false,
|
||||
isActive: true,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_role',
|
||||
entityId: created.id,
|
||||
action: 'create',
|
||||
afterData: {
|
||||
sourceCode,
|
||||
role: mapRole(created)
|
||||
}
|
||||
});
|
||||
|
||||
return mapRole(created);
|
||||
}
|
||||
@@ -52,7 +52,8 @@ const businessRoleOptions = [
|
||||
{ value: 'sales', label: 'Sales' },
|
||||
{ value: 'sales_support', label: 'Sales Support' },
|
||||
{ value: 'department_manager', label: 'Department Manager' },
|
||||
{ value: 'top_manager', label: 'Top Manager' }
|
||||
{ value: 'top_manager', label: 'Top Manager' },
|
||||
{ value: 'crm_admin', label: 'CRM Admin' }
|
||||
] as const;
|
||||
|
||||
export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
|
||||
|
||||
export const userSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.string().email('Please enter a valid email'),
|
||||
@@ -11,7 +9,7 @@ export const userSchema = z.object({
|
||||
z.object({
|
||||
organizationId: z.string().min(1),
|
||||
membershipRole: z.enum(['admin', 'user']),
|
||||
businessRole: z.enum(BUSINESS_ROLES)
|
||||
businessRole: z.string().min(1)
|
||||
})
|
||||
)
|
||||
.min(1, 'Select at least one organization')
|
||||
|
||||
Reference in New Issue
Block a user