This commit is contained in:
phaichayon
2026-06-22 10:22:45 +07:00
parent 51d67ef7c2
commit b154a8de33
37 changed files with 5952 additions and 332 deletions

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

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

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

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

View File

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

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