task-ep.1.1

This commit is contained in:
phaichayon
2026-07-10 10:33:45 +07:00
parent f47fea5d6f
commit 9ae4f31f28
33 changed files with 9680 additions and 22 deletions

View File

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

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

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

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

View File

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

View File

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