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

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