task-ep.1.1
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatCrmActivityLabel, type CrmActivityType } from '@/features/crm/activity/types';
|
||||
|
||||
export function ActivityBadge({ activityType }: { activityType: CrmActivityType }) {
|
||||
return <Badge variant='outline'>{formatCrmActivityLabel(activityType)}</Badge>;
|
||||
}
|
||||
64
src/features/crm/activities/components/activity-detail.tsx
Normal file
64
src/features/crm/activities/components/activity-detail.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { formatDateTime } from '@/lib/date-format';
|
||||
import type { ActivityRecord } from '../api/types';
|
||||
import { ActivityBadge } from './activity-badge';
|
||||
import { ActivityPriorityBadge } from './activity-priority-badge';
|
||||
import { ActivityStatusBadge } from './activity-status-badge';
|
||||
|
||||
function Field({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||
<div className='text-sm'>{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityDetail({ activity }: { activity: ActivityRecord | null }) {
|
||||
if (!activity) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||
Select an activity to review its detail.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='space-y-3'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<CardTitle>{activity.subject}</CardTitle>
|
||||
<CardDescription>{activity.primaryRecordLabel ?? 'Unlinked activity'}</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<ActivityStatusBadge status={activity.effectiveStatus} />
|
||||
<ActivityPriorityBadge priority={activity.priority} />
|
||||
<ActivityBadge activityType={activity.activityType} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Field label='Owner' value={activity.ownerName} />
|
||||
<Field label='Assignee' value={activity.assignedToName} />
|
||||
<Field label='Primary Entity' value={activity.primaryEntityType} />
|
||||
<Field label='Primary Record Id' value={activity.primaryEntityId} />
|
||||
<Field label='Scheduled' value={formatDateTime(activity.scheduledStartAt)} />
|
||||
<Field label='Due' value={formatDateTime(activity.dueAt)} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='grid gap-4'>
|
||||
<Field label='Description' value={activity.description} />
|
||||
<Field label='Outcome Summary' value={activity.outcomeSummary} />
|
||||
<Field label='Next Action' value={activity.nextAction} />
|
||||
<Field label='Cancellation Reason' value={activity.cancellationReason} />
|
||||
<Field label='Skip Reason' value={activity.skipReason} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
249
src/features/crm/activities/components/activity-form-sheet.tsx
Normal file
249
src/features/crm/activities/components/activity-form-sheet.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { createActivityMutation, updateActivityMutation } from '../api/mutations';
|
||||
import type {
|
||||
ActivityMutationPayload,
|
||||
ActivityRecord,
|
||||
ActivityReferenceData
|
||||
} from '../api/types';
|
||||
import { activityFormSchema, type ActivityFormValues } from '../schemas/activity.schema';
|
||||
|
||||
function dateToIso(value?: string): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(`${value}T00:00:00.000Z`).toISOString();
|
||||
}
|
||||
|
||||
function toDefaultValues(activity?: ActivityRecord): ActivityFormValues {
|
||||
return {
|
||||
primaryEntityType: activity?.primaryEntityType ?? 'internal',
|
||||
primaryEntityId: activity?.primaryEntityId ?? '',
|
||||
activityType: activity?.activityType ?? 'follow_up',
|
||||
subject: activity?.subject ?? '',
|
||||
description: activity?.description ?? '',
|
||||
ownerId: activity?.ownerId ?? '',
|
||||
assignedToId: activity?.assignedToId ?? '',
|
||||
scheduledDate: activity?.scheduledStartAt?.slice(0, 10) ?? '',
|
||||
dueDate: activity?.dueAt?.slice(0, 10) ?? '',
|
||||
status: activity?.status ?? 'draft',
|
||||
priority: activity?.priority ?? 'normal',
|
||||
nextAction: activity?.nextAction ?? '',
|
||||
isInternalOnly: activity?.isInternalOnly ?? false,
|
||||
isPricingSensitive: activity?.isPricingSensitive ?? false
|
||||
};
|
||||
}
|
||||
|
||||
function toPayload(values: ActivityFormValues): ActivityMutationPayload {
|
||||
return {
|
||||
primaryEntityType: values.primaryEntityType,
|
||||
primaryEntityId: values.primaryEntityId?.trim() ? values.primaryEntityId.trim() : null,
|
||||
activityType: values.activityType,
|
||||
subject: values.subject.trim(),
|
||||
description: values.description?.trim() || null,
|
||||
ownerId: values.ownerId,
|
||||
assignedToId: values.assignedToId?.trim() ? values.assignedToId : null,
|
||||
scheduledStartAt: dateToIso(values.scheduledDate),
|
||||
dueAt: dateToIso(values.dueDate),
|
||||
status: values.status,
|
||||
priority: values.priority,
|
||||
nextAction: values.nextAction?.trim() || null,
|
||||
isInternalOnly: values.isInternalOnly,
|
||||
isPricingSensitive: values.isPricingSensitive
|
||||
};
|
||||
}
|
||||
|
||||
export function ActivityFormSheet({
|
||||
activity,
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData,
|
||||
defaultPrimaryEntityType,
|
||||
defaultPrimaryEntityId
|
||||
}: {
|
||||
activity?: ActivityRecord;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
referenceData: ActivityReferenceData;
|
||||
defaultPrimaryEntityType?: ActivityFormValues['primaryEntityType'];
|
||||
defaultPrimaryEntityId?: string;
|
||||
}) {
|
||||
const isEdit = Boolean(activity);
|
||||
const defaultValues = useMemo(() => {
|
||||
const values = toDefaultValues(activity);
|
||||
if (!activity && defaultPrimaryEntityType) {
|
||||
values.primaryEntityType = defaultPrimaryEntityType;
|
||||
}
|
||||
if (!activity && defaultPrimaryEntityId) {
|
||||
values.primaryEntityId = defaultPrimaryEntityId;
|
||||
}
|
||||
return values;
|
||||
}, [activity, defaultPrimaryEntityId, defaultPrimaryEntityType]);
|
||||
|
||||
const { FormTextField, FormTextareaField, FormDatePickerField, FormSelectField, FormCheckboxField } =
|
||||
useFormFields<ActivityFormValues>();
|
||||
|
||||
const createMutation = useMutation({
|
||||
...createActivityMutation,
|
||||
onSuccess: async () => {
|
||||
toast.success('Activity created');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to create activity');
|
||||
}
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateActivityMutation,
|
||||
onSuccess: async () => {
|
||||
toast.success('Activity updated');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to update activity');
|
||||
}
|
||||
});
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: activityFormSchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const payload = toPayload(value);
|
||||
if (isEdit && activity) {
|
||||
await updateMutation.mutateAsync({ id: activity.id, values: payload });
|
||||
return;
|
||||
}
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues);
|
||||
}, [defaultValues, form, open]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex flex-col sm:max-w-2xl'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Activity' : 'Create Activity'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Foundation form for shared operational activity records.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<form.AppForm>
|
||||
<form.Form id='crm-activity-form'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FormTextField name='subject' label='Subject' required placeholder='Follow up on revised quotation' />
|
||||
<FormSelectField
|
||||
name='activityType'
|
||||
label='Activity Type'
|
||||
required
|
||||
options={referenceData.activityTypes.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='primaryEntityType'
|
||||
label='Primary Entity'
|
||||
required
|
||||
options={referenceData.entityTypes.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label
|
||||
}))}
|
||||
disabled={Boolean(defaultPrimaryEntityType)}
|
||||
/>
|
||||
<FormTextField
|
||||
name='primaryEntityId'
|
||||
label='Primary Record Id'
|
||||
placeholder='Existing CRM record id'
|
||||
disabled={Boolean(defaultPrimaryEntityId)}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='ownerId'
|
||||
label='Owner'
|
||||
required
|
||||
options={referenceData.assignableUsers.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='assignedToId'
|
||||
label='Assignee'
|
||||
options={referenceData.assignableUsers.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='status'
|
||||
label='Status'
|
||||
required
|
||||
options={referenceData.statuses.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='priority'
|
||||
label='Priority'
|
||||
required
|
||||
options={referenceData.priorities.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormDatePickerField name='scheduledDate' label='Scheduled Date' />
|
||||
<FormDatePickerField name='dueDate' label='Due Date' />
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextField name='nextAction' label='Next Action' placeholder='Book follow-up meeting' />
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextareaField
|
||||
name='description'
|
||||
size='md'
|
||||
label='Description'
|
||||
placeholder='Business context and execution notes'
|
||||
/>
|
||||
</div>
|
||||
<FormCheckboxField name='isInternalOnly' label='Internal only' />
|
||||
<FormCheckboxField name='isPricingSensitive' label='Pricing sensitive' />
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='crm-activity-form' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'Update Activity' : 'Create Activity'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
88
src/features/crm/activities/components/activity-listing.tsx
Normal file
88
src/features/crm/activities/components/activity-listing.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { ActivityListFilters } from '../api/types';
|
||||
import { activitiesQueryOptions, activityReferenceDataOptions } from '../api/queries';
|
||||
import { ActivityBadge } from './activity-badge';
|
||||
import { ActivityDetail } from './activity-detail';
|
||||
import { ActivityFormSheet } from './activity-form-sheet';
|
||||
import { ActivityPriorityBadge } from './activity-priority-badge';
|
||||
import { ActivityStatusBadge } from './activity-status-badge';
|
||||
|
||||
export function ActivityListing({ initialFilters }: { initialFilters: ActivityListFilters }) {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const { data } = useSuspenseQuery(activitiesQueryOptions(initialFilters));
|
||||
const { data: referenceData } = useSuspenseQuery(activityReferenceDataOptions());
|
||||
|
||||
const selectedActivity = useMemo(
|
||||
() => data.items.find((item) => item.id === (selectedId ?? data.items[0]?.id)) ?? null,
|
||||
[data.items, selectedId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-end'>
|
||||
<Button onClick={() => setSheetOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Create Activity
|
||||
</Button>
|
||||
</div>
|
||||
<div className='grid gap-4 lg:grid-cols-[1.2fr_0.8fr]'>
|
||||
<div className='space-y-3'>
|
||||
{!data.items.length ? (
|
||||
<Card>
|
||||
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||
No activities created yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
data.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type='button'
|
||||
className='w-full text-left'
|
||||
aria-label={`View activity ${item.subject}`}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
<Card className='transition-colors hover:bg-muted/40'>
|
||||
<CardContent className='space-y-3 p-4'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='font-medium'>{item.subject}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{item.primaryRecordLabel ?? 'Unlinked activity'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<ActivityStatusBadge status={item.effectiveStatus} />
|
||||
<ActivityPriorityBadge priority={item.priority} />
|
||||
<ActivityBadge activityType={item.activityType} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Owner: {item.ownerName ?? '-'} | Assignee: {item.assignedToName ?? '-'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<ActivityDetail activity={selectedActivity ?? null} />
|
||||
</div>
|
||||
<ActivityFormSheet
|
||||
activity={undefined}
|
||||
open={sheetOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSheetOpen(open);
|
||||
}}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatCrmActivityLabel, type CrmActivityPriority } from '@/features/crm/activity/types';
|
||||
|
||||
const PRIORITY_VARIANTS: Record<
|
||||
CrmActivityPriority,
|
||||
'secondary' | 'outline' | 'default' | 'destructive'
|
||||
> = {
|
||||
low: 'outline',
|
||||
normal: 'secondary',
|
||||
high: 'default',
|
||||
critical: 'destructive'
|
||||
};
|
||||
|
||||
export function ActivityPriorityBadge({ priority }: { priority: CrmActivityPriority }) {
|
||||
return <Badge variant={PRIORITY_VARIANTS[priority]}>{formatCrmActivityLabel(priority)}</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatCrmActivityLabel, type CrmActivityStatus } from '@/features/crm/activity/types';
|
||||
|
||||
const STATUS_VARIANTS: Record<CrmActivityStatus, 'secondary' | 'outline' | 'default' | 'destructive'> = {
|
||||
draft: 'outline',
|
||||
scheduled: 'secondary',
|
||||
in_progress: 'default',
|
||||
completed: 'secondary',
|
||||
cancelled: 'destructive',
|
||||
skipped: 'outline',
|
||||
overdue: 'destructive'
|
||||
};
|
||||
|
||||
export function ActivityStatusBadge({ status }: { status: CrmActivityStatus }) {
|
||||
return <Badge variant={STATUS_VARIANTS[status]}>{formatCrmActivityLabel(status)}</Badge>;
|
||||
}
|
||||
Reference in New Issue
Block a user