task-ep.1.6
This commit is contained in:
37
src/features/crm/calendar/components/calendar-adapter.ts
Normal file
37
src/features/crm/calendar/components/calendar-adapter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { BigCalendarEvent, CalendarProjectionItem } from '../api/types';
|
||||
|
||||
export function mapCalendarProjectionToBigCalendarEvent(
|
||||
item: CalendarProjectionItem
|
||||
): BigCalendarEvent {
|
||||
const activityEditable = item.editable && !item.milestone && item.activityId !== null;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
start: item.startAt,
|
||||
end: item.endAt,
|
||||
allDay: item.allDay,
|
||||
editable: activityEditable,
|
||||
draggable: activityEditable,
|
||||
resizable: activityEditable,
|
||||
category: item.category,
|
||||
priority: item.priority,
|
||||
source: {
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId,
|
||||
activityId: item.activityId,
|
||||
customerId: item.customerId,
|
||||
leadId: item.leadId,
|
||||
opportunityId: item.opportunityId,
|
||||
quotationId: item.quotationId,
|
||||
approvalId: item.approvalId
|
||||
},
|
||||
display: {
|
||||
summary: item.summary,
|
||||
location: item.location,
|
||||
overdue: item.overdue,
|
||||
hotProject: item.hotProject,
|
||||
milestone: item.milestone
|
||||
}
|
||||
};
|
||||
}
|
||||
295
src/features/crm/calendar/components/calendar-workspace.tsx
Normal file
295
src/features/crm/calendar/components/calendar-workspace.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDate, formatTime } from '@/lib/date-format';
|
||||
import { calendarItemsQueryOptions } from '../api/queries';
|
||||
import type { BigCalendarEvent, CalendarListFilters, CalendarView } from '../api/types';
|
||||
import { mapCalendarProjectionToBigCalendarEvent } from './calendar-adapter';
|
||||
|
||||
interface CalendarWorkspaceProps {
|
||||
filters: Omit<CalendarListFilters, 'organizationId'>;
|
||||
}
|
||||
|
||||
const VIEW_LABELS: Record<CalendarView, string> = {
|
||||
day: 'Day',
|
||||
week: 'Week',
|
||||
month: 'Month',
|
||||
agenda: 'Agenda'
|
||||
};
|
||||
|
||||
export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
|
||||
const [view, setView] = useState<CalendarView>('agenda');
|
||||
const [search, setSearch] = useState('');
|
||||
const { data } = useSuspenseQuery(calendarItemsQueryOptions(filters));
|
||||
const events = useMemo(
|
||||
() =>
|
||||
data.items
|
||||
.map(mapCalendarProjectionToBigCalendarEvent)
|
||||
.filter((event) => event.title.toLowerCase().includes(search.toLowerCase())),
|
||||
[data.items, search]
|
||||
);
|
||||
const groupedEvents = groupEventsByDate(events);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<CalendarSummaryCards summary={data.summary} />
|
||||
|
||||
<Card>
|
||||
<CardHeader className='gap-4'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Icons.calendar className='h-5 w-5' />
|
||||
Calendar Workspace
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Projection workspace for scheduled activities and read-only business milestones.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
|
||||
<div className='relative'>
|
||||
<Icons.search className='text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4' />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder='Search calendar'
|
||||
className='w-full pl-8 sm:w-[240px]'
|
||||
/>
|
||||
</div>
|
||||
<Badge variant='outline'>Lens: {filters.lens ?? 'personal'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={view} onValueChange={(value) => setView(value as CalendarView)}>
|
||||
<TabsList className='mb-4 grid w-full grid-cols-4 lg:w-[420px]'>
|
||||
{Object.entries(VIEW_LABELS).map(([value, label]) => (
|
||||
<TabsTrigger key={value} value={value}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='agenda'>
|
||||
<AgendaView groupedEvents={groupedEvents} />
|
||||
</TabsContent>
|
||||
<TabsContent value='day'>
|
||||
<CompactCalendarGrid events={events.slice(0, 12)} columns={1} />
|
||||
</TabsContent>
|
||||
<TabsContent value='week'>
|
||||
<CompactCalendarGrid events={events.slice(0, 28)} columns={7} />
|
||||
</TabsContent>
|
||||
<TabsContent value='month'>
|
||||
<CompactCalendarGrid events={events.slice(0, 42)} columns={7} month />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarWorkspaceSkeleton() {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className='h-24 rounded-xl' />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='h-[520px] rounded-xl' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarSummaryCards({ summary }: { summary: CalendarWorkspaceSummary }) {
|
||||
const cards = [
|
||||
{ label: 'Due Today', value: summary.dueToday, icon: Icons.calendar },
|
||||
{ label: 'Overdue', value: summary.overdue, icon: Icons.warning },
|
||||
{ label: 'Meetings', value: summary.meetings, icon: Icons.clock },
|
||||
{ label: 'Hot Projects', value: summary.hotProjects, icon: Icons.check }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<CardContent className='flex items-center gap-3 p-4'>
|
||||
<div className='bg-muted rounded-lg p-2'>
|
||||
<card.icon className='text-muted-foreground h-5 w-5' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-2xl font-bold'>{card.value}</p>
|
||||
<p className='text-muted-foreground text-xs'>{card.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgendaView({ groupedEvents }: { groupedEvents: Array<[string, BigCalendarEvent[]]> }) {
|
||||
if (!groupedEvents.length) {
|
||||
return <CalendarEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-5'>
|
||||
{groupedEvents.map(([date, events]) => (
|
||||
<section key={date} className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='bg-border h-px flex-1' />
|
||||
<p className='text-muted-foreground text-xs font-medium uppercase tracking-wide'>
|
||||
{formatDate(date)}
|
||||
</p>
|
||||
<div className='bg-border h-px flex-1' />
|
||||
</div>
|
||||
<div className='grid gap-3'>
|
||||
{events.map((event) => (
|
||||
<CalendarEventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCalendarGrid({
|
||||
events,
|
||||
columns,
|
||||
month
|
||||
}: {
|
||||
events: BigCalendarEvent[];
|
||||
columns: number;
|
||||
month?: boolean;
|
||||
}) {
|
||||
if (!events.length) {
|
||||
return <CalendarEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3',
|
||||
columns === 1 && 'grid-cols-1',
|
||||
columns === 7 && 'grid-cols-1 md:grid-cols-7'
|
||||
)}
|
||||
>
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
'border-border/70 bg-muted/20 min-h-28 rounded-lg border p-2',
|
||||
month && 'min-h-36'
|
||||
)}
|
||||
>
|
||||
<p className='text-muted-foreground text-xs'>{formatDate(event.start)}</p>
|
||||
<CalendarEventPill event={event} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEventCard({ event }: { event: BigCalendarEvent }) {
|
||||
return (
|
||||
<article className='border-border/70 hover:bg-muted/30 rounded-lg border p-4 transition-colors'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge variant={event.display.milestone ? 'secondary' : 'outline'}>
|
||||
{event.category.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
{event.display.overdue ? <Badge variant='destructive'>Overdue</Badge> : null}
|
||||
{event.display.hotProject ? <Badge variant='default'>Hot Project</Badge> : null}
|
||||
{!event.editable ? <Badge variant='outline'>Read-only</Badge> : null}
|
||||
</div>
|
||||
<h3 className='font-medium'>{event.title}</h3>
|
||||
{event.display.summary ? (
|
||||
<p className='text-muted-foreground text-sm'>{event.display.summary}</p>
|
||||
) : null}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatTime(event.start)} - {formatTime(event.end)}
|
||||
{event.display.location ? ` · ${event.display.location}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button variant='outline' size='sm'>
|
||||
Open Source
|
||||
</Button>
|
||||
{event.editable ? (
|
||||
<>
|
||||
<Button variant='outline' size='sm'>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button variant='outline' size='sm'>
|
||||
Complete
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant='outline' size='sm'>
|
||||
Create Activity
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEventPill({ event }: { event: BigCalendarEvent }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 rounded-md border px-2 py-1 text-xs',
|
||||
event.display.overdue
|
||||
? 'border-destructive/40 bg-destructive/10 text-destructive'
|
||||
: 'border-primary/20 bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium'>{formatTime(event.start)}</span> {event.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEmptyState() {
|
||||
return (
|
||||
<div className='border-border/70 rounded-xl border border-dashed p-10 text-center'>
|
||||
<Icons.calendar className='text-muted-foreground mx-auto mb-3 h-8 w-8' />
|
||||
<p className='font-medium'>No scheduled work in this period</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Try changing filters or create an Activity from the owning CRM record.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupEventsByDate(events: BigCalendarEvent[]): Array<[string, BigCalendarEvent[]]> {
|
||||
const grouped = new Map<string, BigCalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const key = event.start.slice(0, 10);
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), event]);
|
||||
}
|
||||
return [...grouped.entries()];
|
||||
}
|
||||
|
||||
type CalendarWorkspaceSummary = {
|
||||
dueToday: number;
|
||||
overdue: number;
|
||||
meetings: number;
|
||||
visits: number;
|
||||
milestones: number;
|
||||
approvalDue: number;
|
||||
hotProjects: number;
|
||||
};
|
||||
Reference in New Issue
Block a user