commit task-ep.1.6.2-r1
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }))} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user