This commit is contained in:
phaichayon
2026-07-01 08:44:35 +07:00
parent 0c39472dca
commit 6d6cd3a6df
32 changed files with 1284 additions and 333 deletions

View File

@@ -23,7 +23,7 @@ export function DatePickerField({
label,
description,
required,
placeholder = 'DD/MM/YYYY',
placeholder = 'YYYY-MM-DD',
disabled
}: DatePickerFieldProps) {
void disabled;

View File

@@ -0,0 +1,86 @@
export const CRM_ACTIVITY_ENTITY_TYPES = [
'lead',
'opportunity',
'quotation',
'customer',
'contact',
'purchase_order',
'service_report'
] as const;
export const CRM_ACTIVITY_TYPES = [
'site_visit',
'meeting',
'phone_call',
'email_follow_up',
'quotation_follow_up',
'site_survey',
'requirement_discussion',
'internal_task',
'other'
] as const;
export const CRM_ACTIVITY_STATUSES = [
'planned',
'in_progress',
'completed',
'cancelled',
'overdue'
] as const;
export const CRM_ACTIVITY_PRIORITIES = ['low', 'medium', 'high', 'urgent'] as const;
export type CrmActivityEntityType = (typeof CRM_ACTIVITY_ENTITY_TYPES)[number];
export type CrmActivityType = (typeof CRM_ACTIVITY_TYPES)[number];
export type CrmActivityStatus = (typeof CRM_ACTIVITY_STATUSES)[number];
export type CrmActivityPriority = (typeof CRM_ACTIVITY_PRIORITIES)[number];
export interface CrmActivityRecord {
id: string;
organizationId: string;
entityType: CrmActivityEntityType;
entityId: string;
activityType: CrmActivityType;
subject: string;
description: string | null;
ownerId: string | null;
assignedToId: string | null;
dueDate: string | null;
dueTime: string | null;
completedAt: string | null;
status: Exclude<CrmActivityStatus, 'overdue'>;
priority: CrmActivityPriority;
outcome: string | null;
nextAction: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export const CRM_ACTIVITY_PERMISSIONS = {
read: PERMISSIONS.crmActivityRead,
create: PERMISSIONS.crmActivityCreate,
update: PERMISSIONS.crmActivityUpdate,
complete: PERMISSIONS.crmActivityComplete,
delete: PERMISSIONS.crmActivityDelete,
auditLogRead: PERMISSIONS.crmAuditLogRead
} as const;
export function resolveCrmActivityStatus(input: {
status: Exclude<CrmActivityStatus, 'overdue'>;
dueDate?: string | null;
completedAt?: string | null;
now?: Date;
}): CrmActivityStatus {
if (input.completedAt || input.status === 'completed' || input.status === 'cancelled') {
return input.status;
}
if (!input.dueDate) {
return input.status;
}
const today = (input.now ?? new Date()).toISOString().slice(0, 10);
return input.dueDate < today ? 'overdue' : input.status;
}
import { PERMISSIONS } from '@/lib/auth/rbac';

View File

@@ -0,0 +1,12 @@
export interface CrmAuditLogRecord {
id: string;
action: string;
entityType: string;
entityId: string;
createdAt: string;
userId: string;
actorName: string | null;
beforeData: unknown;
afterData: unknown;
requestId: string | null;
}

View File

@@ -0,0 +1,119 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { formatDateTime } from '@/lib/date-format';
import { cn } from '@/lib/utils';
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
function formatActionLabel(action: string) {
return action.replaceAll('_', ' ');
}
function formatEntityLabel(entityType: string) {
return entityType.replace(/^crm_/, '').replaceAll('_', ' ');
}
function normalizeAuditValue(value: unknown) {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function AuditPayloadBlock({
label,
value
}: {
label: string;
value: unknown;
}) {
const normalizedValue = normalizeAuditValue(value);
if (!normalizedValue) {
return null;
}
return (
<div className='flex flex-col gap-2 rounded-lg border bg-muted/30 p-3'>
<div className='text-muted-foreground text-xs font-medium'>{label}</div>
<pre className='overflow-x-auto whitespace-pre-wrap break-words text-xs'>
{normalizedValue}
</pre>
</div>
);
}
export function AuditLogTab({
title = 'Audit Log',
description = 'System-generated immutable history for this CRM record.',
emptyMessage = 'No audit log recorded yet.',
items,
className
}: {
title?: string;
description?: string;
emptyMessage?: string;
items: CrmAuditLogRecord[];
className?: string;
}) {
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
{!items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
{emptyMessage}
</div>
) : (
items.map((item, index) => (
<div key={item.id} className='flex flex-col gap-4'>
<div className='flex flex-col gap-3 rounded-lg border p-4'>
<div className='flex flex-wrap items-center gap-2'>
<Badge variant='outline' className='capitalize'>
{formatActionLabel(item.action)}
</Badge>
<Badge variant='secondary' className='capitalize'>
{formatEntityLabel(item.entityType)}
</Badge>
<Badge variant='secondary'>System</Badge>
</div>
<div className='flex flex-col gap-1 text-sm'>
<div className='font-medium'>{item.actorName ?? item.userId}</div>
<div className='text-muted-foreground text-xs'>
{formatDateTime(item.createdAt)}
</div>
</div>
<div
className={cn(
'grid gap-3',
normalizeAuditValue(item.beforeData) && normalizeAuditValue(item.afterData)
? 'md:grid-cols-2'
: 'md:grid-cols-1'
)}
>
<AuditPayloadBlock label='Before' value={item.beforeData} />
<AuditPayloadBlock label='After' value={item.afterData} />
</div>
</div>
{index < items.length - 1 ? <Separator /> : null}
</div>
))
)}
</CardContent>
</Card>
);
}

View File

@@ -50,41 +50,50 @@ function toDateValue(date?: Date) {
export function CrmDateInput({
value,
onChange,
placeholder = 'DD/MM/YYYY',
className
placeholder = 'YYYY-MM-DD',
className,
allowClear = true
}: {
value?: string | null;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
allowClear?: boolean;
}) {
const selectedDate = parseDateValue(value);
return (
<Popover>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
className={cn(
'w-full justify-start overflow-hidden text-left font-normal',
!value && 'text-muted-foreground',
className
)}
>
<Icons.calendar className='mr-2 h-4 w-4 shrink-0' />
<span className='truncate'>{value ? formatCrmDate(value) : placeholder}</span>
<div className={cn('flex items-center gap-2', className)}>
<Popover>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
className={cn(
'min-w-0 flex-1 justify-start overflow-hidden text-left font-normal',
!value && 'text-muted-foreground'
)}
>
<Icons.calendar className='mr-2 h-4 w-4 shrink-0' />
<span className='truncate'>{value ? formatCrmDate(value) : placeholder}</span>
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode='single'
selected={selectedDate}
onSelect={(date) => onChange(toDateValue(date))}
initialFocus
/>
</PopoverContent>
</Popover>
{allowClear && value ? (
<Button type='button' variant='outline' size='icon' onClick={() => onChange('')}>
<Icons.close />
<span className='sr-only'>Clear date</span>
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode='single'
selected={selectedDate}
onSelect={(date) => onChange(toDateValue(date))}
initialFocus
/>
</PopoverContent>
</Popover>
) : null}
</div>
);
}

View File

@@ -111,15 +111,7 @@ export interface CustomerContactRecord {
shares: ContactShareRecord[];
}
export interface CustomerActivityRecord {
id: string;
action: string;
entityType: string;
entityId: string;
createdAt: string;
userId: string;
actorName: string | null;
}
export type CustomerActivityRecord = CrmAuditLogRecord;
export interface CustomerRelatedOpportunityItem {
id: string;
@@ -234,3 +226,4 @@ export interface MutationSuccessResponse {
success: boolean;
message: string;
}
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';

View File

@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
import { formatDateTime } from '@/lib/format';
import { formatNumber } from '@/lib/number-format';
import { customerByIdOptions } from '../api/queries';
@@ -129,7 +130,7 @@ export function CustomerDetail({
<TabsList>
<TabsTrigger value='overview'></TabsTrigger>
{canReadContacts ? <TabsTrigger value='contacts'></TabsTrigger> : null}
<TabsTrigger value='activity'></TabsTrigger>
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
<TabsTrigger value='related'></TabsTrigger>
</TabsList>
<TabsContent value='overview'>
@@ -194,41 +195,11 @@ export function CustomerDetail({
</TabsContent>
) : null}
<TabsContent value='activity'>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{!data.activity.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
</div>
) : (
data.activity.map((item, index) => (
<div key={item.id} className='space-y-4'>
<div className='flex items-start gap-3'>
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<Badge variant='outline' className='capitalize'>
{item.action}
</Badge>
<span className='text-sm font-medium'>
{item.actorName ?? item.userId}
</span>
</div>
<div className='text-muted-foreground text-xs'>
{formatDateTime(item.createdAt)}
</div>
</div>
</div>
{index < data.activity.length - 1 ? <Separator /> : null}
</div>
))
)}
</CardContent>
</Card>
<AuditLogTab
items={data.activity}
emptyMessage='No audit log recorded yet.'
description='System-generated immutable history for this customer.'
/>
</TabsContent>
<TabsContent value='related'>
<Card>

View File

@@ -1033,6 +1033,9 @@ export async function getCustomerActivity(
createdAt: log.createdAt,
userId: log.userId,
actorName: actorMap.get(log.userId) ?? null,
beforeData: log.beforeData,
afterData: log.afterData,
requestId: log.requestId,
}));
}

View File

@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
import { formatDateTime } from '@/lib/date-format';
import { formatNumber } from '@/lib/number-format';
import { leadByIdOptions } from '../api/queries';
@@ -111,7 +112,7 @@ export function LeadDetail({
<TabsList>
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
<TabsTrigger value='linked-opportunities'>Linked Opportunities</TabsTrigger>
<TabsTrigger value='activity'>Activity</TabsTrigger>
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
</TabsList>
<TabsContent value='followups'>
@@ -152,26 +153,11 @@ export function LeadDetail({
</TabsContent>
<TabsContent value='activity'>
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{lead.activity.length === 0 ? (
<div className='text-muted-foreground text-sm'>No activity yet.</div>
) : (
lead.activity.map((activity) => (
<div key={activity.id} className='border-border rounded-md border p-3'>
<div className='font-medium'>{activity.action}</div>
<div className='text-muted-foreground mt-1 text-xs'>
{activity.actorName ?? activity.userId} -{' '}
{formatDateTime(activity.createdAt)}
</div>
</div>
))
)}
</CardContent>
</Card>
<AuditLogTab
items={lead.activity}
emptyMessage='No audit log recorded yet.'
description='System-generated immutable history for this lead.'
/>
</TabsContent>
</Tabs>
</div>

View File

@@ -23,7 +23,15 @@ export function LeadFollowupPanel({
leadId: string;
detail: LeadDetailResponse;
}) {
const { FormTextField, FormSelectField, FormTextareaField } = useFormFields<FollowupFormValues>();
const { FormDatePickerField, FormSelectField, FormTextareaField } =
useFormFields<FollowupFormValues>();
const mutation = useMutation({
...createLeadFollowupMutation,
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Unable to save lead follow-up');
}
});
const form = useAppForm({
defaultValues: {
@@ -42,80 +50,78 @@ export function LeadFollowupPanel({
nextFollowupDate: value.nextFollowupDate || null
}
});
}
});
const mutation = useMutation({
...createLeadFollowupMutation,
onSuccess: () => {
toast.success('บันทึก follow-up สำเร็จ');
toast.success('Saved lead follow-up successfully');
form.reset();
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'ไม่สามารถบันทึก follow-up ได้');
}
});
return (
<div className='space-y-4'>
<Card>
<CardHeader>
<CardTitle>New Follow-up</CardTitle>
</CardHeader>
<CardContent>
<form.AppForm>
<form.Form id='lead-followup-form' className='grid gap-4 md:grid-cols-2'>
<FormTextField name='followupDate' label='Follow-up Date' placeholder='YYYY-MM-DD' />
<FormTextField
name='nextFollowupDate'
label='Next Follow-up Date'
placeholder='YYYY-MM-DD'
<Card>
<CardHeader>
<CardTitle>Follow-ups</CardTitle>
</CardHeader>
<CardContent className='flex flex-col gap-6'>
<form.AppForm>
<form.Form id='lead-followup-form' className='grid gap-4 md:grid-cols-2'>
<FormDatePickerField
name='followupDate'
label='Follow-up Date'
required
placeholder='YYYY-MM-DD'
/>
<FormDatePickerField
name='nextFollowupDate'
label='Next Follow-up Date'
placeholder='YYYY-MM-DD'
/>
<FormSelectField
name='followupStatus'
label='Follow-up Status'
options={detail.referenceData.followupStatuses.map((item) => ({
value: item.id,
label: item.label
}))}
/>
<div className='md:col-span-2'>
<FormTextareaField
name='note'
label='Note'
size='md'
placeholder='Summarize the latest customer conversation and next step.'
/>
<FormSelectField
name='followupStatus'
label='Follow-up Status'
options={detail.referenceData.followupStatuses.map((item) => ({
value: item.id,
label: item.label
}))}
/>
<div />
<FormTextareaField name='note' label='Note' className='md:col-span-2' />
<div className='md:col-span-2'>
<Button type='submit' form='lead-followup-form' isLoading={mutation.isPending}>
Save Follow-up
</Button>
</div>
</form.Form>
</form.AppForm>
</CardContent>
</Card>
</div>
</form.Form>
</form.AppForm>
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
<div className='flex justify-end'>
<Button type='submit' form='lead-followup-form' isLoading={mutation.isPending}>
Save Follow-up
</Button>
</div>
<div className='flex flex-col gap-3'>
{detail.followups.length === 0 ? (
<div className='text-muted-foreground text-sm'>No follow-up history yet.</div>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No follow-ups yet.
</div>
) : (
detail.followups.map((followup) => (
<div key={followup.id} className='border-border rounded-md border p-3'>
<div className='flex items-center justify-between gap-3'>
<div className='font-medium'>
{followup.followupStatusLabel ?? followup.followupStatus}
<div key={followup.id} className='rounded-lg border p-4'>
<div className='font-medium'>{followup.followupStatusLabel ?? followup.followupStatus}</div>
<div className='text-muted-foreground mt-1 text-xs'>
{formatDate(followup.followupDate)}
</div>
<div className='mt-2 text-sm'>{followup.note || '-'}</div>
{followup.nextFollowupDate ? (
<div className='text-muted-foreground mt-2 text-xs'>
Next: {formatDate(followup.nextFollowupDate)}
</div>
<div className='text-muted-foreground text-xs'>{formatDate(followup.followupDate)}</div>
</div>
{followup.note ? <div className='mt-2 text-sm'>{followup.note}</div> : null}
<div className='text-muted-foreground mt-2 text-xs'>
{followup.createdByName ?? followup.createdBy}
</div>
) : null}
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -258,7 +258,10 @@ async function listLeadActivity(
entityId: log.entityId,
createdAt: log.createdAt,
userId: log.userId,
actorName: actorMap.get(log.userId) ?? null
actorName: actorMap.get(log.userId) ?? null,
beforeData: log.beforeData,
afterData: log.afterData,
requestId: log.requestId
}));
}
@@ -1077,4 +1080,3 @@ export function buildLeadAccessContext(input: {
}) {
return buildCrmSecurityContext(input);
}

View File

@@ -47,15 +47,7 @@ export interface LeadLinkedOpportunitySummary {
updatedAt: string;
}
export interface LeadActivityRecord {
id: string;
action: string;
entityType: string;
entityId: string;
createdAt: string;
userId: string;
actorName: string | null;
}
export type LeadActivityRecord = CrmAuditLogRecord;
export interface LeadReferenceData {
awarenesses: LeadOption[];
@@ -225,4 +217,5 @@ export interface LeadMutationSuccessResponse {
lead?: LeadDetail;
followup?: LeadFollowupSummary;
}
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';

View File

@@ -166,15 +166,7 @@ export interface OpportunityFollowupRecord {
updatedBy: string;
}
export interface OpportunityActivityRecord {
id: string;
action: string;
entityType: string;
entityId: string;
createdAt: string;
userId: string;
actorName: string | null;
}
export type OpportunityActivityRecord = CrmAuditLogRecord;
export interface OpportunityReferenceData {
branches: OpportunityBranchOption[];
@@ -337,3 +329,4 @@ export interface MutationSuccessResponse {
success: boolean;
message: string;
}
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';

View File

@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
import { formatDate, formatDateTime } from '@/lib/date-format';
import { formatNumber } from '@/lib/number-format';
import type {
@@ -284,7 +285,7 @@ export function OpportunityDetail({
<TabsList>
<TabsTrigger value='followups'>Follow-up Timeline</TabsTrigger>
<TabsTrigger value='quotations'>Linked Quotations</TabsTrigger>
<TabsTrigger value='activity'>Activity</TabsTrigger>
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
</TabsList>
<TabsContent value='followups'>
@@ -331,26 +332,11 @@ export function OpportunityDetail({
</TabsContent>
<TabsContent value='activity'>
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{data.activity.length === 0 ? (
<div className='text-muted-foreground text-sm'>No activity yet.</div>
) : (
data.activity.map((activity) => (
<div key={activity.id} className='border-border rounded-md border p-3'>
<div className='font-medium'>{activity.action}</div>
<div className='text-muted-foreground mt-1 text-xs'>
{activity.actorName ?? activity.userId} -{' '}
{formatDateTime(activity.createdAt)}
</div>
</div>
))
)}
</CardContent>
</Card>
<AuditLogTab
items={data.activity}
emptyMessage='No audit log recorded yet.'
description='System-generated immutable history for this opportunity and its follow-ups.'
/>
</TabsContent>
</Tabs>
</div>

View File

@@ -1136,7 +1136,10 @@ export async function getOpportunityActivity(
entityId: log.entityId,
createdAt: log.createdAt,
userId: log.userId,
actorName: actorMap.get(log.userId) ?? null
actorName: actorMap.get(log.userId) ?? null,
beforeData: log.beforeData,
afterData: log.afterData,
requestId: log.requestId
}));
}

View File

@@ -257,15 +257,7 @@ export interface QuotationAttachmentRecord {
deletedAt: string | null;
}
export interface QuotationActivityRecord {
id: string;
action: string;
entityType: string;
entityId: string;
createdAt: string;
userId: string;
actorName: string | null;
}
export type QuotationActivityRecord = CrmAuditLogRecord;
export interface QuotationRelationItem {
id: string;
@@ -481,3 +473,4 @@ export interface QuotationCustomerPackageGenerateResponse {
pageCount: number;
} | null;
}
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';

View File

@@ -27,6 +27,7 @@ import {
SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
import { formatDate, formatDateTime } from '@/lib/date-format';
import { formatNumber } from '@/lib/number-format';
import {
@@ -1231,7 +1232,7 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
<TabsTrigger value='topics'>Topics</TabsTrigger>
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
<TabsTrigger value='activity'>Activity</TabsTrigger>
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
<TabsTrigger value='approval'>Approval</TabsTrigger>
</TabsList>
@@ -1657,45 +1658,12 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
</Card>
</TabsContent>
<TabsContent value='activity'>
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>
Audit events for quotation mutations and child records.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{!data.activity.length ? (
<EmptyState message='No activity recorded yet.' />
) : (
data.activity.map((item, index) => (
<div key={item.id} className='space-y-4'>
<div className='flex items-start gap-3'>
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<Badge variant='outline' className='capitalize'>
{item.action}
</Badge>
<Badge variant='secondary'>
{item.entityType.replaceAll('_', ' ')}
</Badge>
<span className='text-sm font-medium'>
{item.actorName ?? item.userId}
</span>
</div>
<div className='text-muted-foreground text-xs'>
{formatDateTime(item.createdAt)}
</div>
</div>
</div>
{index < data.activity.length - 1 ? <Separator /> : null}
</div>
))
)}
</CardContent>
</Card>
<TabsContent value='activity'>
<AuditLogTab
items={data.activity}
emptyMessage='No audit log recorded yet.'
description='System-generated immutable history for this quotation and its child records.'
/>
</TabsContent>
<TabsContent value='approval'>

View File

@@ -1444,7 +1444,10 @@ export async function getQuotationActivity(
entityId: log.entityId,
createdAt: log.createdAt,
userId: log.userId,
actorName: actorMap.get(log.userId) ?? null
actorName: actorMap.get(log.userId) ?? null,
beforeData: log.beforeData,
afterData: log.afterData,
requestId: log.requestId
}));
}

View File

@@ -39,6 +39,12 @@ export const PERMISSIONS = {
crmLeadRead: 'crm.lead.read',
crmLeadCreate: 'crm.lead.create',
crmLeadUpdate: 'crm.lead.update',
crmActivityRead: 'crm.activity.read',
crmActivityCreate: 'crm.activity.create',
crmActivityUpdate: 'crm.activity.update',
crmActivityComplete: 'crm.activity.complete',
crmActivityDelete: 'crm.activity.delete',
crmAuditLogRead: 'crm.audit_log.read',
crmLeadAssign: 'crm.lead.assign',
crmLeadDelete: 'crm.lead.delete',
crmCustomerRead: 'crm.customer.read',
@@ -513,6 +519,18 @@ PERMISSIONS.crmDocumentTemplateRead,
};
export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{
key: 'activity',
label: 'Activities & Audit',
permissions: [
{ key: PERMISSIONS.crmActivityRead, label: 'Read activities' },
{ key: PERMISSIONS.crmActivityCreate, label: 'Create activities' },
{ key: PERMISSIONS.crmActivityUpdate, label: 'Update activities' },
{ key: PERMISSIONS.crmActivityComplete, label: 'Complete activities' },
{ key: PERMISSIONS.crmActivityDelete, label: 'Delete activities' },
{ key: PERMISSIONS.crmAuditLogRead, label: 'Read audit logs' }
]
},
{
key: 'lead',
label: 'Leads',