From e0fcb3992b006eafe26d2b5b64ae6985c797f697 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Mon, 13 Jul 2026 16:55:55 +0700 Subject: [PATCH] task-ep.1.7 --- ...-my-day-workspace-foundation-2026-07-13.md | 103 +++ plans/task-ep.1.7.md | 649 ++++++++++++++++++ src/app/api/crm/my-day/route.ts | 59 ++ src/app/dashboard/crm/my-day/page.tsx | 29 + src/config/nav-config.ts | 8 + .../components/calendar-workspace.tsx | 239 ++++--- src/features/crm/my-day/api/queries.ts | 15 + src/features/crm/my-day/api/service.ts | 21 + src/features/crm/my-day/api/types.ts | 80 +++ .../my-day/components/my-day-workspace.tsx | 348 ++++++++++ src/features/crm/my-day/server/service.ts | 221 ++++++ .../crm/my-day/server/widget-registry.ts | 91 +++ 12 files changed, 1762 insertions(+), 101 deletions(-) create mode 100644 docs/implementation/task-ep1.7-my-day-workspace-foundation-2026-07-13.md create mode 100644 plans/task-ep.1.7.md create mode 100644 src/app/api/crm/my-day/route.ts create mode 100644 src/app/dashboard/crm/my-day/page.tsx create mode 100644 src/features/crm/my-day/api/queries.ts create mode 100644 src/features/crm/my-day/api/service.ts create mode 100644 src/features/crm/my-day/api/types.ts create mode 100644 src/features/crm/my-day/components/my-day-workspace.tsx create mode 100644 src/features/crm/my-day/server/service.ts create mode 100644 src/features/crm/my-day/server/widget-registry.ts diff --git a/docs/implementation/task-ep1.7-my-day-workspace-foundation-2026-07-13.md b/docs/implementation/task-ep1.7-my-day-workspace-foundation-2026-07-13.md new file mode 100644 index 0000000..624071a --- /dev/null +++ b/docs/implementation/task-ep1.7-my-day-workspace-foundation-2026-07-13.md @@ -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. diff --git a/plans/task-ep.1.7.md b/plans/task-ep.1.7.md new file mode 100644 index 0000000..880eab9 --- /dev/null +++ b/plans/task-ep.1.7.md @@ -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. diff --git a/src/app/api/crm/my-day/route.ts b/src/app/api/crm/my-day/route.ts new file mode 100644 index 0000000..2789505 --- /dev/null +++ b/src/app/api/crm/my-day/route.ts @@ -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 }); + } +} diff --git a/src/app/dashboard/crm/my-day/page.tsx b/src/app/dashboard/crm/my-day/page.tsx new file mode 100644 index 0000000..9a12924 --- /dev/null +++ b/src/app/dashboard/crm/my-day/page.tsx @@ -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 ( + + + }> + + + + + ); +} diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index 1a66eab..53660c7 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -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", diff --git a/src/features/crm/calendar/components/calendar-workspace.tsx b/src/features/crm/calendar/components/calendar-workspace.tsx index 14acc72..62bc23a 100644 --- a/src/features/crm/calendar/components/calendar-workspace.tsx +++ b/src/features/crm/calendar/components/calendar-workspace.tsx @@ -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; + filters: Omit; } const VIEW_LABELS: Record = { - 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('agenda'); - const [search, setSearch] = useState(''); + const [view, setView] = useState("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 ( -
+
- -
+ +
- - + + Calendar Workspace -

- Projection workspace for scheduled activities and read-only business milestones. +

+ Projection workspace for scheduled activities and read-only + business milestones.

-
-
- +
+
+ 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]" />
- Lens: {filters.lens ?? 'personal'} + + Lens: {filters.lens ?? "personal"} +
- setView(value as CalendarView)}> - + setView(value as CalendarView)} + > + {Object.entries(VIEW_LABELS).map(([value, label]) => ( {label} @@ -79,17 +91,21 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) { ))} - + - + - + - - + + @@ -100,36 +116,40 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) { export function CalendarWorkspaceSkeleton() { return ( -
-
+
+
{Array.from({ length: 4 }).map((_, index) => ( - + ))}
- +
); } -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 ( -
+
{cards.map((card) => ( - -
- + +
+
-

{card.value}

-

{card.label}

+

{card.value}

+

{card.label}

@@ -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 ; } return ( -
+
{groupedEvents.map(([date, events]) => ( -
-
-
-

+

+
+
+

{formatDate(date)}

-
+
-
+
{events.map((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 (
{events.map((event) => (
-

{formatDate(event.start)}

+

+ {formatDate(event.start)} +

))} @@ -204,41 +230,49 @@ function CompactCalendarGrid({ function CalendarEventCard({ event }: { event: BigCalendarEvent }) { return ( -
-
-
-
- - {event.category.replaceAll('_', ' ')} +
+
+
+
+ + {event.category.replaceAll("_", " ")} - {event.display.overdue ? Overdue : null} - {event.display.hotProject ? Hot Project : null} - {!event.editable ? Read-only : null} + {event.display.overdue ? ( + Overdue + ) : null} + {event.display.hotProject ? ( + Hot Project + ) : null} + {!event.editable ? ( + Read-only + ) : null}
-

{event.title}

+

{event.title}

{event.display.summary ? ( -

{event.display.summary}

+

+ {event.display.summary} +

) : null} -

+

{formatTime(event.start)} - {formatTime(event.end)} - {event.display.location ? ` · ${event.display.location}` : ''} + {event.display.location ? ` · ${event.display.location}` : ""}

-
- {event.editable ? ( <> - - ) : ( - )} @@ -252,30 +286,33 @@ function CalendarEventPill({ event }: { event: BigCalendarEvent }) { return (
- {formatTime(event.start)} {event.title} + {formatTime(event.start)}{" "} + {event.title}
); } function CalendarEmptyState() { return ( -
- -

No scheduled work in this period

-

+

+ +

No scheduled work in this period

+

Try changing filters or create an Activity from the owning CRM record.

); } -function groupEventsByDate(events: BigCalendarEvent[]): Array<[string, BigCalendarEvent[]]> { +function groupEventsByDate( + events: BigCalendarEvent[], +): Array<[string, BigCalendarEvent[]]> { const grouped = new Map(); for (const event of events) { const key = event.start.slice(0, 10); diff --git a/src/features/crm/my-day/api/queries.ts b/src/features/crm/my-day/api/queries.ts new file mode 100644 index 0000000..ea0be7c --- /dev/null +++ b/src/features/crm/my-day/api/queries.ts @@ -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) + }); diff --git a/src/features/crm/my-day/api/service.ts b/src/features/crm/my-day/api/service.ts new file mode 100644 index 0000000..81b235a --- /dev/null +++ b/src/features/crm/my-day/api/service.ts @@ -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 { + const search = buildSearchParams(filters); + return apiClient(`/crm/my-day${search ? `?${search}` : ''}`); +} diff --git a/src/features/crm/my-day/api/types.ts b/src/features/crm/my-day/api/types.ts new file mode 100644 index 0000000..b19ade0 --- /dev/null +++ b/src/features/crm/my-day/api/types.ts @@ -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; +} diff --git a/src/features/crm/my-day/components/my-day-workspace.tsx b/src/features/crm/my-day/components/my-day-workspace.tsx new file mode 100644 index 0000000..68369dd --- /dev/null +++ b/src/features/crm/my-day/components/my-day-workspace.tsx @@ -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 = { + 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 ( +
+
+ + +
+
+ + {data.mode === 'manager' ? 'Manager mode' : 'Personal mode'} + +

Start with the next best action

+

+ Your operational queue from Calendar, Activities, Approvals, Hot Projects, and Timeline. +

+
+
+ {(['today', 'tomorrow', 'this_week'] as const).map((value) => ( + + ))} + +
+
+
+
+ +
+ +
+ void refetch()} + > + + + void refetch()} + > + + +
+ +
+ void refetch()} + > + ({ + id: item.opportunityId, + title: item.projectName, + subtitle: item.customerName, + href: `/dashboard/crm/opportunities/${item.opportunityId}`, + badge: 'Hot' + }))} + /> + + void refetch()} + > + ({ + id: item.id, + title: item.entityCode ?? 'Approval request', + subtitle: item.entityTitle, + href: `/dashboard/crm/approvals/${item.id}`, + badge: item.currentStepRoleName ?? 'Pending' + }))} + /> + + void refetch()} + > + ({ + 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) + }))} + /> + +
+
+ ); +} + +export function MyDayWorkspaceSkeleton() { + return ( +
+
+ + +
+
+ + +
+
+ + + +
+
+ ); +} + +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 ( + + + My Statistics + + +
+ {cards.map((card) => ( +
+

{card.value}

+

{card.label}

+
+ ))} +
+
+
+ ); +} + +function WidgetShell({ + widget, + children, + isFetching, + onRefresh +}: { + widget: MyDayWidgetDefinition; + children: React.ReactNode; + isFetching: boolean; + onRefresh: () => void; +}) { + const Icon = WIDGET_ICONS[widget.icon] ?? Icons.dashboard; + return ( + + +
+ + + {widget.title} + +

+ Priority {widget.priority} · {widget.refreshStrategy} refresh +

+
+ +
+ {children} +
+ ); +} + +function PriorityQueue({ items }: { items: MyDayPriorityItem[] }) { + if (!items.length) { + return ; + } + + return ( +
+ {items.map((item, index) => ( + +
+
+
+ P{index + 1} + {item.sourceType} + {item.priority} +
+

{item.title}

+ {item.subtitle ?

{item.subtitle}

: null} +
+
+ {item.dueAt ?

{formatDateTime(item.dueAt)}

: null} +

{item.actionLabel}

+
+
+ + ))} +
+ ); +} + +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 ; + } + + return ( +
+ {items.slice(0, 12).map((item) => ( + +
+ {formatTime(item.startAt)} +
+
+
+

{item.title}

+ {item.category.replaceAll('_', ' ')} +
+ {item.summary ?

{item.summary}

: null} +
+ + ))} +
+ ); +} + +function SimpleRows({ + rows, + empty +}: { + rows: Array<{ id: string; title: string; subtitle: string | null; href: string; badge: string }>; + empty: string; +}) { + if (!rows.length) return ; + + return ( +
+ {rows.slice(0, 8).map((row) => ( + +
+
+

{row.title}

+ {row.subtitle ? ( +

{row.subtitle}

+ ) : null} +
+ + {row.badge} + +
+ + ))} +
+ ); +} + +function EmptyMessage({ message }: { message: string }) { + return ( +
+ +

{message}

+
+ ); +} + +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' + } + ); +} diff --git a/src/features/crm/my-day/server/service.ts b/src/features/crm/my-day/server/service.ts new file mode 100644 index 0000000..c1ba1ee --- /dev/null +++ b/src/features/crm/my-day/server/service.ts @@ -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 { + 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>['items']; + dueToday: CalendarProjectionItem[]; + schedule: CalendarProjectionItem[]; + pendingApprovals: Awaited>['approvals']['myPendingApprovals']; + hotProjects: Awaited>['hotProjects']; +}): MyDayPriorityItem[] { + return [ + ...input.overdueActivities.map((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((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((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((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((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(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); +} diff --git a/src/features/crm/my-day/server/widget-registry.ts b/src/features/crm/my-day/server/widget-registry.ts new file mode 100644 index 0000000..ab1d9ee --- /dev/null +++ b/src/features/crm/my-day/server/widget-registry.ts @@ -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 + ); +}