task-ep.1.7
This commit is contained in:
@@ -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
649
plans/task-ep.1.7.md
Normal 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.
|
||||
59
src/app/api/crm/my-day/route.ts
Normal file
59
src/app/api/crm/my-day/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
29
src/app/dashboard/crm/my-day/page.tsx
Normal file
29
src/app/dashboard/crm/my-day/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
15
src/features/crm/my-day/api/queries.ts
Normal file
15
src/features/crm/my-day/api/queries.ts
Normal 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)
|
||||
});
|
||||
21
src/features/crm/my-day/api/service.ts
Normal file
21
src/features/crm/my-day/api/service.ts
Normal 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}` : ''}`);
|
||||
}
|
||||
80
src/features/crm/my-day/api/types.ts
Normal file
80
src/features/crm/my-day/api/types.ts
Normal 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;
|
||||
}
|
||||
348
src/features/crm/my-day/components/my-day-workspace.tsx
Normal file
348
src/features/crm/my-day/components/my-day-workspace.tsx
Normal 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'
|
||||
}
|
||||
);
|
||||
}
|
||||
221
src/features/crm/my-day/server/service.ts
Normal file
221
src/features/crm/my-day/server/service.ts
Normal 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);
|
||||
}
|
||||
91
src/features/crm/my-day/server/widget-registry.ts
Normal file
91
src/features/crm/my-day/server/widget-registry.ts
Normal 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user