439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
'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 { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue
|
|
} from '@/components/ui/select';
|
|
import {
|
|
createCrmUserRoleAssignmentMutation,
|
|
deleteCrmUserRoleAssignmentMutation,
|
|
updateCrmUserRoleAssignmentMutation
|
|
} from '../api/mutations';
|
|
import { crmUserRoleAssignmentsQueryOptions } from '../api/queries';
|
|
import type {
|
|
CrmUserRoleAssignmentMutationPayload,
|
|
CrmUserRoleAssignmentRecord,
|
|
UpdateCrmUserRoleAssignmentPayload
|
|
} from '../api/types';
|
|
|
|
type AssignmentEditorState = {
|
|
branchScopeMode: CrmUserRoleAssignmentMutationPayload['branchScopeMode'];
|
|
branchScopeIds: string[];
|
|
productTypeScopeMode: CrmUserRoleAssignmentMutationPayload['productTypeScopeMode'];
|
|
productTypeScopeIds: string[];
|
|
isPrimary: boolean;
|
|
isActive: boolean;
|
|
};
|
|
|
|
const emptyCreateState: CrmUserRoleAssignmentMutationPayload = {
|
|
userId: '',
|
|
roleProfileId: '',
|
|
branchScopeMode: 'inherit',
|
|
branchScopeIds: [],
|
|
productTypeScopeMode: 'inherit',
|
|
productTypeScopeIds: [],
|
|
isPrimary: false,
|
|
isActive: true
|
|
};
|
|
|
|
function ScopeSelector(props: {
|
|
label: string;
|
|
mode: CrmUserRoleAssignmentMutationPayload['branchScopeMode'];
|
|
ids: string[];
|
|
options: Array<{ id: string; label: string; code: string }>;
|
|
onModeChange: (value: CrmUserRoleAssignmentMutationPayload['branchScopeMode']) => void;
|
|
onIdsChange: (value: string[]) => void;
|
|
}) {
|
|
return (
|
|
<div className='space-y-3'>
|
|
<div className='space-y-2'>
|
|
<Label>{props.label}</Label>
|
|
<Select value={props.mode} onValueChange={props.onModeChange}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value='inherit'>Inherit role profile</SelectItem>
|
|
<SelectItem value='all'>All</SelectItem>
|
|
<SelectItem value='selected'>Selected</SelectItem>
|
|
<SelectItem value='none'>None</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{props.mode === 'selected' ? (
|
|
<div className='grid gap-2 rounded-md border p-3 md:grid-cols-2'>
|
|
{props.options.map((option) => {
|
|
const checked = props.ids.includes(option.id);
|
|
|
|
return (
|
|
<label key={option.id} className='flex items-start gap-3 rounded-md border p-3'>
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(value) => {
|
|
props.onIdsChange(
|
|
value === true
|
|
? [...new Set([...props.ids, option.id])]
|
|
: props.ids.filter((item) => item !== option.id)
|
|
);
|
|
}}
|
|
/>
|
|
<div>
|
|
<div className='font-medium'>{option.label}</div>
|
|
<div className='text-muted-foreground text-xs'>{option.code}</div>
|
|
</div>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function UserRoleAssignmentsPage() {
|
|
const { data } = useSuspenseQuery(crmUserRoleAssignmentsQueryOptions());
|
|
const [selectedId, setSelectedId] = useState<string | null>(data.assignments[0]?.id ?? null);
|
|
const [createState, setCreateState] =
|
|
useState<CrmUserRoleAssignmentMutationPayload>(emptyCreateState);
|
|
const [editorState, setEditorState] = useState<AssignmentEditorState | null>(null);
|
|
const selectedAssignment = useMemo(
|
|
() => data.assignments.find((assignment) => assignment.id === selectedId) ?? null,
|
|
[data.assignments, selectedId]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!selectedAssignment) {
|
|
setEditorState(null);
|
|
return;
|
|
}
|
|
|
|
setEditorState({
|
|
branchScopeMode: selectedAssignment.branchScopeMode,
|
|
branchScopeIds: selectedAssignment.branchScopeIds,
|
|
productTypeScopeMode: selectedAssignment.productTypeScopeMode,
|
|
productTypeScopeIds: selectedAssignment.productTypeScopeIds,
|
|
isPrimary: selectedAssignment.isPrimary,
|
|
isActive: selectedAssignment.isActive
|
|
});
|
|
}, [selectedAssignment]);
|
|
|
|
const createMutation = useMutation({
|
|
...createCrmUserRoleAssignmentMutation,
|
|
onSuccess: () => {
|
|
toast.success('CRM role assignment created successfully');
|
|
setCreateState(emptyCreateState);
|
|
},
|
|
onError: (error) =>
|
|
toast.error(
|
|
error instanceof Error ? error.message : 'Unable to create CRM role assignment'
|
|
)
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
...updateCrmUserRoleAssignmentMutation,
|
|
onSuccess: () => toast.success('CRM role assignment updated successfully'),
|
|
onError: (error) =>
|
|
toast.error(
|
|
error instanceof Error ? error.message : 'Unable to update CRM role assignment'
|
|
)
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
...deleteCrmUserRoleAssignmentMutation,
|
|
onSuccess: () => {
|
|
toast.success('CRM role assignment deleted successfully');
|
|
setSelectedId(null);
|
|
},
|
|
onError: (error) =>
|
|
toast.error(
|
|
error instanceof Error ? error.message : 'Unable to delete CRM role assignment'
|
|
)
|
|
});
|
|
|
|
async function handleCreate() {
|
|
if (!createState.userId || !createState.roleProfileId) {
|
|
toast.error('User and role profile are required');
|
|
return;
|
|
}
|
|
|
|
await createMutation.mutateAsync(createState);
|
|
}
|
|
|
|
async function handleUpdate() {
|
|
if (!selectedAssignment || !editorState) {
|
|
return;
|
|
}
|
|
|
|
await updateMutation.mutateAsync({
|
|
id: selectedAssignment.id,
|
|
payload: editorState satisfies UpdateCrmUserRoleAssignmentPayload
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className='space-y-6'>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Assign CRM Role</CardTitle>
|
|
<CardDescription>
|
|
One user can hold multiple CRM roles in the same organization. Primary role is for
|
|
display; effective access unions across active assignments.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='space-y-6'>
|
|
<div className='grid gap-4 md:grid-cols-2'>
|
|
<div className='space-y-2'>
|
|
<Label>User</Label>
|
|
<Select
|
|
value={createState.userId}
|
|
onValueChange={(value) => setCreateState({ ...createState, userId: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder='Select user' />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{data.users.map((user) => (
|
|
<SelectItem key={user.id} value={user.id}>
|
|
{user.name} ({user.email})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label>Role Profile</Label>
|
|
<Select
|
|
value={createState.roleProfileId}
|
|
onValueChange={(value) =>
|
|
setCreateState({ ...createState, roleProfileId: value })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder='Select role profile' />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{data.roleProfiles.map((role) => (
|
|
<SelectItem key={role.id} value={role.id}>
|
|
{role.name} ({role.code})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='grid gap-6 xl:grid-cols-2'>
|
|
<ScopeSelector
|
|
label='Branch Scope'
|
|
mode={createState.branchScopeMode}
|
|
ids={createState.branchScopeIds}
|
|
options={data.branches}
|
|
onModeChange={(value) =>
|
|
setCreateState({
|
|
...createState,
|
|
branchScopeMode: value,
|
|
branchScopeIds: value === 'selected' ? createState.branchScopeIds : []
|
|
})
|
|
}
|
|
onIdsChange={(value) => setCreateState({ ...createState, branchScopeIds: value })}
|
|
/>
|
|
<ScopeSelector
|
|
label='Product Type Scope'
|
|
mode={createState.productTypeScopeMode}
|
|
ids={createState.productTypeScopeIds}
|
|
options={data.productTypes}
|
|
onModeChange={(value) =>
|
|
setCreateState({
|
|
...createState,
|
|
productTypeScopeMode: value,
|
|
productTypeScopeIds: value === 'selected' ? createState.productTypeScopeIds : []
|
|
})
|
|
}
|
|
onIdsChange={(value) =>
|
|
setCreateState({ ...createState, productTypeScopeIds: value })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className='flex flex-wrap items-center gap-6'>
|
|
<label className='flex items-center gap-3'>
|
|
<Checkbox
|
|
checked={createState.isPrimary}
|
|
onCheckedChange={(value) =>
|
|
setCreateState({ ...createState, isPrimary: value === true })
|
|
}
|
|
/>
|
|
<span className='text-sm font-medium'>Primary role</span>
|
|
</label>
|
|
<label className='flex items-center gap-3'>
|
|
<Checkbox
|
|
checked={createState.isActive}
|
|
onCheckedChange={(value) =>
|
|
setCreateState({ ...createState, isActive: value === true })
|
|
}
|
|
/>
|
|
<span className='text-sm font-medium'>Active</span>
|
|
</label>
|
|
</div>
|
|
|
|
<Button onClick={() => void handleCreate()} isLoading={createMutation.isPending}>
|
|
Assign Role
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className='grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]'>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Existing Assignments</CardTitle>
|
|
<CardDescription>
|
|
Active and historical CRM role assignments in this organization.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='space-y-3'>
|
|
{data.assignments.map((assignment) => (
|
|
<button
|
|
key={assignment.id}
|
|
type='button'
|
|
onClick={() => setSelectedId(assignment.id)}
|
|
className={`w-full rounded-lg border p-3 text-left transition ${
|
|
assignment.id === selectedId
|
|
? 'border-primary bg-primary/5'
|
|
: 'hover:bg-muted/40'
|
|
}`}
|
|
>
|
|
<div className='flex items-center justify-between gap-2'>
|
|
<div className='font-medium'>{assignment.userName}</div>
|
|
<div className='flex gap-2'>
|
|
{assignment.isPrimary ? <Badge>Primary</Badge> : null}
|
|
{!assignment.isActive ? <Badge variant='outline'>Inactive</Badge> : null}
|
|
</div>
|
|
</div>
|
|
<div className='text-muted-foreground mt-1 text-xs'>{assignment.userEmail}</div>
|
|
<div className='mt-2 text-sm'>
|
|
{assignment.roleProfileName} ({assignment.roleProfileCode})
|
|
</div>
|
|
</button>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Assignment Detail</CardTitle>
|
|
<CardDescription>
|
|
Update scope, activation, and primary-role display for the selected CRM assignment.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='space-y-6'>
|
|
{!selectedAssignment || !editorState ? (
|
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
|
Select an assignment to edit.
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className='grid gap-4 md:grid-cols-2'>
|
|
<div className='rounded-lg border p-4'>
|
|
<div className='text-muted-foreground text-xs'>User</div>
|
|
<div className='font-medium'>{selectedAssignment.userName}</div>
|
|
<div className='text-muted-foreground text-sm'>{selectedAssignment.userEmail}</div>
|
|
</div>
|
|
<div className='rounded-lg border p-4'>
|
|
<div className='text-muted-foreground text-xs'>Role Profile</div>
|
|
<div className='font-medium'>{selectedAssignment.roleProfileName}</div>
|
|
<div className='text-muted-foreground text-sm'>
|
|
{selectedAssignment.roleProfileCode}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='grid gap-6 xl:grid-cols-2'>
|
|
<ScopeSelector
|
|
label='Branch Scope'
|
|
mode={editorState.branchScopeMode}
|
|
ids={editorState.branchScopeIds}
|
|
options={data.branches}
|
|
onModeChange={(value) =>
|
|
setEditorState({
|
|
...editorState,
|
|
branchScopeMode: value,
|
|
branchScopeIds: value === 'selected' ? editorState.branchScopeIds : []
|
|
})
|
|
}
|
|
onIdsChange={(value) =>
|
|
setEditorState({ ...editorState, branchScopeIds: value })
|
|
}
|
|
/>
|
|
<ScopeSelector
|
|
label='Product Type Scope'
|
|
mode={editorState.productTypeScopeMode}
|
|
ids={editorState.productTypeScopeIds}
|
|
options={data.productTypes}
|
|
onModeChange={(value) =>
|
|
setEditorState({
|
|
...editorState,
|
|
productTypeScopeMode: value,
|
|
productTypeScopeIds:
|
|
value === 'selected' ? editorState.productTypeScopeIds : []
|
|
})
|
|
}
|
|
onIdsChange={(value) =>
|
|
setEditorState({ ...editorState, productTypeScopeIds: value })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className='flex flex-wrap items-center gap-6'>
|
|
<label className='flex items-center gap-3'>
|
|
<Checkbox
|
|
checked={editorState.isPrimary}
|
|
onCheckedChange={(value) =>
|
|
setEditorState({ ...editorState, isPrimary: value === true })
|
|
}
|
|
/>
|
|
<span className='text-sm font-medium'>Primary role</span>
|
|
</label>
|
|
<label className='flex items-center gap-3'>
|
|
<Checkbox
|
|
checked={editorState.isActive}
|
|
onCheckedChange={(value) =>
|
|
setEditorState({ ...editorState, isActive: value === true })
|
|
}
|
|
/>
|
|
<span className='text-sm font-medium'>Active</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className='flex flex-wrap gap-3'>
|
|
<Button onClick={() => void handleUpdate()} isLoading={updateMutation.isPending}>
|
|
Save Assignment
|
|
</Button>
|
|
<Button
|
|
variant='destructive'
|
|
onClick={() => void deleteMutation.mutateAsync(selectedAssignment.id)}
|
|
isLoading={deleteMutation.isPending}
|
|
>
|
|
Remove Assignment
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|