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,
|
"when": 1783925810194,
|
||||||
"tag": "0005_freezing_wendigo",
|
"tag": "0005_freezing_wendigo",
|
||||||
"breakpoints": true
|
"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: [],
|
items: [],
|
||||||
access: { requireOrg: true, permission: "crm.dashboard.read" },
|
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: "ลูกค้า",
|
title: "ลูกค้า",
|
||||||
url: "/dashboard/crm/customers",
|
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(
|
export const crmCustomers = pgTable(
|
||||||
'crm_customers',
|
'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 {
|
import {
|
||||||
calendarProjectionConsumer,
|
|
||||||
dashboardProjectionConsumer,
|
dashboardProjectionConsumer,
|
||||||
notificationProjectionConsumer
|
notificationProjectionConsumer
|
||||||
} from '../../../foundation/projections/consumers.ts';
|
} from '../../../foundation/projections/consumers.ts';
|
||||||
|
import { calendarProjectionConsumer } from '../../calendar/server/consumer.ts';
|
||||||
import { timelineProjectionConsumer } from './consumer.ts';
|
import { timelineProjectionConsumer } from './consumer.ts';
|
||||||
|
|
||||||
export const CRM_TIMELINE_PROJECTION_CONSUMERS = [
|
export const CRM_TIMELINE_PROJECTION_CONSUMERS = [
|
||||||
|
|||||||
@@ -33,16 +33,16 @@ export const PROJECTION_REGISTRY: ProjectionDefinition[] = [
|
|||||||
{
|
{
|
||||||
projectionName: 'Calendar',
|
projectionName: 'Calendar',
|
||||||
consumerName: 'calendar.projection.consumer',
|
consumerName: 'calendar.projection.consumer',
|
||||||
owningFeature: 'foundation/projections',
|
owningFeature: 'crm/calendar',
|
||||||
consumedEventTypes: ACTIVITY_EVENTS,
|
consumedEventTypes: ACTIVITY_EVENTS,
|
||||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||||
rebuildStrategy: 'hybrid',
|
rebuildStrategy: 'hybrid',
|
||||||
persistence: 'cached-read-model',
|
persistence: 'materialized',
|
||||||
securityClassification: 'organization-scoped; assignment and source-visibility-governed',
|
securityClassification: 'organization-scoped; assignment and source-visibility-governed',
|
||||||
idempotencyKey: 'consumerName + eventId',
|
idempotencyKey: 'consumerName + eventId',
|
||||||
retryPolicy: 'projection-default-v1',
|
retryPolicy: 'projection-default-v1',
|
||||||
retentionPolicy: 'calendar projection rows rebuildable from source activity and events',
|
retentionPolicy: 'calendar projection rows rebuildable from source activity and events',
|
||||||
implementationStatus: 'contract-only'
|
implementationStatus: 'active'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
projectionName: 'Dashboard',
|
projectionName: 'Dashboard',
|
||||||
|
|||||||
Reference in New Issue
Block a user