task-ep.1.6
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
# Task EP.1.6 Calendar Projection, Workspace & Big Calendar Foundation - 2026-07-13
|
||||
|
||||
## Scope Delivered
|
||||
|
||||
- introduced `crm_calendar_projection` materialized read model schema and migration `drizzle/0006_massive_scalphunter.sql`
|
||||
- added Calendar projection contracts under `src/features/crm/calendar/**`
|
||||
- added Activity business-event mapping for scheduled Activity calendar rows
|
||||
- added terminal Activity event handling that removes completed/cancelled/deleted Activity rows from Calendar projection
|
||||
- added Calendar projection consumer, registration seam, rebuild plan contract, query service, API route, React Query service, and dashboard workspace route
|
||||
- added `CalendarProjectionItem -> BigCalendarEvent` adapter so UI never consumes database rows directly
|
||||
- added Calendar Workspace UI at `/dashboard/crm/calendar` with Agenda, Day, Week, Month tabs, summary cards, search, empty/loading states, and read-only/editable visual distinction
|
||||
- added Calendar navigation item under CRM
|
||||
- preserved Activity, Timeline, Dashboard, Reports, Notifications, Approval, Follow-up APIs, and source-domain ownership
|
||||
|
||||
## Review Summary
|
||||
|
||||
Reviewed before/during implementation:
|
||||
|
||||
- `AGENTS.md`
|
||||
- `plans/task-ep.1.6.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-epic-technical-design-2026-07-07.md`
|
||||
- `docs/implementation/task-ar.2-workspace-ui-ux-design-note-2026-07-07.md`
|
||||
- `docs/implementation/task-bu-r.0.1-workspace-activity-business-blueprint-2026-07-07.md`
|
||||
- `docs/implementation/task-ep1.4-projection-foundation-delivery-reliability-2026-07-13.md`
|
||||
- `docs/implementation/task-ep1.4.1-transactional-outbox-activation-worker-runtime-2026-07-13.md`
|
||||
- `docs/implementation/task-ep1.5-timeline-projection-foundation-2026-07-13.md`
|
||||
- existing Projection Runtime, Activity Business Events, Timeline Projection, CRM security, and date formatting utilities
|
||||
|
||||
## Big Calendar Source Audit
|
||||
|
||||
Upstream: `https://github.com/lramos33/big-calendar`
|
||||
|
||||
Findings:
|
||||
|
||||
- Compatibility: upstream is a Next.js, TypeScript, Tailwind, shadcn-oriented calendar app, but targets Next.js 14 and Tailwind v3 while this repo is Next.js 16 and Tailwind v4.
|
||||
- React/Next: source is useful as UI composition reference, but the upstream app shell, provider model, demo data, and direct event mutation flow are not compatible with ALLA OS projection governance.
|
||||
- shadcn/Tailwind: visual approach is compatible, but code must be adapted to existing dashboard shell and token usage.
|
||||
- Accessibility/dark mode/responsive: upstream explicitly supports responsive and dark-mode calendar behavior; ALLA OS implementation keeps shadcn primitives, semantic cards, badges, buttons, and deterministic date rendering.
|
||||
- Timezone: upstream uses general date-fns/date string assumptions; ALLA OS calendar rendering uses `src/lib/date-format.ts` and stores projection timezone as `Asia/Bangkok`.
|
||||
- Drag/drop: upstream supports direct drag/drop rescheduling. ALLA OS does not persist Calendar mutations directly; drag/resize must call Activity APIs, publish Business Events, and refresh the projection.
|
||||
- License: upstream repository is MIT licensed.
|
||||
|
||||
Adoption decision:
|
||||
|
||||
- Adopted conceptually: Agenda/Day/Week/Month views, toolbar/search pattern, event rendering, editable vs read-only event behavior, user/lens filter seam, working-hours/timezone constraints.
|
||||
- Rejected: upstream app shell, backend assumptions, mock event store, direct event CRUD, global Calendar context, uncontrolled user lists, direct drag/drop persistence.
|
||||
- Modification strategy: implement native ALLA OS components under `src/features/crm/calendar/`, keep projection adapter as boundary, and wire future drag/resize only through Activity services.
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### Calendar Schema
|
||||
|
||||
Added `crm_calendar_projection` with organization, branch, product, source refs, activity/customer/lead/opportunity/quotation/approval refs, owner/assignee, event provenance, title/summary/location, start/end/timezone, priority/category, editable/milestone/overdue/hot-project/all-day flags, visibility metadata, source event, projection version, timestamps.
|
||||
|
||||
Indexes cover organization/date range, owner/assignee/date range, branch/date range, category/date range, customer/date range, activity/event lookup, and event/version idempotency.
|
||||
|
||||
### Calendar Builder and Consumer
|
||||
|
||||
Activity events supported:
|
||||
|
||||
- `activity.created`
|
||||
- `activity.assigned`
|
||||
- `activity.reassigned`
|
||||
- `activity.started`
|
||||
- `activity.rescheduled`
|
||||
- `activity.completed`
|
||||
- `activity.cancelled`
|
||||
- `activity.deleted`
|
||||
|
||||
Non-terminal scheduled Activity events upsert one current Calendar row per Activity. Terminal events delete the Activity calendar row so Calendar remains a forward-looking operational workspace, not a history timeline.
|
||||
|
||||
### Query and Security
|
||||
|
||||
Calendar query service supports:
|
||||
|
||||
- date range
|
||||
- user/team user IDs
|
||||
- branch/product
|
||||
- customer/lead/opportunity/quotation
|
||||
- activity type/category/priority
|
||||
- overdue/milestone/hot-project flags
|
||||
- search
|
||||
- Personal/Team/Manager/Executive lens contract
|
||||
|
||||
The service applies bounded SQL first, then server-side CRM scope checks through `CrmSecurityContext` and `canAccessScopedCrmRecord()`. Owner/assignee direct visibility is preserved. Internal-only rows remain restricted unless directly assigned or organization-wide scope applies.
|
||||
|
||||
### Workspace
|
||||
|
||||
Added `/dashboard/crm/calendar` using `PageContainer`, React Query prefetch/hydration, and existing shadcn UI primitives.
|
||||
|
||||
Workspace includes:
|
||||
|
||||
- summary cards: Due Today, Overdue, Meetings, Hot Projects
|
||||
- Agenda default view
|
||||
- Day/Week/Month tabs
|
||||
- search
|
||||
- empty/loading states
|
||||
- event cards with read-only milestone vs editable activity distinction
|
||||
|
||||
### Rebuild and Health
|
||||
|
||||
Calendar rebuild contract added as a plan/result helper for hybrid rebuild:
|
||||
|
||||
- sources: Activity, Opportunity, Quotation, Approval, Business Event replay
|
||||
- supports organization/date range/dry-run/reset/restart contract
|
||||
|
||||
Runtime checkpoint/retry/dead-letter behavior remains owned by Projection Runtime. Calendar consumer can be registered with runtime consumers without changing source-domain writes.
|
||||
|
||||
## Delivery Matrix
|
||||
|
||||
| Source | Business Event / Source Signal | Projection | Calendar Item | Workspace |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Activity | `activity.created`, assigned, reassigned, started, rescheduled | `crm_calendar_projection` | editable Activity event | Calendar Agenda/Day/Week/Month |
|
||||
| Activity | `activity.completed`, cancelled, deleted | delete projection row | removed from forward schedule | Calendar refresh |
|
||||
| Opportunity | Expected Award / Expected Delivery / Hot Project | rebuild contract seam | read-only milestone | future source adapter |
|
||||
| Quotation | Valid Until / Delivery milestone | rebuild contract seam | read-only milestone | future source adapter |
|
||||
| Approval | Due / Escalation | rebuild contract seam | read-only milestone | future source adapter |
|
||||
|
||||
## UI/UX Review
|
||||
|
||||
- Layout follows AR.2 Calendar Workspace note: PageContainer, header, compact filter/search, calendar body, summary cards.
|
||||
- Agenda is default manager/mobile-friendly dense view.
|
||||
- Day/Week/Month views are available as foundation tabs.
|
||||
- State coverage includes loading skeleton and empty state.
|
||||
- Status is not color-only: badges show overdue/hot-project/read-only/category text.
|
||||
- Date/time rendering uses `src/lib/date-format.ts`.
|
||||
- UI avoids importing upstream shell, backend, provider, or mock data.
|
||||
|
||||
## Known Follow-ups
|
||||
|
||||
- Opportunity, Quotation, and Approval milestone projection adapters are contract-ready but not populated until those domains publish durable milestone events or rebuild source adapters are expanded.
|
||||
- Drag/drop and resize UI actions are represented as Activity-only affordances, but direct mutation wiring is intentionally deferred until Activity reschedule API integration is added to Calendar UI.
|
||||
- User/team filter contract exists; UI currently exposes lens/search foundation only. Manager team hierarchy should be wired after team graph/scope UX is formalized.
|
||||
- Working hours and conflict detection are schema/service-ready concepts but need fuller UI and source preference integration in a follow-up slice.
|
||||
- `INITIAL_PROJECTION_CONSUMERS` remains skeleton-safe for foundation runtime tests; CRM runtime composition should opt into `CRM_CALENDAR_PROJECTION_CONSUMERS` or combined CRM registration when running production projection workers.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm run typecheck`
|
||||
- `node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/features/crm/calendar/server/builder.test.ts src/features/crm/timeline/server/builder.test.ts src/features/foundation/projections/runtime.test.ts`
|
||||
- `npx oxlint src/features/crm/calendar src/app/api/crm/calendar src/app/dashboard/crm/calendar src/features/foundation/projections/consumers.ts src/features/foundation/projections/registry.ts src/features/crm/timeline/server/registration.ts src/db/schema.ts`
|
||||
- `npm run db:generate`
|
||||
|
||||
## Outcome
|
||||
|
||||
EP.1.6 now has a governed Calendar Projection and first Calendar Workspace foundation. Calendar remains additive, rebuildable, and non-authoritative. Activity remains the only editable operational work source; Timeline remains unchanged as historical chronology.
|
||||
46
drizzle/0006_massive_scalphunter.sql
Normal file
46
drizzle/0006_massive_scalphunter.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
CREATE TABLE "crm_calendar_projection" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"branch_id" text,
|
||||
"product_type" text,
|
||||
"customer_id" text,
|
||||
"lead_id" text,
|
||||
"opportunity_id" text,
|
||||
"quotation_id" text,
|
||||
"approval_id" text,
|
||||
"activity_id" text,
|
||||
"owner_id" text,
|
||||
"assignee_id" text,
|
||||
"event_id" text NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"summary" text,
|
||||
"location" text,
|
||||
"start_at" timestamp with time zone NOT NULL,
|
||||
"end_at" timestamp with time zone NOT NULL,
|
||||
"timezone" text DEFAULT 'Asia/Bangkok' NOT NULL,
|
||||
"priority" text DEFAULT 'normal' NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"activity_type" text,
|
||||
"editable" boolean DEFAULT false NOT NULL,
|
||||
"milestone" boolean DEFAULT false NOT NULL,
|
||||
"overdue" boolean DEFAULT false NOT NULL,
|
||||
"hot_project" boolean DEFAULT false NOT NULL,
|
||||
"all_day" boolean DEFAULT false NOT NULL,
|
||||
"visibility" jsonb NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"source_event" jsonb NOT NULL,
|
||||
"source_projection_version" integer DEFAULT 1 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_calendar_projection_event_version_idx" ON "crm_calendar_projection" USING btree ("event_id","source_projection_version");--> statement-breakpoint
|
||||
CREATE INDEX "crm_calendar_projection_org_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","start_at");--> statement-breakpoint
|
||||
CREATE INDEX "crm_calendar_projection_org_assignee_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","assignee_id","start_at");--> statement-breakpoint
|
||||
CREATE INDEX "crm_calendar_projection_org_owner_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","owner_id","start_at");--> statement-breakpoint
|
||||
CREATE INDEX "crm_calendar_projection_org_branch_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","branch_id","start_at");--> statement-breakpoint
|
||||
CREATE INDEX "crm_calendar_projection_org_category_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","category","start_at");--> statement-breakpoint
|
||||
CREATE INDEX "crm_calendar_projection_customer_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","customer_id","start_at");--> statement-breakpoint
|
||||
CREATE INDEX "crm_calendar_projection_activity_event_idx" ON "crm_calendar_projection" USING btree ("organization_id","activity_id","event_id");
|
||||
8233
drizzle/meta/0006_snapshot.json
Normal file
8233
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1783925810194,
|
||||
"tag": "0005_freezing_wendigo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1783931878587,
|
||||
"tag": "0006_massive_scalphunter",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
844
plans/task-ep.1.6.md
Normal file
844
plans/task-ep.1.6.md
Normal file
@@ -0,0 +1,844 @@
|
||||
# Task EP.1.6 – Calendar Projection, Workspace & Big Calendar Foundation
|
||||
|
||||
**Status:** Foundation Implemented
|
||||
|
||||
**Priority:** Critical
|
||||
|
||||
**Type:** Projection Platform / Workspace / UI 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
|
||||
* BU-R.0 Business Blueprint Freeze
|
||||
* BU-R.0.1 Workspace & Activity Blueprint
|
||||
* BU-R.1 Business Capability Audit
|
||||
* AR.1 Architecture Transition Plan
|
||||
* AR.2 Epic Technical Design
|
||||
* AR.2 Workspace UI/UX Design Note
|
||||
* ENG.0 Engineering Constitution
|
||||
|
||||
---
|
||||
|
||||
# Objective
|
||||
|
||||
Introduce the first production Calendar Workspace of ALLA OS by combining:
|
||||
|
||||
* Calendar Projection
|
||||
* Calendar Workspace
|
||||
* Big Calendar UI Foundation
|
||||
* Activity scheduling
|
||||
* Business milestones
|
||||
* Team visibility
|
||||
* Calendar interaction
|
||||
|
||||
Calendar becomes the operational scheduling workspace for CRM while preserving existing source-domain ownership.
|
||||
|
||||
Calendar is **NOT**:
|
||||
|
||||
* a source-of-truth
|
||||
* another Activity module
|
||||
* another Opportunity module
|
||||
* another Quotation module
|
||||
|
||||
Calendar is a governed projection and operational workspace.
|
||||
|
||||
---
|
||||
|
||||
# Business Outcome
|
||||
|
||||
After EP.1.6 users can answer:
|
||||
|
||||
* What should I do today?
|
||||
* Which customers should I visit?
|
||||
* Which follow-ups are overdue?
|
||||
* Which quotations are expiring?
|
||||
* Which projects are Hot Projects?
|
||||
* Which approvals are waiting?
|
||||
* Which customer visits are scheduled?
|
||||
* What is my team's workload today?
|
||||
* What are tomorrow's priorities?
|
||||
|
||||
Managers gain a Team Calendar without creating duplicate business data.
|
||||
|
||||
---
|
||||
|
||||
# Calendar Business Principles
|
||||
|
||||
## Calendar is Projection
|
||||
|
||||
Calendar is generated from:
|
||||
|
||||
* Activity
|
||||
* Business Events
|
||||
* Opportunity milestones
|
||||
* Quotation milestones
|
||||
* Approval milestones
|
||||
* Legacy adapters during rebuild
|
||||
|
||||
Calendar rows are rebuildable.
|
||||
|
||||
---
|
||||
|
||||
## Activity Owns Operational Work
|
||||
|
||||
Editable Calendar events always belong to Activity.
|
||||
|
||||
Examples:
|
||||
|
||||
* Follow-up
|
||||
* Meeting
|
||||
* Site Survey
|
||||
* Visit
|
||||
* Internal Task
|
||||
* Phone Call
|
||||
* Reminder
|
||||
|
||||
Calendar actions must call Activity Services.
|
||||
|
||||
Calendar never edits projection rows directly.
|
||||
|
||||
---
|
||||
|
||||
## Milestones Remain Source-Owned
|
||||
|
||||
Read-only Calendar milestones include:
|
||||
|
||||
* Expected Award Date
|
||||
* Expected Delivery Date
|
||||
* Quotation Expiration
|
||||
* Approval Due
|
||||
* Approval Escalation
|
||||
* PO Milestones
|
||||
|
||||
Editing must always occur through the owning domain.
|
||||
|
||||
---
|
||||
|
||||
## Calendar ≠ Timeline
|
||||
|
||||
Timeline answers:
|
||||
|
||||
> What happened?
|
||||
|
||||
Calendar answers:
|
||||
|
||||
> What must happen?
|
||||
|
||||
Both consume Business Events but serve different business purposes.
|
||||
|
||||
---
|
||||
|
||||
# UI Foundation Decision
|
||||
|
||||
The Calendar UI must adopt:
|
||||
|
||||
**lramos33/big-calendar**
|
||||
|
||||
Repository:
|
||||
|
||||
```text
|
||||
https://github.com/lramos33/big-calendar
|
||||
```
|
||||
|
||||
This repository becomes the Calendar UI foundation only.
|
||||
|
||||
It does **NOT** become:
|
||||
|
||||
* Calendar database
|
||||
* Calendar backend
|
||||
* Activity service
|
||||
* Projection runtime
|
||||
* CRM authorization
|
||||
* Source-of-truth
|
||||
|
||||
The governed architecture remains:
|
||||
|
||||
```text
|
||||
Activity / Opportunity / Quotation / Approval
|
||||
↓
|
||||
Business Events
|
||||
↓
|
||||
Projection Runtime
|
||||
↓
|
||||
crm_calendar_projection
|
||||
↓
|
||||
Calendar Adapter
|
||||
↓
|
||||
lramos33/big-calendar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Review Required
|
||||
|
||||
## Governance
|
||||
|
||||
Review:
|
||||
|
||||
* AGENTS.md
|
||||
* Engineering Constitution
|
||||
* Project Foundations
|
||||
* Architecture Rules
|
||||
* UI UX Rules
|
||||
* Task Review Checklist
|
||||
* CRM Authorization Boundaries
|
||||
* LAYOUT.md
|
||||
|
||||
## Business
|
||||
|
||||
Review:
|
||||
|
||||
* Relationship Sales Workspace Blueprint
|
||||
* BU-R.1
|
||||
|
||||
## Architecture
|
||||
|
||||
Review:
|
||||
|
||||
* AR.1
|
||||
* AR.2
|
||||
* Workspace UI UX Design Note
|
||||
|
||||
## Existing Runtime
|
||||
|
||||
Review:
|
||||
|
||||
* Projection Runtime
|
||||
* Worker Runtime
|
||||
* Timeline Projection
|
||||
* Activity Service
|
||||
* Business Event Registry
|
||||
* Calendar-related Activity scheduling
|
||||
* Date formatting utilities
|
||||
* CRM security
|
||||
|
||||
## UI Review
|
||||
|
||||
Must use:
|
||||
|
||||
* ui-ux-pro-max
|
||||
* LAYOUT.md
|
||||
* ui-ux-rules.md
|
||||
|
||||
No code-first Calendar implementation is allowed.
|
||||
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
# Part 1 — Calendar Projection Schema
|
||||
|
||||
Introduce:
|
||||
|
||||
```text
|
||||
crm_calendar_projection
|
||||
```
|
||||
|
||||
Containing:
|
||||
|
||||
* organization
|
||||
* branch
|
||||
* product
|
||||
* activity
|
||||
* customer
|
||||
* lead
|
||||
* opportunity
|
||||
* quotation
|
||||
* approval
|
||||
* owner
|
||||
* assignee
|
||||
* title
|
||||
* summary
|
||||
* location
|
||||
* start
|
||||
* end
|
||||
* timezone
|
||||
* priority
|
||||
* category
|
||||
* editable
|
||||
* milestone
|
||||
* overdue
|
||||
* hot project
|
||||
* projection version
|
||||
* source event
|
||||
* metadata
|
||||
|
||||
Projection rows remain rebuildable.
|
||||
|
||||
---
|
||||
|
||||
# Part 2 — Calendar Projection Builder
|
||||
|
||||
Implement
|
||||
|
||||
CalendarProjectionBuilder
|
||||
|
||||
Responsibilities
|
||||
|
||||
* Business Event mapping
|
||||
* milestone mapping
|
||||
* display enrichment
|
||||
* permission metadata
|
||||
* icon registry
|
||||
* urgency calculation
|
||||
* projection persistence
|
||||
|
||||
Builder never mutates source domains.
|
||||
|
||||
---
|
||||
|
||||
# Part 3 — Activity Event Mapping
|
||||
|
||||
Support
|
||||
|
||||
* activity.created
|
||||
* activity.assigned
|
||||
* activity.reassigned
|
||||
* activity.started
|
||||
* activity.rescheduled
|
||||
* activity.completed
|
||||
* activity.cancelled
|
||||
* activity.deleted
|
||||
|
||||
Projection updates must be incremental through Business Events.
|
||||
|
||||
---
|
||||
|
||||
# Part 4 — Milestone Projection
|
||||
|
||||
Project:
|
||||
|
||||
Opportunity
|
||||
|
||||
* Expected Award Date
|
||||
* Expected Delivery Date
|
||||
* Hot Project review
|
||||
|
||||
Quotation
|
||||
|
||||
* Valid Until
|
||||
* Delivery milestone
|
||||
|
||||
Approval
|
||||
|
||||
* Due
|
||||
* Escalation
|
||||
|
||||
Milestones are read-only.
|
||||
|
||||
---
|
||||
|
||||
# Part 5 — Calendar Projection Consumer
|
||||
|
||||
Activate Calendar consumer through Projection Runtime.
|
||||
|
||||
Must support:
|
||||
|
||||
* checkpoint
|
||||
* retry
|
||||
* dead letter
|
||||
* rebuild
|
||||
* idempotency
|
||||
|
||||
---
|
||||
|
||||
# Part 6 — Calendar Query Service
|
||||
|
||||
Provide secured query service.
|
||||
|
||||
Filters:
|
||||
|
||||
* date range
|
||||
* user
|
||||
* team
|
||||
* branch
|
||||
* product
|
||||
* customer
|
||||
* lead
|
||||
* opportunity
|
||||
* quotation
|
||||
* activity type
|
||||
* category
|
||||
* priority
|
||||
* overdue
|
||||
* milestone
|
||||
* Hot Project
|
||||
* search
|
||||
|
||||
Supported lenses:
|
||||
|
||||
* Personal
|
||||
* Team
|
||||
* Manager
|
||||
* Executive
|
||||
|
||||
---
|
||||
|
||||
# Part 7 — Calendar Rebuild
|
||||
|
||||
Hybrid rebuild.
|
||||
|
||||
Initial build:
|
||||
|
||||
* Activity
|
||||
* Opportunity
|
||||
* Quotation
|
||||
* Approval
|
||||
|
||||
Incremental:
|
||||
|
||||
* Business Events
|
||||
|
||||
Support:
|
||||
|
||||
* organization
|
||||
* customer
|
||||
* opportunity
|
||||
* quotation
|
||||
* date range
|
||||
* dry run
|
||||
* reset existing
|
||||
* restart
|
||||
|
||||
---
|
||||
|
||||
# Part 8 — Calendar Workspace
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/dashboard/crm/calendar
|
||||
```
|
||||
|
||||
Views:
|
||||
|
||||
* Day
|
||||
* Week
|
||||
* Month
|
||||
* Agenda
|
||||
|
||||
Agenda is default for:
|
||||
|
||||
* mobile
|
||||
* manager
|
||||
* high-density work
|
||||
|
||||
---
|
||||
|
||||
# Part 9 — Big Calendar Source Audit
|
||||
|
||||
Before implementation:
|
||||
|
||||
Audit:
|
||||
|
||||
* dependency compatibility
|
||||
* React compatibility
|
||||
* Next.js compatibility
|
||||
* shadcn compatibility
|
||||
* Tailwind compatibility
|
||||
* accessibility
|
||||
* dark mode
|
||||
* timezone assumptions
|
||||
* drag/drop libraries
|
||||
* demo code
|
||||
* mock data
|
||||
|
||||
Document:
|
||||
|
||||
* files adopted
|
||||
* files rejected
|
||||
* modification strategy
|
||||
|
||||
---
|
||||
|
||||
# Part 10 — Big Calendar Adaptation
|
||||
|
||||
Do **NOT** import the whole repository.
|
||||
|
||||
Only adapt:
|
||||
|
||||
* Calendar Views
|
||||
* Toolbar
|
||||
* Event Rendering
|
||||
* Drag & Drop
|
||||
* Resize
|
||||
* Navigation
|
||||
* User Filter
|
||||
* Working Hours
|
||||
|
||||
Move adapted source into:
|
||||
|
||||
```text
|
||||
src/features/crm/calendar/
|
||||
```
|
||||
|
||||
Maintain ALLA OS architecture.
|
||||
|
||||
---
|
||||
|
||||
# Part 11 — Calendar Adapter Layer
|
||||
|
||||
Introduce
|
||||
|
||||
CalendarProjectionItem
|
||||
|
||||
↓
|
||||
|
||||
BigCalendarEvent
|
||||
|
||||
Implement
|
||||
|
||||
```ts
|
||||
mapCalendarProjectionToBigCalendarEvent()
|
||||
```
|
||||
|
||||
UI must never consume database rows directly.
|
||||
|
||||
---
|
||||
|
||||
# Part 12 — Calendar Rendering Strategy
|
||||
|
||||
Differentiate:
|
||||
|
||||
## Editable Activity
|
||||
|
||||
Examples
|
||||
|
||||
* Follow-up
|
||||
* Meeting
|
||||
* Visit
|
||||
* Site Survey
|
||||
|
||||
Supports
|
||||
|
||||
* Complete
|
||||
* Reschedule
|
||||
* Cancel
|
||||
* Open
|
||||
* Reassign
|
||||
|
||||
---
|
||||
|
||||
## Read-only Milestone
|
||||
|
||||
Examples
|
||||
|
||||
* Expected Award
|
||||
* Expected Delivery
|
||||
* Approval Due
|
||||
* Quotation Expiration
|
||||
|
||||
Supports
|
||||
|
||||
* Open Source
|
||||
* View Detail
|
||||
* Create Related Activity
|
||||
|
||||
No editing.
|
||||
|
||||
---
|
||||
|
||||
# Part 13 — Drag and Drop
|
||||
|
||||
For Activities
|
||||
|
||||
Drag
|
||||
|
||||
↓
|
||||
|
||||
Activity API
|
||||
|
||||
↓
|
||||
|
||||
Business Event
|
||||
|
||||
↓
|
||||
|
||||
Projection Update
|
||||
|
||||
↓
|
||||
|
||||
Calendar Refresh
|
||||
|
||||
No direct Calendar persistence.
|
||||
|
||||
Milestones cannot be dragged.
|
||||
|
||||
---
|
||||
|
||||
# Part 14 — User Filter
|
||||
|
||||
Integrate upstream user filter with:
|
||||
|
||||
* CRM permissions
|
||||
* team hierarchy
|
||||
* ownership
|
||||
* manager scope
|
||||
|
||||
Never load unauthorized users.
|
||||
|
||||
---
|
||||
|
||||
# Part 15 — Working Hours
|
||||
|
||||
Support
|
||||
|
||||
* organization defaults
|
||||
* user preferences
|
||||
* Bangkok timezone
|
||||
* all-day events
|
||||
* current time
|
||||
* business hours
|
||||
|
||||
---
|
||||
|
||||
# Part 16 — Schedule Conflict Detection
|
||||
|
||||
Detect
|
||||
|
||||
* overlapping visits
|
||||
* meetings
|
||||
* site surveys
|
||||
* tasks
|
||||
|
||||
Read-only advisory.
|
||||
|
||||
No blocking.
|
||||
|
||||
---
|
||||
|
||||
# Part 17 — Calendar Summary
|
||||
|
||||
Display
|
||||
|
||||
* Due Today
|
||||
* Overdue
|
||||
* Meetings
|
||||
* Visits
|
||||
* Milestones
|
||||
* Approval Due
|
||||
* Hot Projects
|
||||
|
||||
Derived from Calendar projection.
|
||||
|
||||
---
|
||||
|
||||
# Part 18 — Calendar Performance
|
||||
|
||||
Support
|
||||
|
||||
* indexes
|
||||
* bounded queries
|
||||
* lazy loading
|
||||
* virtual agenda
|
||||
* cursor pagination
|
||||
* projection batching
|
||||
|
||||
No N+1.
|
||||
|
||||
---
|
||||
|
||||
# Part 19 — Projection Health
|
||||
|
||||
Track
|
||||
|
||||
* lag
|
||||
* retries
|
||||
* dead letters
|
||||
* row count
|
||||
* stale rows
|
||||
* rebuild status
|
||||
|
||||
---
|
||||
|
||||
# Part 20 — Calendar Delivery Matrix
|
||||
|
||||
Document
|
||||
|
||||
Source
|
||||
|
||||
↓
|
||||
|
||||
Business Event
|
||||
|
||||
↓
|
||||
|
||||
Projection
|
||||
|
||||
↓
|
||||
|
||||
Calendar Item
|
||||
|
||||
↓
|
||||
|
||||
Workspace
|
||||
|
||||
Include:
|
||||
|
||||
* Activity
|
||||
* Opportunity
|
||||
* Quotation
|
||||
* Approval
|
||||
|
||||
---
|
||||
|
||||
# Part 21 — Calendar UI / UX Review
|
||||
|
||||
Review with
|
||||
|
||||
* ui-ux-pro-max
|
||||
* LAYOUT.md
|
||||
* ui-ux-rules.md
|
||||
|
||||
Validate
|
||||
|
||||
* hierarchy
|
||||
* spacing
|
||||
* typography
|
||||
* icons
|
||||
* filters
|
||||
* density
|
||||
* accessibility
|
||||
* dark mode
|
||||
* responsiveness
|
||||
* keyboard
|
||||
* loading
|
||||
* empty state
|
||||
|
||||
---
|
||||
|
||||
# Part 22 — Upstream License
|
||||
|
||||
Retain
|
||||
|
||||
MIT License
|
||||
|
||||
Document
|
||||
|
||||
* adopted files
|
||||
* attribution
|
||||
* modification history
|
||||
|
||||
---
|
||||
|
||||
# Deliverables
|
||||
|
||||
1. Calendar Projection Schema
|
||||
2. Calendar Builder
|
||||
3. Calendar Projection Consumer
|
||||
4. Calendar Query Service
|
||||
5. Calendar Rebuild
|
||||
6. Calendar Workspace
|
||||
7. Day / Week / Month / Agenda
|
||||
8. Big Calendar Source Audit
|
||||
9. Big Calendar Adaptation
|
||||
10. Calendar Adapter Layer
|
||||
11. Activity Calendar Actions
|
||||
12. Milestone Projection
|
||||
13. Drag & Drop Integration
|
||||
14. User Filter Integration
|
||||
15. Working Hours Support
|
||||
16. Schedule Conflict Detection
|
||||
17. Calendar Summary Panels
|
||||
18. Projection Health
|
||||
19. Calendar Delivery Matrix
|
||||
20. UI/UX Review Report
|
||||
21. Upstream License & Attribution Report
|
||||
22. EP.1.7 Readiness Report
|
||||
|
||||
---
|
||||
|
||||
# Constraints
|
||||
|
||||
Must preserve:
|
||||
|
||||
* Activity ownership
|
||||
* Opportunity ownership
|
||||
* Quotation ownership
|
||||
* Approval ownership
|
||||
* Timeline
|
||||
* Dashboard
|
||||
* Reports
|
||||
* Notifications
|
||||
* Audit Log
|
||||
* CRM security
|
||||
* Projection Runtime
|
||||
|
||||
Do NOT implement:
|
||||
|
||||
* Google Calendar Sync
|
||||
* Outlook Sync
|
||||
* recurring engine
|
||||
* My Day
|
||||
* Manager Workspace
|
||||
* Executive Workspace
|
||||
* Relationship Health
|
||||
* Forecast
|
||||
* automatic Activity generation
|
||||
* legacy migration
|
||||
|
||||
Calendar remains an additive projection.
|
||||
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
* Projection
|
||||
* Builder
|
||||
* Consumer
|
||||
* Rebuild
|
||||
* Activity Events
|
||||
* Milestones
|
||||
* Query
|
||||
* Permissions
|
||||
* Drag & Drop
|
||||
* Resize
|
||||
* Agenda
|
||||
* Day
|
||||
* Week
|
||||
* Month
|
||||
* Timezone
|
||||
* Conflict Detection
|
||||
* UI
|
||||
* Accessibility
|
||||
* Dark Mode
|
||||
* Performance
|
||||
* Compatibility
|
||||
|
||||
---
|
||||
|
||||
# Acceptance Criteria
|
||||
|
||||
* Calendar Projection is fully rebuildable.
|
||||
* Calendar uses Business Events for incremental updates.
|
||||
* Activity remains the only editable operational work source.
|
||||
* Opportunity, Quotation, and Approval milestones are projected as read-only Calendar items.
|
||||
* `lramos33/big-calendar` is successfully adopted as the UI foundation without importing its application shell or backend.
|
||||
* A dedicated adapter maps `CalendarProjectionItem` to the Calendar UI event model.
|
||||
* Drag-and-drop and resize update Activities only through Activity Services and Business Events.
|
||||
* Read-only milestones cannot be dragged or resized.
|
||||
* Personal, Team, Manager, and Executive lenses enforce existing CRM authorization.
|
||||
* Day, Week, Month, and Agenda views are available, with Agenda optimized for managers and mobile.
|
||||
* Working hours, timezone, and schedule conflict detection behave consistently.
|
||||
* Calendar integrates with the Projection Runtime (checkpoint, retry, rebuild, health).
|
||||
* UI conforms to `LAYOUT.md`, `ui-ux-rules.md`, and `ui-ux-pro-max`.
|
||||
* Existing Activity, Timeline, Dashboard, Reports, Notifications, Approval flows, and Follow-up APIs remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
# Success Criteria
|
||||
|
||||
ALLA OS gains a production-ready Calendar Workspace built on a governed Projection architecture.
|
||||
|
||||
Users can plan and execute daily operational work from a single Calendar while preserving Activity, Opportunity, Quotation, and Approval as the authoritative business domains.
|
||||
|
||||
The Calendar foundation is ready for:
|
||||
|
||||
**EP.1.7 – My Day Workspace Foundation**
|
||||
100
src/app/api/crm/calendar/route.ts
Normal file
100
src/app/api/crm/calendar/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
|
||||
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||
import { listCalendarItems } from '@/features/crm/calendar/server/service';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
const calendarQuerySchema = z.object({
|
||||
startFrom: z.string().datetime(),
|
||||
startTo: z.string().datetime(),
|
||||
lens: z.enum(['personal', 'team', 'manager', 'executive']).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(),
|
||||
leadId: z.string().optional(),
|
||||
opportunityId: z.string().optional(),
|
||||
quotationId: z.string().optional(),
|
||||
activityType: z.string().optional(),
|
||||
category: z
|
||||
.enum([
|
||||
'activity',
|
||||
'meeting',
|
||||
'visit',
|
||||
'site_survey',
|
||||
'task',
|
||||
'reminder',
|
||||
'opportunity_milestone',
|
||||
'quotation_milestone',
|
||||
'approval_milestone'
|
||||
])
|
||||
.optional(),
|
||||
priority: z.enum(['low', 'normal', 'high', 'critical']).optional(),
|
||||
overdue: z.coerce.boolean().optional(),
|
||||
milestone: z.coerce.boolean().optional(),
|
||||
hotProject: z.coerce.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.coerce.number().int().positive().max(500).optional(),
|
||||
cursor: z.string().optional()
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: CRM_ACTIVITY_PERMISSIONS.read
|
||||
});
|
||||
const query = calendarQuerySchema.parse({
|
||||
startFrom: request.nextUrl.searchParams.get('startFrom') ?? undefined,
|
||||
startTo: request.nextUrl.searchParams.get('startTo') ?? undefined,
|
||||
lens: request.nextUrl.searchParams.get('lens') ?? 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,
|
||||
leadId: request.nextUrl.searchParams.get('leadId') ?? undefined,
|
||||
opportunityId: request.nextUrl.searchParams.get('opportunityId') ?? undefined,
|
||||
quotationId: request.nextUrl.searchParams.get('quotationId') ?? undefined,
|
||||
activityType: request.nextUrl.searchParams.get('activityType') ?? undefined,
|
||||
category: request.nextUrl.searchParams.get('category') ?? undefined,
|
||||
priority: request.nextUrl.searchParams.get('priority') ?? undefined,
|
||||
overdue: request.nextUrl.searchParams.get('overdue') ?? undefined,
|
||||
milestone: request.nextUrl.searchParams.get('milestone') ?? undefined,
|
||||
hotProject: request.nextUrl.searchParams.get('hotProject') ?? undefined,
|
||||
search: request.nextUrl.searchParams.get('search') ?? undefined,
|
||||
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
|
||||
cursor: request.nextUrl.searchParams.get('cursor') ?? undefined
|
||||
});
|
||||
const context = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
|
||||
const response = await listCalendarItems(
|
||||
{
|
||||
...query,
|
||||
organizationId: organization.id
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
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 calendar filters' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.error('[crm/calendar] failed to list calendar projection', error);
|
||||
return NextResponse.json({ message: 'Failed to load calendar' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
40
src/app/dashboard/crm/calendar/page.tsx
Normal file
40
src/app/dashboard/crm/calendar/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { Suspense } from 'react';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import {
|
||||
CalendarWorkspace,
|
||||
CalendarWorkspaceSkeleton
|
||||
} from '@/features/crm/calendar/components/calendar-workspace';
|
||||
import { calendarItemsQueryOptions } from '@/features/crm/calendar/api/queries';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
|
||||
function defaultCalendarRange() {
|
||||
const now = new Date();
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0));
|
||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0, 23, 59, 59));
|
||||
return {
|
||||
startFrom: start.toISOString(),
|
||||
startTo: end.toISOString(),
|
||||
lens: 'personal' as const,
|
||||
limit: 300
|
||||
};
|
||||
}
|
||||
|
||||
export default function CrmCalendarPage() {
|
||||
const queryClient = getQueryClient();
|
||||
const filters = defaultCalendarRange();
|
||||
void queryClient.prefetchQuery(calendarItemsQueryOptions(filters));
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='Calendar'
|
||||
pageDescription='Operational schedule workspace for activities and governed CRM milestones.'
|
||||
>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Suspense fallback={<CalendarWorkspaceSkeleton />}>
|
||||
<CalendarWorkspace filters={filters} />
|
||||
</Suspense>
|
||||
</HydrationBoundary>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,14 @@ import { NavGroup } from "@/types";
|
||||
items: [],
|
||||
access: { requireOrg: true, permission: "crm.dashboard.read" },
|
||||
},
|
||||
{
|
||||
title: "Calendar",
|
||||
url: "/dashboard/crm/calendar",
|
||||
icon: "calendar",
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, permission: "crm.activity.read" },
|
||||
},
|
||||
{
|
||||
title: "ลูกค้า",
|
||||
url: "/dashboard/crm/customers",
|
||||
|
||||
@@ -676,6 +676,87 @@ export const crmTimelineProjection = pgTable(
|
||||
})
|
||||
);
|
||||
|
||||
export const crmCalendarProjection = pgTable(
|
||||
'crm_calendar_projection',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
branchId: text('branch_id'),
|
||||
productType: text('product_type'),
|
||||
customerId: text('customer_id'),
|
||||
leadId: text('lead_id'),
|
||||
opportunityId: text('opportunity_id'),
|
||||
quotationId: text('quotation_id'),
|
||||
approvalId: text('approval_id'),
|
||||
activityId: text('activity_id'),
|
||||
ownerId: text('owner_id'),
|
||||
assigneeId: text('assignee_id'),
|
||||
eventId: text('event_id').notNull(),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
summary: text('summary'),
|
||||
location: text('location'),
|
||||
startAt: timestamp('start_at', { withTimezone: true }).notNull(),
|
||||
endAt: timestamp('end_at', { withTimezone: true }).notNull(),
|
||||
timezone: text('timezone').default('Asia/Bangkok').notNull(),
|
||||
priority: text('priority').default('normal').notNull(),
|
||||
category: text('category').notNull(),
|
||||
activityType: text('activity_type'),
|
||||
editable: boolean('editable').default(false).notNull(),
|
||||
milestone: boolean('milestone').default(false).notNull(),
|
||||
overdue: boolean('overdue').default(false).notNull(),
|
||||
hotProject: boolean('hot_project').default(false).notNull(),
|
||||
allDay: boolean('all_day').default(false).notNull(),
|
||||
visibility: jsonb('visibility').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
sourceEvent: jsonb('source_event').notNull(),
|
||||
sourceProjectionVersion: integer('source_projection_version').default(1).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
eventVersionIdx: uniqueIndex('crm_calendar_projection_event_version_idx').on(
|
||||
table.eventId,
|
||||
table.sourceProjectionVersion
|
||||
),
|
||||
organizationStartIdx: index('crm_calendar_projection_org_start_idx').on(
|
||||
table.organizationId,
|
||||
table.startAt
|
||||
),
|
||||
organizationAssigneeStartIdx: index('crm_calendar_projection_org_assignee_start_idx').on(
|
||||
table.organizationId,
|
||||
table.assigneeId,
|
||||
table.startAt
|
||||
),
|
||||
organizationOwnerStartIdx: index('crm_calendar_projection_org_owner_start_idx').on(
|
||||
table.organizationId,
|
||||
table.ownerId,
|
||||
table.startAt
|
||||
),
|
||||
organizationBranchStartIdx: index('crm_calendar_projection_org_branch_start_idx').on(
|
||||
table.organizationId,
|
||||
table.branchId,
|
||||
table.startAt
|
||||
),
|
||||
organizationCategoryStartIdx: index('crm_calendar_projection_org_category_start_idx').on(
|
||||
table.organizationId,
|
||||
table.category,
|
||||
table.startAt
|
||||
),
|
||||
customerStartIdx: index('crm_calendar_projection_customer_start_idx').on(
|
||||
table.organizationId,
|
||||
table.customerId,
|
||||
table.startAt
|
||||
),
|
||||
activityEventIdx: index('crm_calendar_projection_activity_event_idx').on(
|
||||
table.organizationId,
|
||||
table.activityId,
|
||||
table.eventId
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const crmCustomers = pgTable(
|
||||
'crm_customers',
|
||||
{
|
||||
|
||||
18
src/features/crm/calendar/api/queries.ts
Normal file
18
src/features/crm/calendar/api/queries.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getCalendarItems } from './service';
|
||||
import type { CalendarListFilters } from './types';
|
||||
|
||||
export const calendarKeys = {
|
||||
all: ['crm-calendar'] as const,
|
||||
lists: () => [...calendarKeys.all, 'list'] as const,
|
||||
list: (filters: Omit<CalendarListFilters, 'organizationId'>) =>
|
||||
[...calendarKeys.lists(), filters] as const
|
||||
};
|
||||
|
||||
export const calendarItemsQueryOptions = (
|
||||
filters: Omit<CalendarListFilters, 'organizationId'>
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: calendarKeys.list(filters),
|
||||
queryFn: () => getCalendarItems(filters)
|
||||
});
|
||||
34
src/features/crm/calendar/api/service.ts
Normal file
34
src/features/crm/calendar/api/service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { CalendarListFilters, CalendarListResponse } from './types';
|
||||
|
||||
function buildSearchParams(filters: Omit<CalendarListFilters, 'organizationId'>): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('startFrom', filters.startFrom);
|
||||
params.set('startTo', filters.startTo);
|
||||
if (filters.lens) params.set('lens', filters.lens);
|
||||
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.leadId) params.set('leadId', filters.leadId);
|
||||
if (filters.opportunityId) params.set('opportunityId', filters.opportunityId);
|
||||
if (filters.quotationId) params.set('quotationId', filters.quotationId);
|
||||
if (filters.activityType) params.set('activityType', filters.activityType);
|
||||
if (filters.category) params.set('category', filters.category);
|
||||
if (filters.priority) params.set('priority', filters.priority);
|
||||
if (typeof filters.overdue === 'boolean') params.set('overdue', String(filters.overdue));
|
||||
if (typeof filters.milestone === 'boolean') params.set('milestone', String(filters.milestone));
|
||||
if (typeof filters.hotProject === 'boolean') params.set('hotProject', String(filters.hotProject));
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.limit) params.set('limit', String(filters.limit));
|
||||
if (filters.cursor) params.set('cursor', filters.cursor);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export async function getCalendarItems(
|
||||
filters: Omit<CalendarListFilters, 'organizationId'>
|
||||
): Promise<CalendarListResponse> {
|
||||
const search = buildSearchParams(filters);
|
||||
return apiClient<CalendarListResponse>(`/crm/calendar${search ? `?${search}` : ''}`);
|
||||
}
|
||||
128
src/features/crm/calendar/api/types.ts
Normal file
128
src/features/crm/calendar/api/types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export type CalendarCategory =
|
||||
| 'activity'
|
||||
| 'meeting'
|
||||
| 'visit'
|
||||
| 'site_survey'
|
||||
| 'task'
|
||||
| 'reminder'
|
||||
| 'opportunity_milestone'
|
||||
| 'quotation_milestone'
|
||||
| 'approval_milestone';
|
||||
|
||||
export type CalendarPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
export type CalendarLens = 'personal' | 'team' | 'manager' | 'executive';
|
||||
export type CalendarView = 'day' | 'week' | 'month' | 'agenda';
|
||||
|
||||
export interface CalendarVisibility {
|
||||
productType?: string | null;
|
||||
pricingSensitive: boolean;
|
||||
internalOnly: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarProjectionItem {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
branchId: string | null;
|
||||
productType: string | null;
|
||||
customerId: string | null;
|
||||
leadId: string | null;
|
||||
opportunityId: string | null;
|
||||
quotationId: string | null;
|
||||
approvalId: string | null;
|
||||
activityId: string | null;
|
||||
ownerId: string | null;
|
||||
assigneeId: string | null;
|
||||
eventId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
location: string | null;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
timezone: string;
|
||||
priority: CalendarPriority;
|
||||
category: CalendarCategory;
|
||||
activityType: string | null;
|
||||
editable: boolean;
|
||||
milestone: boolean;
|
||||
overdue: boolean;
|
||||
hotProject: boolean;
|
||||
allDay: boolean;
|
||||
visibility: CalendarVisibility;
|
||||
metadata: Record<string, unknown> | null;
|
||||
sourceProjectionVersion: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CalendarListFilters {
|
||||
organizationId: string;
|
||||
startFrom: string;
|
||||
startTo: string;
|
||||
lens?: CalendarLens;
|
||||
userId?: string;
|
||||
teamUserIds?: string[];
|
||||
branchId?: string;
|
||||
productType?: string;
|
||||
customerId?: string;
|
||||
leadId?: string;
|
||||
opportunityId?: string;
|
||||
quotationId?: string;
|
||||
activityType?: string;
|
||||
category?: CalendarCategory;
|
||||
priority?: CalendarPriority;
|
||||
overdue?: boolean;
|
||||
milestone?: boolean;
|
||||
hotProject?: boolean;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface CalendarSummary {
|
||||
dueToday: number;
|
||||
overdue: number;
|
||||
meetings: number;
|
||||
visits: number;
|
||||
milestones: number;
|
||||
approvalDue: number;
|
||||
hotProjects: number;
|
||||
}
|
||||
|
||||
export interface CalendarListResponse {
|
||||
items: CalendarProjectionItem[];
|
||||
summary: CalendarSummary;
|
||||
nextCursor: string | null;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface BigCalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
editable: boolean;
|
||||
draggable: boolean;
|
||||
resizable: boolean;
|
||||
category: CalendarCategory;
|
||||
priority: CalendarPriority;
|
||||
source: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
activityId: string | null;
|
||||
customerId: string | null;
|
||||
leadId: string | null;
|
||||
opportunityId: string | null;
|
||||
quotationId: string | null;
|
||||
approvalId: string | null;
|
||||
};
|
||||
display: {
|
||||
summary: string | null;
|
||||
location: string | null;
|
||||
overdue: boolean;
|
||||
hotProject: boolean;
|
||||
milestone: boolean;
|
||||
};
|
||||
}
|
||||
37
src/features/crm/calendar/components/calendar-adapter.ts
Normal file
37
src/features/crm/calendar/components/calendar-adapter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { BigCalendarEvent, CalendarProjectionItem } from '../api/types';
|
||||
|
||||
export function mapCalendarProjectionToBigCalendarEvent(
|
||||
item: CalendarProjectionItem
|
||||
): BigCalendarEvent {
|
||||
const activityEditable = item.editable && !item.milestone && item.activityId !== null;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
start: item.startAt,
|
||||
end: item.endAt,
|
||||
allDay: item.allDay,
|
||||
editable: activityEditable,
|
||||
draggable: activityEditable,
|
||||
resizable: activityEditable,
|
||||
category: item.category,
|
||||
priority: item.priority,
|
||||
source: {
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId,
|
||||
activityId: item.activityId,
|
||||
customerId: item.customerId,
|
||||
leadId: item.leadId,
|
||||
opportunityId: item.opportunityId,
|
||||
quotationId: item.quotationId,
|
||||
approvalId: item.approvalId
|
||||
},
|
||||
display: {
|
||||
summary: item.summary,
|
||||
location: item.location,
|
||||
overdue: item.overdue,
|
||||
hotProject: item.hotProject,
|
||||
milestone: item.milestone
|
||||
}
|
||||
};
|
||||
}
|
||||
295
src/features/crm/calendar/components/calendar-workspace.tsx
Normal file
295
src/features/crm/calendar/components/calendar-workspace.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'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';
|
||||
|
||||
interface CalendarWorkspaceProps {
|
||||
filters: Omit<CalendarListFilters, 'organizationId'>;
|
||||
}
|
||||
|
||||
const VIEW_LABELS: Record<CalendarView, string> = {
|
||||
day: 'Day',
|
||||
week: 'Week',
|
||||
month: 'Month',
|
||||
agenda: 'Agenda'
|
||||
};
|
||||
|
||||
export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
|
||||
const [view, setView] = useState<CalendarView>('agenda');
|
||||
const [search, setSearch] = useState('');
|
||||
const { data } = useSuspenseQuery(calendarItemsQueryOptions(filters));
|
||||
const events = useMemo(
|
||||
() =>
|
||||
data.items
|
||||
.map(mapCalendarProjectionToBigCalendarEvent)
|
||||
.filter((event) => event.title.toLowerCase().includes(search.toLowerCase())),
|
||||
[data.items, search]
|
||||
);
|
||||
const groupedEvents = groupEventsByDate(events);
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<div>
|
||||
<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>
|
||||
</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' />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder='Search calendar'
|
||||
className='w-full pl-8 sm:w-[240px]'
|
||||
/>
|
||||
</div>
|
||||
<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]'>
|
||||
{Object.entries(VIEW_LABELS).map(([value, label]) => (
|
||||
<TabsTrigger key={value} value={value}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='agenda'>
|
||||
<AgendaView groupedEvents={groupedEvents} />
|
||||
</TabsContent>
|
||||
<TabsContent value='day'>
|
||||
<CompactCalendarGrid events={events.slice(0, 12)} columns={1} />
|
||||
</TabsContent>
|
||||
<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>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarWorkspaceSkeleton() {
|
||||
return (
|
||||
<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' />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='h-[520px] rounded-xl' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }
|
||||
];
|
||||
|
||||
return (
|
||||
<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' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-2xl font-bold'>{card.value}</p>
|
||||
<p className='text-muted-foreground text-xs'>{card.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgendaView({ groupedEvents }: { groupedEvents: Array<[string, BigCalendarEvent[]]> }) {
|
||||
if (!groupedEvents.length) {
|
||||
return <CalendarEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-5'>
|
||||
{groupedEvents.map(([date, events]) => (
|
||||
<section key={date} className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='bg-border h-px flex-1' />
|
||||
<p className='text-muted-foreground text-xs font-medium uppercase tracking-wide'>
|
||||
{formatDate(date)}
|
||||
</p>
|
||||
<div className='bg-border h-px flex-1' />
|
||||
</div>
|
||||
<div className='grid gap-3'>
|
||||
{events.map((event) => (
|
||||
<CalendarEventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCalendarGrid({
|
||||
events,
|
||||
columns,
|
||||
month
|
||||
}: {
|
||||
events: BigCalendarEvent[];
|
||||
columns: number;
|
||||
month?: boolean;
|
||||
}) {
|
||||
if (!events.length) {
|
||||
return <CalendarEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
<p className='text-muted-foreground text-xs'>{formatDate(event.start)}</p>
|
||||
<CalendarEventPill event={event} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEventCard({ event }: { event: BigCalendarEvent }) {
|
||||
return (
|
||||
<article className='border-border/70 hover:bg-muted/30 rounded-lg border p-4 transition-colors'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge variant={event.display.milestone ? 'secondary' : 'outline'}>
|
||||
{event.category.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
{event.display.overdue ? <Badge variant='destructive'>Overdue</Badge> : null}
|
||||
{event.display.hotProject ? <Badge variant='default'>Hot Project</Badge> : null}
|
||||
{!event.editable ? <Badge variant='outline'>Read-only</Badge> : null}
|
||||
</div>
|
||||
<h3 className='font-medium'>{event.title}</h3>
|
||||
{event.display.summary ? (
|
||||
<p className='text-muted-foreground text-sm'>{event.display.summary}</p>
|
||||
) : null}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatTime(event.start)} - {formatTime(event.end)}
|
||||
{event.display.location ? ` · ${event.display.location}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button variant='outline' size='sm'>
|
||||
Open Source
|
||||
</Button>
|
||||
{event.editable ? (
|
||||
<>
|
||||
<Button variant='outline' size='sm'>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button variant='outline' size='sm'>
|
||||
Complete
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant='outline' size='sm'>
|
||||
Create Activity
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEventPill({ event }: { event: BigCalendarEvent }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 rounded-md border px-2 py-1 text-xs',
|
||||
event.display.overdue
|
||||
? 'border-destructive/40 bg-destructive/10 text-destructive'
|
||||
: 'border-primary/20 bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium'>{formatTime(event.start)}</span> {event.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEmptyState() {
|
||||
return (
|
||||
<div className='border-border/70 rounded-xl border border-dashed p-10 text-center'>
|
||||
<Icons.calendar className='text-muted-foreground mx-auto mb-3 h-8 w-8' />
|
||||
<p className='font-medium'>No scheduled work in this period</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Try changing filters or create an Activity from the owning CRM record.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupEventsByDate(events: BigCalendarEvent[]): Array<[string, BigCalendarEvent[]]> {
|
||||
const grouped = new Map<string, BigCalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const key = event.start.slice(0, 10);
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), event]);
|
||||
}
|
||||
return [...grouped.entries()];
|
||||
}
|
||||
|
||||
type CalendarWorkspaceSummary = {
|
||||
dueToday: number;
|
||||
overdue: number;
|
||||
meetings: number;
|
||||
visits: number;
|
||||
milestones: number;
|
||||
approvalDue: number;
|
||||
hotProjects: number;
|
||||
};
|
||||
12
src/features/crm/calendar/index.ts
Normal file
12
src/features/crm/calendar/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
BigCalendarEvent,
|
||||
CalendarCategory,
|
||||
CalendarLens,
|
||||
CalendarListFilters,
|
||||
CalendarListResponse,
|
||||
CalendarPriority,
|
||||
CalendarProjectionItem,
|
||||
CalendarSummary,
|
||||
CalendarView
|
||||
} from './api/types';
|
||||
export { mapCalendarProjectionToBigCalendarEvent } from './components/calendar-adapter';
|
||||
86
src/features/crm/calendar/server/builder.test.ts
Normal file
86
src/features/crm/calendar/server/builder.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBusinessEvent } from '../../../foundation/business-events/publisher.ts';
|
||||
import { buildCalendarChangeFromEvent } from './builder.ts';
|
||||
import { createCalendarProjectionConsumer } from './consumer.ts';
|
||||
|
||||
function scheduledActivityEvent(eventType = 'activity.created') {
|
||||
return createBusinessEvent({
|
||||
eventType,
|
||||
organizationId: 'org-1',
|
||||
branchId: 'branch-1',
|
||||
entityType: 'activity',
|
||||
entityId: 'activity-1',
|
||||
primaryRecord: { type: 'opportunity', id: 'opp-1' },
|
||||
relatedRecords: [
|
||||
{ type: 'customer', id: 'customer-1' },
|
||||
{ type: 'quotation', id: 'quotation-1' }
|
||||
],
|
||||
actorUserId: 'user-1',
|
||||
occurredAt: '2026-07-13T00:00:00.000Z',
|
||||
correlationId: 'corr-1',
|
||||
visibility: { productType: 'product-a', pricingSensitive: true, internalOnly: false },
|
||||
payload: {
|
||||
activityId: 'activity-1',
|
||||
activityCode: 'ACT-1',
|
||||
activityType: 'meeting',
|
||||
subject: 'Customer planning meeting',
|
||||
priority: 'high',
|
||||
ownerId: 'owner-1',
|
||||
assignedToId: 'assignee-1',
|
||||
scheduledStartAt: '2026-07-14T03:00:00.000Z',
|
||||
scheduledEndAt: '2026-07-14T04:00:00.000Z',
|
||||
status: 'scheduled'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unscheduledActivityEvent() {
|
||||
return createBusinessEvent({
|
||||
eventType: 'activity.created',
|
||||
organizationId: 'org-1',
|
||||
branchId: 'branch-1',
|
||||
entityType: 'activity',
|
||||
entityId: 'activity-1',
|
||||
primaryRecord: { type: 'activity', id: 'activity-1' },
|
||||
relatedRecords: [],
|
||||
actorUserId: 'user-1',
|
||||
occurredAt: '2026-07-13T00:00:00.000Z',
|
||||
correlationId: 'corr-1',
|
||||
visibility: { productType: null, pricingSensitive: false, internalOnly: false },
|
||||
payload: {
|
||||
activityId: 'activity-1',
|
||||
subject: 'No date'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('calendar builder maps scheduled activity into editable calendar item', () => {
|
||||
const change = buildCalendarChangeFromEvent(scheduledActivityEvent());
|
||||
assert.equal(change?.action, 'upsert');
|
||||
assert.equal(change?.item.category, 'meeting');
|
||||
assert.equal(change?.item.editable, true);
|
||||
assert.equal(change?.item.milestone, false);
|
||||
assert.equal(change?.item.customerId, 'customer-1');
|
||||
assert.equal(change?.item.quotationId, 'quotation-1');
|
||||
assert.equal(change?.item.ownerId, 'owner-1');
|
||||
assert.equal(change?.item.assigneeId, 'assignee-1');
|
||||
assert.equal(change?.item.visibility.pricingSensitive, true);
|
||||
});
|
||||
|
||||
test('calendar builder deletes terminal activity from projection', () => {
|
||||
const change = buildCalendarChangeFromEvent(scheduledActivityEvent('activity.cancelled'));
|
||||
assert.equal(change?.action, 'delete');
|
||||
assert.equal(change?.activityId, 'activity-1');
|
||||
});
|
||||
|
||||
test('calendar consumer skips activity events without schedulable date', async () => {
|
||||
const consumer = createCalendarProjectionConsumer({
|
||||
persist: async () => {
|
||||
throw new Error('should not persist');
|
||||
}
|
||||
});
|
||||
const result = await consumer.handle(unscheduledActivityEvent());
|
||||
assert.equal(result.status, 'skipped');
|
||||
assert.equal(result.sideEffectCount, 0);
|
||||
});
|
||||
184
src/features/crm/calendar/server/builder.ts
Normal file
184
src/features/crm/calendar/server/builder.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { BANGKOK_TIME_ZONE } from '../../../../lib/date-format.ts';
|
||||
import type { BusinessEvent } from '../../../foundation/business-events/types.ts';
|
||||
import type { CalendarCategory, CalendarPriority, CalendarProjectionItem } from '../api/types';
|
||||
|
||||
export const CALENDAR_PROJECTION_VERSION = 1;
|
||||
|
||||
export type CalendarProjectionChange =
|
||||
| { action: 'upsert'; item: CalendarProjectionItem }
|
||||
| { action: 'delete'; organizationId: string; activityId: string; eventId: string };
|
||||
|
||||
const TERMINAL_ACTIVITY_EVENTS = new Set([
|
||||
'activity.completed',
|
||||
'activity.cancelled',
|
||||
'activity.deleted'
|
||||
]);
|
||||
|
||||
const ACTIVITY_CATEGORY_BY_TYPE: Record<string, CalendarCategory> = {
|
||||
meeting: 'meeting',
|
||||
visit: 'visit',
|
||||
site_survey: 'site_survey',
|
||||
internal_task: 'task',
|
||||
reminder: 'reminder',
|
||||
reminder_action: 'reminder',
|
||||
follow_up: 'activity',
|
||||
phone_call: 'activity',
|
||||
email: 'activity',
|
||||
email_follow_up: 'activity',
|
||||
presentation: 'meeting',
|
||||
delivery_coordination: 'activity',
|
||||
approval_action: 'approval_milestone'
|
||||
};
|
||||
|
||||
export function buildCalendarChangeFromEvent(event: BusinessEvent): CalendarProjectionChange | null {
|
||||
if (!event.eventType.startsWith('activity.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
const activityId = text(payload.activityId) ?? (event.entityType === 'activity' ? event.entityId : null);
|
||||
|
||||
if (!activityId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TERMINAL_ACTIVITY_EVENTS.has(event.eventType)) {
|
||||
return {
|
||||
action: 'delete',
|
||||
organizationId: event.organizationId,
|
||||
activityId,
|
||||
eventId: event.eventId
|
||||
};
|
||||
}
|
||||
|
||||
const startAt = dateText(payload.scheduledStartAt) ?? dateText(payload.dueAt);
|
||||
if (!startAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endAt =
|
||||
dateText(payload.scheduledEndAt) ??
|
||||
addMinutesIso(startAt, defaultDurationMinutes(text(payload.activityType)));
|
||||
const refs = resolveEntityRefs(event, payload);
|
||||
const priority = priorityFromPayload(payload);
|
||||
const category = categoryFromPayload(payload);
|
||||
|
||||
return {
|
||||
action: 'upsert',
|
||||
item: {
|
||||
id: `calendar:${event.organizationId}:${activityId}`,
|
||||
organizationId: event.organizationId,
|
||||
branchId: event.branchId ?? null,
|
||||
productType: event.visibility.productType ?? null,
|
||||
customerId: refs.customerId,
|
||||
leadId: refs.leadId,
|
||||
opportunityId: refs.opportunityId,
|
||||
quotationId: refs.quotationId,
|
||||
approvalId: refs.approvalId,
|
||||
activityId,
|
||||
ownerId: text(payload.ownerId),
|
||||
assigneeId: text(payload.assignedToId),
|
||||
eventId: event.eventId,
|
||||
entityType: event.entityType,
|
||||
entityId: event.entityId,
|
||||
title: calendarTitle(payload, category),
|
||||
summary: text(payload.description) ?? text(payload.outcomeSummary) ?? text(payload.nextAction),
|
||||
location: text(payload.location) ?? text(payload.projectLocation),
|
||||
startAt,
|
||||
endAt,
|
||||
timezone: BANGKOK_TIME_ZONE,
|
||||
priority,
|
||||
category,
|
||||
activityType: text(payload.activityType),
|
||||
editable: true,
|
||||
milestone: false,
|
||||
overdue: isOverdue(startAt, text(payload.status)),
|
||||
hotProject: Boolean(payload.hotProject),
|
||||
allDay: Boolean(payload.allDay),
|
||||
visibility: {
|
||||
productType: event.visibility.productType,
|
||||
pricingSensitive: event.visibility.pricingSensitive ?? false,
|
||||
internalOnly: event.visibility.internalOnly ?? false
|
||||
},
|
||||
metadata: {
|
||||
source: 'activity-business-event',
|
||||
activityCode: text(payload.activityCode),
|
||||
contentRedacted: Boolean(payload.contentRedacted),
|
||||
primaryRecord: event.primaryRecord
|
||||
},
|
||||
sourceProjectionVersion: CALENDAR_PROJECTION_VERSION,
|
||||
createdAt: event.occurredAt,
|
||||
updatedAt: event.occurredAt
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEntityRefs(event: BusinessEvent, payload: Record<string, unknown>) {
|
||||
const refs = new Map(event.relatedRecords.map((record) => [record.type, record.id]));
|
||||
return {
|
||||
customerId: text(payload.customerId) ?? refs.get('customer') ?? null,
|
||||
leadId: text(payload.leadId) ?? refs.get('lead') ?? null,
|
||||
opportunityId: text(payload.opportunityId) ?? refs.get('opportunity') ?? null,
|
||||
quotationId: text(payload.quotationId) ?? refs.get('quotation') ?? null,
|
||||
approvalId: text(payload.approvalId) ?? refs.get('approval') ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function calendarTitle(payload: Record<string, unknown>, category: CalendarCategory): string {
|
||||
const subject = text(payload.subject);
|
||||
if (subject) return subject;
|
||||
if (category === 'meeting') return 'Meeting';
|
||||
if (category === 'visit') return 'Customer visit';
|
||||
if (category === 'site_survey') return 'Site survey';
|
||||
if (category === 'reminder') return 'Reminder';
|
||||
return 'Activity';
|
||||
}
|
||||
|
||||
function categoryFromPayload(payload: Record<string, unknown>): CalendarCategory {
|
||||
const activityType = text(payload.activityType);
|
||||
if (activityType && ACTIVITY_CATEGORY_BY_TYPE[activityType]) {
|
||||
return ACTIVITY_CATEGORY_BY_TYPE[activityType];
|
||||
}
|
||||
return 'activity';
|
||||
}
|
||||
|
||||
function priorityFromPayload(payload: Record<string, unknown>): CalendarPriority {
|
||||
const priority = text(payload.priority);
|
||||
if (priority === 'critical' || priority === 'high' || priority === 'low') {
|
||||
return priority;
|
||||
}
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
function defaultDurationMinutes(activityType: string | null): number {
|
||||
if (activityType === 'visit' || activityType === 'site_survey' || activityType === 'presentation') {
|
||||
return 120;
|
||||
}
|
||||
if (activityType === 'meeting') return 60;
|
||||
return 30;
|
||||
}
|
||||
|
||||
function isOverdue(startAt: string, status: string | null): boolean {
|
||||
if (status === 'completed' || status === 'cancelled' || status === 'skipped') {
|
||||
return false;
|
||||
}
|
||||
return new Date(startAt).getTime() < Date.now();
|
||||
}
|
||||
|
||||
function addMinutesIso(value: string, minutes: number): string {
|
||||
const date = new Date(value);
|
||||
date.setMinutes(date.getMinutes() + minutes);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function dateText(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
69
src/features/crm/calendar/server/consumer.ts
Normal file
69
src/features/crm/calendar/server/consumer.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { BusinessEvent } from '../../../foundation/business-events/types.ts';
|
||||
import type { ProjectionConsumer, ProjectionHandleResult } from '../../../foundation/projections/types.ts';
|
||||
import { getProjectionDefinition } from '../../../foundation/projections/registry.ts';
|
||||
import { buildCalendarChangeFromEvent } from './builder.ts';
|
||||
|
||||
const definition = getProjectionDefinition('Calendar');
|
||||
|
||||
interface CalendarProjectionConsumerOptions {
|
||||
persist?: (change: ReturnType<typeof buildCalendarChangeFromEvent>) => Promise<number>;
|
||||
}
|
||||
|
||||
export function createCalendarProjectionConsumer(
|
||||
options: CalendarProjectionConsumerOptions = {}
|
||||
): ProjectionConsumer {
|
||||
const persist = options.persist ?? persistCalendarChange;
|
||||
|
||||
return {
|
||||
consumerName: definition.consumerName,
|
||||
projectionName: definition.projectionName,
|
||||
supportedEventTypes: definition.consumedEventTypes,
|
||||
supportedVersions: definition.supportedVersions,
|
||||
async handle(event: BusinessEvent): Promise<ProjectionHandleResult> {
|
||||
const change = buildCalendarChangeFromEvent(event);
|
||||
if (!change) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
message: 'Calendar event has no schedulable date',
|
||||
sideEffectCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
const sideEffectCount = await persist(change);
|
||||
return {
|
||||
status: 'processed',
|
||||
message:
|
||||
change.action === 'delete'
|
||||
? 'Calendar projection activity item removed'
|
||||
: 'Calendar projection item persisted',
|
||||
sideEffectCount
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const calendarProjectionConsumer = createCalendarProjectionConsumer();
|
||||
|
||||
async function persistCalendarChange(
|
||||
change: ReturnType<typeof buildCalendarChangeFromEvent>
|
||||
): Promise<number> {
|
||||
if (!change) return 0;
|
||||
const { deleteCalendarProjectionForActivity, upsertCalendarProjectionItem } = await import(
|
||||
'./service.ts'
|
||||
);
|
||||
|
||||
if (change.action === 'delete') {
|
||||
await deleteCalendarProjectionForActivity({
|
||||
organizationId: change.organizationId,
|
||||
activityId: change.activityId
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
|
||||
await deleteCalendarProjectionForActivity({
|
||||
organizationId: change.item.organizationId,
|
||||
activityId: change.item.activityId ?? change.item.entityId
|
||||
});
|
||||
await upsertCalendarProjectionItem(change.item);
|
||||
return 1;
|
||||
}
|
||||
51
src/features/crm/calendar/server/rebuild.ts
Normal file
51
src/features/crm/calendar/server/rebuild.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
ProjectionRebuildRequest,
|
||||
ProjectionRebuildResult
|
||||
} from '../../../foundation/projections/types.ts';
|
||||
|
||||
export interface CalendarRebuildPlan {
|
||||
projectionName: 'Calendar';
|
||||
organizationId: string;
|
||||
sources: Array<'activity' | 'opportunity' | 'quotation' | 'approval' | 'business-event-replay'>;
|
||||
resetExisting: boolean;
|
||||
dryRun: boolean;
|
||||
restart: boolean;
|
||||
dateRange: {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function createCalendarRebuildPlan(
|
||||
request: ProjectionRebuildRequest & { restart?: boolean }
|
||||
): CalendarRebuildPlan {
|
||||
return {
|
||||
projectionName: 'Calendar',
|
||||
organizationId: request.organizationId,
|
||||
sources: ['activity', 'opportunity', 'quotation', 'approval', 'business-event-replay'],
|
||||
resetExisting: Boolean(request.resetExisting),
|
||||
dryRun: Boolean(request.dryRun),
|
||||
restart: Boolean(request.restart),
|
||||
dateRange: {
|
||||
from: request.occurredFrom ?? null,
|
||||
to: request.occurredTo ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createCalendarRebuildResult(
|
||||
plan: CalendarRebuildPlan,
|
||||
overrides: Partial<ProjectionRebuildResult> = {}
|
||||
): ProjectionRebuildResult {
|
||||
return {
|
||||
rebuildRunId: `calendar-rebuild:${plan.organizationId}:${Date.now()}`,
|
||||
projectionName: plan.projectionName,
|
||||
organizationId: plan.organizationId,
|
||||
dryRun: plan.dryRun,
|
||||
status: 'pending',
|
||||
processedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
3
src/features/crm/calendar/server/registration.ts
Normal file
3
src/features/crm/calendar/server/registration.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { calendarProjectionConsumer } from './consumer.ts';
|
||||
|
||||
export const CRM_CALENDAR_PROJECTION_CONSUMERS = [calendarProjectionConsumer];
|
||||
260
src/features/crm/calendar/server/service.ts
Normal file
260
src/features/crm/calendar/server/service.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { and, asc, eq, gte, ilike, lte, or, type SQL } from 'drizzle-orm';
|
||||
import { crmCalendarProjection } from '@/db/schema';
|
||||
import {
|
||||
buildCrmSecurityContext,
|
||||
canAccessScopedCrmRecord,
|
||||
type CrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { db } from '@/lib/db';
|
||||
import type { CalendarListFilters, CalendarListResponse, CalendarProjectionItem } from '../api/types';
|
||||
|
||||
export type CalendarProjectionRow = typeof crmCalendarProjection.$inferSelect;
|
||||
export type CalendarDbClient = Pick<typeof db, 'insert' | 'select' | 'delete'>;
|
||||
|
||||
export async function upsertCalendarProjectionItem(
|
||||
item: CalendarProjectionItem,
|
||||
client: CalendarDbClient = db
|
||||
): Promise<void> {
|
||||
await client
|
||||
.insert(crmCalendarProjection)
|
||||
.values(toInsertRow(item))
|
||||
.onConflictDoUpdate({
|
||||
target: crmCalendarProjection.id,
|
||||
set: toInsertRow(item)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCalendarProjectionForActivity(
|
||||
input: { organizationId: string; activityId: string },
|
||||
client: CalendarDbClient = db
|
||||
): Promise<void> {
|
||||
await client
|
||||
.delete(crmCalendarProjection)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCalendarProjection.organizationId, input.organizationId),
|
||||
eq(crmCalendarProjection.activityId, input.activityId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearCalendarProjectionForOrganization(
|
||||
organizationId: string,
|
||||
client: CalendarDbClient = db
|
||||
): Promise<void> {
|
||||
await client
|
||||
.delete(crmCalendarProjection)
|
||||
.where(eq(crmCalendarProjection.organizationId, organizationId));
|
||||
}
|
||||
|
||||
export async function listCalendarItems(
|
||||
filters: CalendarListFilters,
|
||||
context: CrmSecurityContext,
|
||||
client = db
|
||||
): Promise<CalendarListResponse> {
|
||||
const limit = Math.min(filters.limit ?? 200, 500);
|
||||
const rows = await client
|
||||
.select()
|
||||
.from(crmCalendarProjection)
|
||||
.where(and(...buildCalendarWhere(filters, context)))
|
||||
.orderBy(asc(crmCalendarProjection.startAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
const visibleItems = rows.map(mapCalendarRow).filter((item) => canViewCalendarItem(context, item));
|
||||
const items = visibleItems.slice(0, limit);
|
||||
|
||||
return {
|
||||
items,
|
||||
summary: summarizeCalendarItems(items),
|
||||
nextCursor: visibleItems.length > limit ? items.at(-1)?.startAt ?? null : null,
|
||||
totalItems: items.length
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCalendarSecurityContext(input: Parameters<typeof buildCrmSecurityContext>[0]) {
|
||||
return buildCrmSecurityContext(input);
|
||||
}
|
||||
|
||||
function buildCalendarWhere(filters: CalendarListFilters, context: CrmSecurityContext): SQL[] {
|
||||
const where: SQL[] = [
|
||||
eq(crmCalendarProjection.organizationId, filters.organizationId),
|
||||
gte(crmCalendarProjection.startAt, new Date(filters.startFrom)),
|
||||
lte(crmCalendarProjection.startAt, new Date(filters.startTo))
|
||||
];
|
||||
|
||||
if (filters.userId) {
|
||||
where.push(
|
||||
or(
|
||||
eq(crmCalendarProjection.ownerId, filters.userId),
|
||||
eq(crmCalendarProjection.assigneeId, filters.userId)
|
||||
)!
|
||||
);
|
||||
} else if (filters.lens === 'personal') {
|
||||
where.push(
|
||||
or(
|
||||
eq(crmCalendarProjection.ownerId, context.userId),
|
||||
eq(crmCalendarProjection.assigneeId, context.userId)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.teamUserIds?.length) {
|
||||
const teamPredicates = filters.teamUserIds.flatMap((userId) => [
|
||||
eq(crmCalendarProjection.ownerId, userId),
|
||||
eq(crmCalendarProjection.assigneeId, userId)
|
||||
]);
|
||||
where.push(or(...teamPredicates)!);
|
||||
}
|
||||
|
||||
if (filters.branchId) where.push(eq(crmCalendarProjection.branchId, filters.branchId));
|
||||
if (filters.productType) where.push(eq(crmCalendarProjection.productType, filters.productType));
|
||||
if (filters.customerId) where.push(eq(crmCalendarProjection.customerId, filters.customerId));
|
||||
if (filters.leadId) where.push(eq(crmCalendarProjection.leadId, filters.leadId));
|
||||
if (filters.opportunityId) where.push(eq(crmCalendarProjection.opportunityId, filters.opportunityId));
|
||||
if (filters.quotationId) where.push(eq(crmCalendarProjection.quotationId, filters.quotationId));
|
||||
if (filters.activityType) where.push(eq(crmCalendarProjection.activityType, filters.activityType));
|
||||
if (filters.category) where.push(eq(crmCalendarProjection.category, filters.category));
|
||||
if (filters.priority) where.push(eq(crmCalendarProjection.priority, filters.priority));
|
||||
if (typeof filters.overdue === 'boolean') where.push(eq(crmCalendarProjection.overdue, filters.overdue));
|
||||
if (typeof filters.milestone === 'boolean') where.push(eq(crmCalendarProjection.milestone, filters.milestone));
|
||||
if (typeof filters.hotProject === 'boolean') {
|
||||
where.push(eq(crmCalendarProjection.hotProject, filters.hotProject));
|
||||
}
|
||||
if (filters.search) {
|
||||
where.push(
|
||||
or(
|
||||
ilike(crmCalendarProjection.title, `%${filters.search}%`),
|
||||
ilike(crmCalendarProjection.summary, `%${filters.search}%`)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
function canViewCalendarItem(
|
||||
context: CrmSecurityContext,
|
||||
item: CalendarProjectionItem
|
||||
): boolean {
|
||||
const directlyAssigned = item.ownerId === context.userId || item.assigneeId === context.userId;
|
||||
if (item.visibility.internalOnly && !directlyAssigned && context.ownershipScope !== 'organization') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
directlyAssigned ||
|
||||
canAccessScopedCrmRecord(context, {
|
||||
branchId: item.branchId,
|
||||
productType: item.productType,
|
||||
createdBy: item.ownerId,
|
||||
ownerUserId: item.assigneeId ?? item.ownerId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeCalendarItems(items: CalendarProjectionItem[]) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return items.reduce(
|
||||
(summary, item) => {
|
||||
if (item.startAt.slice(0, 10) === today) summary.dueToday += 1;
|
||||
if (item.overdue) summary.overdue += 1;
|
||||
if (item.category === 'meeting') summary.meetings += 1;
|
||||
if (item.category === 'visit') summary.visits += 1;
|
||||
if (item.milestone) summary.milestones += 1;
|
||||
if (item.category === 'approval_milestone') summary.approvalDue += 1;
|
||||
if (item.hotProject) summary.hotProjects += 1;
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
dueToday: 0,
|
||||
overdue: 0,
|
||||
meetings: 0,
|
||||
visits: 0,
|
||||
milestones: 0,
|
||||
approvalDue: 0,
|
||||
hotProjects: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function toInsertRow(item: CalendarProjectionItem): typeof crmCalendarProjection.$inferInsert {
|
||||
return {
|
||||
id: item.id,
|
||||
organizationId: item.organizationId,
|
||||
branchId: item.branchId,
|
||||
productType: item.productType,
|
||||
customerId: item.customerId,
|
||||
leadId: item.leadId,
|
||||
opportunityId: item.opportunityId,
|
||||
quotationId: item.quotationId,
|
||||
approvalId: item.approvalId,
|
||||
activityId: item.activityId,
|
||||
ownerId: item.ownerId,
|
||||
assigneeId: item.assigneeId,
|
||||
eventId: item.eventId,
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId,
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
location: item.location,
|
||||
startAt: new Date(item.startAt),
|
||||
endAt: new Date(item.endAt),
|
||||
timezone: item.timezone,
|
||||
priority: item.priority,
|
||||
category: item.category,
|
||||
activityType: item.activityType,
|
||||
editable: item.editable,
|
||||
milestone: item.milestone,
|
||||
overdue: item.overdue,
|
||||
hotProject: item.hotProject,
|
||||
allDay: item.allDay,
|
||||
visibility: item.visibility,
|
||||
metadata: item.metadata,
|
||||
sourceEvent: {
|
||||
eventId: item.eventId,
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId
|
||||
},
|
||||
sourceProjectionVersion: item.sourceProjectionVersion,
|
||||
updatedAt: new Date(item.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function mapCalendarRow(row: CalendarProjectionRow): CalendarProjectionItem {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
branchId: row.branchId,
|
||||
productType: row.productType,
|
||||
customerId: row.customerId,
|
||||
leadId: row.leadId,
|
||||
opportunityId: row.opportunityId,
|
||||
quotationId: row.quotationId,
|
||||
approvalId: row.approvalId,
|
||||
activityId: row.activityId,
|
||||
ownerId: row.ownerId,
|
||||
assigneeId: row.assigneeId,
|
||||
eventId: row.eventId,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
title: row.title,
|
||||
summary: row.summary,
|
||||
location: row.location,
|
||||
startAt: row.startAt.toISOString(),
|
||||
endAt: row.endAt.toISOString(),
|
||||
timezone: row.timezone,
|
||||
priority: row.priority as CalendarProjectionItem['priority'],
|
||||
category: row.category as CalendarProjectionItem['category'],
|
||||
activityType: row.activityType,
|
||||
editable: row.editable,
|
||||
milestone: row.milestone,
|
||||
overdue: row.overdue,
|
||||
hotProject: row.hotProject,
|
||||
allDay: row.allDay,
|
||||
visibility: row.visibility as CalendarProjectionItem['visibility'],
|
||||
metadata: row.metadata as Record<string, unknown> | null,
|
||||
sourceProjectionVersion: row.sourceProjectionVersion,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
calendarProjectionConsumer,
|
||||
dashboardProjectionConsumer,
|
||||
notificationProjectionConsumer
|
||||
} from '../../../foundation/projections/consumers.ts';
|
||||
import { calendarProjectionConsumer } from '../../calendar/server/consumer.ts';
|
||||
import { timelineProjectionConsumer } from './consumer.ts';
|
||||
|
||||
export const CRM_TIMELINE_PROJECTION_CONSUMERS = [
|
||||
|
||||
@@ -33,16 +33,16 @@ export const PROJECTION_REGISTRY: ProjectionDefinition[] = [
|
||||
{
|
||||
projectionName: 'Calendar',
|
||||
consumerName: 'calendar.projection.consumer',
|
||||
owningFeature: 'foundation/projections',
|
||||
owningFeature: 'crm/calendar',
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'hybrid',
|
||||
persistence: 'cached-read-model',
|
||||
persistence: 'materialized',
|
||||
securityClassification: 'organization-scoped; assignment and source-visibility-governed',
|
||||
idempotencyKey: 'consumerName + eventId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'calendar projection rows rebuildable from source activity and events',
|
||||
implementationStatus: 'contract-only'
|
||||
implementationStatus: 'active'
|
||||
},
|
||||
{
|
||||
projectionName: 'Dashboard',
|
||||
|
||||
Reference in New Issue
Block a user