commit task-ep.1.6.2-r1

This commit is contained in:
phaichayon
2026-07-13 22:29:11 +07:00
parent 7d7548ebd4
commit bb5a910b88
49 changed files with 4663 additions and 361 deletions

View File

@@ -0,0 +1,65 @@
# Task EP.1.6.2 - Direct Big Calendar UI Adoption
Date: 2026-07-13
Status: Foundation Implemented
Upstream reviewed: `lramos33/big-calendar`
Upstream commit: `fbb8485e3d54b267261056c784632229ba6f7bc3`
License: MIT, upstream concepts retained with local ALLA OS adaptation.
## Summary
EP.1.6.2 replaces the CRM calendar workspace's custom tab/grid renderer with a local Big Calendar layer adapted from the upstream component architecture. The implementation preserves ALLA OS backend ownership: Activity, Business Events, Calendar Projection, Projection Runtime, permissions, and API contracts remain unchanged.
The UI now flows through:
```text
CalendarProjectionItem
-> mapCalendarProjectionToBigCalendarEvent()
-> BigCalendarProvider
-> BigCalendarHeader
-> Day / Week / Month / Agenda views
-> Activity reschedule command
```
## Adopted Components And Concepts
- Added `src/features/crm/calendar/big-calendar/provider.tsx` for local calendar state, selected date, selected view, user filter, drag state, working hours, and Activity command dispatch.
- Added `src/features/crm/calendar/big-calendar/header.tsx` for upstream-style Today, previous, next, range display, user filter, and view switching.
- Added `src/features/crm/calendar/big-calendar/views.tsx` for Day, Week, Month, and Agenda rendering.
- Added `src/features/crm/calendar/big-calendar/calendar-slot.tsx` for calendar slots, empty overlays, and HTML5 drop handling.
- Added `src/features/crm/calendar/big-calendar/event-card.tsx` for draggable event cards and resize command affordance.
- Added `src/features/crm/calendar/big-calendar/helpers.ts` and `types.ts` for local view, slot, date range, and command contracts.
- Updated `src/features/crm/calendar/components/calendar-workspace.tsx` to use the Big Calendar provider/header/views instead of local custom renderers.
- Updated `src/features/crm/calendar/components/calendar-adapter.ts` with `mapBigCalendarActionToActivityCommand()`.
- Extended `BigCalendarEvent.source` with `ownerId` and `assigneeId` so the provider can support user filtering without reading database rows.
## Rejected Upstream Demo Code
- Did not import the upstream app shell, demo stores, or mock data ownership.
- Did not add React Hook Form; this repository forbids new RHF usage.
- Did not add upstream persistence or direct database reads.
- Did not mutate `crm_calendar_projection` rows from UI interactions.
- Did not replace the Activity API or Business Event flow.
## Interaction Behavior
- Editable Activity events can emit reschedule commands through `rescheduleActivity()`.
- Drag/drop maps a dropped editable Activity to an Activity reschedule request.
- Resize currently uses a command-based duration extension affordance rather than full upstream edge-resize parity.
- Read-only milestones do not emit Activity commands.
- After successful reschedule, Calendar, Activity, and My Day query groups are invalidated.
- Day, Week, and Month retain EP.1.6.1 behavior by always rendering grid slots, including empty states.
- Agenda keeps list-style empty behavior.
## Upgrade Strategy
Future upstream sync should compare against commit `fbb8485e3d54b267261056c784632229ba6f7bc3`, then port only rendering and interaction improvements into `src/features/crm/calendar/big-calendar/**`. Keep ALLA OS adapter and command boundaries stable.
Full upstream parity still requires a deliberate dependency review for `react-dnd`, upstream resize behavior, and any accessibility changes before adding them to this Next.js 16 / React 19 / shadcn / TanStack stack.
## Verification
- Passed: `npm run typecheck`
- Passed: `npx oxlint src/features/crm/calendar`
- Not run: `npm run db:generate` because EP.1.6.2 made no schema changes.
- Not automated: browser drag/drop, resize, responsive, and screen-reader regression checks.

View File

@@ -0,0 +1,89 @@
# Task EP.1.6.2-R1 - Upstream Big Calendar Source Adoption
Date: 2026-07-13
Status: Implemented
Upstream: `https://github.com/lramos33/big-calendar`
Pinned commit: `fbb8485e3d54b267261056c784632229ba6f7bc3`
## Outcome
R1 replaces the locally reimplemented EP.1.6.2 calendar renderer with copied upstream Big Calendar source under:
```text
src/features/crm/calendar/upstream-big-calendar/**
```
The active `/dashboard/crm/calendar` workspace now renders:
```text
CalendarProvider -> ClientContainer -> CalendarHeader -> Day / Week / Month / Year / Agenda
```
ALLA OS still owns Calendar Projection, Activity lifecycle, Business Events, Projection Runtime, CRM authorization, and React Query invalidation.
## Removal / Replacement Matrix
| Previous EP.1.6.2 file | R1 action | Replacement |
| --- | --- | --- |
| `big-calendar/provider.tsx` | removed | upstream `contexts/calendar-context.tsx` |
| `big-calendar/header.tsx` | removed | upstream `components/header/**` |
| `big-calendar/views.tsx` | removed | upstream `month-view`, `week-and-day-view`, `agenda-view`, `year-view` |
| `big-calendar/calendar-slot.tsx` | removed | upstream day/week/month cells and DnD droppables |
| `big-calendar/event-card.tsx` | removed | upstream event block, month badge, agenda card |
| `big-calendar/helpers.ts` | removed | upstream `helpers.ts` |
| `big-calendar/types.ts` | removed | upstream `interfaces.ts` and `types.ts` |
The previous custom folder was removed from the active source tree.
## Local Adapter Boundary
- `mapCalendarProjectionToUpstreamEvent()` maps `CalendarProjectionItem` into upstream `IEvent`.
- `mapUpstreamEventToActivityCommand()` maps upstream moved events into Activity reschedule commands.
- Upstream `IEvent` carries an `alla` metadata object for source references, editability, milestone/read-only flags, and visibility metadata.
- Read-only milestones are marked non-editable and prevented from dragging through upstream DnD wrappers.
## Upstream Patches
- Import paths were rewritten from `@/calendar/*` to the isolated local upstream directory.
- `lucide-react` imports were mapped to the project icon registry via `upstream-big-calendar/icons.tsx`.
- Upstream demo add/edit dialogs were replaced with ALLA-safe integration shells; React Hook Form was not introduced.
- Upstream mock data, request helpers, and demo schemas were removed.
- `CalendarProvider` was extended with `initialDate`, `initialView`, and `onEventChange`.
- `ClientContainer` now reads selected view from upstream context so the toolbar can switch views inside the ALLA route.
- Header view buttons preserve upstream toolbar composition but no longer navigate to upstream demo routes.
- Upstream `react-dnd` / `react-dnd-html5-backend` behavior is used for drag/drop.
## Dependency Review
Added:
- `react-dnd@16.0.1`
- `react-dnd-html5-backend@16.0.1`
Not added:
- `react-hook-form`
- `@hookform/resolvers`
- `react-aria-components`
- `lucide-react`
Reason: those were only required by upstream demo forms/config controls or were replaced by existing ALLA UI/icon primitives.
`npm install` reported existing audit findings after dependency update: 9 moderate and 3 high. No automatic `audit fix --force` was run because it may introduce unrelated breaking changes.
## Parity Notes
- Adopted upstream Day, Week, Month, Agenda, Year, Header, DnD provider, drag layer, droppable cells, event positioning helpers, multi-day rows, and month event badges.
- Agenda empty state comes from upstream agenda view.
- Day/Week/Month still render actual upstream grids when empty.
- Year view is adopted.
- Drag/drop uses upstream `react-dnd` mechanics and forwards changed editable Activity events to the ALLA Activity reschedule API.
- Resize edge-handle parity remains limited by the reviewed upstream commit; no separate local resize approximation was retained.
- Full browser visual parity, dark-mode parity, and interaction parity still require manual browser verification.
## Verification
- Passed: `npm run typecheck`
- Passed: `npx oxlint src/features/crm/calendar`
- Not run: `npm run db:generate`; no schema changes.
- Not automated: browser drag/drop regression, responsive screenshots, screen-reader pass, dark-mode visual parity.

88
package-lock.json generated
View File

@@ -74,6 +74,8 @@
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.2.4",
"react-dropzone": "^14.4.1",
"react-resizable-panels": "^2.1.9",
@@ -5240,6 +5242,24 @@
"integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==",
"license": "MIT"
},
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==",
"license": "MIT"
},
"node_modules/@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==",
"license": "MIT"
},
"node_modules/@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
"license": "MIT"
},
"node_modules/@rollup/plugin-commonjs": {
"version": "28.0.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
@@ -7460,6 +7480,17 @@
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"license": "MIT",
"dependencies": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
"redux": "^4.2.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -8042,6 +8073,15 @@
}
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
@@ -10107,6 +10147,45 @@
"react": ">=16.8.0"
}
},
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"license": "MIT",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"license": "MIT",
"dependencies": {
"dnd-core": "^16.0.1"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
@@ -10339,6 +10418,15 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/remove-accents": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",

View File

@@ -118,6 +118,8 @@
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.2.4",
"react-dropzone": "^14.4.1",
"react-resizable-panels": "^2.1.9",

1017
plans/task-ep.1.6.2-r1.md Normal file

File diff suppressed because it is too large Load Diff

599
plans/task-ep.1.6.2.md Normal file
View File

@@ -0,0 +1,599 @@
# Task EP.1.6.2 Direct Big Calendar UI Adoption
**Status:** Foundation Implemented
**Priority:** High
**Type:** UI Modernization / Calendar UX / Frontend Refactor
---
# Depends On
* EP.1.6 Calendar Projection, Workspace & Big Calendar Foundation
* EP.1.6.1 Calendar Empty State Rendering Improvement
* EP.1.7 My Day Workspace Foundation
---
# Objective
Replace the current custom Calendar UI implementation with the original **lramos33/big-calendar** component architecture while preserving the ALLA OS backend architecture.
This task is **UI-only**.
No business logic, projection model, Activity ownership, Business Events, or Calendar Projection architecture shall change.
---
# Background
EP.1.6 successfully introduced:
* Calendar Projection
* Calendar Query Service
* Calendar Workspace
* Calendar Projection Runtime
* Activity integration
* Calendar API
* Calendar security
However the current UI only references the upstream project conceptually.
It does **NOT** use the original Calendar UI implementation.
The goal of EP.1.6.2 is to adopt the upstream Calendar components directly so ALLA OS gains the same interaction quality and polished UX.
---
# Review Required
Review before implementation:
* AGENTS.md
* LAYOUT.md
* ui-ux-rules.md
* Engineering Constitution
* EP.1.6
* EP.1.6.1
* EP.1.7
Review upstream project:
```text
https://github.com/lramos33/big-calendar
```
Review:
* component structure
* provider architecture
* drag & drop
* resize
* agenda
* month
* week
* day
* year
* event rendering
* toolbar
* filters
* responsive behavior
* dark mode
---
# Architecture Principle
ALLA OS owns:
* Activity
* Calendar Projection
* Business Events
* Projection Runtime
* Permissions
* APIs
Big Calendar owns:
* Calendar rendering
* Calendar interactions
* View switching
* Event layout
* Drag UX
* Resize UX
* Toolbar
* Navigation
---
# Data Flow
Must remain
```text
Activity
Business Event
Projection Runtime
crm_calendar_projection
CalendarProjectionItem
Calendar Adapter
Big Calendar Components
```
No upstream component may read database rows directly.
---
# Scope
---
# Part 1 — Direct Component Adoption
Adopt upstream source components directly.
Do NOT recreate:
* Month Grid
* Week Grid
* Day Grid
* Agenda Layout
* Calendar Header
* Calendar Navigation
Use upstream implementation instead.
---
# Part 2 — Remove Custom Calendar Rendering
Deprecate current custom:
* Day
* Week
* Month
renderers.
Only keep:
* ALLA adapter
* wrapper
* permissions
* Activity actions
---
# Part 3 — Calendar Provider
Introduce upstream provider.
Wrap ALLA Calendar.
The provider receives
CalendarProjectionItem
through an adapter.
---
# Part 4 — Adapter Layer
Implement
```ts
mapCalendarProjectionToBigCalendarEvent()
```
and
```ts
mapBigCalendarActionToActivityCommand()
```
No business logic inside UI.
---
# Part 5 — Toolbar Integration
Use upstream toolbar.
Adapt:
* Today
* Previous
* Next
* Date Picker
* View Selector
Integrate with:
* URL search params
* React Query
---
# Part 6 — View Integration
Support
* Day
* Week
* Month
* Agenda
Optional
* Year
Do not change upstream layout unnecessarily.
---
# Part 7 — Event Rendering
Differentiate
Editable Activity
vs
Read-only Milestone
Show
* icon
* badge
* customer
* title
* priority
* overdue
* Hot Project
using ALLA adapter.
---
# Part 8 — Drag & Drop
Enable upstream drag.
Rules
Editable
Activity API
Business Event
Projection Refresh
Read-only milestones
cannot move.
---
# Part 9 — Resize
Enable resize.
Only
Activity
may resize.
Never
Milestones.
---
# Part 10 — Event Detail
Selecting an event opens
ALLA Activity Sheet
or
Source Detail Sheet
depending on
Activity
or
Milestone.
---
# Part 11 — Working Hours
Reuse upstream configuration.
Default
Asia/Bangkok
Support
Organization setting
Future
User preference.
---
# Part 12 — Current Time
Enable
Now Indicator
from upstream.
---
# Part 13 — Today Highlight
Use upstream
Today
highlight behavior.
---
# Part 14 — User Filter
Replace upstream demo users.
Use
CRM Users API
Permission aware.
---
# Part 15 — Theme Integration
Align
fonts
spacing
cards
buttons
badges
colors
with ALLA OS.
Avoid redesigning upstream.
---
# Part 16 — Responsive Review
Validate
Desktop
Tablet
Mobile
Agenda remains mobile default.
---
# Part 17 — Accessibility
Validate
* keyboard
* focus
* screen reader
* contrast
* dark mode
---
# Part 18 — Calendar Empty State
Keep
EP.1.6.1 behavior.
Day
Week
Month
render calendar.
Agenda
render empty list.
---
# Part 19 — Source Audit
Document
* adopted files
* modified files
* untouched files
* removed demo code
---
# Part 20 — Upgrade Strategy
Record
* upstream commit hash
* version reviewed
* local patches
* synchronization strategy
Future updates must be traceable.
---
# Part 21 — Performance
Verify
* Month rendering
* Week rendering
* Day rendering
* Agenda rendering
* Virtualization where applicable
* React re-render counts
* Lazy event loading
---
# Part 22 — Regression Testing
Verify
Calendar
does not break
* Activity
* Timeline
* Dashboard
* My Day
* Projection Runtime
* Permission model
---
# Deliverables
1. Direct Big Calendar integration
2. Calendar Provider
3. Calendar Adapter Layer
4. Toolbar Integration
5. Day View
6. Week View
7. Month View
8. Agenda View
9. Optional Year View
10. Drag & Drop
11. Resize
12. Current Time Indicator
13. Today Highlight
14. User Filter
15. Theme Integration
16. Responsive Review
17. Accessibility Review
18. Source Audit Report
19. Upgrade Strategy Document
20. Regression Report
---
# Constraints
Must preserve
* Activity ownership
* Calendar Projection
* Timeline
* Projection Runtime
* Business Events
* CRM permissions
* API contracts
Must NOT
* replace Calendar Projection
* introduce local event storage
* duplicate Activity logic
* bypass Activity API
* bypass Business Events
* mutate projection rows directly
---
# Verification
* `npm run typecheck`
* `npm run db:generate`
* `npx oxlint src/features/crm/calendar`
* Calendar interaction tests
* Drag & Drop tests
* Resize tests
* Permission tests
* Responsive tests
* Accessibility tests
---
# Acceptance Criteria
* The Calendar UI is built directly on the **lramos33/big-calendar** source components rather than custom-rendered Day, Week, and Month grids.
* ALLA OS retains ownership of Activity, Calendar Projection, Business Events, Projection Runtime, and CRM authorization.
* `CalendarProjectionItem` is adapted into the upstream event model through a dedicated adapter layer.
* Upstream toolbar, navigation, Day, Week, Month, and Agenda views are reused with minimal structural modification.
* Editable Activities support drag-and-drop and resize through the Activity API only.
* Read-only milestones cannot be dragged or resized.
* The current-time indicator and Today highlighting are enabled.
* Day, Week, and Month always render the Calendar grid; Agenda retains the list-style empty state from EP.1.6.1.
* The upstream MIT license is preserved, and an audit documents all adopted and modified source files.
* The Calendar visually matches the upstream interaction quality while following `LAYOUT.md`, `ui-ux-rules.md`, and `ui-ux-pro-max`.
* Existing Activity, Calendar Projection, Timeline, My Day, Dashboard, Reports, Notifications, and Projection Runtime behavior remain unchanged.
---
# Success Criteria
ALLA OS transitions from a custom Calendar implementation to a **direct integration of the `lramos33/big-calendar` UI**, delivering a polished enterprise calendar experience while preserving the existing backend architecture, business rules, security model, and Projection-based design.

View File

@@ -117,6 +117,8 @@ export interface BigCalendarEvent {
opportunityId: string | null;
quotationId: string | null;
approvalId: string | null;
ownerId: string | null;
assigneeId: string | null;
};
display: {
summary: string | null;

View File

@@ -1,4 +1,26 @@
import type { BigCalendarEvent, CalendarProjectionItem } from '../api/types';
import type { BigCalendarEvent, CalendarProjectionItem } from "../api/types";
import type { IEvent, IUser } from "../upstream-big-calendar/interfaces";
interface LegacyBigCalendarAction {
type: "move" | "resize";
event: BigCalendarEvent;
start: string;
end: string;
}
interface LegacyBigCalendarActivityCommand {
type: "reschedule";
activityId: string;
scheduledStartAt: string;
scheduledEndAt: string;
}
export interface UpstreamCalendarActivityCommand {
type: "reschedule";
activityId: string;
scheduledStartAt: string;
scheduledEndAt: string;
}
export function mapCalendarProjectionToBigCalendarEvent(
item: CalendarProjectionItem
@@ -24,7 +46,9 @@ export function mapCalendarProjectionToBigCalendarEvent(
leadId: item.leadId,
opportunityId: item.opportunityId,
quotationId: item.quotationId,
approvalId: item.approvalId
approvalId: item.approvalId,
ownerId: item.ownerId,
assigneeId: item.assigneeId
},
display: {
summary: item.summary,
@@ -35,3 +59,80 @@ export function mapCalendarProjectionToBigCalendarEvent(
}
};
}
export function mapBigCalendarActionToActivityCommand(
action: LegacyBigCalendarAction
): LegacyBigCalendarActivityCommand | null {
if (!action.event.editable || !action.event.source.activityId) return null;
return {
type: "reschedule",
activityId: action.event.source.activityId,
scheduledStartAt: action.start,
scheduledEndAt: action.end
};
}
export function mapCalendarProjectionToUpstreamEvent(item: CalendarProjectionItem): IEvent {
const editable = item.editable && !item.milestone && item.activityId !== null;
return {
id: item.id,
title: item.title,
startDate: item.startAt,
endDate: item.endAt,
color: mapCalendarColor(item),
description: [item.summary, item.location].filter(Boolean).join(" - "),
user: mapCrmCalendarUserToUpstreamUser(item),
alla: {
activityId: item.activityId,
editable,
milestone: item.milestone,
sourceEntityType: item.entityType,
sourceEntityId: item.entityId,
customerId: item.customerId,
leadId: item.leadId,
opportunityId: item.opportunityId,
quotationId: item.quotationId,
approvalId: item.approvalId,
overdue: item.overdue,
hotProject: item.hotProject,
pricingSensitive: item.visibility.pricingSensitive,
internalOnly: item.visibility.internalOnly
}
};
}
export function mapCrmCalendarUserToUpstreamUser(item: CalendarProjectionItem): IUser {
const id = item.assigneeId ?? item.ownerId ?? "unassigned";
return {
id,
name: id === "unassigned" ? "Unassigned" : `User ${id.slice(0, 8)}`,
picturePath: null
};
}
export function mapUpstreamEventToActivityCommand(
event: IEvent
): UpstreamCalendarActivityCommand | null {
if (!event.alla?.editable || !event.alla.activityId) return null;
return {
type: "reschedule",
activityId: event.alla.activityId,
scheduledStartAt: event.startDate,
scheduledEndAt: event.endDate
};
}
function mapCalendarColor(item: CalendarProjectionItem): IEvent["color"] {
if (item.overdue) return "red";
if (item.hotProject) return "orange";
if (item.milestone) return "purple";
if (item.priority === "critical") return "red";
if (item.priority === "high") return "yellow";
if (item.category === "meeting") return "blue";
if (item.category === "visit" || item.category === "site_survey") return "green";
return "gray";
}

View File

@@ -1,56 +1,88 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Icons } from "@/components/icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Icons } from "@/components/icons";
import { cn } from "@/lib/utils";
import { formatDate, formatTime } from "@/lib/date-format";
import { calendarItemsQueryOptions } from "../api/queries";
import type {
BigCalendarEvent,
CalendarListFilters,
CalendarView,
} from "../api/types";
import { mapCalendarProjectionToBigCalendarEvent } from "./calendar-adapter";
import { activityKeys } from "@/features/crm/activities/api/queries";
import { rescheduleActivity } from "@/features/crm/activities/api/service";
import { myDayKeys } from "@/features/crm/my-day/api/queries";
import { calendarItemsQueryOptions, calendarKeys } from "../api/queries";
import type { CalendarListFilters, CalendarSummary } from "../api/types";
import { ClientContainer } from "../upstream-big-calendar/components/client-container";
import { CalendarProvider } from "../upstream-big-calendar/contexts/calendar-context";
import type { IEvent, IUser } from "../upstream-big-calendar/interfaces";
import {
mapCalendarProjectionToUpstreamEvent,
mapUpstreamEventToActivityCommand
} 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 queryClient = useQueryClient();
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);
const events = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
return data.items
.map(mapCalendarProjectionToUpstreamEvent)
.filter((event) => {
if (!normalizedSearch) return true;
return [
event.title,
event.description,
event.user.name,
event.alla?.sourceEntityType
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedSearch));
});
}, [data.items, search]);
const users = useMemo(() => buildCalendarUsers(events), [events]);
const rescheduleMutation = useMutation({
mutationFn: (event: IEvent) => {
const command = mapUpstreamEventToActivityCommand(event);
if (!command) throw new Error("This calendar item cannot be rescheduled.");
return rescheduleActivity(command.activityId, {
scheduledStartAt: command.scheduledStartAt,
scheduledEndAt: command.scheduledEndAt,
dueAt: command.scheduledStartAt
});
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: calendarKeys.lists() }),
queryClient.invalidateQueries({ queryKey: activityKeys.lists() }),
queryClient.invalidateQueries({ queryKey: myDayKeys.workspaces() })
]);
toast.success("Calendar updated");
},
onError: (error) => {
queryClient.invalidateQueries({ queryKey: calendarKeys.lists() });
toast.error(error instanceof Error ? error.message : "Unable to update calendar");
}
});
return (
<div className="space-y-6">
<CalendarSummaryCards summary={data.summary} />
<Card>
<Card className="overflow-hidden">
<CardHeader className="gap-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
@@ -59,8 +91,7 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
Calendar Workspace
</CardTitle>
<p className="text-muted-foreground text-sm">
Projection workspace for scheduled activities and read-only
business milestones.
Upstream Big Calendar UI powered by ALLA Calendar Projection.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
@@ -73,50 +104,23 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
className="w-full pl-8 sm:w-[240px]"
/>
</div>
<Badge variant="outline">
Lens: {filters.lens ?? "personal"}
</Badge>
<Badge variant="outline">Lens: {filters.lens ?? "personal"}</Badge>
{rescheduleMutation.isPending ? (
<Badge variant="secondary">Reconciling projection...</Badge>
) : null}
</div>
</div>
</CardHeader>
<CardContent>
<Tabs
value={view}
onValueChange={(value) => setView(value as CalendarView)}
<CalendarProvider
events={events}
users={users}
initialDate={filters.startFrom}
initialView="agenda"
onEventChange={(event) => rescheduleMutation.mutate(event)}
>
<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}
view="day"
anchorDate={filters.startFrom}
/>
</TabsContent>
<TabsContent value="week">
<CompactCalendarGrid
events={events}
view="week"
anchorDate={filters.startFrom}
/>
</TabsContent>
<TabsContent value="month">
<CompactCalendarGrid
events={events}
view="month"
anchorDate={filters.startFrom}
/>
</TabsContent>
</Tabs>
<ClientContainer />
</CalendarProvider>
</CardContent>
</Card>
</div>
@@ -131,21 +135,17 @@ export function CalendarWorkspaceSkeleton() {
<Skeleton key={index} className="h-24 rounded-xl" />
))}
</div>
<Skeleton className="h-[520px] rounded-xl" />
<Skeleton className="h-[620px] rounded-xl" />
</div>
);
}
function CalendarSummaryCards({
summary,
}: {
summary: CalendarWorkspaceSummary;
}) {
function CalendarSummaryCards({ summary }: { summary: CalendarSummary }) {
const cards = [
{ label: "Due Today", value: summary.dueToday, icon: Icons.calendar },
{ label: "Overdue", value: summary.overdue, icon: Icons.warning },
{ label: "Meetings", value: summary.meetings, icon: Icons.clock },
{ label: "Hot Projects", value: summary.hotProjects, icon: Icons.check },
{ label: "Hot Projects", value: summary.hotProjects, icon: Icons.check }
];
return (
@@ -167,285 +167,12 @@ function CalendarSummaryCards({
);
}
function AgendaView({
groupedEvents,
}: {
groupedEvents: Array<[string, BigCalendarEvent[]]>;
}) {
if (!groupedEvents.length) {
return <CalendarEmptyState />;
}
function buildCalendarUsers(events: IEvent[]): IUser[] {
const users = new Map<string, IUser>();
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,
view,
anchorDate,
}: {
events: BigCalendarEvent[];
view: "day" | "week" | "month";
anchorDate: string;
}) {
const cells = buildCalendarGridCells({ events, view, anchorDate });
const isMonth = view === "month";
return (
<div className="relative">
<div
className={cn(
"grid gap-3",
view === "day" && "grid-cols-1",
view !== "day" && "grid-cols-1 md:grid-cols-7",
)}
>
{cells.map((cell) => (
<button
key={cell.id}
type="button"
aria-label={`Select calendar slot ${cell.label}`}
className={cn(
"border-border/70 bg-muted/20 hover:bg-muted/40 min-h-28 rounded-lg border p-2 text-left transition-colors",
isMonth && "min-h-36",
cell.isToday && "ring-primary/30 ring-2",
)}
>
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">{cell.label}</p>
{cell.subLabel ? (
<span className="text-muted-foreground text-[11px]">
{cell.subLabel}
</span>
) : null}
</div>
<div className="mt-2 space-y-2">
{cell.events.map((event) => (
<CalendarEventPill key={event.id} event={event} />
))}
</div>
</button>
))}
</div>
{!events.length ? <CalendarEmptyOverlay view={view} /> : null}
</div>
);
}
interface CalendarGridCell {
id: string;
label: string;
subLabel: string | null;
isToday: boolean;
events: BigCalendarEvent[];
}
function buildCalendarGridCells({
events,
view,
anchorDate,
}: {
events: BigCalendarEvent[];
view: "day" | "week" | "month";
anchorDate: string;
}): CalendarGridCell[] {
if (view === "day") {
const day = startOfUtcDay(anchorDate);
return Array.from({ length: 12 }, (_, index) => {
const slot = new Date(day);
slot.setUTCHours(8 + index, 0, 0, 0);
return {
id: `day-${slot.toISOString()}`,
label: formatTime(slot.toISOString()),
subLabel: index === 0 ? formatDate(slot.toISOString()) : null,
isToday: isSameUtcDate(slot.toISOString(), new Date().toISOString()),
events: events.filter((event) => isSameHour(event.start, slot.toISOString())),
};
});
}
const start = view === "week" ? startOfUtcDay(anchorDate) : startOfMonthGrid(anchorDate);
const length = view === "week" ? 7 : 42;
return Array.from({ length }, (_, index) => {
const date = new Date(start);
date.setUTCDate(start.getUTCDate() + index);
const iso = date.toISOString();
return {
id: `${view}-${iso}`,
label: formatDate(iso),
subLabel: view === "month" ? null : "Working day",
isToday: isSameUtcDate(iso, new Date().toISOString()),
events: events.filter((event) => isSameUtcDate(event.start, iso)),
};
});
}
function CalendarEmptyOverlay({ view }: { view: "day" | "week" | "month" }) {
return (
<div className="pointer-events-none absolute inset-x-4 top-1/2 -translate-y-1/2">
<div className="border-border/70 bg-background/95 mx-auto max-w-md rounded-xl border border-dashed p-4 text-center shadow-sm backdrop-blur">
<Icons.calendar className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
<p className="font-medium">No scheduled work. This {view} is free.</p>
<p className="text-muted-foreground mt-1 text-sm">
The calendar grid is still available for selecting dates and planning Activities.
</p>
<Button asChild className="pointer-events-auto mt-3" size="sm">
<Link href="/dashboard/crm/activities">
<Icons.add className="mr-1 h-4 w-4" />
Add Activity
</Link>
</Button>
</div>
</div>
);
}
function startOfUtcDay(value: string): Date {
const date = new Date(value);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function startOfMonthGrid(value: string): Date {
const firstOfMonth = startOfUtcDay(value);
firstOfMonth.setUTCDate(1);
const gridStart = new Date(firstOfMonth);
gridStart.setUTCDate(firstOfMonth.getUTCDate() - firstOfMonth.getUTCDay());
return gridStart;
}
function isSameUtcDate(left: string, right: string): boolean {
return left.slice(0, 10) === right.slice(0, 10);
}
function isSameHour(left: string, right: string): boolean {
const leftDate = new Date(left);
const rightDate = new Date(right);
return (
isSameUtcDate(leftDate.toISOString(), rightDate.toISOString()) &&
leftDate.getUTCHours() === rightDate.getUTCHours()
);
}
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]);
users.set(event.user.id, event.user);
}
return [...grouped.entries()];
}
type CalendarWorkspaceSummary = {
dueToday: number;
overdue: number;
meetings: number;
visits: number;
milestones: number;
approvalDue: number;
hotProjects: number;
};
return [...users.values()].toSorted((left, right) => left.name.localeCompare(right.name));
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Leonardo Ramos
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,66 @@
# Upstream Big Calendar Import
Source repository: `https://github.com/lramos33/big-calendar`
Pinned commit: `fbb8485e3d54b267261056c784632229ba6f7bc3`
Imported date: 2026-07-13
License: MIT, preserved in `LICENSE`.
## Copied Source
Copied from upstream `src/calendar/**` into:
```text
src/features/crm/calendar/upstream-big-calendar/**
```
The imported source preserves upstream component families:
- `components/client-container.tsx`
- `components/header/**`
- `components/month-view/**`
- `components/week-and-day-view/**`
- `components/agenda-view/**`
- `components/year-view/**`
- `components/dnd/**`
- `contexts/calendar-context.tsx`
- `helpers.ts`
- `interfaces.ts`
- `types.ts`
## Removed Demo-Only Files
These upstream demo files were intentionally not retained in the active ALLA OS source:
- `mocks.ts`
- `requests.ts`
- `schemas.ts`
- `components/change-visible-hours-input.tsx`
- `components/change-working-hours-input.tsx`
Reason: ALLA OS owns data through Calendar Projection and Activity APIs. Upstream mock stores and form schemas would duplicate or bypass app-owned business logic.
## Local Patch Notes
- Rewrote imports from `@/calendar/*` to `@/features/crm/calendar/upstream-big-calendar/*`.
- Mapped upstream `lucide-react` icons to the project icon registry through `icons.tsx`.
- Added small compatibility shims for `SingleCalendar` and `AvatarGroup`.
- Replaced upstream add/edit demo dialogs with ALLA integration shells that do not use React Hook Form.
- Extended upstream `IEvent` with `alla` metadata for Activity/milestone permissions and source references.
- Added provider props for `initialDate`, `initialView`, and `onEventChange`.
- Kept upstream local optimistic event move behavior and forwards changed events to ALLA Activity reschedule integration.
- Adapted the upstream header view buttons to change in-page calendar view instead of navigating to upstream demo routes.
## Dependencies Added
- `react-dnd@16.0.1`
- `react-dnd-html5-backend@16.0.1`
These are required for upstream drag/drop behavior.
## Sync Procedure
1. Compare future upstream changes against commit `fbb8485e3d54b267261056c784632229ba6f7bc3`.
2. Copy rendering and DnD changes into this isolated directory only.
3. Keep ALLA adapter boundaries in `src/features/crm/calendar/components/calendar-adapter.ts`.
4. Do not import upstream mocks, app shell, demo backend, or React Hook Form dialogs.
5. Re-run `npm run typecheck` and `npx oxlint src/features/crm/calendar`.

View File

@@ -0,0 +1,38 @@
import { differenceInDays, format, parseISO, startOfDay } from "date-fns";
import { AgendaEventCard } from "@/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-event-card";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
date: Date;
events: IEvent[];
multiDayEvents: IEvent[];
}
export function AgendaDayGroup({ date, events, multiDayEvents }: IProps) {
const sortedEvents = [...events].toSorted((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
return (
<div className="space-y-4">
<div className="sticky top-0 flex items-center gap-4 bg-background py-2">
<p className="text-sm font-semibold">{format(date, "EEEE, MMMM d, yyyy")}</p>
</div>
<div className="space-y-2">
{multiDayEvents.length > 0 &&
multiDayEvents.map(event => {
const eventStart = startOfDay(parseISO(event.startDate));
const eventEnd = startOfDay(parseISO(event.endDate));
const currentDate = startOfDay(date);
const eventTotalDays = differenceInDays(eventEnd, eventStart) + 1;
const eventCurrentDay = differenceInDays(currentDate, eventStart) + 1;
return <AgendaEventCard key={event.id} event={event} eventCurrentDay={eventCurrentDay} eventTotalDays={eventTotalDays} />;
})}
{sortedEvents.length > 0 && sortedEvents.map(event => <AgendaEventCard key={event.id} event={event} />)}
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
/* oxlint-disable unicorn/consistent-function-scoping */
"use client";
import { format, parseISO } from "date-fns";
import { cva } from "class-variance-authority";
import { Clock, Text, User } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { EventDetailsDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/event-details-dialog";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { VariantProps } from "class-variance-authority";
const agendaEventCardVariants = cva(
"flex select-none items-center justify-between gap-3 rounded-md border p-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
{
variants: {
color: {
// Colored variants
blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300 [&_.event-dot]:fill-blue-600",
green: "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300 [&_.event-dot]:fill-green-600",
red: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300 [&_.event-dot]:fill-red-600",
yellow: "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300 [&_.event-dot]:fill-yellow-600",
purple: "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300 [&_.event-dot]:fill-purple-600",
orange: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300 [&_.event-dot]:fill-orange-600",
gray: "border-neutral-200 bg-neutral-50 text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 [&_.event-dot]:fill-neutral-600",
// Dot variants
"blue-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-blue-600",
"green-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-green-600",
"red-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-red-600",
"orange-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-orange-600",
"purple-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-purple-600",
"yellow-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-yellow-600",
"gray-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-neutral-600",
},
},
defaultVariants: {
color: "blue-dot",
},
}
);
interface IProps {
event: IEvent;
eventCurrentDay?: number;
eventTotalDays?: number;
}
export function AgendaEventCard({ event, eventCurrentDay, eventTotalDays }: IProps) {
const { badgeVariant } = useCalendar();
const startDate = parseISO(event.startDate);
const endDate = parseISO(event.endDate);
const color = (badgeVariant === "dot" ? `${event.color}-dot` : event.color) as VariantProps<typeof agendaEventCardVariants>["color"];
const agendaEventCardClasses = agendaEventCardVariants({ color });
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (e.currentTarget instanceof HTMLElement) e.currentTarget.click();
}
};
return (
<EventDetailsDialog event={event}>
<div
role="button"
tabIndex={0}
aria-label={`Open calendar event ${event.title}`}
className={agendaEventCardClasses}
onKeyDown={handleKeyDown}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
{["mixed", "dot"].includes(badgeVariant) && (
<svg width="8" height="8" viewBox="0 0 8 8" className="event-dot shrink-0">
<circle cx="4" cy="4" r="4" />
</svg>
)}
<p className="font-medium">
{eventCurrentDay && eventTotalDays && (
<span className="mr-1 text-xs">
Day {eventCurrentDay} of {eventTotalDays} {" "}
</span>
)}
{event.title}
</p>
</div>
<div className="mt-1 flex items-center gap-1">
<User className="size-3 shrink-0" />
<p className="text-xs text-foreground">{event.user.name}</p>
</div>
<div className="flex items-center gap-1">
<Clock className="size-3 shrink-0" />
<p className="text-xs text-foreground">
{format(startDate, "h:mm a")} - {format(endDate, "h:mm a")}
</p>
</div>
<div className="flex items-center gap-1">
<Text className="size-3 shrink-0" />
<p className="text-xs text-foreground">{event.description}</p>
</div>
</div>
</div>
</EventDetailsDialog>
);
}

View File

@@ -0,0 +1,80 @@
import { useMemo } from "react";
import { CalendarX2 } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { parseISO, format, endOfDay, startOfDay, isSameMonth } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AgendaDayGroup } from "@/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-day-group";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
singleDayEvents: IEvent[];
multiDayEvents: IEvent[];
}
export function CalendarAgendaView({ singleDayEvents, multiDayEvents }: IProps) {
const { selectedDate } = useCalendar();
const eventsByDay = useMemo(() => {
const allDates = new Map<string, { date: Date; events: IEvent[]; multiDayEvents: IEvent[] }>();
singleDayEvents.forEach(event => {
const eventDate = parseISO(event.startDate);
if (!isSameMonth(eventDate, selectedDate)) return;
const dateKey = format(eventDate, "yyyy-MM-dd");
if (!allDates.has(dateKey)) {
allDates.set(dateKey, { date: startOfDay(eventDate), events: [], multiDayEvents: [] });
}
allDates.get(dateKey)?.events.push(event);
});
multiDayEvents.forEach(event => {
const eventStart = parseISO(event.startDate);
const eventEnd = parseISO(event.endDate);
let currentDate = startOfDay(eventStart);
const lastDate = endOfDay(eventEnd);
while (currentDate <= lastDate) {
if (isSameMonth(currentDate, selectedDate)) {
const dateKey = format(currentDate, "yyyy-MM-dd");
if (!allDates.has(dateKey)) {
allDates.set(dateKey, { date: new Date(currentDate), events: [], multiDayEvents: [] });
}
allDates.get(dateKey)?.multiDayEvents.push(event);
}
currentDate = new Date(currentDate.setDate(currentDate.getDate() + 1));
}
});
return Array.from(allDates.values()).toSorted((a, b) => a.date.getTime() - b.date.getTime());
}, [singleDayEvents, multiDayEvents, selectedDate]);
const hasAnyEvents = singleDayEvents.length > 0 || multiDayEvents.length > 0;
return (
<div className="h-[800px]">
<ScrollArea className="h-full" type="always">
<div className="space-y-6 p-4">
{eventsByDay.map(dayGroup => (
<AgendaDayGroup key={format(dayGroup.date, "yyyy-MM-dd")} date={dayGroup.date} events={dayGroup.events} multiDayEvents={dayGroup.multiDayEvents} />
))}
{!hasAnyEvents && (
<div className="flex flex-col items-center justify-center gap-2 py-20 text-muted-foreground">
<CalendarX2 className="size-10" />
<p className="text-sm md:text-base">No events scheduled for the selected month</p>
</div>
)}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function AvatarGroup({
children,
className
}: {
children: ReactNode;
max?: number;
className?: string;
}) {
return <div className={cn("flex -space-x-2", className)}>{children}</div>;
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function ChangeBadgeVariantInput() {
const { badgeVariant, setBadgeVariant } = useCalendar();
return (
<div className="space-y-2">
<p className="text-sm font-semibold">Change badge variant</p>
<Select value={badgeVariant} onValueChange={setBadgeVariant}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dot">Dot</SelectItem>
<SelectItem value="colored">Colored</SelectItem>
<SelectItem value="mixed">Mixed</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useMemo } from "react";
import { isSameDay, parseISO } from "date-fns";
import { CalendarAgendaView } from "@/features/crm/calendar/upstream-big-calendar/components/agenda-view/calendar-agenda-view";
import { DndProviderWrapper } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/dnd-provider";
import { CalendarHeader } from "@/features/crm/calendar/upstream-big-calendar/components/header/calendar-header";
import { CalendarMonthView } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/calendar-month-view";
import { CalendarDayView } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-day-view";
import { CalendarWeekView } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-week-view";
import { CalendarYearView } from "@/features/crm/calendar/upstream-big-calendar/components/year-view/calendar-year-view";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
export function ClientContainer() {
const { selectedDate, selectedUserId, selectedView, events } = useCalendar();
const filteredEvents = useMemo(() => {
return events.filter((event) => {
const eventStartDate = parseISO(event.startDate);
const eventEndDate = parseISO(event.endDate);
const isUserMatch = selectedUserId === "all" || event.user.id === selectedUserId;
if (selectedView === "year") {
const yearStart = new Date(selectedDate.getFullYear(), 0, 1);
const yearEnd = new Date(selectedDate.getFullYear(), 11, 31, 23, 59, 59, 999);
return eventStartDate <= yearEnd && eventEndDate >= yearStart && isUserMatch;
}
if (selectedView === "month" || selectedView === "agenda") {
const monthStart = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
const monthEnd = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth() + 1,
0,
23,
59,
59,
999
);
return eventStartDate <= monthEnd && eventEndDate >= monthStart && isUserMatch;
}
if (selectedView === "week") {
const weekStart = new Date(selectedDate);
weekStart.setDate(selectedDate.getDate() - selectedDate.getDay());
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
return eventStartDate <= weekEnd && eventEndDate >= weekStart && isUserMatch;
}
return (
(isSameDay(eventStartDate, selectedDate) || isSameDay(eventEndDate, selectedDate)) &&
isUserMatch
);
});
}, [events, selectedDate, selectedUserId, selectedView]);
const singleDayEvents = filteredEvents.filter((event) => {
const startDate = parseISO(event.startDate);
const endDate = parseISO(event.endDate);
return isSameDay(startDate, endDate);
});
const multiDayEvents = filteredEvents.filter((event) => {
const startDate = parseISO(event.startDate);
const endDate = parseISO(event.endDate);
return !isSameDay(startDate, endDate);
});
const eventStartDates = useMemo(() => {
return filteredEvents.map((event) => ({
...event,
endDate: event.startDate
}));
}, [filteredEvents]);
return (
<div className="overflow-hidden rounded-xl border">
<CalendarHeader view={selectedView} events={filteredEvents} />
<DndProviderWrapper>
{selectedView === "day" && (
<CalendarDayView singleDayEvents={singleDayEvents} multiDayEvents={multiDayEvents} />
)}
{selectedView === "month" && (
<CalendarMonthView singleDayEvents={singleDayEvents} multiDayEvents={multiDayEvents} />
)}
{selectedView === "week" && (
<CalendarWeekView singleDayEvents={singleDayEvents} multiDayEvents={multiDayEvents} />
)}
{selectedView === "year" && <CalendarYearView allEvents={eventStartDates} />}
{selectedView === "agenda" && (
<CalendarAgendaView singleDayEvents={singleDayEvents} multiDayEvents={multiDayEvents} />
)}
</DndProviderWrapper>
</div>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
interface IProps {
children: ReactNode;
startDate?: Date;
startTime?: { hour: number; minute: number };
}
export function AddEventDialog({ children }: IProps) {
return (
<Link href="/dashboard/crm/activities" aria-label="Create related Activity">
{children}
</Link>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
event: IEvent;
children: ReactNode;
}
export function EditEventDialog({ event, children }: IProps) {
return (
<Link href={`/dashboard/crm/activities?calendarEventId=${event.id}`}>
{children}
</Link>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { format, parseISO } from "date-fns";
import { Calendar, Clock, Text, User } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { Button } from "@/components/ui/button";
import { EditEventDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/edit-event-dialog";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
event: IEvent;
children: React.ReactNode;
}
export function EventDetailsDialog({ event, children }: IProps) {
const startDate = parseISO(event.startDate);
const endDate = parseISO(event.endDate);
return (
<>
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{event.title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-start gap-2">
<User className="mt-1 size-4 shrink-0" />
<div>
<p className="text-sm font-medium">Responsible</p>
<p className="text-sm text-muted-foreground">{event.user.name}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="mt-1 size-4 shrink-0" />
<div>
<p className="text-sm font-medium">Start Date</p>
<p className="text-sm text-muted-foreground">{format(startDate, "MMM d, yyyy h:mm a")}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Clock className="mt-1 size-4 shrink-0" />
<div>
<p className="text-sm font-medium">End Date</p>
<p className="text-sm text-muted-foreground">{format(endDate, "MMM d, yyyy h:mm a")}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Text className="mt-1 size-4 shrink-0" />
<div>
<p className="text-sm font-medium">Description</p>
<p className="text-sm text-muted-foreground">{event.description}</p>
</div>
</div>
</div>
<DialogFooter>
<EditEventDialog event={event}>
<Button type="button" variant="outline">
Edit
</Button>
</EditEventDialog>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useDragLayer } from "react-dnd";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IDragItem {
event: IEvent;
children: React.ReactNode;
width: number;
height: number;
}
export function CustomDragLayer() {
const { isDragging, item, currentOffset, initialOffset, initialClientOffset } = useDragLayer(monitor => ({
item: monitor.getItem() as IDragItem | null,
itemType: monitor.getItemType(),
isDragging: monitor.isDragging(),
currentOffset: monitor.getClientOffset(),
initialOffset: monitor.getInitialSourceClientOffset(),
initialClientOffset: monitor.getInitialClientOffset(),
}));
if (!isDragging || !item || !currentOffset || !initialOffset || !initialClientOffset) {
return null;
}
const offsetX = initialClientOffset.x - initialOffset.x;
const offsetY = initialClientOffset.y - initialOffset.y;
const layerStyles: React.CSSProperties = {
position: "fixed",
pointerEvents: "none",
zIndex: 100,
left: currentOffset.x - offsetX,
top: currentOffset.y - offsetY,
};
return (
<div style={layerStyles}>
<div
className=""
style={{
width: item.width,
height: item.height,
}}
>
{item.children}
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { CustomDragLayer } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/custom-drag-layer";
interface DndProviderWrapperProps {
children: React.ReactNode;
}
export function DndProviderWrapper({ children }: DndProviderWrapperProps) {
return (
<DndProvider backend={HTML5Backend}>
{children}
<CustomDragLayer />
</DndProvider>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useDrag } from "react-dnd";
import { useRef, useEffect } from "react";
import { getEmptyImage } from "react-dnd-html5-backend";
import { cn } from "@/lib/utils";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
export const ItemTypes = {
EVENT: "event",
};
interface DraggableEventProps {
event: IEvent;
children: React.ReactNode;
}
export function DraggableEvent({ event, children }: DraggableEventProps) {
const ref = useRef<HTMLDivElement>(null);
const canDrag = event.alla?.editable !== false;
const [{ isDragging }, drag, preview] = useDrag(() => ({
type: ItemTypes.EVENT,
canDrag: () => canDrag,
item: () => {
const width = ref.current?.offsetWidth || 0;
const height = ref.current?.offsetHeight || 0;
return { event, children, width, height };
},
collect: monitor => ({ isDragging: monitor.isDragging() }),
}), [canDrag, children, event]);
// Hide the default drag preview
useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true });
}, [preview]);
if (canDrag) drag(ref);
return (
<div ref={ref} className={cn(isDragging && "opacity-40")}>
{children}
</div>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { useDrop } from "react-dnd";
import { parseISO, differenceInMilliseconds } from "date-fns";
import { useUpdateEvent } from "@/features/crm/calendar/upstream-big-calendar/hooks/use-update-event";
import { cn } from "@/lib/utils";
import { ItemTypes } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event";
import type { IEvent, ICalendarCell } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface DroppableDayCellProps {
cell: ICalendarCell;
children: React.ReactNode;
}
export function DroppableDayCell({ cell, children }: DroppableDayCellProps) {
const { updateEvent } = useUpdateEvent();
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: ItemTypes.EVENT,
drop: (item: { event: IEvent }) => {
const droppedEvent = item.event;
const eventStartDate = parseISO(droppedEvent.startDate);
const eventEndDate = parseISO(droppedEvent.endDate);
const eventDurationMs = differenceInMilliseconds(eventEndDate, eventStartDate);
const newStartDate = new Date(cell.date);
newStartDate.setHours(eventStartDate.getHours(), eventStartDate.getMinutes(), eventStartDate.getSeconds(), eventStartDate.getMilliseconds());
const newEndDate = new Date(newStartDate.getTime() + eventDurationMs);
updateEvent({
...droppedEvent,
startDate: newStartDate.toISOString(),
endDate: newEndDate.toISOString(),
});
return { moved: true };
},
collect: monitor => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[cell.date, updateEvent]
);
return (
<div ref={drop as unknown as React.RefObject<HTMLDivElement>} className={cn(isOver && canDrop && "bg-accent/50")}>
{children}
</div>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { useDrop } from "react-dnd";
import { parseISO, differenceInMilliseconds } from "date-fns";
import { useUpdateEvent } from "@/features/crm/calendar/upstream-big-calendar/hooks/use-update-event";
import { cn } from "@/lib/utils";
import { ItemTypes } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface DroppableTimeBlockProps {
date: Date;
hour: number;
minute: number;
children: React.ReactNode;
}
export function DroppableTimeBlock({ date, hour, minute, children }: DroppableTimeBlockProps) {
const { updateEvent } = useUpdateEvent();
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: ItemTypes.EVENT,
canDrop: (item: { event: IEvent }) => item.event.alla?.editable !== false,
drop: (item: { event: IEvent }) => {
const droppedEvent = item.event;
const eventStartDate = parseISO(droppedEvent.startDate);
const eventEndDate = parseISO(droppedEvent.endDate);
const eventDurationMs = differenceInMilliseconds(eventEndDate, eventStartDate);
const newStartDate = new Date(date);
newStartDate.setHours(hour, minute, 0, 0);
const newEndDate = new Date(newStartDate.getTime() + eventDurationMs);
updateEvent({
...droppedEvent,
startDate: newStartDate.toISOString(),
endDate: newEndDate.toISOString(),
});
return { moved: true };
},
collect: monitor => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[date, hour, minute, updateEvent]
);
return (
<div ref={drop as unknown as React.RefObject<HTMLDivElement>} className={cn("h-[24px]", isOver && canDrop && "bg-accent/50")}>
{children}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { CalendarRange, Columns, Grid2x2, Grid3x3, List, Plus } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { Button } from "@/components/ui/button";
import { UserSelect } from "@/features/crm/calendar/upstream-big-calendar/components/header/user-select";
import { TodayButton } from "@/features/crm/calendar/upstream-big-calendar/components/header/today-button";
import { DateNavigator } from "@/features/crm/calendar/upstream-big-calendar/components/header/date-navigator";
import { AddEventDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/add-event-dialog";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { TCalendarView } from "@/features/crm/calendar/upstream-big-calendar/types";
interface IProps {
view: TCalendarView;
events: IEvent[];
}
const VIEW_BUTTONS: Array<{
view: TCalendarView;
label: string;
icon: typeof List;
}> = [
{ view: "day", label: "View by day", icon: List },
{ view: "week", label: "View by week", icon: Columns },
{ view: "month", label: "View by month", icon: Grid3x3 },
{ view: "year", label: "View by year", icon: Grid2x2 },
{ view: "agenda", label: "View by agenda", icon: CalendarRange }
];
export function CalendarHeader({ view, events }: IProps) {
const { setSelectedView } = useCalendar();
return (
<div className="flex flex-col gap-4 border-b p-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<TodayButton />
<DateNavigator view={view} events={events} />
</div>
<div className="flex flex-col items-center gap-1.5 sm:flex-row sm:justify-between">
<div className="flex w-full items-center gap-1.5">
<div className="inline-flex first:rounded-r-none last:rounded-l-none [&:not(:first-child):not(:last-child)]:rounded-none">
{VIEW_BUTTONS.map((item, index) => {
const Icon = item.icon;
return (
<Button
key={item.view}
aria-label={item.label}
size="icon"
type="button"
variant={view === item.view ? "default" : "outline"}
className={[
"[&_svg]:size-5",
index === 0 ? "rounded-r-none" : "",
index === VIEW_BUTTONS.length - 1 ? "rounded-l-none" : "",
index > 0 && index < VIEW_BUTTONS.length - 1 ? "rounded-none" : "",
index > 0 ? "border-l-0" : ""
].join(" ")}
onClick={() => setSelectedView(item.view)}
>
<Icon strokeWidth={1.8} />
</Button>
);
})}
</div>
<UserSelect />
</div>
<AddEventDialog>
<Button className="w-full sm:w-auto" type="button">
<Plus className="size-4" />
Add Activity
</Button>
</AddEventDialog>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useMemo } from "react";
import { formatDate } from "date-fns";
import { ChevronLeft, ChevronRight } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { getEventsCount, navigateDate, rangeText } from "@/features/crm/calendar/upstream-big-calendar/helpers";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { TCalendarView } from "@/features/crm/calendar/upstream-big-calendar/types";
interface IProps {
view: TCalendarView;
events: IEvent[];
}
export function DateNavigator({ view, events }: IProps) {
const { selectedDate, setSelectedDate } = useCalendar();
const month = formatDate(selectedDate, "MMMM");
const year = selectedDate.getFullYear();
const eventCount = useMemo(() => getEventsCount(events, selectedDate, view), [events, selectedDate, view]);
const handlePrevious = () => setSelectedDate(navigateDate(selectedDate, view, "previous"));
const handleNext = () => setSelectedDate(navigateDate(selectedDate, view, "next"));
return (
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">
{month} {year}
</span>
<Badge variant="outline" className="px-1.5">
{eventCount} events
</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="size-6.5 px-0 [&_svg]:size-4.5" onClick={handlePrevious}>
<ChevronLeft />
</Button>
<p className="text-sm text-muted-foreground">{rangeText(view, selectedDate)}</p>
<Button variant="outline" className="size-6.5 px-0 [&_svg]:size-4.5" onClick={handleNext}>
<ChevronRight />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { formatDate } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
export function TodayButton() {
const { setSelectedDate } = useCalendar();
const today = new Date();
const handleClick = () => setSelectedDate(today);
return (
<button
className="flex size-14 flex-col items-start overflow-hidden rounded-lg border focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onClick={handleClick}
>
<p className="flex h-6 w-full items-center justify-center bg-primary text-center text-xs font-semibold text-primary-foreground">
{formatDate(today, "MMM").toUpperCase()}
</p>
<p className="flex w-full items-center justify-center text-lg font-bold">{today.getDate()}</p>
</button>
);
}

View File

@@ -0,0 +1,46 @@
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { AvatarGroup } from "@/features/crm/calendar/upstream-big-calendar/components/avatar-group";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function UserSelect() {
const { users, selectedUserId, setSelectedUserId } = useCalendar();
return (
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger className="flex-1 md:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">
<div className="flex items-center gap-1">
<AvatarGroup max={2}>
{users.map(user => (
<Avatar key={user.id} className="size-6 text-xxs">
<AvatarImage src={user.picturePath ?? undefined} alt={user.name} />
<AvatarFallback className="text-xxs">{user.name[0]}</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
All
</div>
</SelectItem>
{users.map(user => (
<SelectItem key={user.id} value={user.id} className="flex-1">
<div className="flex items-center gap-2">
<Avatar key={user.id} className="size-6">
<AvatarImage src={user.picturePath ?? undefined} alt={user.name} />
<AvatarFallback className="text-xxs">{user.name[0]}</AvatarFallback>
</Avatar>
<p className="truncate">{user.name}</p>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,47 @@
import { useMemo } from "react";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { DayCell } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/day-cell";
import { getCalendarCells, calculateMonthEventPositions } from "@/features/crm/calendar/upstream-big-calendar/helpers";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
singleDayEvents: IEvent[];
multiDayEvents: IEvent[];
}
const WEEK_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export function CalendarMonthView({ singleDayEvents, multiDayEvents }: IProps) {
const { selectedDate } = useCalendar();
const allEvents = [...multiDayEvents, ...singleDayEvents];
const cells = useMemo(() => getCalendarCells(selectedDate), [selectedDate]);
const eventPositions = useMemo(
() => calculateMonthEventPositions(multiDayEvents, singleDayEvents, selectedDate),
[multiDayEvents, singleDayEvents, selectedDate]
);
return (
<div>
<div className="grid grid-cols-7 divide-x">
{WEEK_DAYS.map(day => (
<div key={day} className="flex items-center justify-center py-2">
<span className="text-xs font-medium text-muted-foreground">{day}</span>
</div>
))}
</div>
<div className="grid grid-cols-7 overflow-hidden">
{cells.map(cell => (
<DayCell key={cell.date.toISOString()} cell={cell} events={allEvents} eventPositions={eventPositions} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { isToday, startOfDay } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { EventBullet } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/event-bullet";
import { DroppableDayCell } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-day-cell";
import { MonthEventBadge } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/month-event-badge";
import { cn } from "@/lib/utils";
import { getMonthCellEvents } from "@/features/crm/calendar/upstream-big-calendar/helpers";
import type { ICalendarCell, IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
cell: ICalendarCell;
events: IEvent[];
eventPositions: Record<string, number>;
}
const MAX_VISIBLE_EVENTS = 3;
export function DayCell({ cell, events, eventPositions }: IProps) {
const { push } = useRouter();
const { setSelectedDate } = useCalendar();
const { day, currentMonth, date } = cell;
const cellEvents = useMemo(() => getMonthCellEvents(date, events, eventPositions), [date, events, eventPositions]);
const isSunday = date.getDay() === 0;
const handleClick = () => {
setSelectedDate(date);
push("/day-view");
};
return (
<DroppableDayCell cell={cell}>
<div className={cn("flex h-full flex-col gap-1 border-l border-t py-1.5 lg:pb-2 lg:pt-1", isSunday && "border-l-0")}>
<button
onClick={handleClick}
className={cn(
"flex size-6 translate-x-1 items-center justify-center rounded-full text-xs font-semibold hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring lg:px-2",
!currentMonth && "opacity-20",
isToday(date) && "bg-primary font-bold text-primary-foreground hover:bg-primary"
)}
>
{day}
</button>
<div className={cn("flex h-6 gap-1 px-2 lg:h-[94px] lg:flex-col lg:gap-2 lg:px-0", !currentMonth && "opacity-50")}>
{[0, 1, 2].map(position => {
const event = cellEvents.find(e => e.position === position);
const eventKey = event ? `event-${event.id}-${position}` : `empty-${position}`;
return (
<div key={eventKey} className="lg:flex-1">
{event && (
<>
<EventBullet className="lg:hidden" color={event.color} />
<MonthEventBadge className="hidden lg:flex" event={event} cellDate={startOfDay(date)} />
</>
)}
</div>
);
})}
</div>
{cellEvents.length > MAX_VISIBLE_EVENTS && (
<p className={cn("h-4.5 px-1.5 text-xs font-semibold text-muted-foreground", !currentMonth && "opacity-50")}>
<span className="sm:hidden">+{cellEvents.length - MAX_VISIBLE_EVENTS}</span>
<span className="hidden sm:inline"> {cellEvents.length - MAX_VISIBLE_EVENTS} more...</span>
</p>
)}
</div>
</DroppableDayCell>
);
}

View File

@@ -0,0 +1,26 @@
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
import type { TEventColor } from "@/features/crm/calendar/upstream-big-calendar/types";
const eventBulletVariants = cva("size-2 rounded-full", {
variants: {
color: {
blue: "bg-blue-600 dark:bg-blue-500",
green: "bg-green-600 dark:bg-green-500",
red: "bg-red-600 dark:bg-red-500",
yellow: "bg-yellow-600 dark:bg-yellow-500",
purple: "bg-purple-600 dark:bg-purple-500",
gray: "bg-neutral-600 dark:bg-neutral-500",
orange: "bg-orange-600 dark:bg-orange-500",
},
},
defaultVariants: {
color: "blue",
},
});
export function EventBullet({ color, className }: { color: TEventColor; className: string }) {
return <div className={cn(eventBulletVariants({ color, className }))} />;
}

View File

@@ -0,0 +1,125 @@
/* oxlint-disable unicorn/consistent-function-scoping */
import { cva } from "class-variance-authority";
import { endOfDay, format, isSameDay, parseISO, startOfDay } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { DraggableEvent } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event";
import { EventDetailsDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/event-details-dialog";
import { cn } from "@/lib/utils";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { VariantProps } from "class-variance-authority";
const eventBadgeVariants = cva(
"mx-1 flex size-auto h-6.5 select-none items-center justify-between gap-1.5 truncate whitespace-nowrap rounded-md border px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
{
variants: {
color: {
// Colored and mixed variants
blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300 [&_.event-dot]:fill-blue-600",
green: "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300 [&_.event-dot]:fill-green-600",
red: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300 [&_.event-dot]:fill-red-600",
yellow: "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300 [&_.event-dot]:fill-yellow-600",
purple: "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300 [&_.event-dot]:fill-purple-600",
orange: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300 [&_.event-dot]:fill-orange-600",
gray: "border-neutral-200 bg-neutral-50 text-neutral-900 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 [&_.event-dot]:fill-neutral-600",
// Dot variants
"blue-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-blue-600",
"green-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-green-600",
"red-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-red-600",
"yellow-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-yellow-600",
"purple-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-purple-600",
"orange-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-orange-600",
"gray-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-neutral-600",
},
multiDayPosition: {
first: "relative z-10 mr-0 w-[calc(100%_-_3px)] rounded-r-none border-r-0 [&>span]:mr-2.5",
middle: "relative z-10 mx-0 w-[calc(100%_+_1px)] rounded-none border-x-0",
last: "ml-0 rounded-l-none border-l-0",
none: "",
},
},
defaultVariants: {
color: "blue-dot",
},
}
);
interface IProps extends Omit<VariantProps<typeof eventBadgeVariants>, "color" | "multiDayPosition"> {
event: IEvent;
cellDate: Date;
eventCurrentDay?: number;
eventTotalDays?: number;
className?: string;
position?: "first" | "middle" | "last" | "none";
}
export function MonthEventBadge({ event, cellDate, eventCurrentDay, eventTotalDays, className, position: propPosition }: IProps) {
const { badgeVariant } = useCalendar();
const itemStart = startOfDay(parseISO(event.startDate));
const itemEnd = endOfDay(parseISO(event.endDate));
if (cellDate < itemStart || cellDate > itemEnd) return null;
let position: "first" | "middle" | "last" | "none" | undefined;
if (propPosition) {
position = propPosition;
} else if (eventCurrentDay && eventTotalDays) {
position = "none";
} else if (isSameDay(itemStart, itemEnd)) {
position = "none";
} else if (isSameDay(cellDate, itemStart)) {
position = "first";
} else if (isSameDay(cellDate, itemEnd)) {
position = "last";
} else {
position = "middle";
}
const renderBadgeText = ["first", "none"].includes(position);
const color = (badgeVariant === "dot" ? `${event.color}-dot` : event.color) as VariantProps<typeof eventBadgeVariants>["color"];
const eventBadgeClasses = cn(eventBadgeVariants({ color, multiDayPosition: position, className }));
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (e.currentTarget instanceof HTMLElement) e.currentTarget.click();
}
};
return (
<DraggableEvent event={event}>
<EventDetailsDialog event={event}>
<div role="button" tabIndex={0} className={eventBadgeClasses} onKeyDown={handleKeyDown}>
<div className="flex items-center gap-1.5 truncate">
{!["middle", "last"].includes(position) && ["mixed", "dot"].includes(badgeVariant) && (
<svg width="8" height="8" viewBox="0 0 8 8" className="event-dot shrink-0">
<circle cx="4" cy="4" r="4" />
</svg>
)}
{renderBadgeText && (
<p className="flex-1 truncate font-semibold">
{eventCurrentDay && (
<span className="text-xs">
Day {eventCurrentDay} of {eventTotalDays} {" "}
</span>
)}
{event.title}
</p>
)}
</div>
{renderBadgeText && <span>{format(new Date(event.startDate), "h:mm a")}</span>}
</div>
</EventDetailsDialog>
</DraggableEvent>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { Calendar } from "@/components/ui/calendar";
export function SingleCalendar({
className,
selected,
onSelect
}: {
className?: string;
initialFocus?: boolean;
mode?: "single";
selected?: Date;
onSelect?: (date: Date | undefined) => void;
}) {
return (
<Calendar
mode="single"
selected={selected}
onSelect={onSelect}
className={className ?? "rounded-md border"}
/>
);
}

View File

@@ -0,0 +1,196 @@
import { Calendar, Clock, User } from "@/features/crm/calendar/upstream-big-calendar/icons";
import { parseISO, areIntervalsOverlapping, format } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SingleCalendar } from "@/features/crm/calendar/upstream-big-calendar/components/single-calendar";
import { AddEventDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/add-event-dialog";
import { EventBlock } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/event-block";
import { DroppableTimeBlock } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-time-block";
import { CalendarTimeline } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-time-line";
import { DayViewMultiDayEventsRow } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/day-view-multi-day-events-row";
import { cn } from "@/lib/utils";
import { groupEvents, getEventBlockStyle, isWorkingHour, getCurrentEvents, getVisibleHours } from "@/features/crm/calendar/upstream-big-calendar/helpers";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
singleDayEvents: IEvent[];
multiDayEvents: IEvent[];
}
export function CalendarDayView({ singleDayEvents, multiDayEvents }: IProps) {
const { selectedDate, setSelectedDate, users, visibleHours, workingHours } = useCalendar();
const { hours, earliestEventHour, latestEventHour } = getVisibleHours(visibleHours, singleDayEvents);
const currentEvents = getCurrentEvents(singleDayEvents);
const dayEvents = singleDayEvents.filter(event => {
const eventDate = parseISO(event.startDate);
return (
eventDate.getDate() === selectedDate.getDate() &&
eventDate.getMonth() === selectedDate.getMonth() &&
eventDate.getFullYear() === selectedDate.getFullYear()
);
});
const groupedEvents = groupEvents(dayEvents);
return (
<div className="flex">
<div className="flex flex-1 flex-col">
<div>
<DayViewMultiDayEventsRow selectedDate={selectedDate} multiDayEvents={multiDayEvents} />
{/* Day header */}
<div className="relative z-20 flex border-b">
<div className="w-18"></div>
<span className="flex-1 border-l py-2 text-center text-xs font-medium text-muted-foreground">
{format(selectedDate, "EE")} <span className="font-semibold text-foreground">{format(selectedDate, "d")}</span>
</span>
</div>
</div>
<ScrollArea className="h-[800px]" type="always">
<div className="flex">
{/* Hours column */}
<div className="relative w-18">
{hours.map((hour, index) => (
<div key={hour} className="relative" style={{ height: "96px" }}>
<div className="absolute -top-3 right-2 flex h-6 items-center">
{index !== 0 && <span className="text-xs text-muted-foreground">{format(new Date().setHours(hour, 0, 0, 0), "hh a")}</span>}
</div>
</div>
))}
</div>
{/* Day grid */}
<div className="relative flex-1 border-l">
<div className="relative">
{hours.map((hour, index) => {
const isDisabled = !isWorkingHour(selectedDate, hour, workingHours);
return (
<div key={hour} className={cn("relative", isDisabled && "bg-calendar-disabled-hour")} style={{ height: "96px" }}>
{index !== 0 && <div className="pointer-events-none absolute inset-x-0 top-0 border-b"></div>}
<DroppableTimeBlock date={selectedDate} hour={hour} minute={0}>
<AddEventDialog startDate={selectedDate} startTime={{ hour, minute: 0 }}>
<div className="absolute inset-x-0 top-0 h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<DroppableTimeBlock date={selectedDate} hour={hour} minute={15}>
<AddEventDialog startDate={selectedDate} startTime={{ hour, minute: 15 }}>
<div className="absolute inset-x-0 top-[24px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<div className="pointer-events-none absolute inset-x-0 top-1/2 border-b border-dashed"></div>
<DroppableTimeBlock date={selectedDate} hour={hour} minute={30}>
<AddEventDialog startDate={selectedDate} startTime={{ hour, minute: 30 }}>
<div className="absolute inset-x-0 top-[48px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<DroppableTimeBlock date={selectedDate} hour={hour} minute={45}>
<AddEventDialog startDate={selectedDate} startTime={{ hour, minute: 45 }}>
<div className="absolute inset-x-0 top-[72px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
</div>
);
})}
{groupedEvents.map((group, groupIndex) =>
group.map(event => {
let style = getEventBlockStyle(event, selectedDate, groupIndex, groupedEvents.length, { from: earliestEventHour, to: latestEventHour });
const hasOverlap = groupedEvents.some(
(otherGroup, otherIndex) =>
otherIndex !== groupIndex &&
otherGroup.some(otherEvent =>
areIntervalsOverlapping(
{ start: parseISO(event.startDate), end: parseISO(event.endDate) },
{ start: parseISO(otherEvent.startDate), end: parseISO(otherEvent.endDate) }
)
)
);
if (!hasOverlap) style = { ...style, width: "100%", left: "0%" };
return (
<div key={event.id} className="absolute p-1" style={style}>
<EventBlock event={event} />
</div>
);
})
)}
</div>
<CalendarTimeline firstVisibleHour={earliestEventHour} lastVisibleHour={latestEventHour} />
</div>
</div>
</ScrollArea>
</div>
<div className="hidden w-64 divide-y border-l md:block">
<SingleCalendar className="mx-auto w-fit" mode="single" selected={selectedDate} onSelect={setSelectedDate} initialFocus />
<div className="flex-1 space-y-3">
{currentEvents.length > 0 ? (
<div className="flex items-start gap-2 px-4 pt-4">
<span className="relative mt-[5px] flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex size-2.5 rounded-full bg-green-600"></span>
</span>
<p className="text-sm font-semibold text-foreground">Happening now</p>
</div>
) : (
<p className="p-4 text-center text-sm italic text-muted-foreground">No appointments or consultations at the moment</p>
)}
{currentEvents.length > 0 && (
<ScrollArea className="h-[422px] px-4" type="always">
<div className="space-y-6 pb-4">
{currentEvents.map(event => {
const user = users.find(user => user.id === event.user.id);
return (
<div key={event.id} className="space-y-1.5">
<p className="line-clamp-2 text-sm font-semibold">{event.title}</p>
{user && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<User className="size-3.5" />
<span className="text-sm">{user.name}</span>
</div>
)}
<div className="flex items-center gap-1.5 text-muted-foreground">
<Calendar className="size-3.5" />
<span className="text-sm">{format(new Date(), "MMM d, yyyy")}</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="size-3.5" />
<span className="text-sm">
{format(parseISO(event.startDate), "h:mm a")} - {format(parseISO(event.endDate), "h:mm a")}
</span>
</div>
</div>
);
})}
</div>
</ScrollArea>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { format } from "date-fns";
import { useEffect, useState } from "react";
interface IProps {
firstVisibleHour: number;
lastVisibleHour: number;
}
export function CalendarTimeline({ firstVisibleHour, lastVisibleHour }: IProps) {
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 60 * 1000);
return () => clearInterval(timer);
}, []);
const getCurrentTimePosition = () => {
const minutes = currentTime.getHours() * 60 + currentTime.getMinutes();
const visibleStartMinutes = firstVisibleHour * 60;
const visibleEndMinutes = lastVisibleHour * 60;
const visibleRangeMinutes = visibleEndMinutes - visibleStartMinutes;
return ((minutes - visibleStartMinutes) / visibleRangeMinutes) * 100;
};
const formatCurrentTime = () => {
return format(currentTime, "h:mm a");
};
const currentHour = currentTime.getHours();
if (currentHour < firstVisibleHour || currentHour >= lastVisibleHour) return null;
return (
<div className="pointer-events-none absolute inset-x-0 z-50 border-t border-primary" style={{ top: `${getCurrentTimePosition()}%` }}>
<div className="absolute left-0 top-0 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"></div>
<div className="absolute -left-18 flex w-16 -translate-y-1/2 justify-end bg-background pr-1 text-xs font-medium text-primary">{formatCurrentTime()}</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { startOfWeek, addDays, format, parseISO, isSameDay, areIntervalsOverlapping } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AddEventDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/add-event-dialog";
import { EventBlock } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/event-block";
import { DroppableTimeBlock } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-time-block";
import { CalendarTimeline } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-time-line";
import { WeekViewMultiDayEventsRow } from "@/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/week-view-multi-day-events-row";
import { cn } from "@/lib/utils";
import { groupEvents, getEventBlockStyle, isWorkingHour, getVisibleHours } from "@/features/crm/calendar/upstream-big-calendar/helpers";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
singleDayEvents: IEvent[];
multiDayEvents: IEvent[];
}
export function CalendarWeekView({ singleDayEvents, multiDayEvents }: IProps) {
const { selectedDate, workingHours, visibleHours } = useCalendar();
const { hours, earliestEventHour, latestEventHour } = getVisibleHours(visibleHours, singleDayEvents);
const weekStart = startOfWeek(selectedDate);
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
return (
<>
<div className="flex flex-col items-center justify-center border-b py-4 text-sm text-muted-foreground sm:hidden">
<p>Weekly view is not available on smaller devices.</p>
<p>Please switch to daily or monthly view.</p>
</div>
<div className="hidden flex-col sm:flex">
<div>
<WeekViewMultiDayEventsRow selectedDate={selectedDate} multiDayEvents={multiDayEvents} />
{/* Week header */}
<div className="relative z-20 flex border-b">
<div className="w-18"></div>
<div className="grid flex-1 grid-cols-7 divide-x border-l">
{weekDays.map((day, index) => (
<span key={index} className="py-2 text-center text-xs font-medium text-muted-foreground">
{format(day, "EE")} <span className="ml-1 font-semibold text-foreground">{format(day, "d")}</span>
</span>
))}
</div>
</div>
</div>
<ScrollArea className="h-[736px]" type="always">
<div className="flex overflow-hidden">
{/* Hours column */}
<div className="relative w-18">
{hours.map((hour, index) => (
<div key={hour} className="relative" style={{ height: "96px" }}>
<div className="absolute -top-3 right-2 flex h-6 items-center">
{index !== 0 && <span className="text-xs text-muted-foreground">{format(new Date().setHours(hour, 0, 0, 0), "hh a")}</span>}
</div>
</div>
))}
</div>
{/* Week grid */}
<div className="relative flex-1 border-l">
<div className="grid grid-cols-7 divide-x">
{weekDays.map((day, dayIndex) => {
const dayEvents = singleDayEvents.filter(event => isSameDay(parseISO(event.startDate), day) || isSameDay(parseISO(event.endDate), day));
const groupedEvents = groupEvents(dayEvents);
return (
<div key={dayIndex} className="relative">
{hours.map((hour, index) => {
const isDisabled = !isWorkingHour(day, hour, workingHours);
return (
<div key={hour} className={cn("relative", isDisabled && "bg-calendar-disabled-hour")} style={{ height: "96px" }}>
{index !== 0 && <div className="pointer-events-none absolute inset-x-0 top-0 border-b"></div>}
<DroppableTimeBlock date={day} hour={hour} minute={0}>
<AddEventDialog startDate={day} startTime={{ hour, minute: 0 }}>
<div className="absolute inset-x-0 top-0 h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<DroppableTimeBlock date={day} hour={hour} minute={15}>
<AddEventDialog startDate={day} startTime={{ hour, minute: 15 }}>
<div className="absolute inset-x-0 top-[24px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<div className="pointer-events-none absolute inset-x-0 top-1/2 border-b border-dashed"></div>
<DroppableTimeBlock date={day} hour={hour} minute={30}>
<AddEventDialog startDate={day} startTime={{ hour, minute: 30 }}>
<div className="absolute inset-x-0 top-[48px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
<DroppableTimeBlock date={day} hour={hour} minute={45}>
<AddEventDialog startDate={day} startTime={{ hour, minute: 45 }}>
<div className="absolute inset-x-0 top-[72px] h-[24px] cursor-pointer transition-colors hover:bg-accent" />
</AddEventDialog>
</DroppableTimeBlock>
</div>
);
})}
{groupedEvents.map((group, groupIndex) =>
group.map(event => {
let style = getEventBlockStyle(event, day, groupIndex, groupedEvents.length, { from: earliestEventHour, to: latestEventHour });
const hasOverlap = groupedEvents.some(
(otherGroup, otherIndex) =>
otherIndex !== groupIndex &&
otherGroup.some(otherEvent =>
areIntervalsOverlapping(
{ start: parseISO(event.startDate), end: parseISO(event.endDate) },
{ start: parseISO(otherEvent.startDate), end: parseISO(otherEvent.endDate) }
)
)
);
if (!hasOverlap) style = { ...style, width: "100%", left: "0%" };
return (
<div key={event.id} className="absolute p-1" style={style}>
<EventBlock event={event} />
</div>
);
})
)}
</div>
);
})}
</div>
<CalendarTimeline firstVisibleHour={earliestEventHour} lastVisibleHour={latestEventHour} />
</div>
</div>
</ScrollArea>
</div>
</>
);
}

View File

@@ -0,0 +1,53 @@
import { parseISO, isWithinInterval, differenceInDays, startOfDay, endOfDay } from "date-fns";
import { MonthEventBadge } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/month-event-badge";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
selectedDate: Date;
multiDayEvents: IEvent[];
}
export function DayViewMultiDayEventsRow({ selectedDate, multiDayEvents }: IProps) {
const dayStart = startOfDay(selectedDate);
const dayEnd = endOfDay(selectedDate);
const multiDayEventsInDay = multiDayEvents
.filter(event => {
const eventStart = parseISO(event.startDate);
const eventEnd = parseISO(event.endDate);
const isOverlapping =
isWithinInterval(dayStart, { start: eventStart, end: eventEnd }) ||
isWithinInterval(dayEnd, { start: eventStart, end: eventEnd }) ||
(eventStart <= dayStart && eventEnd >= dayEnd);
return isOverlapping;
})
.toSorted((a, b) => {
const durationA = differenceInDays(parseISO(a.endDate), parseISO(a.startDate));
const durationB = differenceInDays(parseISO(b.endDate), parseISO(b.startDate));
return durationB - durationA;
});
if (multiDayEventsInDay.length === 0) return null;
return (
<div className="flex border-b">
<div className="w-18"></div>
<div className="flex flex-1 flex-col gap-1 border-l py-1">
{multiDayEventsInDay.map(event => {
const eventStart = startOfDay(parseISO(event.startDate));
const eventEnd = startOfDay(parseISO(event.endDate));
const currentDate = startOfDay(selectedDate);
const eventTotalDays = differenceInDays(eventEnd, eventStart) + 1;
const eventCurrentDay = differenceInDays(currentDate, eventStart) + 1;
return <MonthEventBadge key={event.id} event={event} cellDate={selectedDate} eventCurrentDay={eventCurrentDay} eventTotalDays={eventTotalDays} />;
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
/* oxlint-disable unicorn/consistent-function-scoping */
import { cva } from "class-variance-authority";
import { format, differenceInMinutes, parseISO } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { DraggableEvent } from "@/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event";
import { EventDetailsDialog } from "@/features/crm/calendar/upstream-big-calendar/components/dialogs/event-details-dialog";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "react";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { VariantProps } from "class-variance-authority";
const calendarWeekEventCardVariants = cva(
"flex select-none flex-col gap-0.5 truncate whitespace-nowrap rounded-md border px-2 py-1.5 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
{
variants: {
color: {
// Colored and mixed variants
blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300 [&_.event-dot]:fill-blue-600",
green: "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300 [&_.event-dot]:fill-green-600",
red: "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300 [&_.event-dot]:fill-red-600",
yellow: "border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300 [&_.event-dot]:fill-yellow-600",
purple: "border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-300 [&_.event-dot]:fill-purple-600",
orange: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-300 [&_.event-dot]:fill-orange-600",
gray: "border-neutral-200 bg-neutral-50 text-neutral-700 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 [&_.event-dot]:fill-neutral-600",
// Dot variants
"blue-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-blue-600",
"green-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-green-600",
"red-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-red-600",
"orange-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-orange-600",
"purple-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-purple-600",
"yellow-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-yellow-600",
"gray-dot": "bg-neutral-50 dark:bg-neutral-900 [&_.event-dot]:fill-neutral-600",
},
},
defaultVariants: {
color: "blue-dot",
},
}
);
interface IProps extends HTMLAttributes<HTMLDivElement>, Omit<VariantProps<typeof calendarWeekEventCardVariants>, "color"> {
event: IEvent;
}
export function EventBlock({ event, className }: IProps) {
const { badgeVariant } = useCalendar();
const start = parseISO(event.startDate);
const end = parseISO(event.endDate);
const durationInMinutes = differenceInMinutes(end, start);
const heightInPixels = (durationInMinutes / 60) * 96 - 8;
const color = (badgeVariant === "dot" ? `${event.color}-dot` : event.color) as VariantProps<typeof calendarWeekEventCardVariants>["color"];
const calendarWeekEventCardClasses = cn(calendarWeekEventCardVariants({ color, className }), durationInMinutes < 35 && "py-0 justify-center");
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (e.currentTarget instanceof HTMLElement) e.currentTarget.click();
}
};
return (
<DraggableEvent event={event}>
<EventDetailsDialog event={event}>
<div role="button" tabIndex={0} className={calendarWeekEventCardClasses} style={{ height: `${heightInPixels}px` }} onKeyDown={handleKeyDown}>
<div className="flex items-center gap-1.5 truncate">
{["mixed", "dot"].includes(badgeVariant) && (
<svg width="8" height="8" viewBox="0 0 8 8" className="event-dot shrink-0">
<circle cx="4" cy="4" r="4" />
</svg>
)}
<p className="truncate font-semibold">{event.title}</p>
</div>
{durationInMinutes > 25 && (
<p>
{format(start, "h:mm a")} - {format(end, "h:mm a")}
</p>
)}
</div>
</EventDetailsDialog>
</DraggableEvent>
);
}

View File

@@ -0,0 +1,112 @@
import { useMemo } from "react";
import { parseISO, startOfDay, startOfWeek, endOfWeek, addDays, differenceInDays, isBefore, isAfter } from "date-fns";
import { MonthEventBadge } from "@/features/crm/calendar/upstream-big-calendar/components/month-view/month-event-badge";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
selectedDate: Date;
multiDayEvents: IEvent[];
}
export function WeekViewMultiDayEventsRow({ selectedDate, multiDayEvents }: IProps) {
const weekStart = startOfWeek(selectedDate);
const weekEnd = endOfWeek(selectedDate);
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
const processedEvents = useMemo(() => {
return multiDayEvents
.map(event => {
const start = parseISO(event.startDate);
const end = parseISO(event.endDate);
const adjustedStart = isBefore(start, weekStart) ? weekStart : start;
const adjustedEnd = isAfter(end, weekEnd) ? weekEnd : end;
const startIndex = differenceInDays(adjustedStart, weekStart);
const endIndex = differenceInDays(adjustedEnd, weekStart);
return {
...event,
adjustedStart,
adjustedEnd,
startIndex,
endIndex,
};
})
.toSorted((a, b) => {
const startDiff = a.adjustedStart.getTime() - b.adjustedStart.getTime();
if (startDiff !== 0) return startDiff;
return b.endIndex - b.startIndex - (a.endIndex - a.startIndex);
});
}, [multiDayEvents, weekStart, weekEnd]);
const eventRows = useMemo(() => {
const rows: (typeof processedEvents)[] = [];
processedEvents.forEach(event => {
let rowIndex = rows.findIndex(row => row.every(e => e.endIndex < event.startIndex || e.startIndex > event.endIndex));
if (rowIndex === -1) {
rowIndex = rows.length;
rows.push([]);
}
rows[rowIndex].push(event);
});
return rows;
}, [processedEvents]);
const hasEventsInWeek = useMemo(() => {
return multiDayEvents.some(event => {
const start = parseISO(event.startDate);
const end = parseISO(event.endDate);
return (
// Event starts within the week
(start >= weekStart && start <= weekEnd) ||
// Event ends within the week
(end >= weekStart && end <= weekEnd) ||
// Event spans the entire week
(start <= weekStart && end >= weekEnd)
);
});
}, [multiDayEvents, weekStart, weekEnd]);
if (!hasEventsInWeek) {
return null;
}
return (
<div className="hidden overflow-hidden sm:flex">
<div className="w-18 border-b"></div>
<div className="grid flex-1 grid-cols-7 divide-x border-b border-l">
{weekDays.map((day, dayIndex) => (
<div key={day.toISOString()} className="flex h-full flex-col gap-1 py-1">
{eventRows.map((row, rowIndex) => {
const event = row.find(e => e.startIndex <= dayIndex && e.endIndex >= dayIndex);
if (!event) {
return <div key={`${rowIndex}-${dayIndex}`} className="h-6.5" />;
}
let position: "first" | "middle" | "last" | "none" = "none";
if (dayIndex === event.startIndex && dayIndex === event.endIndex) {
position = "none";
} else if (dayIndex === event.startIndex) {
position = "first";
} else if (dayIndex === event.endIndex) {
position = "last";
} else {
position = "middle";
}
return <MonthEventBadge key={`${event.id}-${dayIndex}`} event={event} cellDate={startOfDay(day)} position={position} />;
})}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useMemo } from "react";
import { addMonths, startOfYear } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { YearViewMonth } from "@/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-month";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
allEvents: IEvent[];
}
export function CalendarYearView({ allEvents }: IProps) {
const { selectedDate } = useCalendar();
const months = useMemo(() => {
const yearStart = startOfYear(selectedDate);
return Array.from({ length: 12 }, (_, i) => addMonths(yearStart, i));
}, [selectedDate]);
return (
<div className="p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{months.map(month => (
<YearViewMonth key={month.toString()} month={month} events={allEvents} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { isToday } from "date-fns";
import { useRouter } from "next/navigation";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { cn } from "@/lib/utils";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
day: number;
date: Date;
events: IEvent[];
}
export function YearViewDayCell({ day, date, events }: IProps) {
const { push } = useRouter();
const { setSelectedDate } = useCalendar();
const maxIndicators = 3;
const eventCount = events.length;
const handleClick = () => {
setSelectedDate(date);
push("/day-view");
};
return (
<button
onClick={handleClick}
type="button"
className="flex h-11 flex-1 flex-col items-center justify-start gap-0.5 rounded-md pt-1 hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<div
className={cn(
"flex size-6 items-center justify-center rounded-full text-xs font-medium",
isToday(date) && "bg-primary font-semibold text-primary-foreground"
)}
>
{day}
</div>
{eventCount > 0 && (
<div className="mt-0.5 flex gap-0.5">
{eventCount <= maxIndicators ? (
events.map(event => (
<div
key={event.id}
className={cn(
"size-1.5 rounded-full",
event.color === "blue" && "bg-blue-600",
event.color === "green" && "bg-green-600",
event.color === "red" && "bg-red-600",
event.color === "yellow" && "bg-yellow-600",
event.color === "purple" && "bg-purple-600",
event.color === "orange" && "bg-orange-600",
event.color === "gray" && "bg-neutral-600"
)}
/>
))
) : (
<>
<div
className={cn(
"size-1.5 rounded-full",
events[0].color === "blue" && "bg-blue-600",
events[0].color === "green" && "bg-green-600",
events[0].color === "red" && "bg-red-600",
events[0].color === "yellow" && "bg-yellow-600",
events[0].color === "purple" && "bg-purple-600",
events[0].color === "orange" && "bg-orange-600"
)}
/>
<span className="text-[7px] text-muted-foreground">+{eventCount - 1}</span>
</>
)}
</div>
)}
</button>
);
}

View File

@@ -0,0 +1,71 @@
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { format, isSameDay, parseISO, getDaysInMonth, startOfMonth } from "date-fns";
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import { YearViewDayCell } from "@/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-day-cell";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
interface IProps {
month: Date;
events: IEvent[];
}
export function YearViewMonth({ month, events }: IProps) {
const { push } = useRouter();
const { setSelectedDate } = useCalendar();
const monthName = format(month, "MMMM");
const daysInMonth = useMemo(() => {
const totalDays = getDaysInMonth(month);
const firstDay = startOfMonth(month).getDay();
const days = Array.from({ length: totalDays }, (_, i) => i + 1);
const blanks = Array(firstDay).fill(null);
return [...blanks, ...days];
}, [month]);
const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const handleClick = () => {
setSelectedDate(new Date(month.getFullYear(), month.getMonth(), 1));
push("/month-view");
};
return (
<div className="flex flex-col">
<button
type="button"
onClick={handleClick}
className="w-full rounded-t-lg border px-3 py-2 text-sm font-semibold hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{monthName}
</button>
<div className="flex-1 space-y-2 rounded-b-lg border border-t-0 p-3">
<div className="grid grid-cols-7 gap-x-0.5 text-center">
{weekDays.map((day, index) => (
<div key={index} className="text-xs font-medium text-muted-foreground">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-x-0.5 gap-y-2">
{daysInMonth.map((day, index) => {
if (day === null) return <div key={`blank-${index}`} className="h-10" />;
const date = new Date(month.getFullYear(), month.getMonth(), day);
const dayEvents = events.filter(event => isSameDay(parseISO(event.startDate), date) || isSameDay(parseISO(event.endDate), date));
return <YearViewDayCell key={`day-${day}`} day={day} date={date} events={dayEvents} />;
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import type { IEvent, IUser } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type {
TBadgeVariant,
TCalendarView,
TVisibleHours,
TWorkingHours
} from "@/features/crm/calendar/upstream-big-calendar/types";
interface ICalendarContext {
selectedDate: Date;
setSelectedDate: (date: Date | undefined) => void;
selectedUserId: IUser["id"] | "all";
setSelectedUserId: (userId: IUser["id"] | "all") => void;
selectedView: TCalendarView;
setSelectedView: (view: TCalendarView) => void;
badgeVariant: TBadgeVariant;
setBadgeVariant: (variant: TBadgeVariant) => void;
users: IUser[];
workingHours: TWorkingHours;
setWorkingHours: Dispatch<SetStateAction<TWorkingHours>>;
visibleHours: TVisibleHours;
setVisibleHours: Dispatch<SetStateAction<TVisibleHours>>;
events: IEvent[];
setLocalEvents: Dispatch<SetStateAction<IEvent[]>>;
onEventChange?: (event: IEvent) => void;
}
const CalendarContext = createContext<ICalendarContext | null>(null);
const WORKING_HOURS = {
0: { from: 0, to: 0 },
1: { from: 8, to: 17 },
2: { from: 8, to: 17 },
3: { from: 8, to: 17 },
4: { from: 8, to: 17 },
5: { from: 8, to: 17 },
6: { from: 8, to: 12 }
};
const VISIBLE_HOURS = { from: 7, to: 18 };
export function CalendarProvider({
children,
users,
events,
initialDate,
initialView = "agenda",
onEventChange
}: {
children: ReactNode;
users: IUser[];
events: IEvent[];
initialDate?: string;
initialView?: TCalendarView;
onEventChange?: (event: IEvent) => void;
}) {
const [badgeVariant, setBadgeVariant] = useState<TBadgeVariant>("colored");
const [visibleHours, setVisibleHours] = useState<TVisibleHours>(VISIBLE_HOURS);
const [workingHours, setWorkingHours] = useState<TWorkingHours>(WORKING_HOURS);
const [selectedDate, setSelectedDateState] = useState(() =>
initialDate ? new Date(initialDate) : new Date()
);
const [selectedView, setSelectedView] = useState<TCalendarView>(initialView);
const [selectedUserId, setSelectedUserId] = useState<IUser["id"] | "all">("all");
const [localEvents, setLocalEvents] = useState<IEvent[]>(events);
useEffect(() => {
setLocalEvents(events);
}, [events]);
const setSelectedDate = (date: Date | undefined) => {
if (date) setSelectedDateState(date);
};
return (
<CalendarContext.Provider
value={{
selectedDate,
setSelectedDate,
selectedUserId,
setSelectedUserId,
selectedView,
setSelectedView,
badgeVariant,
setBadgeVariant,
users,
workingHours,
setWorkingHours,
visibleHours,
setVisibleHours,
events: localEvents,
setLocalEvents,
onEventChange
}}
>
{children}
</CalendarContext.Provider>
);
}
export function useCalendar() {
const context = useContext(CalendarContext);
if (!context) throw new Error("useCalendar must be used within CalendarProvider.");
return context;
}

View File

@@ -0,0 +1,277 @@
/* oxlint-disable unicorn/consistent-function-scoping */
import {
addDays,
addMonths,
addWeeks,
subDays,
subMonths,
subWeeks,
isSameWeek,
isSameDay,
isSameMonth,
startOfWeek,
startOfMonth,
endOfMonth,
endOfWeek,
format,
parseISO,
differenceInMinutes,
eachDayOfInterval,
startOfDay,
differenceInDays,
endOfYear,
startOfYear,
subYears,
addYears,
isSameYear,
isWithinInterval,
} from "date-fns";
import type { ICalendarCell, IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
import type { TCalendarView, TVisibleHours, TWorkingHours } from "@/features/crm/calendar/upstream-big-calendar/types";
// ================ Header helper functions ================ //
export function rangeText(view: TCalendarView, date: Date) {
const formatString = "MMM d, yyyy";
let start: Date;
let end: Date;
switch (view) {
case "agenda":
start = startOfMonth(date);
end = endOfMonth(date);
break;
case "year":
start = startOfYear(date);
end = endOfYear(date);
break;
case "month":
start = startOfMonth(date);
end = endOfMonth(date);
break;
case "week":
start = startOfWeek(date);
end = endOfWeek(date);
break;
case "day":
return format(date, formatString);
default:
return "Error while formatting ";
}
return `${format(start, formatString)} - ${format(end, formatString)}`;
}
export function navigateDate(date: Date, view: TCalendarView, direction: "previous" | "next"): Date {
const operations = {
agenda: direction === "next" ? addMonths : subMonths,
year: direction === "next" ? addYears : subYears,
month: direction === "next" ? addMonths : subMonths,
week: direction === "next" ? addWeeks : subWeeks,
day: direction === "next" ? addDays : subDays,
};
return operations[view](date, 1);
}
export function getEventsCount(events: IEvent[], date: Date, view: TCalendarView): number {
const compareFns = {
agenda: isSameMonth,
year: isSameYear,
day: isSameDay,
week: isSameWeek,
month: isSameMonth,
};
return events.filter(event => compareFns[view](new Date(event.startDate), date)).length;
}
// ================ Week and day view helper functions ================ //
export function getCurrentEvents(events: IEvent[]) {
const now = new Date();
return events.filter(event => isWithinInterval(now, { start: parseISO(event.startDate), end: parseISO(event.endDate) })) || null;
}
export function groupEvents(dayEvents: IEvent[]) {
const sortedEvents = dayEvents.toSorted((a, b) => parseISO(a.startDate).getTime() - parseISO(b.startDate).getTime());
const groups: IEvent[][] = [];
for (const event of sortedEvents) {
const eventStart = parseISO(event.startDate);
let placed = false;
for (const group of groups) {
const lastEventInGroup = group[group.length - 1];
const lastEventEnd = parseISO(lastEventInGroup.endDate);
if (eventStart >= lastEventEnd) {
group.push(event);
placed = true;
break;
}
}
if (!placed) groups.push([event]);
}
return groups;
}
export function getEventBlockStyle(event: IEvent, day: Date, groupIndex: number, groupSize: number, visibleHoursRange?: { from: number; to: number }) {
const startDate = parseISO(event.startDate);
const dayStart = new Date(day.setHours(0, 0, 0, 0));
const eventStart = startDate < dayStart ? dayStart : startDate;
const startMinutes = differenceInMinutes(eventStart, dayStart);
let top;
if (visibleHoursRange) {
const visibleStartMinutes = visibleHoursRange.from * 60;
const visibleEndMinutes = visibleHoursRange.to * 60;
const visibleRangeMinutes = visibleEndMinutes - visibleStartMinutes;
top = ((startMinutes - visibleStartMinutes) / visibleRangeMinutes) * 100;
} else {
top = (startMinutes / 1440) * 100;
}
const width = 100 / groupSize;
const left = groupIndex * width;
return { top: `${top}%`, width: `${width}%`, left: `${left}%` };
}
export function isWorkingHour(day: Date, hour: number, workingHours: TWorkingHours) {
const dayIndex = day.getDay() as keyof typeof workingHours;
const dayHours = workingHours[dayIndex];
return hour >= dayHours.from && hour < dayHours.to;
}
export function getVisibleHours(visibleHours: TVisibleHours, singleDayEvents: IEvent[]) {
let earliestEventHour = visibleHours.from;
let latestEventHour = visibleHours.to;
singleDayEvents.forEach(event => {
const startHour = parseISO(event.startDate).getHours();
const endTime = parseISO(event.endDate);
const endHour = endTime.getHours() + (endTime.getMinutes() > 0 ? 1 : 0);
if (startHour < earliestEventHour) earliestEventHour = startHour;
if (endHour > latestEventHour) latestEventHour = endHour;
});
latestEventHour = Math.min(latestEventHour, 24);
const hours = Array.from({ length: latestEventHour - earliestEventHour }, (_, i) => i + earliestEventHour);
return { hours, earliestEventHour, latestEventHour };
}
// ================ Month view helper functions ================ //
export function getCalendarCells(selectedDate: Date): ICalendarCell[] {
const currentYear = selectedDate.getFullYear();
const currentMonth = selectedDate.getMonth();
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
const daysInMonth = getDaysInMonth(currentYear, currentMonth);
const firstDayOfMonth = getFirstDayOfMonth(currentYear, currentMonth);
const daysInPrevMonth = getDaysInMonth(currentYear, currentMonth - 1);
const totalDays = firstDayOfMonth + daysInMonth;
const prevMonthCells = Array.from({ length: firstDayOfMonth }, (_, i) => ({
day: daysInPrevMonth - firstDayOfMonth + i + 1,
currentMonth: false,
date: new Date(currentYear, currentMonth - 1, daysInPrevMonth - firstDayOfMonth + i + 1),
}));
const currentMonthCells = Array.from({ length: daysInMonth }, (_, i) => ({
day: i + 1,
currentMonth: true,
date: new Date(currentYear, currentMonth, i + 1),
}));
const nextMonthCells = Array.from({ length: (7 - (totalDays % 7)) % 7 }, (_, i) => ({
day: i + 1,
currentMonth: false,
date: new Date(currentYear, currentMonth + 1, i + 1),
}));
return [...prevMonthCells, ...currentMonthCells, ...nextMonthCells];
}
export function calculateMonthEventPositions(multiDayEvents: IEvent[], singleDayEvents: IEvent[], selectedDate: Date) {
const monthStart = startOfMonth(selectedDate);
const monthEnd = endOfMonth(selectedDate);
const eventPositions: { [key: string]: number } = {};
const occupiedPositions: { [key: string]: boolean[] } = {};
eachDayOfInterval({ start: monthStart, end: monthEnd }).forEach(day => {
occupiedPositions[day.toISOString()] = [false, false, false];
});
const sortedEvents = [
...multiDayEvents.toSorted((a, b) => {
const aDuration = differenceInDays(parseISO(a.endDate), parseISO(a.startDate));
const bDuration = differenceInDays(parseISO(b.endDate), parseISO(b.startDate));
return bDuration - aDuration || parseISO(a.startDate).getTime() - parseISO(b.startDate).getTime();
}),
...singleDayEvents.toSorted((a, b) => parseISO(a.startDate).getTime() - parseISO(b.startDate).getTime()),
];
sortedEvents.forEach(event => {
const eventStart = parseISO(event.startDate);
const eventEnd = parseISO(event.endDate);
const eventDays = eachDayOfInterval({
start: eventStart < monthStart ? monthStart : eventStart,
end: eventEnd > monthEnd ? monthEnd : eventEnd,
});
let position = -1;
for (let i = 0; i < 3; i++) {
if (
eventDays.every(day => {
const dayPositions = occupiedPositions[startOfDay(day).toISOString()];
return dayPositions && !dayPositions[i];
})
) {
position = i;
break;
}
}
if (position !== -1) {
eventDays.forEach(day => {
const dayKey = startOfDay(day).toISOString();
occupiedPositions[dayKey][position] = true;
});
eventPositions[event.id] = position;
}
});
return eventPositions;
}
export function getMonthCellEvents(date: Date, events: IEvent[], eventPositions: Record<string, number>) {
const eventsForDate = events.filter(event => {
const eventStart = parseISO(event.startDate);
const eventEnd = parseISO(event.endDate);
return (date >= eventStart && date <= eventEnd) || isSameDay(date, eventStart) || isSameDay(date, eventEnd);
});
return eventsForDate
.map(event => ({
...event,
position: eventPositions[event.id] ?? -1,
isMultiDay: event.startDate !== event.endDate,
}))
.toSorted((a, b) => {
if (a.isMultiDay && !b.isMultiDay) return -1;
if (!a.isMultiDay && b.isMultiDay) return 1;
return a.position - b.position;
});
}

View File

@@ -0,0 +1,26 @@
import { useCalendar } from "@/features/crm/calendar/upstream-big-calendar/contexts/calendar-context";
import type { IEvent } from "@/features/crm/calendar/upstream-big-calendar/interfaces";
export function useUpdateEvent() {
const { setLocalEvents, onEventChange } = useCalendar();
const updateEvent = (event: IEvent) => {
if (event.alla?.editable === false) return;
const nextEvent: IEvent = {
...event,
startDate: new Date(event.startDate).toISOString(),
endDate: new Date(event.endDate).toISOString()
};
setLocalEvents((prev) => {
const index = prev.findIndex((item) => item.id === nextEvent.id);
if (index === -1) return prev;
return [...prev.slice(0, index), nextEvent, ...prev.slice(index + 1)];
});
onEventChange?.(nextEvent);
};
return { updateEvent };
}

View File

@@ -0,0 +1,18 @@
import { Icons } from "@/components/icons";
export const AlertTriangle = Icons.warning;
export const Calendar = Icons.calendar;
export const CalendarRange = Icons.calendar;
export const CalendarX2 = Icons.calendar;
export const ChevronLeft = Icons.chevronLeft;
export const ChevronRight = Icons.chevronRight;
export const Clock = Icons.clock;
export const Columns = Icons.panelLeft;
export const Grid2x2 = Icons.dashboard;
export const Grid3x3 = Icons.dashboard;
export const Info = Icons.info;
export const List = Icons.forms;
export const Moon = Icons.moon;
export const Plus = Icons.add;
export const Text = Icons.post;
export const User = Icons.user;

View File

@@ -0,0 +1,39 @@
import type { TEventColor } from "@/features/crm/calendar/upstream-big-calendar/types";
export interface IUser {
id: string;
name: string;
picturePath: string | null;
}
export interface IEvent {
id: string;
startDate: string;
endDate: string;
title: string;
color: TEventColor;
description: string;
user: IUser;
alla?: {
activityId: string | null;
editable: boolean;
milestone: boolean;
sourceEntityType: string;
sourceEntityId: string;
customerId: string | null;
leadId: string | null;
opportunityId: string | null;
quotationId: string | null;
approvalId: string | null;
overdue: boolean;
hotProject: boolean;
pricingSensitive: boolean;
internalOnly: boolean;
};
}
export interface ICalendarCell {
day: number;
currentMonth: boolean;
date: Date;
}

View File

@@ -0,0 +1,5 @@
export type TCalendarView = "day" | "week" | "month" | "year" | "agenda";
export type TEventColor = "blue" | "green" | "red" | "yellow" | "purple" | "orange" | "gray";
export type TBadgeVariant = "dot" | "colored" | "mixed";
export type TWorkingHours = { [key: number]: { from: number; to: number } };
export type TVisibleHours = { from: number; to: number };