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

@@ -0,0 +1,103 @@
# Task EP.1.7 My Day Workspace Foundation - 2026-07-13
## Scope Delivered
- added My Day feature module under `src/features/crm/my-day/**`
- added `/api/crm/my-day` route handler with organization access and CRM Activity read permission
- added `/dashboard/crm/my-day` workspace route with server prefetch, React Query hydration, loading states, and widget layout
- added My Day Widget Registry with widget id, title, priority, icon, permission, visibility, refresh strategy, and empty state
- added My Day service composition over existing foundations:
- Calendar Projection for today's schedule and due-today work
- Activity service for overdue Activity queue
- CRM Dashboard service for Hot Projects and pending approvals
- Timeline Projection for recent user-authored updates
- added priority queue ordering for overdue, due today, approvals, hot projects, and schedule items
- added CRM nav entry for My Day before CRM Dashboard
- preserved Activity, Calendar, Timeline, Opportunity, Quotation, Approval, Dashboard, Report, Notification behavior
## Review Summary
Reviewed before/during implementation:
- `AGENTS.md`
- `plans/task-ep.1.7.md`
- `LAYOUT.md`
- `docs/standards/engineering-constitution.md`
- `docs/standards/project-foundations.md`
- `docs/standards/architecture-rules.md`
- `docs/standards/ui-ux-rules.md`
- `docs/standards/task-review-checklist.md`
- `docs/security/crm-authorization-boundaries.md`
- `docs/business/relationship-sales-workspace-blueprint-v1.md`
- `docs/implementation/task-ar.2-workspace-ui-ux-design-note-2026-07-07.md`
- `docs/implementation/task-ep1.5-timeline-projection-foundation-2026-07-13.md`
- `docs/implementation/task-ep1.6-calendar-projection-workspace-big-calendar-foundation-2026-07-13.md`
- existing Activity, Calendar, Timeline, Dashboard, CRM security, and dashboard UI patterns
## Architecture Notes
My Day is read-oriented and does not own business data. It composes existing source-domain and projection services instead of introducing duplicate Activity, Calendar, Timeline, Approval, Opportunity, or Quotation logic.
Primary service:
- `src/features/crm/my-day/server/service.ts`
Public contracts:
- `src/features/crm/my-day/api/types.ts`
- `src/features/crm/my-day/api/service.ts`
- `src/features/crm/my-day/api/queries.ts`
Widget registry:
- `src/features/crm/my-day/server/widget-registry.ts`
## Widget Delivery
Implemented foundation widgets:
- Overdue Activities
- Today's Schedule
- Due Today priority items
- Hot Projects
- Pending Approvals
- Recently Updated
- My Statistics
Quotation Attention is represented in the registry and response contract, but no bespoke quotation-risk query was added in this slice to avoid duplicating Quotation/Dashboard logic before a governed urgency adapter exists.
## Security Notes
- Route requires active organization access and `crm.activity.read`.
- Activity visibility is enforced by existing Activity service.
- Calendar visibility is enforced by existing Calendar projection query service.
- Hot Projects and approvals reuse Dashboard service visibility rules.
- Recent Timeline is limited to the current actor to avoid organization-wide timeline leakage until team-scoped Timeline access is formalized.
- Manager mode is contract-ready but intentionally conservative while team graph scope remains a known governance limitation.
## UI/UX Review
- Follows AR.2 My Day design note: high-priority queue first, summary stats, active schedule, hot projects, approvals, recent updates.
- Uses `PageContainer`, shadcn cards, badges, buttons, and skeletons.
- Adds Add Activity entry point via existing Activity workspace instead of creating a duplicate form.
- Uses deterministic date/time display through `src/lib/date-format.ts`.
- Empty states exist per rendered widget.
- Status is not color-only: priority, source type, refresh strategy, and action labels are visible text.
## Known Follow-ups
- Wire Add Activity to an Activity form sheet with context-aware prefills once reusable Activity form entry is exposed outside the Activity listing.
- Implement source-owned Quotation Attention adapter for expiring soon, pending approval, and returned revision.
- Add manager team filter UI after team hierarchy/scope behavior is formalized.
- Add independent per-widget query endpoints if operational telemetry shows full workspace refetch is too coarse.
- Add workspace analytics for widget usage, quick action usage, Add Activity usage, and completion rate with sanitized payloads.
- Decide rollout for making My Day the post-login/default CRM landing without disrupting the existing dashboard route.
## Verification
- `npm run typecheck`
- `npx oxlint src/features/crm/my-day src/app/api/crm/my-day src/app/dashboard/crm/my-day src/config/nav-config.ts`
## Outcome
EP.1.7 now has a governed My Day Workspace foundation that lets users start from a prioritized operational queue while preserving source-domain ownership and existing CRM workflows.

649
plans/task-ep.1.7.md Normal file
View File

@@ -0,0 +1,649 @@
# Task EP.1.7 My Day Workspace Foundation
**Status:** Foundation Implemented
**Priority:** Critical
**Type:** Workspace / Operational Dashboard / Productivity Foundation
---
# Depends On
* EP.1.1 Activity Domain Foundation
* EP.1.2 Activity Integration & Legacy Follow-up Adapter
* EP.1.3 Business Event Foundation
* EP.1.4 Projection Foundation & Delivery Reliability
* EP.1.4.1 Transactional Outbox Activation & Worker Runtime
* EP.1.5 Timeline Projection Foundation
* EP.1.6 Calendar Projection, Workspace & Big Calendar Foundation
* BU-R.0
* BU-R.0.1
* BU-R.1
* AR.1
* AR.2
* ENG.0
---
# Objective
Introduce the first operational workspace that users open every morning.
My Day becomes the single productivity workspace where Marketing, Sales, Managers, and Executives immediately understand:
* what must be done today
* what is overdue
* what is important
* what is blocked
* what is approaching
* what requires attention
without navigating through Lead, Opportunity, Quotation, Calendar, Timeline, and Approval individually.
My Day is **NOT** another business domain.
It is a read-oriented operational workspace.
---
# Business Outcome
When a Sales user logs in, the first screen should answer:
> What should I do next?
When a Manager logs in, the first screen should answer:
> Which team members require attention?
---
# Workspace Principles
## My Day never owns business data
My Day aggregates information.
It never becomes the source of:
* Activity
* Lead
* Opportunity
* Quotation
* Approval
* Calendar
---
## Activity remains the operational owner
Every actionable task ultimately resolves to Activity.
My Day never edits projection rows.
---
## Calendar owns scheduling
My Day consumes Calendar Projection.
Calendar remains responsible for scheduling.
---
## Timeline owns history
Timeline answers:
"What happened?"
My Day answers:
"What should happen next?"
---
# Review Required
Review:
* AGENTS.md
* Engineering Constitution
* Project Foundations
* Architecture Rules
* UI UX Rules
* LAYOUT.md
* AR.2 Workspace UI UX Design Note
* EP.1.5 Timeline
* EP.1.6 Calendar
* CRM Security
* Activity Domain
* Projection Runtime
UI review must use:
* ui-ux-pro-max
---
# Scope
# Part 1 — Workspace Route
Introduce
```text
/dashboard/crm/my-day
```
This becomes the default landing page for CRM users after login.
---
# Part 2 — Workspace Composition
The page is composed of widgets.
Widgets are independent read models.
Widgets never own business logic.
---
# Part 3 — Workspace Widget Registry
Introduce
WorkspaceWidgetRegistry
Each widget defines:
* id
* title
* priority
* icon
* permission
* visibility
* refresh strategy
* empty state
Widgets must be individually reusable.
---
# Part 4 — Default Widgets
Minimum widgets
## Today's Schedule
Consumes Calendar.
Shows:
* Meeting
* Visit
* Site Survey
* Follow-up
---
## Overdue Activities
Consumes Activity.
---
## Due Today
Consumes Calendar.
---
## Hot Projects
Consumes Opportunity.
Only projects currently flagged as Hot Project.
---
## Quotations Requiring Attention
Examples
* expiring soon
* pending approval
* returned for revision
---
## Pending Approvals
Consumes Approval.
---
## Recently Updated
Consumes Timeline.
---
## My Statistics
Examples
* Activities Today
* Meetings
* Visits
* Closed Activities
---
# Part 5 — Workspace Data Priority Matrix
Freeze rendering priority.
Example
Priority 1
Overdue
Priority 2
Today's Meetings
Priority 3
Today's Visits
Priority 4
Approvals
Priority 5
Quotation Expiring
Priority 6
Expected Award
Priority 7
Hot Project
Priority 8
Upcoming Activities
Widgets should appear in business importance order.
---
# Part 6 — Add Activity
Primary workspace action.
Display
```text
+ Add Activity
```
Must reuse existing
Activity Form
Activity API
Activity Service
No duplicate Activity implementation.
Support
* Meeting
* Visit
* Follow-up
* Phone Call
* Reminder
* Internal Task
* Site Survey
Context-aware prefills:
* Customer
* Lead
* Opportunity
* Quotation
* selected Calendar date
---
# Part 7 — Quick Actions
Examples
Complete
Reschedule
Cancel
Open Activity
Open Customer
Open Opportunity
Open Quotation
Open Calendar
Actions must call source services.
---
# Part 8 — Workspace Filters
Support
* Today
* Tomorrow
* This Week
* User
* Team
* Branch
* Product
* Customer
* Activity Type
* Hot Project
---
# Part 9 — Workspace Layout
Suggested layout
```text
----------------------------------------------------
Good Morning
Summary KPIs
----------------------------------------------------
Today's Schedule
Overdue
Hot Projects
Pending Approvals
----------------------------------------------------
Quotation Attention
Recent Activity
----------------------------------------------------
Quick Actions
----------------------------------------------------
```
Must follow
LAYOUT.md
and
ui-ux-rules.md
---
# Part 10 — Personal vs Manager Mode
Personal
Shows
* own Activities
* own Calendar
* own Quotations
* own Approvals
Manager
Adds
* team workload
* overdue team Activities
* team Calendar
* team approvals
Uses existing CRM permissions.
---
# Part 11 — Widget Refresh
Widgets should refresh independently.
No full-page reload.
Reuse React Query.
---
# Part 12 — Empty States
Every widget must define
* loading
* empty
* permission denied
---
# Part 13 — Notification Entry Points
Widgets provide links into
* Calendar
* Timeline
* Activity
* Customer
* Opportunity
* Quotation
Notifications remain outside scope.
---
# Part 14 — Workspace Performance
Support
* lazy loading
* independent queries
* cached widgets
* bounded datasets
---
# Part 15 — Responsive Layout
Desktop
Tablet
Mobile
Mobile defaults to
Today's Schedule
---
# Part 16 — Workspace Security
Every widget must reuse
CRM Security Context
No widget bypasses permission.
---
# Part 17 — My Day UX Review
Review with
* ui-ux-pro-max
* LAYOUT.md
* ui-ux-rules.md
Review
* hierarchy
* density
* responsiveness
* accessibility
* dark mode
* keyboard
* empty state
* loading
* interaction consistency
---
# Part 18 — Workspace Analytics
Track
* widget usage
* quick action usage
* Add Activity usage
* completion rate
No sensitive business payload stored.
---
# Deliverables
1. My Day Workspace
2. Widget Registry
3. Workspace Layout
4. Today's Schedule Widget
5. Overdue Widget
6. Due Today Widget
7. Hot Project Widget
8. Pending Approval Widget
9. Quotation Attention Widget
10. Recent Activity Widget
11. My Statistics Widget
12. Add Activity Action
13. Quick Actions
14. Workspace Filters
15. Manager Mode
16. Widget Refresh Strategy
17. Responsive Layout
18. Workspace Analytics
19. My Day UX Review Report
20. EP.2.0 Readiness Report
---
# Constraints
Must preserve
* Activity ownership
* Calendar ownership
* Timeline ownership
* Opportunity ownership
* Quotation ownership
* Approval ownership
Must not introduce
* duplicate Activity
* duplicate Calendar
* duplicate Timeline
* duplicate Notification
No breaking API changes.
---
# Testing
* Widget rendering
* Widget refresh
* Permission
* Add Activity
* Quick Actions
* Filters
* Responsive
* Mobile
* Manager Mode
* Empty State
* Loading
* Dark Mode
* Accessibility
* Performance
---
# Acceptance Criteria
* My Day becomes the default operational workspace for CRM users.
* All widgets consume existing source domains or projections without introducing duplicate business logic.
* The primary **Add Activity** action reuses the existing Activity form, API, and service.
* Widgets refresh independently using React Query and do not require full-page reloads.
* Personal and Manager modes enforce existing CRM permissions and team visibility rules.
* The Workspace Widget Registry defines widget composition, priority, permissions, and refresh behavior.
* The Workspace Data Priority Matrix determines the display order of actionable information.
* UI follows `LAYOUT.md`, `ui-ux-rules.md`, and is reviewed with `ui-ux-pro-max`.
* Existing Activity, Calendar, Timeline, Approval, Dashboard, Reports, and Notification behavior remain unchanged.
---
# Success Criteria
ALLA OS gains a unified **My Day Operational Workspace** where users can start their day, understand priorities, create and manage Activities, and navigate seamlessly to Calendar, Timeline, Customers, Opportunities, Quotations, and Approvals from a single productivity-focused workspace.

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { CRM_ACTIVITY_PERMISSIONS, CRM_ACTIVITY_TYPES } from '@/features/crm/activity/types';
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
import { getMyDayWorkspace } from '@/features/crm/my-day/server/service';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const myDayQuerySchema = z.object({
mode: z.enum(['personal', 'manager']).optional(),
range: z.enum(['today', 'tomorrow', 'this_week']).optional(),
userId: z.string().optional(),
teamUserIds: z
.string()
.optional()
.transform((value) => value?.split(',').filter(Boolean)),
branchId: z.string().optional(),
productType: z.string().optional(),
customerId: z.string().optional(),
activityType: z.enum(CRM_ACTIVITY_TYPES).optional(),
hotProject: z.coerce.boolean().optional()
});
export async function GET(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess({
permission: CRM_ACTIVITY_PERMISSIONS.read
});
const filters = myDayQuerySchema.parse({
mode: request.nextUrl.searchParams.get('mode') ?? undefined,
range: request.nextUrl.searchParams.get('range') ?? undefined,
userId: request.nextUrl.searchParams.get('userId') ?? undefined,
teamUserIds: request.nextUrl.searchParams.get('teamUserIds') ?? undefined,
branchId: request.nextUrl.searchParams.get('branchId') ?? undefined,
productType: request.nextUrl.searchParams.get('productType') ?? undefined,
customerId: request.nextUrl.searchParams.get('customerId') ?? undefined,
activityType: request.nextUrl.searchParams.get('activityType') ?? undefined,
hotProject: request.nextUrl.searchParams.get('hotProject') ?? undefined
});
const context = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const response = await getMyDayWorkspace(organization.id, context, filters);
return NextResponse.json(response);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json({ message: 'Invalid My Day filters' }, { status: 400 });
}
console.error('[crm/my-day] failed to load workspace', error);
return NextResponse.json({ message: 'Failed to load My Day workspace' }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { Suspense } from 'react';
import PageContainer from '@/components/layout/page-container';
import { MyDayWorkspace, MyDayWorkspaceSkeleton } from '@/features/crm/my-day/components/my-day-workspace';
import { myDayQueryOptions } from '@/features/crm/my-day/api/queries';
import { getQueryClient } from '@/lib/query-client';
export const metadata = {
title: 'CRM: My Day'
};
export default function MyDayPage() {
const queryClient = getQueryClient();
const filters = { mode: 'personal' as const, range: 'today' as const };
void queryClient.prefetchQuery(myDayQueryOptions(filters));
return (
<PageContainer
pageTitle='My Day'
pageDescription='Personal operational workspace for today, overdue work, approvals, hot projects, and recent updates.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<MyDayWorkspaceSkeleton />}>
<MyDayWorkspace initialFilters={filters} />
</Suspense>
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -4,6 +4,14 @@ import { NavGroup } from "@/types";
{
label: "CRM",
items: [
{
title: "My Day",
url: "/dashboard/crm/my-day",
icon: "calendar",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.activity.read" },
},
{
title: "Dashboard",
url: "/dashboard/crm",

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}
</div>
<h3 className='font-medium'>{event.title}</h3>
{event.display.summary ? (
<p className='text-muted-foreground text-sm'>{event.display.summary}</p>
{event.display.overdue ? (
<Badge variant="destructive">Overdue</Badge>
) : null}
<p className='text-muted-foreground text-xs'>
{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}` : ''}
{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
);
}