task-ep.1.7

This commit is contained in:
phaichayon
2026-07-13 16:55:55 +07:00
parent ab56852c47
commit e0fcb3992b
12 changed files with 1762 additions and 101 deletions

View File

@@ -1,77 +1,89 @@
'use client';
"use client";
import { useMemo, useState } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
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 { useMemo, useState } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
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";
interface CalendarWorkspaceProps {
filters: Omit<CalendarListFilters, 'organizationId'>;
filters: Omit<CalendarListFilters, "organizationId">;
}
const VIEW_LABELS: Record<CalendarView, string> = {
day: 'Day',
week: 'Week',
month: 'Month',
agenda: 'Agenda'
day: "Day",
week: "Week",
month: "Month",
agenda: "Agenda",
};
export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
const [view, setView] = useState<CalendarView>('agenda');
const [search, setSearch] = useState('');
const [view, setView] = useState<CalendarView>("agenda");
const [search, setSearch] = useState("");
const { data } = useSuspenseQuery(calendarItemsQueryOptions(filters));
const events = useMemo(
() =>
data.items
.map(mapCalendarProjectionToBigCalendarEvent)
.filter((event) => event.title.toLowerCase().includes(search.toLowerCase())),
[data.items, search]
.filter((event) =>
event.title.toLowerCase().includes(search.toLowerCase()),
),
[data.items, search],
);
const groupedEvents = groupEventsByDate(events);
return (
<div className='space-y-6'>
<div className="space-y-6">
<CalendarSummaryCards summary={data.summary} />
<Card>
<CardHeader className='gap-4'>
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
<CardHeader className="gap-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<CardTitle className='flex items-center gap-2'>
<Icons.calendar className='h-5 w-5' />
<CardTitle className="flex items-center gap-2">
<Icons.calendar className="h-5 w-5" />
Calendar Workspace
</CardTitle>
<p className='text-muted-foreground text-sm'>
Projection workspace for scheduled activities and read-only business milestones.
<p className="text-muted-foreground text-sm">
Projection workspace for scheduled activities and read-only
business milestones.
</p>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<div className='relative'>
<Icons.search className='text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4' />
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative">
<Icons.search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder='Search calendar'
className='w-full pl-8 sm:w-[240px]'
placeholder="Search calendar"
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>
</div>
</div>
</CardHeader>
<CardContent>
<Tabs value={view} onValueChange={(value) => setView(value as CalendarView)}>
<TabsList className='mb-4 grid w-full grid-cols-4 lg:w-[420px]'>
<Tabs
value={view}
onValueChange={(value) => setView(value as CalendarView)}
>
<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}
@@ -79,17 +91,21 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
))}
</TabsList>
<TabsContent value='agenda'>
<TabsContent value="agenda">
<AgendaView groupedEvents={groupedEvents} />
</TabsContent>
<TabsContent value='day'>
<TabsContent value="day">
<CompactCalendarGrid events={events.slice(0, 12)} columns={1} />
</TabsContent>
<TabsContent value='week'>
<TabsContent value="week">
<CompactCalendarGrid events={events.slice(0, 28)} columns={7} />
</TabsContent>
<TabsContent value='month'>
<CompactCalendarGrid events={events.slice(0, 42)} columns={7} month />
<TabsContent value="month">
<CompactCalendarGrid
events={events.slice(0, 42)}
columns={7}
month
/>
</TabsContent>
</Tabs>
</CardContent>
@@ -100,36 +116,40 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
export function CalendarWorkspaceSkeleton() {
return (
<div className='space-y-6'>
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className='h-24 rounded-xl' />
<Skeleton key={index} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className='h-[520px] rounded-xl' />
<Skeleton className="h-[520px] rounded-xl" />
</div>
);
}
function CalendarSummaryCards({ summary }: { summary: CalendarWorkspaceSummary }) {
function CalendarSummaryCards({
summary,
}: {
summary: CalendarWorkspaceSummary;
}) {
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: "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 },
];
return (
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => (
<Card key={card.label}>
<CardContent className='flex items-center gap-3 p-4'>
<div className='bg-muted rounded-lg p-2'>
<card.icon className='text-muted-foreground h-5 w-5' />
<CardContent className="flex items-center gap-3 p-4">
<div className="bg-muted rounded-lg p-2">
<card.icon className="text-muted-foreground h-5 w-5" />
</div>
<div>
<p className='text-2xl font-bold'>{card.value}</p>
<p className='text-muted-foreground text-xs'>{card.label}</p>
<p className="text-2xl font-bold">{card.value}</p>
<p className="text-muted-foreground text-xs">{card.label}</p>
</div>
</CardContent>
</Card>
@@ -138,23 +158,27 @@ function CalendarSummaryCards({ summary }: { summary: CalendarWorkspaceSummary }
);
}
function AgendaView({ groupedEvents }: { groupedEvents: Array<[string, BigCalendarEvent[]]> }) {
function AgendaView({
groupedEvents,
}: {
groupedEvents: Array<[string, BigCalendarEvent[]]>;
}) {
if (!groupedEvents.length) {
return <CalendarEmptyState />;
}
return (
<div className='space-y-5'>
<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'>
<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 className="bg-border h-px flex-1" />
</div>
<div className='grid gap-3'>
<div className="grid gap-3">
{events.map((event) => (
<CalendarEventCard key={event.id} event={event} />
))}
@@ -168,7 +192,7 @@ function AgendaView({ groupedEvents }: { groupedEvents: Array<[string, BigCalend
function CompactCalendarGrid({
events,
columns,
month
month,
}: {
events: BigCalendarEvent[];
columns: number;
@@ -181,20 +205,22 @@ function CompactCalendarGrid({
return (
<div
className={cn(
'grid gap-3',
columns === 1 && 'grid-cols-1',
columns === 7 && 'grid-cols-1 md:grid-cols-7'
"grid gap-3",
columns === 1 && "grid-cols-1",
columns === 7 && "grid-cols-1 md:grid-cols-7",
)}
>
{events.map((event) => (
<div
key={event.id}
className={cn(
'border-border/70 bg-muted/20 min-h-28 rounded-lg border p-2',
month && 'min-h-36'
"border-border/70 bg-muted/20 min-h-28 rounded-lg border p-2",
month && "min-h-36",
)}
>
<p className='text-muted-foreground text-xs'>{formatDate(event.start)}</p>
<p className="text-muted-foreground text-xs">
{formatDate(event.start)}
</p>
<CalendarEventPill event={event} />
</div>
))}
@@ -204,41 +230,49 @@ function CompactCalendarGrid({
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('_', ' ')}
<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}
{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>
<h3 className="font-medium">{event.title}</h3>
{event.display.summary ? (
<p className='text-muted-foreground text-sm'>{event.display.summary}</p>
<p className="text-muted-foreground text-sm">
{event.display.summary}
</p>
) : null}
<p className='text-muted-foreground text-xs'>
<p className="text-muted-foreground text-xs">
{formatTime(event.start)} - {formatTime(event.end)}
{event.display.location ? ` · ${event.display.location}` : ''}
{event.display.location ? ` · ${event.display.location}` : ""}
</p>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm'>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm">
Open Source
</Button>
{event.editable ? (
<>
<Button variant='outline' size='sm'>
<Button variant="outline" size="sm">
Reschedule
</Button>
<Button variant='outline' size='sm'>
<Button variant="outline" size="sm">
Complete
</Button>
</>
) : (
<Button variant='outline' size='sm'>
<Button variant="outline" size="sm">
Create Activity
</Button>
)}
@@ -252,30 +286,33 @@ function CalendarEventPill({ event }: { event: BigCalendarEvent }) {
return (
<div
className={cn(
'mt-2 rounded-md border px-2 py-1 text-xs',
"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'
? "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}
<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'>
<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[]]> {
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);

View File

@@ -0,0 +1,15 @@
import { queryOptions } from '@tanstack/react-query';
import { getMyDay } from './service';
import type { MyDayFilters } from './types';
export const myDayKeys = {
all: ['crm-my-day'] as const,
workspaces: () => [...myDayKeys.all, 'workspace'] as const,
workspace: (filters: MyDayFilters) => [...myDayKeys.workspaces(), filters] as const
};
export const myDayQueryOptions = (filters: MyDayFilters) =>
queryOptions({
queryKey: myDayKeys.workspace(filters),
queryFn: () => getMyDay(filters)
});

View File

@@ -0,0 +1,21 @@
import { apiClient } from '@/lib/api-client';
import type { MyDayFilters, MyDayResponse } from './types';
function buildSearchParams(filters: MyDayFilters): string {
const params = new URLSearchParams();
if (filters.mode) params.set('mode', filters.mode);
if (filters.range) params.set('range', filters.range);
if (filters.userId) params.set('userId', filters.userId);
if (filters.teamUserIds?.length) params.set('teamUserIds', filters.teamUserIds.join(','));
if (filters.branchId) params.set('branchId', filters.branchId);
if (filters.productType) params.set('productType', filters.productType);
if (filters.customerId) params.set('customerId', filters.customerId);
if (filters.activityType) params.set('activityType', filters.activityType);
if (typeof filters.hotProject === 'boolean') params.set('hotProject', String(filters.hotProject));
return params.toString();
}
export async function getMyDay(filters: MyDayFilters): Promise<MyDayResponse> {
const search = buildSearchParams(filters);
return apiClient<MyDayResponse>(`/crm/my-day${search ? `?${search}` : ''}`);
}

View File

@@ -0,0 +1,80 @@
import type { ActivityRecord } from '../../activities/api/types';
import type { CrmActivityType } from '../../activity/types';
import type { CalendarProjectionItem, CalendarSummary } from '../../calendar/api/types';
import type {
CrmDashboardApprovalRow,
CrmDashboardHotProjectRow
} from '../../dashboard/api/types';
import type { TimelineItem } from '../../timeline/api/types';
export type MyDayMode = 'personal' | 'manager';
export type MyDayRange = 'today' | 'tomorrow' | 'this_week';
export type MyDayWidgetRefreshStrategy = 'live' | 'interval' | 'manual';
export interface MyDayFilters {
mode?: MyDayMode;
range?: MyDayRange;
userId?: string;
teamUserIds?: string[];
branchId?: string;
productType?: string;
customerId?: string;
activityType?: CrmActivityType;
hotProject?: boolean;
}
export interface MyDayWidgetDefinition {
id: string;
title: string;
priority: number;
icon: string;
permission: string;
visibility: MyDayMode[];
refreshStrategy: MyDayWidgetRefreshStrategy;
emptyState: string;
}
export interface MyDayPriorityItem {
id: string;
widgetId: string;
title: string;
subtitle: string | null;
dueAt: string | null;
priority: 'low' | 'normal' | 'high' | 'critical';
sourceType: 'activity' | 'calendar' | 'approval' | 'quotation' | 'opportunity' | 'timeline';
sourceId: string;
href: string;
actionLabel: string;
}
export interface MyDayStatistics {
activitiesToday: number;
meetings: number;
visits: number;
overdue: number;
hotProjects: number;
pendingApprovals: number;
}
export interface MyDayWidgetPayloads {
todaysSchedule: CalendarProjectionItem[];
dueToday: CalendarProjectionItem[];
overdueActivities: ActivityRecord[];
hotProjects: CrmDashboardHotProjectRow[];
pendingApprovals: CrmDashboardApprovalRow[];
quotationAttention: MyDayPriorityItem[];
recentlyUpdated: TimelineItem[];
statistics: MyDayStatistics;
}
export interface MyDayResponse {
success: boolean;
time: string;
message: string;
mode: MyDayMode;
range: MyDayRange;
widgets: MyDayWidgetDefinition[];
priorityQueue: MyDayPriorityItem[];
payloads: MyDayWidgetPayloads;
calendarSummary: CalendarSummary;
}

View File

@@ -0,0 +1,348 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Icons, type Icon } from '@/components/icons';
import { cn } from '@/lib/utils';
import { formatDateTime, formatTime } from '@/lib/date-format';
import { myDayQueryOptions } from '../api/queries';
import type { MyDayFilters, MyDayPriorityItem, MyDayWidgetDefinition } from '../api/types';
interface MyDayWorkspaceProps {
initialFilters: MyDayFilters;
}
const WIDGET_ICONS: Record<string, Icon> = {
warning: Icons.warning,
calendar: Icons.calendar,
clock: Icons.clock,
checks: Icons.checks,
post: Icons.post,
dashboard: Icons.dashboard
};
export function MyDayWorkspace({ initialFilters }: MyDayWorkspaceProps) {
const [range, setRange] = useState(initialFilters.range ?? 'today');
const filters = { ...initialFilters, range };
const { data, refetch, isFetching } = useSuspenseQuery(myDayQueryOptions(filters));
return (
<div className='space-y-6'>
<section className='grid gap-4 xl:grid-cols-[1.35fr_0.65fr]'>
<Card className='overflow-hidden'>
<CardContent className='p-6'>
<div className='flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between'>
<div>
<Badge variant='outline' className='mb-3'>
{data.mode === 'manager' ? 'Manager mode' : 'Personal mode'}
</Badge>
<h2 className='text-2xl font-semibold tracking-tight'>Start with the next best action</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Your operational queue from Calendar, Activities, Approvals, Hot Projects, and Timeline.
</p>
</div>
<div className='flex flex-wrap gap-2'>
{(['today', 'tomorrow', 'this_week'] as const).map((value) => (
<Button
key={value}
variant={range === value ? 'default' : 'outline'}
size='sm'
onClick={() => setRange(value)}
>
{value.replace('_', ' ')}
</Button>
))}
<Button asChild size='sm'>
<Link href='/dashboard/crm/activities'>
<Icons.add className='mr-1 h-4 w-4' />
Add Activity
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
<MyDayStats stats={data.payloads.statistics} />
</section>
<section className='grid gap-6 xl:grid-cols-[1fr_0.85fr]'>
<WidgetShell
widget={findWidget(data.widgets, 'overdue-activities')}
isFetching={isFetching}
onRefresh={() => void refetch()}
>
<PriorityQueue items={data.priorityQueue} />
</WidgetShell>
<WidgetShell
widget={findWidget(data.widgets, 'todays-schedule')}
isFetching={isFetching}
onRefresh={() => void refetch()}
>
<ScheduleList items={data.payloads.todaysSchedule} />
</WidgetShell>
</section>
<section className='grid gap-6 xl:grid-cols-3'>
<WidgetShell
widget={findWidget(data.widgets, 'hot-projects')}
isFetching={isFetching}
onRefresh={() => void refetch()}
>
<SimpleRows
empty='No hot projects in scope.'
rows={data.payloads.hotProjects.map((item) => ({
id: item.opportunityId,
title: item.projectName,
subtitle: item.customerName,
href: `/dashboard/crm/opportunities/${item.opportunityId}`,
badge: 'Hot'
}))}
/>
</WidgetShell>
<WidgetShell
widget={findWidget(data.widgets, 'pending-approvals')}
isFetching={isFetching}
onRefresh={() => void refetch()}
>
<SimpleRows
empty='No pending approvals.'
rows={data.payloads.pendingApprovals.map((item) => ({
id: item.id,
title: item.entityCode ?? 'Approval request',
subtitle: item.entityTitle,
href: `/dashboard/crm/approvals/${item.id}`,
badge: item.currentStepRoleName ?? 'Pending'
}))}
/>
</WidgetShell>
<WidgetShell
widget={findWidget(data.widgets, 'recently-updated')}
isFetching={isFetching}
onRefresh={() => void refetch()}
>
<SimpleRows
empty='No recent updates.'
rows={data.payloads.recentlyUpdated.map((item) => ({
id: item.id,
title: item.title,
subtitle: item.summary ?? item.timelineCategory,
href: item.activityId ? '/dashboard/crm/activities' : '/dashboard/crm/my-day',
badge: formatDateTime(item.occurredAt)
}))}
/>
</WidgetShell>
</section>
</div>
);
}
export function MyDayWorkspaceSkeleton() {
return (
<div className='space-y-6'>
<div className='grid gap-4 xl:grid-cols-[1.35fr_0.65fr]'>
<Skeleton className='h-40 rounded-xl' />
<Skeleton className='h-40 rounded-xl' />
</div>
<div className='grid gap-6 xl:grid-cols-[1fr_0.85fr]'>
<Skeleton className='h-96 rounded-xl' />
<Skeleton className='h-96 rounded-xl' />
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<Skeleton className='h-72 rounded-xl' />
<Skeleton className='h-72 rounded-xl' />
<Skeleton className='h-72 rounded-xl' />
</div>
</div>
);
}
function MyDayStats({ stats }: { stats: { activitiesToday: number; meetings: number; visits: number; overdue: number; hotProjects: number; pendingApprovals: number } }) {
const cards = [
{ label: 'Activities', value: stats.activitiesToday },
{ label: 'Meetings', value: stats.meetings },
{ label: 'Visits', value: stats.visits },
{ label: 'Overdue', value: stats.overdue },
{ label: 'Hot', value: stats.hotProjects },
{ label: 'Approvals', value: stats.pendingApprovals }
];
return (
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-base'>My Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className='grid grid-cols-3 gap-3'>
{cards.map((card) => (
<div key={card.label} className='bg-muted/40 rounded-lg p-3'>
<p className='text-xl font-semibold'>{card.value}</p>
<p className='text-muted-foreground text-xs'>{card.label}</p>
</div>
))}
</div>
</CardContent>
</Card>
);
}
function WidgetShell({
widget,
children,
isFetching,
onRefresh
}: {
widget: MyDayWidgetDefinition;
children: React.ReactNode;
isFetching: boolean;
onRefresh: () => void;
}) {
const Icon = WIDGET_ICONS[widget.icon] ?? Icons.dashboard;
return (
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='flex items-center gap-2 text-base'>
<Icon className='h-4 w-4' />
{widget.title}
</CardTitle>
<p className='text-muted-foreground mt-1 text-xs'>
Priority {widget.priority} · {widget.refreshStrategy} refresh
</p>
</div>
<Button variant='outline' size='sm' onClick={onRefresh} disabled={isFetching}>
{isFetching ? 'Refreshing' : 'Refresh'}
</Button>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
function PriorityQueue({ items }: { items: MyDayPriorityItem[] }) {
if (!items.length) {
return <EmptyMessage message='Nothing needs immediate attention. Review Calendar or add a new Activity.' />;
}
return (
<div className='space-y-3'>
{items.map((item, index) => (
<Link
key={item.id}
href={item.href}
aria-label={`${item.actionLabel}: ${item.title}`}
className='hover:bg-muted/40 block rounded-lg border p-4 transition-colors'
>
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
<div className='space-y-1'>
<div className='flex flex-wrap items-center gap-2'>
<Badge variant={index < 3 ? 'destructive' : 'outline'}>P{index + 1}</Badge>
<Badge variant='secondary'>{item.sourceType}</Badge>
<Badge variant='outline'>{item.priority}</Badge>
</div>
<p className='font-medium'>{item.title}</p>
{item.subtitle ? <p className='text-muted-foreground text-sm'>{item.subtitle}</p> : null}
</div>
<div className='text-muted-foreground text-sm lg:text-right'>
{item.dueAt ? <p>{formatDateTime(item.dueAt)}</p> : null}
<p className='font-medium text-primary'>{item.actionLabel}</p>
</div>
</div>
</Link>
))}
</div>
);
}
function ScheduleList({ items }: { items: Array<{ id: string; title: string; startAt: string; endAt: string; category: string; priority: string; summary: string | null; activityId: string | null }> }) {
if (!items.length) {
return <EmptyMessage message='No scheduled work in this range.' />;
}
return (
<div className='space-y-3'>
{items.slice(0, 12).map((item) => (
<Link
key={item.id}
href={item.activityId ? '/dashboard/crm/activities' : '/dashboard/crm/calendar'}
aria-label={`Open scheduled item: ${item.title}`}
className='hover:bg-muted/40 flex items-start gap-3 rounded-lg border p-3 transition-colors'
>
<div className='bg-primary/10 text-primary rounded-md px-2 py-1 text-xs font-medium'>
{formatTime(item.startAt)}
</div>
<div className='min-w-0 flex-1'>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{item.title}</p>
<Badge variant='outline'>{item.category.replaceAll('_', ' ')}</Badge>
</div>
{item.summary ? <p className='text-muted-foreground mt-1 text-sm'>{item.summary}</p> : null}
</div>
</Link>
))}
</div>
);
}
function SimpleRows({
rows,
empty
}: {
rows: Array<{ id: string; title: string; subtitle: string | null; href: string; badge: string }>;
empty: string;
}) {
if (!rows.length) return <EmptyMessage message={empty} />;
return (
<div className='space-y-3'>
{rows.slice(0, 8).map((row) => (
<Link
key={row.id}
href={row.href}
aria-label={`Open ${row.title}`}
className='hover:bg-muted/40 block rounded-lg border p-3 transition-colors'
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='truncate font-medium'>{row.title}</p>
{row.subtitle ? (
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>{row.subtitle}</p>
) : null}
</div>
<Badge variant='outline' className='shrink-0'>
{row.badge}
</Badge>
</div>
</Link>
))}
</div>
);
}
function EmptyMessage({ message }: { message: string }) {
return (
<div className={cn('rounded-lg border border-dashed p-8 text-center')}>
<Icons.check className='text-muted-foreground mx-auto mb-2 h-7 w-7' />
<p className='text-muted-foreground text-sm'>{message}</p>
</div>
);
}
function findWidget(widgets: MyDayWidgetDefinition[], id: string): MyDayWidgetDefinition {
return (
widgets.find((widget) => widget.id === id) ?? {
id,
title: id,
priority: 99,
icon: 'dashboard',
permission: 'crm.activity.read',
visibility: ['personal'],
refreshStrategy: 'manual',
emptyState: 'No data'
}
);
}

View File

@@ -0,0 +1,221 @@
import type { CrmSecurityContext } from '@/features/crm/security/server/service';
import { listActivities } from '../../activities/server/service';
import { listCalendarItems } from '../../calendar/server/service';
import { getCrmDashboardData } from '../../dashboard/server/service';
import { listTimelineItems } from '../../timeline/server/service';
import type { CalendarProjectionItem } from '../../calendar/api/types';
import type { MyDayFilters, MyDayPriorityItem, MyDayRange, MyDayResponse } from '../api/types';
import { getVisibleMyDayWidgets } from './widget-registry';
interface DateWindow {
start: Date;
end: Date;
}
export async function getMyDayWorkspace(
organizationId: string,
context: CrmSecurityContext,
filters: MyDayFilters
): Promise<MyDayResponse> {
const mode = filters.mode ?? 'personal';
const range = filters.range ?? 'today';
const window = getRangeWindow(range);
const dashboardFilters = {
dateFrom: toDateOnly(window.start),
dateTo: toDateOnly(window.end),
branch: filters.branchId ?? null,
salesman: filters.userId ?? null,
projectPartyRole: null,
productType: filters.productType ?? null
};
const [calendar, overdueOwnerActivities, overdueAssignedActivities, dashboard, recentTimeline] =
await Promise.all([
listCalendarItems(
{
organizationId,
startFrom: window.start.toISOString(),
startTo: window.end.toISOString(),
lens: mode === 'manager' ? 'manager' : 'personal',
userId: filters.userId,
teamUserIds: filters.teamUserIds,
branchId: filters.branchId,
productType: filters.productType,
customerId: filters.customerId,
activityType: filters.activityType,
hotProject: filters.hotProject,
limit: 80
},
context
),
listActivities(
organizationId,
{
status: 'overdue',
ownerId: filters.userId ?? context.userId,
activityType: filters.activityType
},
context
),
listActivities(
organizationId,
{
status: 'overdue',
assignedToId: filters.userId ?? context.userId,
activityType: filters.activityType
},
context
),
getCrmDashboardData(context, dashboardFilters),
listTimelineItems({
organizationId,
actorId: context.userId,
occurredFrom: window.start.toISOString(),
occurredTo: window.end.toISOString(),
includeInternalOnly: false,
limit: 8
})
]);
const overdueActivities = dedupeActivities([
...overdueOwnerActivities.items,
...overdueAssignedActivities.items
]).slice(0, 8);
const dueToday = calendar.items.filter((item) => isSameDate(item.startAt, new Date()));
const priorityQueue = buildPriorityQueue({
overdueActivities,
dueToday,
schedule: calendar.items,
pendingApprovals: dashboard.approvals.myPendingApprovals,
hotProjects: dashboard.hotProjects
});
return {
success: true,
time: new Date().toISOString(),
message: 'My Day workspace loaded successfully',
mode,
range,
widgets: getVisibleMyDayWidgets(mode),
priorityQueue,
payloads: {
todaysSchedule: calendar.items,
dueToday,
overdueActivities,
hotProjects: dashboard.hotProjects,
pendingApprovals: dashboard.approvals.myPendingApprovals,
quotationAttention: [],
recentlyUpdated: recentTimeline.items,
statistics: {
activitiesToday: dueToday.length,
meetings: calendar.summary.meetings,
visits: calendar.summary.visits,
overdue: overdueActivities.length,
hotProjects: dashboard.hotProjects.length,
pendingApprovals: dashboard.approvals.myPendingApprovals.length
}
},
calendarSummary: calendar.summary
};
}
function buildPriorityQueue(input: {
overdueActivities: Awaited<ReturnType<typeof listActivities>>['items'];
dueToday: CalendarProjectionItem[];
schedule: CalendarProjectionItem[];
pendingApprovals: Awaited<ReturnType<typeof getCrmDashboardData>>['approvals']['myPendingApprovals'];
hotProjects: Awaited<ReturnType<typeof getCrmDashboardData>>['hotProjects'];
}): MyDayPriorityItem[] {
return [
...input.overdueActivities.map<MyDayPriorityItem>((item) => ({
id: `overdue:${item.id}`,
widgetId: 'overdue-activities',
title: item.subject,
subtitle: item.primaryRecordLabel,
dueAt: item.dueAt ?? item.scheduledStartAt,
priority: item.priority,
sourceType: 'activity',
sourceId: item.id,
href: '/dashboard/crm/activities',
actionLabel: 'Open Activity'
})),
...input.dueToday.slice(0, 8).map<MyDayPriorityItem>((item) => ({
id: `due:${item.id}`,
widgetId: 'due-today',
title: item.title,
subtitle: item.summary,
dueAt: item.startAt,
priority: item.priority,
sourceType: 'calendar',
sourceId: item.id,
href: item.activityId ? '/dashboard/crm/activities' : '/dashboard/crm/calendar',
actionLabel: item.activityId ? 'Open Activity' : 'Open Calendar'
})),
...input.pendingApprovals.slice(0, 6).map<MyDayPriorityItem>((item) => ({
id: `approval:${item.id}`,
widgetId: 'pending-approvals',
title: item.entityCode ?? 'Approval request',
subtitle: item.entityTitle,
dueAt: item.requestedAt,
priority: 'high',
sourceType: 'approval',
sourceId: item.id,
href: `/dashboard/crm/approvals/${item.id}`,
actionLabel: 'Review Approval'
})),
...input.hotProjects.slice(0, 6).map<MyDayPriorityItem>((item) => ({
id: `hot:${item.opportunityId}`,
widgetId: 'hot-projects',
title: item.projectName,
subtitle: item.customerName,
dueAt: null,
priority: 'high',
sourceType: 'opportunity',
sourceId: item.opportunityId,
href: `/dashboard/crm/opportunities/${item.opportunityId}`,
actionLabel: 'Open Opportunity'
})),
...input.schedule.slice(0, 8).map<MyDayPriorityItem>((item) => ({
id: `schedule:${item.id}`,
widgetId: 'todays-schedule',
title: item.title,
subtitle: item.summary,
dueAt: item.startAt,
priority: item.priority,
sourceType: 'calendar',
sourceId: item.id,
href: item.activityId ? '/dashboard/crm/activities' : '/dashboard/crm/calendar',
actionLabel: 'Open Calendar'
}))
].slice(0, 24);
}
function dedupeActivities<T extends { id: string }>(items: T[]): T[] {
return [...new Map(items.map((item) => [item.id, item])).values()];
}
function getRangeWindow(range: MyDayRange): DateWindow {
const now = new Date();
const start = new Date(now);
start.setUTCHours(0, 0, 0, 0);
if (range === 'tomorrow') {
start.setUTCDate(start.getUTCDate() + 1);
}
const end = new Date(start);
if (range === 'this_week') {
end.setUTCDate(start.getUTCDate() + 6);
}
end.setUTCHours(23, 59, 59, 999);
return { start, end };
}
function toDateOnly(value: Date): string {
return value.toISOString().slice(0, 10);
}
function isSameDate(value: string, date: Date): boolean {
return value.slice(0, 10) === date.toISOString().slice(0, 10);
}

View File

@@ -0,0 +1,91 @@
import { CRM_ACTIVITY_PERMISSIONS } from '../../activity/types';
import type { MyDayMode, MyDayWidgetDefinition } from '../api/types';
export const MY_DAY_WIDGET_REGISTRY: MyDayWidgetDefinition[] = [
{
id: 'overdue-activities',
title: 'Overdue Activities',
priority: 1,
icon: 'warning',
permission: CRM_ACTIVITY_PERMISSIONS.read,
visibility: ['personal', 'manager'],
refreshStrategy: 'interval',
emptyState: 'No overdue activities. The queue is clear.'
},
{
id: 'todays-schedule',
title: "Today's Schedule",
priority: 2,
icon: 'calendar',
permission: CRM_ACTIVITY_PERMISSIONS.read,
visibility: ['personal', 'manager'],
refreshStrategy: 'interval',
emptyState: 'No scheduled work for the selected period.'
},
{
id: 'due-today',
title: 'Due Today',
priority: 3,
icon: 'clock',
permission: CRM_ACTIVITY_PERMISSIONS.read,
visibility: ['personal', 'manager'],
refreshStrategy: 'interval',
emptyState: 'Nothing due today.'
},
{
id: 'pending-approvals',
title: 'Pending Approvals',
priority: 4,
icon: 'checks',
permission: 'crm.approval.read',
visibility: ['personal', 'manager'],
refreshStrategy: 'interval',
emptyState: 'No approval blockers need your attention.'
},
{
id: 'quotation-attention',
title: 'Quotation Attention',
priority: 5,
icon: 'post',
permission: 'crm.quotation.read',
visibility: ['personal', 'manager'],
refreshStrategy: 'manual',
emptyState: 'No urgent quotation attention items in this foundation slice.'
},
{
id: 'hot-projects',
title: 'Hot Projects',
priority: 6,
icon: 'warning',
permission: 'crm.opportunity.read',
visibility: ['personal', 'manager'],
refreshStrategy: 'interval',
emptyState: 'No hot projects in your current scope.'
},
{
id: 'recently-updated',
title: 'Recently Updated',
priority: 7,
icon: 'clock',
permission: CRM_ACTIVITY_PERMISSIONS.read,
visibility: ['personal', 'manager'],
refreshStrategy: 'manual',
emptyState: 'No recent timeline activity yet.'
},
{
id: 'my-statistics',
title: 'My Statistics',
priority: 8,
icon: 'dashboard',
permission: CRM_ACTIVITY_PERMISSIONS.read,
visibility: ['personal', 'manager'],
refreshStrategy: 'interval',
emptyState: 'No statistics available for this period.'
}
];
export function getVisibleMyDayWidgets(mode: MyDayMode): MyDayWidgetDefinition[] {
return MY_DAY_WIDGET_REGISTRY.filter((widget) => widget.visibility.includes(mode)).toSorted(
(a, b) => a.priority - b.priority
);
}