d.6
This commit is contained in:
86
src/features/crm/activity/types.ts
Normal file
86
src/features/crm/activity/types.ts
Normal 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';
|
||||
12
src/features/crm/audit-log/types.ts
Normal file
12
src/features/crm/audit-log/types.ts
Normal 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;
|
||||
}
|
||||
119
src/features/crm/components/audit-log-tab.tsx
Normal file
119
src/features/crm/components/audit-log-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user