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

@@ -117,6 +117,8 @@ export interface BigCalendarEvent {
opportunityId: string | null;
quotationId: string | null;
approvalId: string | null;
ownerId: string | null;
assigneeId: string | null;
};
display: {
summary: string | null;

View File

@@ -1,4 +1,26 @@
import type { BigCalendarEvent, CalendarProjectionItem } from '../api/types';
import type { BigCalendarEvent, CalendarProjectionItem } from "../api/types";
import type { IEvent, IUser } from "../upstream-big-calendar/interfaces";
interface LegacyBigCalendarAction {
type: "move" | "resize";
event: BigCalendarEvent;
start: string;
end: string;
}
interface LegacyBigCalendarActivityCommand {
type: "reschedule";
activityId: string;
scheduledStartAt: string;
scheduledEndAt: string;
}
export interface UpstreamCalendarActivityCommand {
type: "reschedule";
activityId: string;
scheduledStartAt: string;
scheduledEndAt: string;
}
export function mapCalendarProjectionToBigCalendarEvent(
item: CalendarProjectionItem
@@ -24,7 +46,9 @@ export function mapCalendarProjectionToBigCalendarEvent(
leadId: item.leadId,
opportunityId: item.opportunityId,
quotationId: item.quotationId,
approvalId: item.approvalId
approvalId: item.approvalId,
ownerId: item.ownerId,
assigneeId: item.assigneeId
},
display: {
summary: item.summary,
@@ -35,3 +59,80 @@ export function mapCalendarProjectionToBigCalendarEvent(
}
};
}
export function mapBigCalendarActionToActivityCommand(
action: LegacyBigCalendarAction
): LegacyBigCalendarActivityCommand | null {
if (!action.event.editable || !action.event.source.activityId) return null;
return {
type: "reschedule",
activityId: action.event.source.activityId,
scheduledStartAt: action.start,
scheduledEndAt: action.end
};
}
export function mapCalendarProjectionToUpstreamEvent(item: CalendarProjectionItem): IEvent {
const editable = item.editable && !item.milestone && item.activityId !== null;
return {
id: item.id,
title: item.title,
startDate: item.startAt,
endDate: item.endAt,
color: mapCalendarColor(item),
description: [item.summary, item.location].filter(Boolean).join(" - "),
user: mapCrmCalendarUserToUpstreamUser(item),
alla: {
activityId: item.activityId,
editable,
milestone: item.milestone,
sourceEntityType: item.entityType,
sourceEntityId: item.entityId,
customerId: item.customerId,
leadId: item.leadId,
opportunityId: item.opportunityId,
quotationId: item.quotationId,
approvalId: item.approvalId,
overdue: item.overdue,
hotProject: item.hotProject,
pricingSensitive: item.visibility.pricingSensitive,
internalOnly: item.visibility.internalOnly
}
};
}
export function mapCrmCalendarUserToUpstreamUser(item: CalendarProjectionItem): IUser {
const id = item.assigneeId ?? item.ownerId ?? "unassigned";
return {
id,
name: id === "unassigned" ? "Unassigned" : `User ${id.slice(0, 8)}`,
picturePath: null
};
}
export function mapUpstreamEventToActivityCommand(
event: IEvent
): UpstreamCalendarActivityCommand | null {
if (!event.alla?.editable || !event.alla.activityId) return null;
return {
type: "reschedule",
activityId: event.alla.activityId,
scheduledStartAt: event.startDate,
scheduledEndAt: event.endDate
};
}
function mapCalendarColor(item: CalendarProjectionItem): IEvent["color"] {
if (item.overdue) return "red";
if (item.hotProject) return "orange";
if (item.milestone) return "purple";
if (item.priority === "critical") return "red";
if (item.priority === "high") return "yellow";
if (item.category === "meeting") return "blue";
if (item.category === "visit" || item.category === "site_survey") return "green";
return "gray";
}

View File

@@ -1,56 +1,88 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Icons } from "@/components/icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import { formatDate, formatTime } from "@/lib/date-format";
import { calendarItemsQueryOptions } from "../api/queries";
import type {
BigCalendarEvent,
CalendarListFilters,
CalendarView,
} from "../api/types";
import { mapCalendarProjectionToBigCalendarEvent } from "./calendar-adapter";
import { activityKeys } from "@/features/crm/activities/api/queries";
import { rescheduleActivity } from "@/features/crm/activities/api/service";
import { myDayKeys } from "@/features/crm/my-day/api/queries";
import { calendarItemsQueryOptions, calendarKeys } from "../api/queries";
import type { CalendarListFilters, CalendarSummary } from "../api/types";
import { ClientContainer } from "../upstream-big-calendar/components/client-container";
import { CalendarProvider } from "../upstream-big-calendar/contexts/calendar-context";
import type { IEvent, IUser } from "../upstream-big-calendar/interfaces";
import {
mapCalendarProjectionToUpstreamEvent,
mapUpstreamEventToActivityCommand
} from "./calendar-adapter";
interface CalendarWorkspaceProps {
filters: Omit<CalendarListFilters, "organizationId">;
}
const VIEW_LABELS: Record<CalendarView, string> = {
day: "Day",
week: "Week",
month: "Month",
agenda: "Agenda",
};
export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
const [view, setView] = useState<CalendarView>("agenda");
const [search, setSearch] = useState("");
const queryClient = useQueryClient();
const { data } = useSuspenseQuery(calendarItemsQueryOptions(filters));
const events = useMemo(
() =>
data.items
.map(mapCalendarProjectionToBigCalendarEvent)
.filter((event) =>
event.title.toLowerCase().includes(search.toLowerCase()),
),
[data.items, search],
);
const groupedEvents = groupEventsByDate(events);
const events = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
return data.items
.map(mapCalendarProjectionToUpstreamEvent)
.filter((event) => {
if (!normalizedSearch) return true;
return [
event.title,
event.description,
event.user.name,
event.alla?.sourceEntityType
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedSearch));
});
}, [data.items, search]);
const users = useMemo(() => buildCalendarUsers(events), [events]);
const rescheduleMutation = useMutation({
mutationFn: (event: IEvent) => {
const command = mapUpstreamEventToActivityCommand(event);
if (!command) throw new Error("This calendar item cannot be rescheduled.");
return rescheduleActivity(command.activityId, {
scheduledStartAt: command.scheduledStartAt,
scheduledEndAt: command.scheduledEndAt,
dueAt: command.scheduledStartAt
});
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: calendarKeys.lists() }),
queryClient.invalidateQueries({ queryKey: activityKeys.lists() }),
queryClient.invalidateQueries({ queryKey: myDayKeys.workspaces() })
]);
toast.success("Calendar updated");
},
onError: (error) => {
queryClient.invalidateQueries({ queryKey: calendarKeys.lists() });
toast.error(error instanceof Error ? error.message : "Unable to update calendar");
}
});
return (
<div className="space-y-6">
<CalendarSummaryCards summary={data.summary} />
<Card>
<Card className="overflow-hidden">
<CardHeader className="gap-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
@@ -59,8 +91,7 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
Calendar Workspace
</CardTitle>
<p className="text-muted-foreground text-sm">
Projection workspace for scheduled activities and read-only
business milestones.
Upstream Big Calendar UI powered by ALLA Calendar Projection.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
@@ -73,50 +104,23 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
className="w-full pl-8 sm:w-[240px]"
/>
</div>
<Badge variant="outline">
Lens: {filters.lens ?? "personal"}
</Badge>
<Badge variant="outline">Lens: {filters.lens ?? "personal"}</Badge>
{rescheduleMutation.isPending ? (
<Badge variant="secondary">Reconciling projection...</Badge>
) : null}
</div>
</div>
</CardHeader>
<CardContent>
<Tabs
value={view}
onValueChange={(value) => setView(value as CalendarView)}
<CalendarProvider
events={events}
users={users}
initialDate={filters.startFrom}
initialView="agenda"
onEventChange={(event) => rescheduleMutation.mutate(event)}
>
<TabsList className="mb-4 grid w-full grid-cols-4 lg:w-[420px]">
{Object.entries(VIEW_LABELS).map(([value, label]) => (
<TabsTrigger key={value} value={value}>
{label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="agenda">
<AgendaView groupedEvents={groupedEvents} />
</TabsContent>
<TabsContent value="day">
<CompactCalendarGrid
events={events}
view="day"
anchorDate={filters.startFrom}
/>
</TabsContent>
<TabsContent value="week">
<CompactCalendarGrid
events={events}
view="week"
anchorDate={filters.startFrom}
/>
</TabsContent>
<TabsContent value="month">
<CompactCalendarGrid
events={events}
view="month"
anchorDate={filters.startFrom}
/>
</TabsContent>
</Tabs>
<ClientContainer />
</CalendarProvider>
</CardContent>
</Card>
</div>
@@ -131,21 +135,17 @@ export function CalendarWorkspaceSkeleton() {
<Skeleton key={index} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-[520px] rounded-xl" />
<Skeleton className="h-[620px] rounded-xl" />
</div>
);
}
function CalendarSummaryCards({
summary,
}: {
summary: CalendarWorkspaceSummary;
}) {
function CalendarSummaryCards({ summary }: { summary: CalendarSummary }) {
const cards = [
{ label: "Due Today", value: summary.dueToday, icon: Icons.calendar },
{ label: "Overdue", value: summary.overdue, icon: Icons.warning },
{ label: "Meetings", value: summary.meetings, icon: Icons.clock },
{ label: "Hot Projects", value: summary.hotProjects, icon: Icons.check },
{ label: "Hot Projects", value: summary.hotProjects, icon: Icons.check }
];
return (
@@ -167,285 +167,12 @@ function CalendarSummaryCards({
);
}
function AgendaView({
groupedEvents,
}: {
groupedEvents: Array<[string, BigCalendarEvent[]]>;
}) {
if (!groupedEvents.length) {
return <CalendarEmptyState />;
}
function buildCalendarUsers(events: IEvent[]): IUser[] {
const users = new Map<string, IUser>();
return (
<div className="space-y-5">
{groupedEvents.map(([date, events]) => (
<section key={date} className="space-y-3">
<div className="flex items-center gap-2">
<div className="bg-border h-px flex-1" />
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{formatDate(date)}
</p>
<div className="bg-border h-px flex-1" />
</div>
<div className="grid gap-3">
{events.map((event) => (
<CalendarEventCard key={event.id} event={event} />
))}
</div>
</section>
))}
</div>
);
}
function CompactCalendarGrid({
events,
view,
anchorDate,
}: {
events: BigCalendarEvent[];
view: "day" | "week" | "month";
anchorDate: string;
}) {
const cells = buildCalendarGridCells({ events, view, anchorDate });
const isMonth = view === "month";
return (
<div className="relative">
<div
className={cn(
"grid gap-3",
view === "day" && "grid-cols-1",
view !== "day" && "grid-cols-1 md:grid-cols-7",
)}
>
{cells.map((cell) => (
<button
key={cell.id}
type="button"
aria-label={`Select calendar slot ${cell.label}`}
className={cn(
"border-border/70 bg-muted/20 hover:bg-muted/40 min-h-28 rounded-lg border p-2 text-left transition-colors",
isMonth && "min-h-36",
cell.isToday && "ring-primary/30 ring-2",
)}
>
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">{cell.label}</p>
{cell.subLabel ? (
<span className="text-muted-foreground text-[11px]">
{cell.subLabel}
</span>
) : null}
</div>
<div className="mt-2 space-y-2">
{cell.events.map((event) => (
<CalendarEventPill key={event.id} event={event} />
))}
</div>
</button>
))}
</div>
{!events.length ? <CalendarEmptyOverlay view={view} /> : null}
</div>
);
}
interface CalendarGridCell {
id: string;
label: string;
subLabel: string | null;
isToday: boolean;
events: BigCalendarEvent[];
}
function buildCalendarGridCells({
events,
view,
anchorDate,
}: {
events: BigCalendarEvent[];
view: "day" | "week" | "month";
anchorDate: string;
}): CalendarGridCell[] {
if (view === "day") {
const day = startOfUtcDay(anchorDate);
return Array.from({ length: 12 }, (_, index) => {
const slot = new Date(day);
slot.setUTCHours(8 + index, 0, 0, 0);
return {
id: `day-${slot.toISOString()}`,
label: formatTime(slot.toISOString()),
subLabel: index === 0 ? formatDate(slot.toISOString()) : null,
isToday: isSameUtcDate(slot.toISOString(), new Date().toISOString()),
events: events.filter((event) => isSameHour(event.start, slot.toISOString())),
};
});
}
const start = view === "week" ? startOfUtcDay(anchorDate) : startOfMonthGrid(anchorDate);
const length = view === "week" ? 7 : 42;
return Array.from({ length }, (_, index) => {
const date = new Date(start);
date.setUTCDate(start.getUTCDate() + index);
const iso = date.toISOString();
return {
id: `${view}-${iso}`,
label: formatDate(iso),
subLabel: view === "month" ? null : "Working day",
isToday: isSameUtcDate(iso, new Date().toISOString()),
events: events.filter((event) => isSameUtcDate(event.start, iso)),
};
});
}
function CalendarEmptyOverlay({ view }: { view: "day" | "week" | "month" }) {
return (
<div className="pointer-events-none absolute inset-x-4 top-1/2 -translate-y-1/2">
<div className="border-border/70 bg-background/95 mx-auto max-w-md rounded-xl border border-dashed p-4 text-center shadow-sm backdrop-blur">
<Icons.calendar className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
<p className="font-medium">No scheduled work. This {view} is free.</p>
<p className="text-muted-foreground mt-1 text-sm">
The calendar grid is still available for selecting dates and planning Activities.
</p>
<Button asChild className="pointer-events-auto mt-3" size="sm">
<Link href="/dashboard/crm/activities">
<Icons.add className="mr-1 h-4 w-4" />
Add Activity
</Link>
</Button>
</div>
</div>
);
}
function startOfUtcDay(value: string): Date {
const date = new Date(value);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function startOfMonthGrid(value: string): Date {
const firstOfMonth = startOfUtcDay(value);
firstOfMonth.setUTCDate(1);
const gridStart = new Date(firstOfMonth);
gridStart.setUTCDate(firstOfMonth.getUTCDate() - firstOfMonth.getUTCDay());
return gridStart;
}
function isSameUtcDate(left: string, right: string): boolean {
return left.slice(0, 10) === right.slice(0, 10);
}
function isSameHour(left: string, right: string): boolean {
const leftDate = new Date(left);
const rightDate = new Date(right);
return (
isSameUtcDate(leftDate.toISOString(), rightDate.toISOString()) &&
leftDate.getUTCHours() === rightDate.getUTCHours()
);
}
function CalendarEventCard({ event }: { event: BigCalendarEvent }) {
return (
<article className="border-border/70 hover:bg-muted/30 rounded-lg border p-4 transition-colors">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={event.display.milestone ? "secondary" : "outline"}>
{event.category.replaceAll("_", " ")}
</Badge>
{event.display.overdue ? (
<Badge variant="destructive">Overdue</Badge>
) : null}
{event.display.hotProject ? (
<Badge variant="default">Hot Project</Badge>
) : null}
{!event.editable ? (
<Badge variant="outline">Read-only</Badge>
) : null}
</div>
<h3 className="font-medium">{event.title}</h3>
{event.display.summary ? (
<p className="text-muted-foreground text-sm">
{event.display.summary}
</p>
) : null}
<p className="text-muted-foreground text-xs">
{formatTime(event.start)} - {formatTime(event.end)}
{event.display.location ? ` · ${event.display.location}` : ""}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm">
Open Source
</Button>
{event.editable ? (
<>
<Button variant="outline" size="sm">
Reschedule
</Button>
<Button variant="outline" size="sm">
Complete
</Button>
</>
) : (
<Button variant="outline" size="sm">
Create Activity
</Button>
)}
</div>
</div>
</article>
);
}
function CalendarEventPill({ event }: { event: BigCalendarEvent }) {
return (
<div
className={cn(
"mt-2 rounded-md border px-2 py-1 text-xs",
event.display.overdue
? "border-destructive/40 bg-destructive/10 text-destructive"
: "border-primary/20 bg-primary/10 text-primary",
)}
>
<span className="font-medium">{formatTime(event.start)}</span>{" "}
{event.title}
</div>
);
}
function CalendarEmptyState() {
return (
<div className="border-border/70 rounded-xl border border-dashed p-10 text-center">
<Icons.calendar className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
<p className="font-medium">No scheduled work in this period</p>
<p className="text-muted-foreground mt-1 text-sm">
Try changing filters or create an Activity from the owning CRM record.
</p>
</div>
);
}
function groupEventsByDate(
events: BigCalendarEvent[],
): Array<[string, BigCalendarEvent[]]> {
const grouped = new Map<string, BigCalendarEvent[]>();
for (const event of events) {
const key = event.start.slice(0, 10);
grouped.set(key, [...(grouped.get(key) ?? []), event]);
users.set(event.user.id, event.user);
}
return [...grouped.entries()];
}
type CalendarWorkspaceSummary = {
dueToday: number;
overdue: number;
meetings: number;
visits: number;
milestones: number;
approvalDue: number;
hotProjects: number;
};
return [...users.values()].toSorted((left, right) => left.name.localeCompare(right.name));
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Leonardo Ramos
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,66 @@
# Upstream Big Calendar Import
Source repository: `https://github.com/lramos33/big-calendar`
Pinned commit: `fbb8485e3d54b267261056c784632229ba6f7bc3`
Imported date: 2026-07-13
License: MIT, preserved in `LICENSE`.
## Copied Source
Copied from upstream `src/calendar/**` into:
```text
src/features/crm/calendar/upstream-big-calendar/**
```
The imported source preserves upstream component families:
- `components/client-container.tsx`
- `components/header/**`
- `components/month-view/**`
- `components/week-and-day-view/**`
- `components/agenda-view/**`
- `components/year-view/**`
- `components/dnd/**`
- `contexts/calendar-context.tsx`
- `helpers.ts`
- `interfaces.ts`
- `types.ts`
## Removed Demo-Only Files
These upstream demo files were intentionally not retained in the active ALLA OS source:
- `mocks.ts`
- `requests.ts`
- `schemas.ts`
- `components/change-visible-hours-input.tsx`
- `components/change-working-hours-input.tsx`
Reason: ALLA OS owns data through Calendar Projection and Activity APIs. Upstream mock stores and form schemas would duplicate or bypass app-owned business logic.
## Local Patch Notes
- Rewrote imports from `@/calendar/*` to `@/features/crm/calendar/upstream-big-calendar/*`.
- Mapped upstream `lucide-react` icons to the project icon registry through `icons.tsx`.
- Added small compatibility shims for `SingleCalendar` and `AvatarGroup`.
- Replaced upstream add/edit demo dialogs with ALLA integration shells that do not use React Hook Form.
- Extended upstream `IEvent` with `alla` metadata for Activity/milestone permissions and source references.
- Added provider props for `initialDate`, `initialView`, and `onEventChange`.
- Kept upstream local optimistic event move behavior and forwards changed events to ALLA Activity reschedule integration.
- Adapted the upstream header view buttons to change in-page calendar view instead of navigating to upstream demo routes.
## Dependencies Added
- `react-dnd@16.0.1`
- `react-dnd-html5-backend@16.0.1`
These are required for upstream drag/drop behavior.
## Sync Procedure
1. Compare future upstream changes against commit `fbb8485e3d54b267261056c784632229ba6f7bc3`.
2. Copy rendering and DnD changes into this isolated directory only.
3. Keep ALLA adapter boundaries in `src/features/crm/calendar/components/calendar-adapter.ts`.
4. Do not import upstream mocks, app shell, demo backend, or React Hook Form dialogs.
5. Re-run `npm run typecheck` and `npx oxlint src/features/crm/calendar`.

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

View File

@@ -0,0 +1,110 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import type { IEvent, IUser } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type {
TBadgeVariant,
TCalendarView,
TVisibleHours,
TWorkingHours
} from "@/features/crm/calendar/upstream-big-calendar/types";
interface ICalendarContext {
selectedDate: Date;
setSelectedDate: (date: Date | undefined) => void;
selectedUserId: IUser["id"] | "all";
setSelectedUserId: (userId: IUser["id"] | "all") => void;
selectedView: TCalendarView;
setSelectedView: (view: TCalendarView) => void;
badgeVariant: TBadgeVariant;
setBadgeVariant: (variant: TBadgeVariant) => void;
users: IUser[];
workingHours: TWorkingHours;
setWorkingHours: Dispatch<SetStateAction<TWorkingHours>>;
visibleHours: TVisibleHours;
setVisibleHours: Dispatch<SetStateAction<TVisibleHours>>;
events: IEvent[];
setLocalEvents: Dispatch<SetStateAction<IEvent[]>>;
onEventChange?: (event: IEvent) => void;
}
const CalendarContext = createContext<ICalendarContext | null>(null);
const WORKING_HOURS = {
0: { from: 0, to: 0 },
1: { from: 8, to: 17 },
2: { from: 8, to: 17 },
3: { from: 8, to: 17 },
4: { from: 8, to: 17 },
5: { from: 8, to: 17 },
6: { from: 8, to: 12 }
};
const VISIBLE_HOURS = { from: 7, to: 18 };
export function CalendarProvider({
children,
users,
events,
initialDate,
initialView = "agenda",
onEventChange
}: {
children: ReactNode;
users: IUser[];
events: IEvent[];
initialDate?: string;
initialView?: TCalendarView;
onEventChange?: (event: IEvent) => void;
}) {
const [badgeVariant, setBadgeVariant] = useState<TBadgeVariant>("colored");
const [visibleHours, setVisibleHours] = useState<TVisibleHours>(VISIBLE_HOURS);
const [workingHours, setWorkingHours] = useState<TWorkingHours>(WORKING_HOURS);
const [selectedDate, setSelectedDateState] = useState(() =>
initialDate ? new Date(initialDate) : new Date()
);
const [selectedView, setSelectedView] = useState<TCalendarView>(initialView);
const [selectedUserId, setSelectedUserId] = useState<IUser["id"] | "all">("all");
const [localEvents, setLocalEvents] = useState<IEvent[]>(events);
useEffect(() => {
setLocalEvents(events);
}, [events]);
const setSelectedDate = (date: Date | undefined) => {
if (date) setSelectedDateState(date);
};
return (
<CalendarContext.Provider
value={{
selectedDate,
setSelectedDate,
selectedUserId,
setSelectedUserId,
selectedView,
setSelectedView,
badgeVariant,
setBadgeVariant,
users,
workingHours,
setWorkingHours,
visibleHours,
setVisibleHours,
events: localEvents,
setLocalEvents,
onEventChange
}}
>
{children}
</CalendarContext.Provider>
);
}
export function useCalendar() {
const context = useContext(CalendarContext);
if (!context) throw new Error("useCalendar must be used within CalendarProvider.");
return context;
}

View File

@@ -0,0 +1,277 @@
/* oxlint-disable unicorn/consistent-function-scoping */
import {
addDays,
addMonths,
addWeeks,
subDays,
subMonths,
subWeeks,
isSameWeek,
isSameDay,
isSameMonth,
startOfWeek,
startOfMonth,
endOfMonth,
endOfWeek,
format,
parseISO,
differenceInMinutes,
eachDayOfInterval,
startOfDay,
differenceInDays,
endOfYear,
startOfYear,
subYears,
addYears,
isSameYear,
isWithinInterval,
} from "date-fns";
import type { ICalendarCell, IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { TCalendarView, TVisibleHours, TWorkingHours } from "@/features/crm/calendar/upstream-big-calendar/types";
// ================ Header helper functions ================ //
export function rangeText(view: TCalendarView, date: Date) {
const formatString = "MMM d, yyyy";
let start: Date;
let end: Date;
switch (view) {
case "agenda":
start = startOfMonth(date);
end = endOfMonth(date);
break;
case "year":
start = startOfYear(date);
end = endOfYear(date);
break;
case "month":
start = startOfMonth(date);
end = endOfMonth(date);
break;
case "week":
start = startOfWeek(date);
end = endOfWeek(date);
break;
case "day":
return format(date, formatString);
default:
return "Error while formatting ";
}
return `${format(start, formatString)} - ${format(end, formatString)}`;
}
export function navigateDate(date: Date, view: TCalendarView, direction: "previous" | "next"): Date {
const operations = {
agenda: direction === "next" ? addMonths : subMonths,
year: direction === "next" ? addYears : subYears,
month: direction === "next" ? addMonths : subMonths,
week: direction === "next" ? addWeeks : subWeeks,
day: direction === "next" ? addDays : subDays,
};
return operations[view](date, 1);
}
export function getEventsCount(events: IEvent[], date: Date, view: TCalendarView): number {
const compareFns = {
agenda: isSameMonth,
year: isSameYear,
day: isSameDay,
week: isSameWeek,
month: isSameMonth,
};
return events.filter(event => compareFns[view](new Date(event.startDate), date)).length;
}
// ================ Week and day view helper functions ================ //
export function getCurrentEvents(events: IEvent[]) {
const now = new Date();
return events.filter(event => isWithinInterval(now, { start: parseISO(event.startDate), end: parseISO(event.endDate) })) || null;
}
export function groupEvents(dayEvents: IEvent[]) {
const sortedEvents = dayEvents.toSorted((a, b) => parseISO(a.startDate).getTime() - parseISO(b.startDate).getTime());
const groups: IEvent[][] = [];
for (const event of sortedEvents) {
const eventStart = parseISO(event.startDate);
let placed = false;
for (const group of groups) {
const lastEventInGroup = group[group.length - 1];
const lastEventEnd = parseISO(lastEventInGroup.endDate);
if (eventStart >= lastEventEnd) {
group.push(event);
placed = true;
break;
}
}
if (!placed) groups.push([event]);
}
return groups;
}
export function getEventBlockStyle(event: IEvent, day: Date, groupIndex: number, groupSize: number, visibleHoursRange?: { from: number; to: number }) {
const startDate = parseISO(event.startDate);
const dayStart = new Date(day.setHours(0, 0, 0, 0));
const eventStart = startDate < dayStart ? dayStart : startDate;
const startMinutes = differenceInMinutes(eventStart, dayStart);
let top;
if (visibleHoursRange) {
const visibleStartMinutes = visibleHoursRange.from * 60;
const visibleEndMinutes = visibleHoursRange.to * 60;
const visibleRangeMinutes = visibleEndMinutes - visibleStartMinutes;
top = ((startMinutes - visibleStartMinutes) / visibleRangeMinutes) * 100;
} else {
top = (startMinutes / 1440) * 100;
}
const width = 100 / groupSize;
const left = groupIndex * width;
return { top: `${top}%`, width: `${width}%`, left: `${left}%` };
}
export function isWorkingHour(day: Date, hour: number, workingHours: TWorkingHours) {
const dayIndex = day.getDay() as keyof typeof workingHours;
const dayHours = workingHours[dayIndex];
return hour >= dayHours.from && hour < dayHours.to;
}
export function getVisibleHours(visibleHours: TVisibleHours, singleDayEvents: IEvent[]) {
let earliestEventHour = visibleHours.from;
let latestEventHour = visibleHours.to;
singleDayEvents.forEach(event => {
const startHour = parseISO(event.startDate).getHours();
const endTime = parseISO(event.endDate);
const endHour = endTime.getHours() + (endTime.getMinutes() > 0 ? 1 : 0);
if (startHour < earliestEventHour) earliestEventHour = startHour;
if (endHour > latestEventHour) latestEventHour = endHour;
});
latestEventHour = Math.min(latestEventHour, 24);
const hours = Array.from({ length: latestEventHour - earliestEventHour }, (_, i) => i + earliestEventHour);
return { hours, earliestEventHour, latestEventHour };
}
// ================ Month view helper functions ================ //
export function getCalendarCells(selectedDate: Date): ICalendarCell[] {
const currentYear = selectedDate.getFullYear();
const currentMonth = selectedDate.getMonth();
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
const daysInMonth = getDaysInMonth(currentYear, currentMonth);
const firstDayOfMonth = getFirstDayOfMonth(currentYear, currentMonth);
const daysInPrevMonth = getDaysInMonth(currentYear, currentMonth - 1);
const totalDays = firstDayOfMonth + daysInMonth;
const prevMonthCells = Array.from({ length: firstDayOfMonth }, (_, i) => ({
day: daysInPrevMonth - firstDayOfMonth + i + 1,
currentMonth: false,
date: new Date(currentYear, currentMonth - 1, daysInPrevMonth - firstDayOfMonth + i + 1),
}));
const currentMonthCells = Array.from({ length: daysInMonth }, (_, i) => ({
day: i + 1,
currentMonth: true,
date: new Date(currentYear, currentMonth, i + 1),
}));
const nextMonthCells = Array.from({ length: (7 - (totalDays % 7)) % 7 }, (_, i) => ({
day: i + 1,
currentMonth: false,
date: new Date(currentYear, currentMonth + 1, i + 1),
}));
return [...prevMonthCells, ...currentMonthCells, ...nextMonthCells];
}
export function calculateMonthEventPositions(multiDayEvents: IEvent[], singleDayEvents: IEvent[], selectedDate: Date) {
const monthStart = startOfMonth(selectedDate);
const monthEnd = endOfMonth(selectedDate);
const eventPositions: { [key: string]: number } = {};
const occupiedPositions: { [key: string]: boolean[] } = {};
eachDayOfInterval({ start: monthStart, end: monthEnd }).forEach(day => {
occupiedPositions[day.toISOString()] = [false, false, false];
});
const sortedEvents = [
...multiDayEvents.toSorted((a, b) => {
const aDuration = differenceInDays(parseISO(a.endDate), parseISO(a.startDate));
const bDuration = differenceInDays(parseISO(b.endDate), parseISO(b.startDate));
return bDuration - aDuration || parseISO(a.startDate).getTime() - parseISO(b.startDate).getTime();
}),
...singleDayEvents.toSorted((a, b) => parseISO(a.startDate).getTime() - parseISO(b.startDate).getTime()),
];
sortedEvents.forEach(event => {
const eventStart = parseISO(event.startDate);
const eventEnd = parseISO(event.endDate);
const eventDays = eachDayOfInterval({
start: eventStart < monthStart ? monthStart : eventStart,
end: eventEnd > monthEnd ? monthEnd : eventEnd,
});
let position = -1;
for (let i = 0; i < 3; i++) {
if (
eventDays.every(day => {
const dayPositions = occupiedPositions[startOfDay(day).toISOString()];
return dayPositions && !dayPositions[i];
})
) {
position = i;
break;
}
}
if (position !== -1) {
eventDays.forEach(day => {
const dayKey = startOfDay(day).toISOString();
occupiedPositions[dayKey][position] = true;
});
eventPositions[event.id] = position;
}
});
return eventPositions;
}
export function getMonthCellEvents(date: Date, events: IEvent[], eventPositions: Record<string, number>) {
const eventsForDate = events.filter(event => {
const eventStart = parseISO(event.startDate);
const eventEnd = parseISO(event.endDate);
return (date >= eventStart && date <= eventEnd) || isSameDay(date, eventStart) || isSameDay(date, eventEnd);
});
return eventsForDate
.map(event => ({
...event,
position: eventPositions[event.id] ?? -1,
isMultiDay: event.startDate !== event.endDate,
}))
.toSorted((a, b) => {
if (a.isMultiDay && !b.isMultiDay) return -1;
if (!a.isMultiDay && b.isMultiDay) return 1;
return a.position - b.position;
});
}

View File

@@ -0,0 +1,26 @@
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
export function useUpdateEvent() {
const { setLocalEvents, onEventChange } = useCalendar();
const updateEvent = (event: IEvent) => {
if (event.alla?.editable === false) return;
const nextEvent: IEvent = {
...event,
startDate: new Date(event.startDate).toISOString(),
endDate: new Date(event.endDate).toISOString()
};
setLocalEvents((prev) => {
const index = prev.findIndex((item) => item.id === nextEvent.id);
if (index === -1) return prev;
return [...prev.slice(0, index), nextEvent, ...prev.slice(index + 1)];
});
onEventChange?.(nextEvent);
};
return { updateEvent };
}

View File

@@ -0,0 +1,18 @@
import { Icons } from "@/components/icons";
export const AlertTriangle = Icons.warning;
export const Calendar = Icons.calendar;
export const CalendarRange = Icons.calendar;
export const CalendarX2 = Icons.calendar;
export const ChevronLeft = Icons.chevronLeft;
export const ChevronRight = Icons.chevronRight;
export const Clock = Icons.clock;
export const Columns = Icons.panelLeft;
export const Grid2x2 = Icons.dashboard;
export const Grid3x3 = Icons.dashboard;
export const Info = Icons.info;
export const List = Icons.forms;
export const Moon = Icons.moon;
export const Plus = Icons.add;
export const Text = Icons.post;
export const User = Icons.user;

View File

@@ -0,0 +1,39 @@
import type { TEventColor } from "@/features/crm/calendar/upstream-big-calendar/types";
export interface IUser {
id: string;
name: string;
picturePath: string | null;
}
export interface IEvent {
id: string;
startDate: string;
endDate: string;
title: string;
color: TEventColor;
description: string;
user: IUser;
alla?: {
activityId: string | null;
editable: boolean;
milestone: boolean;
sourceEntityType: string;
sourceEntityId: string;
customerId: string | null;
leadId: string | null;
opportunityId: string | null;
quotationId: string | null;
approvalId: string | null;
overdue: boolean;
hotProject: boolean;
pricingSensitive: boolean;
internalOnly: boolean;
};
}
export interface ICalendarCell {
day: number;
currentMonth: boolean;
date: Date;
}

View File

@@ -0,0 +1,5 @@
export type TCalendarView = "day" | "week" | "month" | "year" | "agenda";
export type TEventColor = "blue" | "green" | "red" | "yellow" | "purple" | "orange" | "gray";
export type TBadgeVariant = "dot" | "colored" | "mixed";
export type TWorkingHours = { [key: number]: { from: number; to: number } };
export type TVisibleHours = { from: number; to: number };