task-ep.1.7

This commit is contained in:
phaichayon
2026-07-13 16:55:55 +07:00
parent ab56852c47
commit e0fcb3992b
12 changed files with 1762 additions and 101 deletions

View File

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