commit task-ep.1.6.2-r1
This commit is contained in:
@@ -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.
|
||||
@@ -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
88
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
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
599
plans/task-ep.1.6.2.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
return [...grouped.entries()];
|
||||
users.set(event.user.id, event.user);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
21
src/features/crm/calendar/upstream-big-calendar/LICENSE
Normal file
21
src/features/crm/calendar/upstream-big-calendar/LICENSE
Normal 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.
|
||||
66
src/features/crm/calendar/upstream-big-calendar/UPSTREAM.md
Normal file
66
src/features/crm/calendar/upstream-big-calendar/UPSTREAM.md
Normal 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`.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }))} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
277
src/features/crm/calendar/upstream-big-calendar/helpers.ts
Normal file
277
src/features/crm/calendar/upstream-big-calendar/helpers.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
18
src/features/crm/calendar/upstream-big-calendar/icons.tsx
Normal file
18
src/features/crm/calendar/upstream-big-calendar/icons.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
5
src/features/crm/calendar/upstream-big-calendar/types.ts
Normal file
5
src/features/crm/calendar/upstream-big-calendar/types.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user