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