commit task-ep.1.6.2-r1

This commit is contained in:
phaichayon
2026-07-13 22:29:11 +07:00
parent 7d7548ebd4
commit bb5a910b88
49 changed files with 4663 additions and 361 deletions

View File

@@ -0,0 +1,38 @@
import { differenceInDays, format, parseISO, startOfDay } from "date-fns";
import { AgendaEventCard } from "@/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-event-card";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
date: Date;
events: IEvent[];
multiDayEvents: IEvent[];
}
export function AgendaDayGroup({ date, events, multiDayEvents }: IProps) {
const sortedEvents = [...events].toSorted((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
return (
<div className="space-y-4">
<div className="sticky top-0 flex items-center gap-4 bg-background py-2">
<p className="text-sm font-semibold">{format(date, "EEEE, MMMM d, yyyy")}</p>
</div>
<div className="space-y-2">
{multiDayEvents.length > 0 &&
multiDayEvents.map(event => {
const eventStart = startOfDay(parseISO(event.startDate));
const eventEnd = startOfDay(parseISO(event.endDate));
const currentDate = startOfDay(date);
const eventTotalDays = differenceInDays(eventEnd, eventStart) + 1;
const eventCurrentDay = differenceInDays(currentDate, eventStart) + 1;
return <AgendaEventCard key={event.id} event={event} eventCurrentDay={eventCurrentDay} eventTotalDays={eventTotalDays} />;
})}
{sortedEvents.length > 0 && sortedEvents.map(event => <AgendaEventCard key={event.id} event={event} />)}
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
/* oxlint-disable unicorn/consistent-function-scoping */
"use client";
import { format, parseISO } from "date-fns";
import { cva } from "class-variance-authority";
import { Clock, Text, User } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { EventDetailsDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/event-details-dialog";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { VariantProps } from "class-variance-authority";
const agendaEventCardVariants = cva(
"flex select-none items-center justify-between gap-3 rounded-md border p-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
{
variants: {
color: {
// Colored variants
blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300 [&_.event-dot]:fill-blue-600",
green: "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300 [&_.event-dot]:fill-green-600",
red: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300 [&_.event-dot]:fill-red-600",
yellow: "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300 [&_.event-dot]:fill-yellow-600",
purple: "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300 [&_.event-dot]:fill-purple-600",
orange: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300 [&_.event-dot]:fill-orange-600",
gray: "border-neutral-200 bg-neutral-50 text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 [&_.event-dot]:fill-neutral-600",
// Dot variants
"blue-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-blue-600",
"green-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-green-600",
"red-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-red-600",
"orange-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-orange-600",
"purple-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-purple-600",
"yellow-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-yellow-600",
"gray-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-neutral-600",
},
},
defaultVariants: {
color: "blue-dot",
},
}
);
interface IProps {
event: IEvent;
eventCurrentDay?: number;
eventTotalDays?: number;
}
export function AgendaEventCard({ event, eventCurrentDay, eventTotalDays }: IProps) {
const { badgeVariant } = useCalendar();
const startDate = parseISO(event.startDate);
const endDate = parseISO(event.endDate);
const color = (badgeVariant === "dot" ? `${event.color}-dot` : event.color) as VariantProps<typeof agendaEventCardVariants>["color"];
const agendaEventCardClasses = agendaEventCardVariants({ color });
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (e.currentTarget instanceof HTMLElement) e.currentTarget.click();
}
};
return (
<EventDetailsDialog event={event}>
<div
role="button"
tabIndex={0}
aria-label={`Open calendar event ${event.title}`}
className={agendaEventCardClasses}
onKeyDown={handleKeyDown}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
{["mixed", "dot"].includes(badgeVariant) && (
<svg width="8" height="8" viewBox="0 0 8 8" className="event-dot shrink-0">
<circle cx="4" cy="4" r="4" />
</svg>
)}
<p className="font-medium">
{eventCurrentDay && eventTotalDays && (
<span className="mr-1 text-xs">
Day {eventCurrentDay} of {eventTotalDays} {" "}
</span>
)}
{event.title}
</p>
</div>
<div className="mt-1 flex items-center gap-1">
<User className="size-3 shrink-0" />
<p className="text-xs text-foreground">{event.user.name}</p>
</div>
<div className="flex items-center gap-1">
<Clock className="size-3 shrink-0" />
<p className="text-xs text-foreground">
{format(startDate, "h:mm a")} - {format(endDate, "h:mm a")}
</p>
</div>
<div className="flex items-center gap-1">
<Text className="size-3 shrink-0" />
<p className="text-xs text-foreground">{event.description}</p>
</div>
</div>
</div>
</EventDetailsDialog>
);
}

View File

@@ -0,0 +1,80 @@
import { useMemo } from "react";
import { CalendarX2 } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { parseISO, format, endOfDay, startOfDay, isSameMonth } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AgendaDayGroup } from "@/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-day-group";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
singleDayEvents: IEvent[];
multiDayEvents: IEvent[];
}
export function CalendarAgendaView({ singleDayEvents, multiDayEvents }: IProps) {
const { selectedDate } = useCalendar();
const eventsByDay = useMemo(() => {
const allDates = new Map<string, { date: Date; events: IEvent[]; multiDayEvents: IEvent[] }>();
singleDayEvents.forEach(event => {
const eventDate = parseISO(event.startDate);
if (!isSameMonth(eventDate, selectedDate)) return;
const dateKey = format(eventDate, "yyyy-MM-dd");
if (!allDates.has(dateKey)) {
allDates.set(dateKey, { date: startOfDay(eventDate), events: [], multiDayEvents: [] });
}
allDates.get(dateKey)?.events.push(event);
});
multiDayEvents.forEach(event => {
const eventStart = parseISO(event.startDate);
const eventEnd = parseISO(event.endDate);
let currentDate = startOfDay(eventStart);
const lastDate = endOfDay(eventEnd);
while (currentDate <= lastDate) {
if (isSameMonth(currentDate, selectedDate)) {
const dateKey = format(currentDate, "yyyy-MM-dd");
if (!allDates.has(dateKey)) {
allDates.set(dateKey, { date: new Date(currentDate), events: [], multiDayEvents: [] });
}
allDates.get(dateKey)?.multiDayEvents.push(event);
}
currentDate = new Date(currentDate.setDate(currentDate.getDate() + 1));
}
});
return Array.from(allDates.values()).toSorted((a, b) => a.date.getTime() - b.date.getTime());
}, [singleDayEvents, multiDayEvents, selectedDate]);
const hasAnyEvents = singleDayEvents.length > 0 || multiDayEvents.length > 0;
return (
<div className="h-[800px]">
<ScrollArea className="h-full" type="always">
<div className="space-y-6 p-4">
{eventsByDay.map(dayGroup => (
<AgendaDayGroup key={format(dayGroup.date, "yyyy-MM-dd")} date={dayGroup.date} events={dayGroup.events} multiDayEvents={dayGroup.multiDayEvents} />
))}
{!hasAnyEvents && (
<div className="flex flex-col items-center justify-center gap-2 py-20 text-muted-foreground">
<CalendarX2 className="size-10" />
<p className="text-sm md:text-base">No events scheduled for the selected month</p>
</div>
)}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function AvatarGroup({
children,
className
}: {
children: ReactNode;
max?: number;
className?: string;
}) {
return <div className={cn("flex -space-x-2", className)}>{children}</div>;
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function ChangeBadgeVariantInput() {
const { badgeVariant, setBadgeVariant } = useCalendar();
return (
<div className="space-y-2">
<p className="text-sm font-semibold">Change badge variant</p>
<Select value={badgeVariant} onValueChange={setBadgeVariant}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dot">Dot</SelectItem>
<SelectItem value="colored">Colored</SelectItem>
<SelectItem value="mixed">Mixed</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useMemo } from "react";
import { isSameDay, parseISO } from "date-fns";
import { CalendarAgendaView } from "@/features/crm/calendar/upstream-big-calendar/components/agenda-view/calendar-agenda-view";
import { DndProviderWrapper } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/dnd-provider";
import { CalendarHeader } from "@/features/crm/calendar/upstream-big-calendar/components/header/calendar-header";
import { CalendarMonthView } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/calendar-month-view";
import { CalendarDayView } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-day-view";
import { CalendarWeekView } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-week-view";
import { CalendarYearView } from "@/features/crm/calendar/upstream-big-calendar/components/year-view/calendar-year-view";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
export function ClientContainer() {
const { selectedDate, selectedUserId, selectedView, events } = useCalendar();
const filteredEvents = useMemo(() => {
return events.filter((event) => {
const eventStartDate = parseISO(event.startDate);
const eventEndDate = parseISO(event.endDate);
const isUserMatch = selectedUserId === "all" || event.user.id === selectedUserId;
if (selectedView === "year") {
const yearStart = new Date(selectedDate.getFullYear(), 0, 1);
const yearEnd = new Date(selectedDate.getFullYear(), 11, 31, 23, 59, 59, 999);
return eventStartDate <= yearEnd && eventEndDate >= yearStart && isUserMatch;
}
if (selectedView === "month" || selectedView === "agenda") {
const monthStart = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
const monthEnd = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth() + 1,
0,
23,
59,
59,
999
);
return eventStartDate <= monthEnd && eventEndDate >= monthStart && isUserMatch;
}
if (selectedView === "week") {
const weekStart = new Date(selectedDate);
weekStart.setDate(selectedDate.getDate() - selectedDate.getDay());
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
return eventStartDate <= weekEnd && eventEndDate >= weekStart && isUserMatch;
}
return (
(isSameDay(eventStartDate, selectedDate) || isSameDay(eventEndDate, selectedDate)) &&
isUserMatch
);
});
}, [events, selectedDate, selectedUserId, selectedView]);
const singleDayEvents = filteredEvents.filter((event) => {
const startDate = parseISO(event.startDate);
const endDate = parseISO(event.endDate);
return isSameDay(startDate, endDate);
});
const multiDayEvents = filteredEvents.filter((event) => {
const startDate = parseISO(event.startDate);
const endDate = parseISO(event.endDate);
return !isSameDay(startDate, endDate);
});
const eventStartDates = useMemo(() => {
return filteredEvents.map((event) => ({
...event,
endDate: event.startDate
}));
}, [filteredEvents]);
return (
<div className="overflow-hidden rounded-xl border">
<CalendarHeader view={selectedView} events={filteredEvents} />
<DndProviderWrapper>
{selectedView === "day" && (
<CalendarDayView singleDayEvents={singleDayEvents} multiDayEvents={multiDayEvents} />
)}
{selectedView === "month" && (
<CalendarMonthView singleDayEvents={singleDayEvents} multiDayEvents={multiDayEvents} />
)}
{selectedView === "week" && (
<CalendarWeekView singleDayEvents={singleDayEvents} multiDayEvents={multiDayEvents} />
)}
{selectedView === "year" && <CalendarYearView allEvents={eventStartDates} />}
{selectedView === "agenda" && (
<CalendarAgendaView singleDayEvents={singleDayEvents} multiDayEvents={multiDayEvents} />
)}
</DndProviderWrapper>
</div>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
interface IProps {
children: ReactNode;
startDate?: Date;
startTime?: { hour: number; minute: number };
}
export function AddEventDialog({ children }: IProps) {
return (
<Link href="/dashboard/crm/activities" aria-label="Create related Activity">
{children}
</Link>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
event: IEvent;
children: ReactNode;
}
export function EditEventDialog({ event, children }: IProps) {
return (
<Link href={`/dashboard/crm/activities?calendarEventId=${event.id}`}>
{children}
</Link>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { format, parseISO } from "date-fns";
import { Calendar, Clock, Text, User } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { Button } from "@/components/ui/button";
import { EditEventDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/edit-event-dialog";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
event: IEvent;
children: React.ReactNode;
}
export function EventDetailsDialog({ event, children }: IProps) {
const startDate = parseISO(event.startDate);
const endDate = parseISO(event.endDate);
return (
<>
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{event.title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-start gap-2">
<User className="mt-1 size-4 shrink-0" />
<div>
<p className="text-sm font-medium">Responsible</p>
<p className="text-sm text-muted-foreground">{event.user.name}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="mt-1 size-4 shrink-0" />
<div>
<p className="text-sm font-medium">Start Date</p>
<p className="text-sm text-muted-foreground">{format(startDate, "MMM d, yyyy h:mm a")}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Clock className="mt-1 size-4 shrink-0" />
<div>
<p className="text-sm font-medium">End Date</p>
<p className="text-sm text-muted-foreground">{format(endDate, "MMM d, yyyy h:mm a")}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Text className="mt-1 size-4 shrink-0" />
<div>
<p className="text-sm font-medium">Description</p>
<p className="text-sm text-muted-foreground">{event.description}</p>
</div>
</div>
</div>
<DialogFooter>
<EditEventDialog event={event}>
<Button type="button" variant="outline">
Edit
</Button>
</EditEventDialog>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useDragLayer } from "react-dnd";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IDragItem {
event: IEvent;
children: React.ReactNode;
width: number;
height: number;
}
export function CustomDragLayer() {
const { isDragging, item, currentOffset, initialOffset, initialClientOffset } = useDragLayer(monitor => ({
item: monitor.getItem() as IDragItem | null,
itemType: monitor.getItemType(),
isDragging: monitor.isDragging(),
currentOffset: monitor.getClientOffset(),
initialOffset: monitor.getInitialSourceClientOffset(),
initialClientOffset: monitor.getInitialClientOffset(),
}));
if (!isDragging || !item || !currentOffset || !initialOffset || !initialClientOffset) {
return null;
}
const offsetX = initialClientOffset.x - initialOffset.x;
const offsetY = initialClientOffset.y - initialOffset.y;
const layerStyles: React.CSSProperties = {
position: "fixed",
pointerEvents: "none",
zIndex: 100,
left: currentOffset.x - offsetX,
top: currentOffset.y - offsetY,
};
return (
<div style={layerStyles}>
<div
className=""
style={{
width: item.width,
height: item.height,
}}
>
{item.children}
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { CustomDragLayer } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/custom-drag-layer";
interface DndProviderWrapperProps {
children: React.ReactNode;
}
export function DndProviderWrapper({ children }: DndProviderWrapperProps) {
return (
<DndProvider backend={HTML5Backend}>
{children}
<CustomDragLayer />
</DndProvider>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useDrag } from "react-dnd";
import { useRef, useEffect } from "react";
import { getEmptyImage } from "react-dnd-html5-backend";
import { cn } from "@/lib/utils";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
export const ItemTypes = {
EVENT: "event",
};
interface DraggableEventProps {
event: IEvent;
children: React.ReactNode;
}
export function DraggableEvent({ event, children }: DraggableEventProps) {
const ref = useRef<HTMLDivElement>(null);
const canDrag = event.alla?.editable !== false;
const [{ isDragging }, drag, preview] = useDrag(() => ({
type: ItemTypes.EVENT,
canDrag: () => canDrag,
item: () => {
const width = ref.current?.offsetWidth || 0;
const height = ref.current?.offsetHeight || 0;
return { event, children, width, height };
},
collect: monitor => ({ isDragging: monitor.isDragging() }),
}), [canDrag, children, event]);
// Hide the default drag preview
useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true });
}, [preview]);
if (canDrag) drag(ref);
return (
<div ref={ref} className={cn(isDragging && "opacity-40")}>
{children}
</div>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useDrop } from "react-dnd";
import { parseISO, differenceInMilliseconds } from "date-fns";
import { useUpdateEvent } from "@/features/crm/calendar/upstream-big-calendar/hooks/use-update-event";
import { cn } from "@/lib/utils";
import { ItemTypes } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event";
import type { IEvent, ICalendarCell } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface DroppableDayCellProps {
cell: ICalendarCell;
children: React.ReactNode;
}
export function DroppableDayCell({ cell, children }: DroppableDayCellProps) {
const { updateEvent } = useUpdateEvent();
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: ItemTypes.EVENT,
drop: (item: { event: IEvent }) => {
const droppedEvent = item.event;
const eventStartDate = parseISO(droppedEvent.startDate);
const eventEndDate = parseISO(droppedEvent.endDate);
const eventDurationMs = differenceInMilliseconds(eventEndDate, eventStartDate);
const newStartDate = new Date(cell.date);
newStartDate.setHours(eventStartDate.getHours(), eventStartDate.getMinutes(), eventStartDate.getSeconds(), eventStartDate.getMilliseconds());
const newEndDate = new Date(newStartDate.getTime() + eventDurationMs);
updateEvent({
...droppedEvent,
startDate: newStartDate.toISOString(),
endDate: newEndDate.toISOString(),
});
return { moved: true };
},
collect: monitor => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[cell.date, updateEvent]
);
return (
<div ref={drop as unknown as React.RefObject<HTMLDivElement>} className={cn(isOver && canDrop && "bg-accent/50")}>
{children}
</div>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { useDrop } from "react-dnd";
import { parseISO, differenceInMilliseconds } from "date-fns";
import { useUpdateEvent } from "@/features/crm/calendar/upstream-big-calendar/hooks/use-update-event";
import { cn } from "@/lib/utils";
import { ItemTypes } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface DroppableTimeBlockProps {
date: Date;
hour: number;
minute: number;
children: React.ReactNode;
}
export function DroppableTimeBlock({ date, hour, minute, children }: DroppableTimeBlockProps) {
const { updateEvent } = useUpdateEvent();
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: ItemTypes.EVENT,
canDrop: (item: { event: IEvent }) => item.event.alla?.editable !== false,
drop: (item: { event: IEvent }) => {
const droppedEvent = item.event;
const eventStartDate = parseISO(droppedEvent.startDate);
const eventEndDate = parseISO(droppedEvent.endDate);
const eventDurationMs = differenceInMilliseconds(eventEndDate, eventStartDate);
const newStartDate = new Date(date);
newStartDate.setHours(hour, minute, 0, 0);
const newEndDate = new Date(newStartDate.getTime() + eventDurationMs);
updateEvent({
...droppedEvent,
startDate: newStartDate.toISOString(),
endDate: newEndDate.toISOString(),
});
return { moved: true };
},
collect: monitor => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[date, hour, minute, updateEvent]
);
return (
<div ref={drop as unknown as React.RefObject<HTMLDivElement>} className={cn("h-[24px]", isOver && canDrop && "bg-accent/50")}>
{children}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { CalendarRange, Columns, Grid2x2, Grid3x3, List, Plus } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { Button } from "@/components/ui/button";
import { UserSelect } from "@/features/crm/calendar/upstream-big-calendar/components/header/user-select";
import { TodayButton } from "@/features/crm/calendar/upstream-big-calendar/components/header/today-button";
import { DateNavigator } from "@/features/crm/calendar/upstream-big-calendar/components/header/date-navigator";
import { AddEventDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/add-event-dialog";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { TCalendarView } from "@/features/crm/calendar/upstream-big-calendar/types";
interface IProps {
view: TCalendarView;
events: IEvent[];
}
const VIEW_BUTTONS: Array<{
view: TCalendarView;
label: string;
icon: typeof List;
}> = [
{ view: "day", label: "View by day", icon: List },
{ view: "week", label: "View by week", icon: Columns },
{ view: "month", label: "View by month", icon: Grid3x3 },
{ view: "year", label: "View by year", icon: Grid2x2 },
{ view: "agenda", label: "View by agenda", icon: CalendarRange }
];
export function CalendarHeader({ view, events }: IProps) {
const { setSelectedView } = useCalendar();
return (
<div className="flex flex-col gap-4 border-b p-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<TodayButton />
<DateNavigator view={view} events={events} />
</div>
<div className="flex flex-col items-center gap-1.5 sm:flex-row sm:justify-between">
<div className="flex w-full items-center gap-1.5">
<div className="inline-flex first:rounded-r-none last:rounded-l-none [&:not(:first-child):not(:last-child)]:rounded-none">
{VIEW_BUTTONS.map((item, index) => {
const Icon = item.icon;
return (
<Button
key={item.view}
aria-label={item.label}
size="icon"
type="button"
variant={view === item.view ? "default" : "outline"}
className={[
"[&_svg]:size-5",
index === 0 ? "rounded-r-none" : "",
index === VIEW_BUTTONS.length - 1 ? "rounded-l-none" : "",
index > 0 && index < VIEW_BUTTONS.length - 1 ? "rounded-none" : "",
index > 0 ? "border-l-0" : ""
].join(" ")}
onClick={() => setSelectedView(item.view)}
>
<Icon strokeWidth={1.8} />
</Button>
);
})}
</div>
<UserSelect />
</div>
<AddEventDialog>
<Button className="w-full sm:w-auto" type="button">
<Plus className="size-4" />
Add Activity
</Button>
</AddEventDialog>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useMemo } from "react";
import { formatDate } from "date-fns";
import { ChevronLeft, ChevronRight } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { getEventsCount, navigateDate, rangeText } from "@/features/crm/calendar/upstream-big-calendar/helpers";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { TCalendarView } from "@/features/crm/calendar/upstream-big-calendar/types";
interface IProps {
view: TCalendarView;
events: IEvent[];
}
export function DateNavigator({ view, events }: IProps) {
const { selectedDate, setSelectedDate } = useCalendar();
const month = formatDate(selectedDate, "MMMM");
const year = selectedDate.getFullYear();
const eventCount = useMemo(() => getEventsCount(events, selectedDate, view), [events, selectedDate, view]);
const handlePrevious = () => setSelectedDate(navigateDate(selectedDate, view, "previous"));
const handleNext = () => setSelectedDate(navigateDate(selectedDate, view, "next"));
return (
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">
{month} {year}
</span>
<Badge variant="outline" className="px-1.5">
{eventCount} events
</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="size-6.5 px-0 [&_svg]:size-4.5" onClick={handlePrevious}>
<ChevronLeft />
</Button>
<p className="text-sm text-muted-foreground">{rangeText(view, selectedDate)}</p>
<Button variant="outline" className="size-6.5 px-0 [&_svg]:size-4.5" onClick={handleNext}>
<ChevronRight />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { formatDate } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
export function TodayButton() {
const { setSelectedDate } = useCalendar();
const today = new Date();
const handleClick = () => setSelectedDate(today);
return (
<button
className="flex size-14 flex-col items-start overflow-hidden rounded-lg border focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onClick={handleClick}
>
<p className="flex h-6 w-full items-center justify-center bg-primary text-center text-xs font-semibold text-primary-foreground">
{formatDate(today, "MMM").toUpperCase()}
</p>
<p className="flex w-full items-center justify-center text-lg font-bold">{today.getDate()}</p>
</button>
);
}

View File

@@ -0,0 +1,46 @@
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { AvatarGroup } from "@/features/crm/calendar/upstream-big-calendar/components/avatar-group";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function UserSelect() {
const { users, selectedUserId, setSelectedUserId } = useCalendar();
return (
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger className="flex-1 md:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">
<div className="flex items-center gap-1">
<AvatarGroup max={2}>
{users.map(user => (
<Avatar key={user.id} className="size-6 text-xxs">
<AvatarImage src={user.picturePath ?? undefined} alt={user.name} />
<AvatarFallback className="text-xxs">{user.name[0]}</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
All
</div>
</SelectItem>
{users.map(user => (
<SelectItem key={user.id} value={user.id} className="flex-1">
<div className="flex items-center gap-2">
<Avatar key={user.id} className="size-6">
<AvatarImage src={user.picturePath ?? undefined} alt={user.name} />
<AvatarFallback className="text-xxs">{user.name[0]}</AvatarFallback>
</Avatar>
<p className="truncate">{user.name}</p>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,47 @@
import { useMemo } from "react";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { DayCell } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/day-cell";
import { getCalendarCells, calculateMonthEventPositions } from "@/features/crm/calendar/upstream-big-calendar/helpers";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
singleDayEvents: IEvent[];
multiDayEvents: IEvent[];
}
const WEEK_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export function CalendarMonthView({ singleDayEvents, multiDayEvents }: IProps) {
const { selectedDate } = useCalendar();
const allEvents = [...multiDayEvents, ...singleDayEvents];
const cells = useMemo(() => getCalendarCells(selectedDate), [selectedDate]);
const eventPositions = useMemo(
() => calculateMonthEventPositions(multiDayEvents, singleDayEvents, selectedDate),
[multiDayEvents, singleDayEvents, selectedDate]
);
return (
<div>
<div className="grid grid-cols-7 divide-x">
{WEEK_DAYS.map(day => (
<div key={day} className="flex items-center justify-center py-2">
<span className="text-xs font-medium text-muted-foreground">{day}</span>
</div>
))}
</div>
<div className="grid grid-cols-7 overflow-hidden">
{cells.map(cell => (
<DayCell key={cell.date.toISOString()} cell={cell} events={allEvents} eventPositions={eventPositions} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { isToday, startOfDay } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { EventBullet } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/event-bullet";
import { DroppableDayCell } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-day-cell";
import { MonthEventBadge } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/month-event-badge";
import { cn } from "@/lib/utils";
import { getMonthCellEvents } from "@/features/crm/calendar/upstream-big-calendar/helpers";
import type { ICalendarCell, IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
cell: ICalendarCell;
events: IEvent[];
eventPositions: Record<string, number>;
}
const MAX_VISIBLE_EVENTS = 3;
export function DayCell({ cell, events, eventPositions }: IProps) {
const { push } = useRouter();
const { setSelectedDate } = useCalendar();
const { day, currentMonth, date } = cell;
const cellEvents = useMemo(() => getMonthCellEvents(date, events, eventPositions), [date, events, eventPositions]);
const isSunday = date.getDay() === 0;
const handleClick = () => {
setSelectedDate(date);
push("/day-view");
};
return (
<DroppableDayCell cell={cell}>
<div className={cn("flex h-full flex-col gap-1 border-l border-t py-1.5 lg:pb-2 lg:pt-1", isSunday && "border-l-0")}>
<button
onClick={handleClick}
className={cn(
"flex size-6 translate-x-1 items-center justify-center rounded-full text-xs font-semibold hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring lg:px-2",
!currentMonth && "opacity-20",
isToday(date) && "bg-primary font-bold text-primary-foreground hover:bg-primary"
)}
>
{day}
</button>
<div className={cn("flex h-6 gap-1 px-2 lg:h-[94px] lg:flex-col lg:gap-2 lg:px-0", !currentMonth && "opacity-50")}>
{[0, 1, 2].map(position => {
const event = cellEvents.find(e => e.position === position);
const eventKey = event ? `event-${event.id}-${position}` : `empty-${position}`;
return (
<div key={eventKey} className="lg:flex-1">
{event && (
<>
<EventBullet className="lg:hidden" color={event.color} />
<MonthEventBadge className="hidden lg:flex" event={event} cellDate={startOfDay(date)} />
</>
)}
</div>
);
})}
</div>
{cellEvents.length > MAX_VISIBLE_EVENTS && (
<p className={cn("h-4.5 px-1.5 text-xs font-semibold text-muted-foreground", !currentMonth && "opacity-50")}>
<span className="sm:hidden">+{cellEvents.length - MAX_VISIBLE_EVENTS}</span>
<span className="hidden sm:inline"> {cellEvents.length - MAX_VISIBLE_EVENTS} more...</span>
</p>
)}
</div>
</DroppableDayCell>
);
}

View File

@@ -0,0 +1,26 @@
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
import type { TEventColor } from "@/features/crm/calendar/upstream-big-calendar/types";
const eventBulletVariants = cva("size-2 rounded-full", {
variants: {
color: {
blue: "bg-blue-600 dark:bg-blue-500",
green: "bg-green-600 dark:bg-green-500",
red: "bg-red-600 dark:bg-red-500",
yellow: "bg-yellow-600 dark:bg-yellow-500",
purple: "bg-purple-600 dark:bg-purple-500",
gray: "bg-neutral-600 dark:bg-neutral-500",
orange: "bg-orange-600 dark:bg-orange-500",
},
},
defaultVariants: {
color: "blue",
},
});
export function EventBullet({ color, className }: { color: TEventColor; className: string }) {
return <div className={cn(eventBulletVariants({ color, className }))} />;
}

View File

@@ -0,0 +1,125 @@
/* oxlint-disable unicorn/consistent-function-scoping */
import { cva } from "class-variance-authority";
import { endOfDay, format, isSameDay, parseISO, startOfDay } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { DraggableEvent } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event";
import { EventDetailsDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/event-details-dialog";
import { cn } from "@/lib/utils";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { VariantProps } from "class-variance-authority";
const eventBadgeVariants = cva(
"mx-1 flex size-auto h-6.5 select-none items-center justify-between gap-1.5 truncate whitespace-nowrap rounded-md border px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
{
variants: {
color: {
// Colored and mixed variants
blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300 [&_.event-dot]:fill-blue-600",
green: "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300 [&_.event-dot]:fill-green-600",
red: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300 [&_.event-dot]:fill-red-600",
yellow: "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300 [&_.event-dot]:fill-yellow-600",
purple: "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300 [&_.event-dot]:fill-purple-600",
orange: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300 [&_.event-dot]:fill-orange-600",
gray: "border-neutral-200 bg-neutral-50 text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 [&_.event-dot]:fill-neutral-600",
// Dot variants
"blue-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-blue-600",
"green-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-green-600",
"red-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-red-600",
"yellow-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-yellow-600",
"purple-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-purple-600",
"orange-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-orange-600",
"gray-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-neutral-600",
},
multiDayPosition: {
first: "relative z-10 mr-0 w-[calc(100%_-_3px)] rounded-r-none border-r-0 [&>span]:mr-2.5",
middle: "relative z-10 mx-0 w-[calc(100%_+_1px)] rounded-none border-x-0",
last: "ml-0 rounded-l-none border-l-0",
none: "",
},
},
defaultVariants: {
color: "blue-dot",
},
}
);
interface IProps extends Omit<VariantProps<typeof eventBadgeVariants>, "color" | "multiDayPosition"> {
event: IEvent;
cellDate: Date;
eventCurrentDay?: number;
eventTotalDays?: number;
className?: string;
position?: "first" | "middle" | "last" | "none";
}
export function MonthEventBadge({ event, cellDate, eventCurrentDay, eventTotalDays, className, position: propPosition }: IProps) {
const { badgeVariant } = useCalendar();
const itemStart = startOfDay(parseISO(event.startDate));
const itemEnd = endOfDay(parseISO(event.endDate));
if (cellDate < itemStart || cellDate > itemEnd) return null;
let position: "first" | "middle" | "last" | "none" | undefined;
if (propPosition) {
position = propPosition;
} else if (eventCurrentDay && eventTotalDays) {
position = "none";
} else if (isSameDay(itemStart, itemEnd)) {
position = "none";
} else if (isSameDay(cellDate, itemStart)) {
position = "first";
} else if (isSameDay(cellDate, itemEnd)) {
position = "last";
} else {
position = "middle";
}
const renderBadgeText = ["first", "none"].includes(position);
const color = (badgeVariant === "dot" ? `${event.color}-dot` : event.color) as VariantProps<typeof eventBadgeVariants>["color"];
const eventBadgeClasses = cn(eventBadgeVariants({ color, multiDayPosition: position, className }));
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (e.currentTarget instanceof HTMLElement) e.currentTarget.click();
}
};
return (
<DraggableEvent event={event}>
<EventDetailsDialog event={event}>
<div role="button" tabIndex={0} className={eventBadgeClasses} onKeyDown={handleKeyDown}>
<div className="flex items-center gap-1.5 truncate">
{!["middle", "last"].includes(position) && ["mixed", "dot"].includes(badgeVariant) && (
<svg width="8" height="8" viewBox="0 0 8 8" className="event-dot shrink-0">
<circle cx="4" cy="4" r="4" />
</svg>
)}
{renderBadgeText && (
<p className="flex-1 truncate font-semibold">
{eventCurrentDay && (
<span className="text-xs">
Day {eventCurrentDay} of {eventTotalDays} {" "}
</span>
)}
{event.title}
</p>
)}
</div>
{renderBadgeText && <span>{format(new Date(event.startDate), "h:mm a")}</span>}
</div>
</EventDetailsDialog>
</DraggableEvent>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { Calendar } from "@/components/ui/calendar";
export function SingleCalendar({
className,
selected,
onSelect
}: {
className?: string;
initialFocus?: boolean;
mode?: "single";
selected?: Date;
onSelect?: (date: Date | undefined) => void;
}) {
return (
<Calendar
mode="single"
selected={selected}
onSelect={onSelect}
className={className ?? "rounded-md border"}
/>
);
}

View File

@@ -0,0 +1,196 @@
import { Calendar, Clock, User } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { parseISO, areIntervalsOverlapping, format } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SingleCalendar } from "@/features/crm/calendar/upstream-big-calendar/components/single-calendar";
import { AddEventDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/add-event-dialog";
import { EventBlock } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/event-block";
import { DroppableTimeBlock } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-time-block";
import { CalendarTimeline } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-time-line";
import { DayViewMultiDayEventsRow } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/day-view-multi-day-events-row";
import { cn } from "@/lib/utils";
import { groupEvents, getEventBlockStyle, isWorkingHour, getCurrentEvents, getVisibleHours } from "@/features/crm/calendar/upstream-big-calendar/helpers";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
singleDayEvents: IEvent[];
multiDayEvents: IEvent[];
}
export function CalendarDayView({ singleDayEvents, multiDayEvents }: IProps) {
const { selectedDate, setSelectedDate, users, visibleHours, workingHours } = useCalendar();
const { hours, earliestEventHour, latestEventHour } = getVisibleHours(visibleHours, singleDayEvents);
const currentEvents = getCurrentEvents(singleDayEvents);
const dayEvents = singleDayEvents.filter(event => {
const eventDate = parseISO(event.startDate);
return (
eventDate.getDate() === selectedDate.getDate() &&
eventDate.getMonth() === selectedDate.getMonth() &&
eventDate.getFullYear() === selectedDate.getFullYear()
);
});
const groupedEvents = groupEvents(dayEvents);
return (
<div className="flex">
<div className="flex flex-1 flex-col">
<div>
<DayViewMultiDayEventsRow selectedDate={selectedDate} multiDayEvents={multiDayEvents} />
{/* Day header */}
<div className="relative z-20 flex border-b">
<div className="w-18"></div>
<span className="flex-1 border-l py-2 text-center text-xs font-medium text-muted-foreground">
{format(selectedDate, "EE")} <span className="font-semibold text-foreground">{format(selectedDate, "d")}</span>
</span>
</div>
</div>
<ScrollArea className="h-[800px]" type="always">
<div className="flex">
{/* Hours column */}
<div className="relative w-18">
{hours.map((hour, index) => (
<div key={hour} className="relative" style={{ height: "96px" }}>
<div className="absolute -top-3 right-2 flex h-6 items-center">
{index !== 0 && <span className="text-xs text-muted-foreground">{format(new Date().setHours(hour, 0, 0, 0), "hh a")}</span>}
</div>
</div>
))}
</div>
{/* Day grid */}
<div className="relative flex-1 border-l">
<div className="relative">
{hours.map((hour, index) => {
const isDisabled = !isWorkingHour(selectedDate, hour, workingHours);
return (
<div key={hour} className={cn("relative", isDisabled && "bg-calendar-disabled-hour")} style={{ height: "96px" }}>
{index !== 0 && <div className="pointer-events-none absolute inset-x-0 top-0 border-b"></div>}
<DroppableTimeBlock date={selectedDate} hour={hour} minute={0}>
<AddEventDialog startDate={selectedDate} startTime={{ hour, minute: 0 }}>
<div className="absolute inset-x-0 top-0 h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<DroppableTimeBlock date={selectedDate} hour={hour} minute={15}>
<AddEventDialog startDate={selectedDate} startTime={{ hour, minute: 15 }}>
<div className="absolute inset-x-0 top-[24px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<div className="pointer-events-none absolute inset-x-0 top-1/2 border-b border-dashed"></div>
<DroppableTimeBlock date={selectedDate} hour={hour} minute={30}>
<AddEventDialog startDate={selectedDate} startTime={{ hour, minute: 30 }}>
<div className="absolute inset-x-0 top-[48px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<DroppableTimeBlock date={selectedDate} hour={hour} minute={45}>
<AddEventDialog startDate={selectedDate} startTime={{ hour, minute: 45 }}>
<div className="absolute inset-x-0 top-[72px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
</div>
);
})}
{groupedEvents.map((group, groupIndex) =>
group.map(event => {
let style = getEventBlockStyle(event, selectedDate, groupIndex, groupedEvents.length, { from: earliestEventHour, to: latestEventHour });
const hasOverlap = groupedEvents.some(
(otherGroup, otherIndex) =>
otherIndex !== groupIndex &&
otherGroup.some(otherEvent =>
areIntervalsOverlapping(
{ start: parseISO(event.startDate), end: parseISO(event.endDate) },
{ start: parseISO(otherEvent.startDate), end: parseISO(otherEvent.endDate) }
)
)
);
if (!hasOverlap) style = { ...style, width: "100%", left: "0%" };
return (
<div key={event.id} className="absolute p-1" style={style}>
<EventBlock event={event} />
</div>
);
})
)}
</div>
<CalendarTimeline firstVisibleHour={earliestEventHour} lastVisibleHour={latestEventHour} />
</div>
</div>
</ScrollArea>
</div>
<div className="hidden w-64 divide-y border-l md:block">
<SingleCalendar className="mx-auto w-fit" mode="single" selected={selectedDate} onSelect={setSelectedDate} initialFocus />
<div className="flex-1 space-y-3">
{currentEvents.length > 0 ? (
<div className="flex items-start gap-2 px-4 pt-4">
<span className="relative mt-[5px] flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex size-2.5 rounded-full bg-green-600"></span>
</span>
<p className="text-sm font-semibold text-foreground">Happening now</p>
</div>
) : (
<p className="p-4 text-center text-sm italic text-muted-foreground">No appointments or consultations at the moment</p>
)}
{currentEvents.length > 0 && (
<ScrollArea className="h-[422px] px-4" type="always">
<div className="space-y-6 pb-4">
{currentEvents.map(event => {
const user = users.find(user => user.id === event.user.id);
return (
<div key={event.id} className="space-y-1.5">
<p className="line-clamp-2 text-sm font-semibold">{event.title}</p>
{user && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<User className="size-3.5" />
<span className="text-sm">{user.name}</span>
</div>
)}
<div className="flex items-center gap-1.5 text-muted-foreground">
<Calendar className="size-3.5" />
<span className="text-sm">{format(new Date(), "MMM d, yyyy")}</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="size-3.5" />
<span className="text-sm">
{format(parseISO(event.startDate), "h:mm a")} - {format(parseISO(event.endDate), "h:mm a")}
</span>
</div>
</div>
);
})}
</div>
</ScrollArea>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { format } from "date-fns";
import { useEffect, useState } from "react";
interface IProps {
firstVisibleHour: number;
lastVisibleHour: number;
}
export function CalendarTimeline({ firstVisibleHour, lastVisibleHour }: IProps) {
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 60 * 1000);
return () => clearInterval(timer);
}, []);
const getCurrentTimePosition = () => {
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
const visibleStartMinutes = firstVisibleHour * 60;
const visibleEndMinutes = lastVisibleHour * 60;
const visibleRangeMinutes = visibleEndMinutes - visibleStartMinutes;
return ((minutes - visibleStartMinutes) / visibleRangeMinutes) * 100;
};
const formatCurrentTime = () => {
return format(currentTime, "h:mm a");
};
const currentHour = currentTime.getHours();
if (currentHour < firstVisibleHour || currentHour >= lastVisibleHour) return null;
return (
<div className="pointer-events-none absolute inset-x-0 z-50 border-t border-primary" style={{ top: `${getCurrentTimePosition()}%` }}>
<div className="absolute left-0 top-0 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"></div>
<div className="absolute -left-18 flex w-16 -translate-y-1/2 justify-end bg-background pr-1 text-xs font-medium text-primary">{formatCurrentTime()}</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { startOfWeek, addDays, format, parseISO, isSameDay, areIntervalsOverlapping } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AddEventDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/add-event-dialog";
import { EventBlock } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/event-block";
import { DroppableTimeBlock } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-time-block";
import { CalendarTimeline } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-time-line";
import { WeekViewMultiDayEventsRow } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/week-view-multi-day-events-row";
import { cn } from "@/lib/utils";
import { groupEvents, getEventBlockStyle, isWorkingHour, getVisibleHours } from "@/features/crm/calendar/upstream-big-calendar/helpers";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
singleDayEvents: IEvent[];
multiDayEvents: IEvent[];
}
export function CalendarWeekView({ singleDayEvents, multiDayEvents }: IProps) {
const { selectedDate, workingHours, visibleHours } = useCalendar();
const { hours, earliestEventHour, latestEventHour } = getVisibleHours(visibleHours, singleDayEvents);
const weekStart = startOfWeek(selectedDate);
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
return (
<>
<div className="flex flex-col items-center justify-center border-b py-4 text-sm text-muted-foreground sm:hidden">
<p>Weekly view is not available on smaller devices.</p>
<p>Please switch to daily or monthly view.</p>
</div>
<div className="hidden flex-col sm:flex">
<div>
<WeekViewMultiDayEventsRow selectedDate={selectedDate} multiDayEvents={multiDayEvents} />
{/* Week header */}
<div className="relative z-20 flex border-b">
<div className="w-18"></div>
<div className="grid flex-1 grid-cols-7 divide-x border-l">
{weekDays.map((day, index) => (
<span key={index} className="py-2 text-center text-xs font-medium text-muted-foreground">
{format(day, "EE")} <span className="ml-1 font-semibold text-foreground">{format(day, "d")}</span>
</span>
))}
</div>
</div>
</div>
<ScrollArea className="h-[736px]" type="always">
<div className="flex overflow-hidden">
{/* Hours column */}
<div className="relative w-18">
{hours.map((hour, index) => (
<div key={hour} className="relative" style={{ height: "96px" }}>
<div className="absolute -top-3 right-2 flex h-6 items-center">
{index !== 0 && <span className="text-xs text-muted-foreground">{format(new Date().setHours(hour, 0, 0, 0), "hh a")}</span>}
</div>
</div>
))}
</div>
{/* Week grid */}
<div className="relative flex-1 border-l">
<div className="grid grid-cols-7 divide-x">
{weekDays.map((day, dayIndex) => {
const dayEvents = singleDayEvents.filter(event => isSameDay(parseISO(event.startDate), day) || isSameDay(parseISO(event.endDate), day));
const groupedEvents = groupEvents(dayEvents);
return (
<div key={dayIndex} className="relative">
{hours.map((hour, index) => {
const isDisabled = !isWorkingHour(day, hour, workingHours);
return (
<div key={hour} className={cn("relative", isDisabled && "bg-calendar-disabled-hour")} style={{ height: "96px" }}>
{index !== 0 && <div className="pointer-events-none absolute inset-x-0 top-0 border-b"></div>}
<DroppableTimeBlock date={day} hour={hour} minute={0}>
<AddEventDialog startDate={day} startTime={{ hour, minute: 0 }}>
<div className="absolute inset-x-0 top-0 h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<DroppableTimeBlock date={day} hour={hour} minute={15}>
<AddEventDialog startDate={day} startTime={{ hour, minute: 15 }}>
<div className="absolute inset-x-0 top-[24px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<div className="pointer-events-none absolute inset-x-0 top-1/2 border-b border-dashed"></div>
<DroppableTimeBlock date={day} hour={hour} minute={30}>
<AddEventDialog startDate={day} startTime={{ hour, minute: 30 }}>
<div className="absolute inset-x-0 top-[48px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<DroppableTimeBlock date={day} hour={hour} minute={45}>
<AddEventDialog startDate={day} startTime={{ hour, minute: 45 }}>
<div className="absolute inset-x-0 top-[72px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
</div>
);
})}
{groupedEvents.map((group, groupIndex) =>
group.map(event => {
let style = getEventBlockStyle(event, day, groupIndex, groupedEvents.length, { from: earliestEventHour, to: latestEventHour });
const hasOverlap = groupedEvents.some(
(otherGroup, otherIndex) =>
otherIndex !== groupIndex &&
otherGroup.some(otherEvent =>
areIntervalsOverlapping(
{ start: parseISO(event.startDate), end: parseISO(event.endDate) },
{ start: parseISO(otherEvent.startDate), end: parseISO(otherEvent.endDate) }
)
)
);
if (!hasOverlap) style = { ...style, width: "100%", left: "0%" };
return (
<div key={event.id} className="absolute p-1" style={style}>
<EventBlock event={event} />
</div>
);
})
)}
</div>
);
})}
</div>
<CalendarTimeline firstVisibleHour={earliestEventHour} lastVisibleHour={latestEventHour} />
</div>
</div>
</ScrollArea>
</div>
</>
);
}

View File

@@ -0,0 +1,53 @@
import { parseISO, isWithinInterval, differenceInDays, startOfDay, endOfDay } from "date-fns";
import { MonthEventBadge } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/month-event-badge";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
selectedDate: Date;
multiDayEvents: IEvent[];
}
export function DayViewMultiDayEventsRow({ selectedDate, multiDayEvents }: IProps) {
const dayStart = startOfDay(selectedDate);
const dayEnd = endOfDay(selectedDate);
const multiDayEventsInDay = multiDayEvents
.filter(event => {
const eventStart = parseISO(event.startDate);
const eventEnd = parseISO(event.endDate);
const isOverlapping =
isWithinInterval(dayStart, { start: eventStart, end: eventEnd }) ||
isWithinInterval(dayEnd, { start: eventStart, end: eventEnd }) ||
(eventStart <= dayStart && eventEnd >= dayEnd);
return isOverlapping;
})
.toSorted((a, b) => {
const durationA = differenceInDays(parseISO(a.endDate), parseISO(a.startDate));
const durationB = differenceInDays(parseISO(b.endDate), parseISO(b.startDate));
return durationB - durationA;
});
if (multiDayEventsInDay.length === 0) return null;
return (
<div className="flex border-b">
<div className="w-18"></div>
<div className="flex flex-1 flex-col gap-1 border-l py-1">
{multiDayEventsInDay.map(event => {
const eventStart = startOfDay(parseISO(event.startDate));
const eventEnd = startOfDay(parseISO(event.endDate));
const currentDate = startOfDay(selectedDate);
const eventTotalDays = differenceInDays(eventEnd, eventStart) + 1;
const eventCurrentDay = differenceInDays(currentDate, eventStart) + 1;
return <MonthEventBadge key={event.id} event={event} cellDate={selectedDate} eventCurrentDay={eventCurrentDay} eventTotalDays={eventTotalDays} />;
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
/* oxlint-disable unicorn/consistent-function-scoping */
import { cva } from "class-variance-authority";
import { format, differenceInMinutes, parseISO } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { DraggableEvent } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event";
import { EventDetailsDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/event-details-dialog";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "react";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { VariantProps } from "class-variance-authority";
const calendarWeekEventCardVariants = cva(
"flex select-none flex-col gap-0.5 truncate whitespace-nowrap rounded-md border px-2 py-1.5 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
{
variants: {
color: {
// Colored and mixed variants
blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300 [&_.event-dot]:fill-blue-600",
green: "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300 [&_.event-dot]:fill-green-600",
red: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300 [&_.event-dot]:fill-red-600",
yellow: "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300 [&_.event-dot]:fill-yellow-600",
purple: "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300 [&_.event-dot]:fill-purple-600",
orange: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300 [&_.event-dot]:fill-orange-600",
gray: "border-neutral-200 bg-neutral-50 text-neutral-700 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 [&_.event-dot]:fill-neutral-600",
// Dot variants
"blue-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-blue-600",
"green-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-green-600",
"red-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-red-600",
"orange-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-orange-600",
"purple-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-purple-600",
"yellow-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-yellow-600",
"gray-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-neutral-600",
},
},
defaultVariants: {
color: "blue-dot",
},
}
);
interface IProps extends HTMLAttributes<HTMLDivElement>, Omit<VariantProps<typeof calendarWeekEventCardVariants>, "color"> {
event: IEvent;
}
export function EventBlock({ event, className }: IProps) {
const { badgeVariant } = useCalendar();
const start = parseISO(event.startDate);
const end = parseISO(event.endDate);
const durationInMinutes = differenceInMinutes(end, start);
const heightInPixels = (durationInMinutes / 60) * 96 - 8;
const color = (badgeVariant === "dot" ? `${event.color}-dot` : event.color) as VariantProps<typeof calendarWeekEventCardVariants>["color"];
const calendarWeekEventCardClasses = cn(calendarWeekEventCardVariants({ color, className }), durationInMinutes < 35 && "py-0 justify-center");
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (e.currentTarget instanceof HTMLElement) e.currentTarget.click();
}
};
return (
<DraggableEvent event={event}>
<EventDetailsDialog event={event}>
<div role="button" tabIndex={0} className={calendarWeekEventCardClasses} style={{ height: `${heightInPixels}px` }} onKeyDown={handleKeyDown}>
<div className="flex items-center gap-1.5 truncate">
{["mixed", "dot"].includes(badgeVariant) && (
<svg width="8" height="8" viewBox="0 0 8 8" className="event-dot shrink-0">
<circle cx="4" cy="4" r="4" />
</svg>
)}
<p className="truncate font-semibold">{event.title}</p>
</div>
{durationInMinutes > 25 && (
<p>
{format(start, "h:mm a")} - {format(end, "h:mm a")}
</p>
)}
</div>
</EventDetailsDialog>
</DraggableEvent>
);
}

View File

@@ -0,0 +1,112 @@
import { useMemo } from "react";
import { parseISO, startOfDay, startOfWeek, endOfWeek, addDays, differenceInDays, isBefore, isAfter } from "date-fns";
import { MonthEventBadge } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/month-event-badge";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
selectedDate: Date;
multiDayEvents: IEvent[];
}
export function WeekViewMultiDayEventsRow({ selectedDate, multiDayEvents }: IProps) {
const weekStart = startOfWeek(selectedDate);
const weekEnd = endOfWeek(selectedDate);
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
const processedEvents = useMemo(() => {
return multiDayEvents
.map(event => {
const start = parseISO(event.startDate);
const end = parseISO(event.endDate);
const adjustedStart = isBefore(start, weekStart) ? weekStart : start;
const adjustedEnd = isAfter(end, weekEnd) ? weekEnd : end;
const startIndex = differenceInDays(adjustedStart, weekStart);
const endIndex = differenceInDays(adjustedEnd, weekStart);
return {
...event,
adjustedStart,
adjustedEnd,
startIndex,
endIndex,
};
})
.toSorted((a, b) => {
const startDiff = a.adjustedStart.getTime() - b.adjustedStart.getTime();
if (startDiff !== 0) return startDiff;
return b.endIndex - b.startIndex - (a.endIndex - a.startIndex);
});
}, [multiDayEvents, weekStart, weekEnd]);
const eventRows = useMemo(() => {
const rows: (typeof processedEvents)[] = [];
processedEvents.forEach(event => {
let rowIndex = rows.findIndex(row => row.every(e => e.endIndex < event.startIndex || e.startIndex > event.endIndex));
if (rowIndex === -1) {
rowIndex = rows.length;
rows.push([]);
}
rows[rowIndex].push(event);
});
return rows;
}, [processedEvents]);
const hasEventsInWeek = useMemo(() => {
return multiDayEvents.some(event => {
const start = parseISO(event.startDate);
const end = parseISO(event.endDate);
return (
// Event starts within the week
(start >= weekStart && start <= weekEnd) ||
// Event ends within the week
(end >= weekStart && end <= weekEnd) ||
// Event spans the entire week
(start <= weekStart && end >= weekEnd)
);
});
}, [multiDayEvents, weekStart, weekEnd]);
if (!hasEventsInWeek) {
return null;
}
return (
<div className="hidden overflow-hidden sm:flex">
<div className="w-18 border-b"></div>
<div className="grid flex-1 grid-cols-7 divide-x border-b border-l">
{weekDays.map((day, dayIndex) => (
<div key={day.toISOString()} className="flex h-full flex-col gap-1 py-1">
{eventRows.map((row, rowIndex) => {
const event = row.find(e => e.startIndex <= dayIndex && e.endIndex >= dayIndex);
if (!event) {
return <div key={`${rowIndex}-${dayIndex}`} className="h-6.5" />;
}
let position: "first" | "middle" | "last" | "none" = "none";
if (dayIndex === event.startIndex && dayIndex === event.endIndex) {
position = "none";
} else if (dayIndex === event.startIndex) {
position = "first";
} else if (dayIndex === event.endIndex) {
position = "last";
} else {
position = "middle";
}
return <MonthEventBadge key={`${event.id}-${dayIndex}`} event={event} cellDate={startOfDay(day)} position={position} />;
})}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useMemo } from "react";
import { addMonths, startOfYear } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { YearViewMonth } from "@/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-month";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
allEvents: IEvent[];
}
export function CalendarYearView({ allEvents }: IProps) {
const { selectedDate } = useCalendar();
const months = useMemo(() => {
const yearStart = startOfYear(selectedDate);
return Array.from({ length: 12 }, (_, i) => addMonths(yearStart, i));
}, [selectedDate]);
return (
<div className="p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{months.map(month => (
<YearViewMonth key={month.toString()} month={month} events={allEvents} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { isToday } from "date-fns";
import { useRouter } from "next/navigation";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { cn } from "@/lib/utils";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
day: number;
date: Date;
events: IEvent[];
}
export function YearViewDayCell({ day, date, events }: IProps) {
const { push } = useRouter();
const { setSelectedDate } = useCalendar();
const maxIndicators = 3;
const eventCount = events.length;
const handleClick = () => {
setSelectedDate(date);
push("/day-view");
};
return (
<button
onClick={handleClick}
type="button"
className="flex h-11 flex-1 flex-col items-center justify-start gap-0.5 rounded-md pt-1 hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<div
className={cn(
"flex size-6 items-center justify-center rounded-full text-xs font-medium",
isToday(date) && "bg-primary font-semibold text-primary-foreground"
)}
>
{day}
</div>
{eventCount > 0 && (
<div className="mt-0.5 flex gap-0.5">
{eventCount <= maxIndicators ? (
events.map(event => (
<div
key={event.id}
className={cn(
"size-1.5 rounded-full",
event.color === "blue" && "bg-blue-600",
event.color === "green" && "bg-green-600",
event.color === "red" && "bg-red-600",
event.color === "yellow" && "bg-yellow-600",
event.color === "purple" && "bg-purple-600",
event.color === "orange" && "bg-orange-600",
event.color === "gray" && "bg-neutral-600"
)}
/>
))
) : (
<>
<div
className={cn(
"size-1.5 rounded-full",
events[0].color === "blue" && "bg-blue-600",
events[0].color === "green" && "bg-green-600",
events[0].color === "red" && "bg-red-600",
events[0].color === "yellow" && "bg-yellow-600",
events[0].color === "purple" && "bg-purple-600",
events[0].color === "orange" && "bg-orange-600"
)}
/>
<span className="text-[7px] text-muted-foreground">+{eventCount - 1}</span>
</>
)}
</div>
)}
</button>
);
}

View File

@@ -0,0 +1,71 @@
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { format, isSameDay, parseISO, getDaysInMonth, startOfMonth } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { YearViewDayCell } from "@/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-day-cell";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
month: Date;
events: IEvent[];
}
export function YearViewMonth({ month, events }: IProps) {
const { push } = useRouter();
const { setSelectedDate } = useCalendar();
const monthName = format(month, "MMMM");
const daysInMonth = useMemo(() => {
const totalDays = getDaysInMonth(month);
const firstDay = startOfMonth(month).getDay();
const days = Array.from({ length: totalDays }, (_, i) => i + 1);
const blanks = Array(firstDay).fill(null);
return [...blanks, ...days];
}, [month]);
const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const handleClick = () => {
setSelectedDate(new Date(month.getFullYear(), month.getMonth(), 1));
push("/month-view");
};
return (
<div className="flex flex-col">
<button
type="button"
onClick={handleClick}
className="w-full rounded-t-lg border px-3 py-2 text-sm font-semibold hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{monthName}
</button>
<div className="flex-1 space-y-2 rounded-b-lg border border-t-0 p-3">
<div className="grid grid-cols-7 gap-x-0.5 text-center">
{weekDays.map((day, index) => (
<div key={index} className="text-xs font-medium text-muted-foreground">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-x-0.5 gap-y-2">
{daysInMonth.map((day, index) => {
if (day === null) return <div key={`blank-${index}`} className="h-10" />;
const date = new Date(month.getFullYear(), month.getMonth(), day);
const dayEvents = events.filter(event => isSameDay(parseISO(event.startDate), date) || isSameDay(parseISO(event.endDate), date));
return <YearViewDayCell key={`day-${day}`} day={day} date={date} events={dayEvents} />;
})}
</div>
</div>
</div>
);
}