task-ep.1.7
This commit is contained in:
@@ -1,77 +1,89 @@
|
||||
'use client';
|
||||
"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';
|
||||
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'>;
|
||||
filters: Omit<CalendarListFilters, "organizationId">;
|
||||
}
|
||||
|
||||
const VIEW_LABELS: Record<CalendarView, string> = {
|
||||
day: 'Day',
|
||||
week: 'Week',
|
||||
month: 'Month',
|
||||
agenda: 'Agenda'
|
||||
day: "Day",
|
||||
week: "Week",
|
||||
month: "Month",
|
||||
agenda: "Agenda",
|
||||
};
|
||||
|
||||
export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
|
||||
const [view, setView] = useState<CalendarView>('agenda');
|
||||
const [search, setSearch] = useState('');
|
||||
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]
|
||||
.filter((event) =>
|
||||
event.title.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
[data.items, search],
|
||||
);
|
||||
const groupedEvents = groupEventsByDate(events);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<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'>
|
||||
<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' />
|
||||
<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 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' />
|
||||
<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]'
|
||||
placeholder="Search calendar"
|
||||
className="w-full pl-8 sm:w-[240px]"
|
||||
/>
|
||||
</div>
|
||||
<Badge variant='outline'>Lens: {filters.lens ?? 'personal'}</Badge>
|
||||
<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]'>
|
||||
<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}
|
||||
@@ -79,17 +91,21 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='agenda'>
|
||||
<TabsContent value="agenda">
|
||||
<AgendaView groupedEvents={groupedEvents} />
|
||||
</TabsContent>
|
||||
<TabsContent value='day'>
|
||||
<TabsContent value="day">
|
||||
<CompactCalendarGrid events={events.slice(0, 12)} columns={1} />
|
||||
</TabsContent>
|
||||
<TabsContent value='week'>
|
||||
<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 value="month">
|
||||
<CompactCalendarGrid
|
||||
events={events.slice(0, 42)}
|
||||
columns={7}
|
||||
month
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
@@ -100,36 +116,40 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
|
||||
|
||||
export function CalendarWorkspaceSkeleton() {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
<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' />
|
||||
<Skeleton key={index} className="h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='h-[520px] rounded-xl' />
|
||||
<Skeleton className="h-[520px] rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarSummaryCards({ summary }: { summary: CalendarWorkspaceSummary }) {
|
||||
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 }
|
||||
{ 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'>
|
||||
<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' />
|
||||
<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>
|
||||
<p className="text-2xl font-bold">{card.value}</p>
|
||||
<p className="text-muted-foreground text-xs">{card.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -138,23 +158,27 @@ function CalendarSummaryCards({ summary }: { summary: CalendarWorkspaceSummary }
|
||||
);
|
||||
}
|
||||
|
||||
function AgendaView({ groupedEvents }: { groupedEvents: Array<[string, BigCalendarEvent[]]> }) {
|
||||
function AgendaView({
|
||||
groupedEvents,
|
||||
}: {
|
||||
groupedEvents: Array<[string, BigCalendarEvent[]]>;
|
||||
}) {
|
||||
if (!groupedEvents.length) {
|
||||
return <CalendarEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-5'>
|
||||
<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'>
|
||||
<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 className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
<div className='grid gap-3'>
|
||||
<div className="grid gap-3">
|
||||
{events.map((event) => (
|
||||
<CalendarEventCard key={event.id} event={event} />
|
||||
))}
|
||||
@@ -168,7 +192,7 @@ function AgendaView({ groupedEvents }: { groupedEvents: Array<[string, BigCalend
|
||||
function CompactCalendarGrid({
|
||||
events,
|
||||
columns,
|
||||
month
|
||||
month,
|
||||
}: {
|
||||
events: BigCalendarEvent[];
|
||||
columns: number;
|
||||
@@ -181,20 +205,22 @@ function CompactCalendarGrid({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3',
|
||||
columns === 1 && 'grid-cols-1',
|
||||
columns === 7 && 'grid-cols-1 md:grid-cols-7'
|
||||
"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'
|
||||
"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>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatDate(event.start)}
|
||||
</p>
|
||||
<CalendarEventPill event={event} />
|
||||
</div>
|
||||
))}
|
||||
@@ -204,41 +230,49 @@ function CompactCalendarGrid({
|
||||
|
||||
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('_', ' ')}
|
||||
<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}
|
||||
{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>
|
||||
<h3 className="font-medium">{event.title}</h3>
|
||||
{event.display.summary ? (
|
||||
<p className='text-muted-foreground text-sm'>{event.display.summary}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{event.display.summary}
|
||||
</p>
|
||||
) : null}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatTime(event.start)} - {formatTime(event.end)}
|
||||
{event.display.location ? ` · ${event.display.location}` : ''}
|
||||
{event.display.location ? ` · ${event.display.location}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button variant='outline' size='sm'>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Open Source
|
||||
</Button>
|
||||
{event.editable ? (
|
||||
<>
|
||||
<Button variant='outline' size='sm'>
|
||||
<Button variant="outline" size="sm">
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button variant='outline' size='sm'>
|
||||
<Button variant="outline" size="sm">
|
||||
Complete
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant='outline' size='sm'>
|
||||
<Button variant="outline" size="sm">
|
||||
Create Activity
|
||||
</Button>
|
||||
)}
|
||||
@@ -252,30 +286,33 @@ function CalendarEventPill({ event }: { event: BigCalendarEvent }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 rounded-md border px-2 py-1 text-xs',
|
||||
"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'
|
||||
? "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}
|
||||
<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'>
|
||||
<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[]]> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user