task-ep.1.6

This commit is contained in:
phaichayon
2026-07-13 16:29:40 +07:00
parent 2bc8dd184c
commit ab56852c47
23 changed files with 10693 additions and 4 deletions

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

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