From bb5a910b881a122ea776f7211fbe6b47a4f41dee Mon Sep 17 00:00:00 2001 From: phaichayon Date: Mon, 13 Jul 2026 22:29:11 +0700 Subject: [PATCH] commit task-ep.1.6.2-r1 --- ...ect-big-calendar-ui-adoption-2026-07-13.md | 65 ++ ...big-calendar-source-adoption-2026-07-13.md | 89 ++ package-lock.json | 88 ++ package.json | 2 + plans/task-ep.1.6.2-r1.md | 1017 +++++++++++++++++ plans/task-ep.1.6.2.md | 599 ++++++++++ src/features/crm/calendar/api/types.ts | 2 + .../calendar/components/calendar-adapter.ts | 105 +- .../components/calendar-workspace.tsx | 445 ++------ .../calendar/upstream-big-calendar/LICENSE | 21 + .../upstream-big-calendar/UPSTREAM.md | 66 ++ .../agenda-view/agenda-day-group.tsx | 38 + .../agenda-view/agenda-event-card.tsx | 115 ++ .../agenda-view/calendar-agenda-view.tsx | 80 ++ .../components/avatar-group.tsx | 14 + .../components/change-badge-variant-input.tsx | 27 + .../components/client-container.tsx | 102 ++ .../components/dialogs/add-event-dialog.tsx | 18 + .../components/dialogs/edit-event-dialog.tsx | 19 + .../dialogs/event-details-dialog.tsx | 76 ++ .../components/dnd/custom-drag-layer.tsx | 52 + .../components/dnd/dnd-provider.tsx | 19 + .../components/dnd/draggable-event.tsx | 47 + .../components/dnd/droppable-day-cell.tsx | 57 + .../components/dnd/droppable-time-block.tsx | 60 + .../components/header/calendar-header.tsx | 78 ++ .../components/header/date-navigator.tsx | 55 + .../components/header/today-button.tsx | 22 + .../components/header/user-select.tsx | 46 + .../month-view/calendar-month-view.tsx | 47 + .../components/month-view/day-cell.tsx | 79 ++ .../components/month-view/event-bullet.tsx | 26 + .../month-view/month-event-badge.tsx | 125 ++ .../components/single-calendar.tsx | 24 + .../week-and-day-view/calendar-day-view.tsx | 196 ++++ .../week-and-day-view/calendar-time-line.tsx | 40 + .../week-and-day-view/calendar-week-view.tsx | 148 +++ .../day-view-multi-day-events-row.tsx | 53 + .../week-and-day-view/event-block.tsx | 92 ++ .../week-view-multi-day-events-row.tsx | 112 ++ .../year-view/calendar-year-view.tsx | 31 + .../year-view/year-view-day-cell.tsx | 81 ++ .../components/year-view/year-view-month.tsx | 71 ++ .../contexts/calendar-context.tsx | 110 ++ .../calendar/upstream-big-calendar/helpers.ts | 277 +++++ .../hooks/use-update-event.ts | 26 + .../calendar/upstream-big-calendar/icons.tsx | 18 + .../upstream-big-calendar/interfaces.ts | 39 + .../calendar/upstream-big-calendar/types.ts | 5 + 49 files changed, 4663 insertions(+), 361 deletions(-) create mode 100644 docs/implementation/task-ep1.6.2-direct-big-calendar-ui-adoption-2026-07-13.md create mode 100644 docs/implementation/task-ep1.6.2-r1-upstream-big-calendar-source-adoption-2026-07-13.md create mode 100644 plans/task-ep.1.6.2-r1.md create mode 100644 plans/task-ep.1.6.2.md create mode 100644 src/features/crm/calendar/upstream-big-calendar/LICENSE create mode 100644 src/features/crm/calendar/upstream-big-calendar/UPSTREAM.md create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-day-group.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-event-card.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/agenda-view/calendar-agenda-view.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/avatar-group.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/change-badge-variant-input.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/client-container.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/dialogs/add-event-dialog.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/dialogs/edit-event-dialog.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/dialogs/event-details-dialog.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/dnd/custom-drag-layer.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/dnd/dnd-provider.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-day-cell.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-time-block.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/header/calendar-header.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/header/date-navigator.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/header/today-button.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/header/user-select.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/month-view/calendar-month-view.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/month-view/day-cell.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/month-view/event-bullet.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/month-view/month-event-badge.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/single-calendar.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-day-view.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-time-line.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-week-view.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/day-view-multi-day-events-row.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/event-block.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/week-view-multi-day-events-row.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/year-view/calendar-year-view.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-day-cell.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-month.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/contexts/calendar-context.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/helpers.ts create mode 100644 src/features/crm/calendar/upstream-big-calendar/hooks/use-update-event.ts create mode 100644 src/features/crm/calendar/upstream-big-calendar/icons.tsx create mode 100644 src/features/crm/calendar/upstream-big-calendar/interfaces.ts create mode 100644 src/features/crm/calendar/upstream-big-calendar/types.ts diff --git a/docs/implementation/task-ep1.6.2-direct-big-calendar-ui-adoption-2026-07-13.md b/docs/implementation/task-ep1.6.2-direct-big-calendar-ui-adoption-2026-07-13.md new file mode 100644 index 0000000..ce8e137 --- /dev/null +++ b/docs/implementation/task-ep1.6.2-direct-big-calendar-ui-adoption-2026-07-13.md @@ -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. diff --git a/docs/implementation/task-ep1.6.2-r1-upstream-big-calendar-source-adoption-2026-07-13.md b/docs/implementation/task-ep1.6.2-r1-upstream-big-calendar-source-adoption-2026-07-13.md new file mode 100644 index 0000000..a4109b1 --- /dev/null +++ b/docs/implementation/task-ep1.6.2-r1-upstream-big-calendar-source-adoption-2026-07-13.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 988f65b..9715b00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ae1bd70..4cd9c27 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plans/task-ep.1.6.2-r1.md b/plans/task-ep.1.6.2-r1.md new file mode 100644 index 0000000..7129a14 --- /dev/null +++ b/plans/task-ep.1.6.2-r1.md @@ -0,0 +1,1017 @@ +# Task EP.1.6.2-R1 – Replace Reimplemented Calendar with Upstream Big Calendar Source + +Status: Implemented + +Priority: Critical + +Type: Corrective UI Refactor / Direct Upstream Adoption + +Supersedes: + +- EP.1.6.2 custom Calendar UI implementation + +--- + +# Objective + +Remove the locally reimplemented Calendar UI and replace it with the actual source components from: + +```text +https://github.com/lramos33/big-calendar +``` + +Pinned upstream commit: + +```text +fbb8485e3d54b267261056c784632229ba6f7bc3 +``` + +The goal is: + +> Use Big Calendar itself, not an ALLA OS Calendar that imitates Big Calendar. + +The implementation must preserve the upstream Calendar structure, rendering, navigation, views, event layout, drag-and-drop, resize behavior, responsive behavior, and dark-mode behavior. + +Customization is limited to: + +- ALLA OS theme integration +- Calendar projection adapter +- CRM permission enforcement +- Activity integration +- source-record navigation +- timezone and date integration + +Reimplementation of Calendar rendering is prohibited. + +--- + +# Background + +EP.1.6.2 intended to adopt `lramos33/big-calendar` directly. + +However, the current implementation recreated Calendar concepts locally, including: + +- custom Calendar provider +- custom Calendar header +- custom Day view +- custom Week view +- custom Month view +- custom Agenda view +- custom Calendar slots +- custom event cards +- custom drag-and-drop handling +- command-based resize approximation + +The resulting UI is structurally and visually different from the upstream Big Calendar. + +This task corrects the implementation by removing the recreated Calendar UI and adopting the upstream source components directly. + +--- + +# Depends On + +- EP.1.6 Calendar Projection, Workspace & Big Calendar Foundation +- EP.1.6.1 Calendar Empty State Rendering Improvement +- EP.1.6.2 Direct Big Calendar UI Adoption +- EP.1.7 My Day Workspace Foundation +- AR.2 Workspace UI/UX Design Note +- ENG.0 Engineering Constitution + +--- + +# Review Required + +Review before implementation: + +## Governance + +- `AGENTS.md` +- `LAYOUT.md` +- `docs/standards/engineering-constitution.md` +- `docs/standards/architecture-rules.md` +- `docs/standards/ui-ux-rules.md` +- `docs/standards/task-review-checklist.md` +- `docs/security/crm-authorization-boundaries.md` + +## Existing ALLA OS Implementation + +- current Calendar Workspace +- current Calendar projection adapter +- current Calendar API +- `crm_calendar_projection` +- Activity APIs and services +- Business Event Outbox +- Projection Runtime +- Calendar projection consumer +- My Day Calendar consumers +- CRM navigation +- timezone and date utilities + +## Upstream Source + +Review: + +```text +https://github.com/lramos33/big-calendar +``` + +Pinned commit: + +```text +fbb8485e3d54b267261056c784632229ba6f7bc3 +``` + +Review upstream: + +- `src/calendar/**` +- provider and context +- Calendar container +- header and navigation +- Day view +- Week view +- Month view +- Agenda view +- Year view +- event components +- event-positioning logic +- overlapping-event handling +- multi-day-event handling +- drag-and-drop +- resize +- user filtering +- working hours +- responsive behavior +- dark mode +- accessibility +- dependencies +- MIT license + +--- + +# Non-Negotiable Rule + +The implementation must not recreate Calendar UI components that already exist upstream. + +The following are prohibited: + +- writing a new Month grid +- writing a new Week grid +- writing a new Day grid +- writing a new Agenda renderer +- writing a new Calendar toolbar +- writing a new Calendar header +- writing a new event-positioning engine +- writing a new overlapping-event algorithm +- writing a new multi-day-event layout +- writing a new drag-preview system +- writing a new resize system +- replacing upstream Calendar views with shadcn card grids +- retaining the current locally recreated Calendar as the active renderer + +Where an upstream source component exists, that component must be copied and adapted directly. + +--- + +# Approved Customization Boundary + +Only the following customization is permitted. + +## Theme Integration + +Allowed: + +- map colors to ALLA OS semantic tokens +- map typography +- map borders +- map radius +- map focus ring +- map dark-mode variables +- map button variants +- map icon registry + +Not allowed: + +- redesign the Calendar composition +- replace upstream grids +- replace upstream toolbar layout +- replace upstream event layout +- replace event-positioning behavior + +## Projection Adapter + +Allowed: + +- map `CalendarProjectionItem` to upstream Calendar event types +- normalize date and timezone values +- map owner and assignee +- map customer and project context +- attach editable and milestone metadata +- attach source-domain references +- attach visibility metadata + +The adapter must remain independent from React rendering. + +## CRM Permission Integration + +Allowed: + +- remove unauthorized events before rendering +- disable drag and resize for read-only items +- restrict internal-only Activities +- redact pricing-sensitive content +- restrict user filters to authorized users +- enforce manager and team scope + +The server remains authoritative. + +Client-side flags must not replace server permission checks. + +## Activity Integration + +Allowed: + +- create Activity +- open Activity +- reschedule Activity +- resize Activity +- complete Activity +- cancel Activity +- reassign Activity +- navigate to Customer +- navigate to Lead +- navigate to Opportunity +- navigate to Quotation + +All mutations must use existing Activity APIs and services. + +--- + +# Architecture Boundary + +The governed data flow remains: + +```text +Activity / Opportunity / Quotation / Approval + ↓ + Business Events + ↓ + Projection Runtime + ↓ + crm_calendar_projection + ↓ + CalendarProjectionItem + ↓ + ALLA Calendar Adapter + ↓ + Upstream Big Calendar Source +``` + +The upstream Calendar must not: + +- read database rows directly +- query source domains directly +- become a source of truth +- persist Calendar records +- mutate projection rows +- own Activity lifecycle +- own milestone lifecycle +- calculate CRM authorization +- bypass Activity APIs +- bypass Business Events + +--- + +# Scope + +## Part 1 – Current Reimplementation Inventory + +Inspect all Calendar files introduced by EP.1.6.2. + +Expected candidates include: + +```text +src/features/crm/calendar/big-calendar/provider.tsx +src/features/crm/calendar/big-calendar/header.tsx +src/features/crm/calendar/big-calendar/views.tsx +src/features/crm/calendar/big-calendar/calendar-slot.tsx +src/features/crm/calendar/big-calendar/event-card.tsx +src/features/crm/calendar/big-calendar/helpers.ts +src/features/crm/calendar/big-calendar/types.ts +``` + +Classify every file as: + +- remove +- replace with upstream source +- retain as adapter +- retain as ALLA integration wrapper + +Produce a removal and replacement matrix before implementation. + +## Part 2 – Import Upstream Source + +Copy the required upstream Calendar source into an isolated directory. + +Recommended structure: + +```text +src/features/crm/calendar/upstream-big-calendar/ +├── components/ +├── contexts/ +├── hooks/ +├── helpers/ +├── interfaces/ +├── types/ +├── styles/ +├── LICENSE +└── UPSTREAM.md +``` + +Rules: + +- preserve upstream structure where practical +- preserve upstream file names where practical +- preserve attribution +- keep local modifications reviewable +- do not mix ALLA business integration into upstream files +- record every modified upstream file + +## Part 3 – Preserve Upstream Composition + +Use the upstream composition directly. + +Expected structure: + +```text +CalendarProvider + ↓ +ClientContainer / CalendarContainer + ↓ +CalendarHeader + ↓ +Selected View + ├── Day + ├── Week + ├── Month + ├── Agenda + └── Year +``` + +Do not wrap upstream views inside a replacement Calendar layout that changes their structure. + +## Part 4 – Replace Current Active Calendar UI + +Update: + +```text +/dashboard/crm/calendar +``` + +to render the actual upstream Calendar source. + +The ALLA Calendar Workspace may retain only: + +- `PageContainer` +- permission boundary +- server and React Query data loading +- projection adapter +- user adapter +- Activity mutation handlers +- event detail sheet +- optional supporting status messages + +Remove the locally recreated active Calendar renderer. + +## Part 5 – Calendar Projection Adapter + +Implement or revise: + +```ts +mapCalendarProjectionToUpstreamEvent( + item: CalendarProjectionItem +): UpstreamCalendarEvent +``` + +The mapping must include: + +- id +- title +- description +- start date +- end date +- all-day state +- timezone +- location +- owner +- assignee +- avatar +- Activity type +- Calendar category +- priority +- overdue state +- Hot Project state +- editable state +- milestone state +- source entity type +- source entity id +- customer id +- lead id +- opportunity id +- quotation id +- pricing-sensitive state +- internal-only state + +The upstream component must never receive unrestricted database rows. + +## Part 6 – Permission-Aware User Adapter + +Implement: + +```ts +mapCrmCalendarUserToUpstreamUser() +``` + +User data must come from a server-authorized Calendar user endpoint. + +Support: + +- current user +- permitted team members +- manager scope +- branch scope +- product scope +- existing display resolver +- avatar resolver + +Do not load every organization user and hide unauthorized users in the browser. + +## Part 7 – Direct Header and Toolbar Adoption + +Use the upstream header and toolbar directly for: + +- Today +- Previous +- Next +- Date-range title +- View selector +- User filter +- Date navigation + +A thin URL synchronization adapter may persist: + +- selected date +- selected view +- lens +- selected users + +Do not recreate the toolbar. + +## Part 8 – Direct Day View Adoption + +Use upstream Day view directly. + +Required behavior: + +- real time grid +- working-hour rows +- event positioning based on start and end time +- overlapping-event layout +- current-time indicator +- Today highlight +- drag targets +- resize behavior +- empty grid when there are no events + +Do not render a manual list of hour cards as Day view. + +## Part 9 – Direct Week View Adoption + +Use upstream Week view directly. + +Required behavior: + +- seven-day time grid +- current-day highlight +- current-time indicator +- all-day area where supported +- overlapping events +- event drag between days and times +- resize behavior +- empty grid when there are no events + +Do not recreate the Week grid. + +## Part 10 – Direct Month View Adoption + +Use upstream Month view directly. + +Required behavior: + +- upstream month-cell layout +- upstream row sizing +- Today highlight +- event stacking +- event overflow +- multi-day events +- date-cell interaction +- empty month grid +- responsive event density + +Do not render independent card-style date cells. + +## Part 11 – Direct Agenda View Adoption + +Use upstream Agenda view directly. + +Agenda may show a full list-style empty state when no events exist. + +Remove the separately recreated ALLA Agenda renderer when upstream Agenda is available. + +## Part 12 – Year View Decision + +Adopt upstream Year view when compatible. + +Year view may be excluded only when documented evidence shows: + +- incompatibility +- unacceptable performance +- accessibility issues +- unsupported data behavior + +Document the final decision. + +## Part 13 – Direct Drag-and-Drop Integration + +Use upstream drag-and-drop behavior. + +For editable Activities: + +```text +Upstream Drag + ↓ +ALLA Interaction Adapter + ↓ +Activity Reschedule Command + ↓ +Activity API + ↓ +Business Event Outbox + ↓ +Calendar Projection Refresh +``` + +Rules: + +- permission validated by server +- read-only milestone cannot move +- completed Activity cannot move +- cancelled Activity cannot move +- failed mutation restores the original position +- projection rows are never mutated directly + +## Part 14 – Direct Resize Integration + +Use the actual upstream resize interaction. + +Remove the current duration-extension approximation. + +For editable Activities: + +- support start/end resizing as provided upstream +- validate minimum duration +- validate Activity lifecycle +- call Activity API +- rollback on failure +- reconcile with projection update + +Milestones must not show resize handles. + +## Part 15 – Upstream Event Rendering + +Use upstream event rendering and event-positioning logic. + +Minimal ALLA-specific rendering additions may show: + +- title +- Activity type +- milestone type +- customer +- opportunity or quotation +- owner or assignee +- overdue badge +- Hot Project badge +- read-only badge + +Do not replace every upstream event with an unrelated shadcn card. + +## Part 16 – Event Detail Integration + +Selecting an event opens an ALLA integration surface outside the upstream rendering core. + +### Editable Activity + +Support: + +- Activity detail +- Complete +- Reschedule +- Cancel +- Reassign +- Open source record + +### Read-Only Milestone + +Support: + +- Open Opportunity +- Open Quotation +- Open Approval +- Show milestone explanation +- Create related Activity + +The detail surface must not become a second Calendar state store. + +## Part 17 – Empty-State Behavior + +### Day / Week / Month + +Always render the upstream Calendar grid. + +When empty: + +- pass an empty event list +- preserve navigation +- preserve time and date slots +- optionally show a lightweight non-blocking hint +- allow Activity creation from a slot where supported + +Do not replace the Calendar with a full-page empty card. + +### Agenda + +Use the upstream list-style empty state or a minimally themed version. + +## Part 18 – Optimistic Update and Projection Reconciliation + +Because Calendar projection updates asynchronously: + +- keep optimistic event position after successful Activity API mutation +- track pending projection reconciliation +- invalidate and refetch Calendar data +- confirm updated schedule or projection version +- avoid snapping back to stale projection data +- rollback immediately when Activity API fails +- show a controlled error when reconciliation times out + +Document the reconciliation state machine. + +## Part 19 – Theme Token Integration + +Apply only semantic ALLA OS theme mapping: + +- typography +- background +- foreground +- borders +- focus ring +- dark mode +- icons +- buttons +- badges + +Do not modify upstream structural layout merely to match existing custom Calendar code. + +The visual target is: + +> Upstream Big Calendar with ALLA OS theme tokens. + +## Part 20 – Dependency Compatibility Review + +Review upstream dependencies against: + +- Next.js 16 +- React 19 +- Tailwind CSS 4 +- shadcn/ui +- date-fns +- drag-and-drop library +- TypeScript +- Oxlint +- current build configuration + +When a dependency is required for upstream parity, perform a compatibility and security review. + +Do not replace a dependency-driven upstream interaction with an incomplete custom approximation merely to avoid installing the dependency. + +## Part 21 – Accessibility Validation + +Validate: + +- keyboard navigation +- focus visibility +- screen-reader labels +- date-cell semantics +- event semantics +- drag accessibility +- resize accessibility +- color contrast +- dark mode +- reduced motion + +Document all deviations from upstream. + +## Part 22 – Responsive Validation + +Validate: + +- desktop +- laptop +- tablet +- mobile + +Agenda may remain the default mobile view. + +Day, Week, and Month must remain available. + +Do not replace mobile views with unrelated custom renderers. + +## Part 23 – Remove Obsolete Custom Code + +After upstream parity is verified: + +- delete the custom Day renderer +- delete the custom Week renderer +- delete the custom Month renderer +- delete the custom Agenda renderer +- delete the custom Calendar header +- delete the custom slot renderer +- delete the custom event-positioning logic +- delete the resize approximation +- delete unused styles +- delete dead hooks +- delete unused types and helpers + +Do not keep two active Calendar implementations. + +## Part 24 – Feature Flag and Rollback + +A temporary feature flag may be used: + +```text +CRM_BIG_CALENDAR_DIRECT_UI +``` + +Support: + +- upstream direct Calendar UI +- previous Calendar UI only as short-lived rollback + +After acceptance and stabilization: + +- remove the old implementation +- remove the rollback flag +- document the removal + +## Part 25 – Upstream License and Tracking + +Preserve the MIT license. + +Create: + +```text +src/features/crm/calendar/upstream-big-calendar/LICENSE +src/features/crm/calendar/upstream-big-calendar/UPSTREAM.md +``` + +Document: + +- repository URL +- commit hash +- copied files +- modified files +- removed files +- dependencies added +- local patch notes +- synchronization procedure + +--- + +# Deliverables + +1. Custom Calendar Removal Matrix +2. Upstream Source Import +3. Upstream Provider Integration +4. Upstream Container Integration +5. Upstream Header and Toolbar +6. Upstream Day View +7. Upstream Week View +8. Upstream Month View +9. Upstream Agenda View +10. Year View Adoption Decision +11. Calendar Projection Adapter +12. Permission-Aware User Adapter +13. Direct Drag-and-Drop Integration +14. Direct Resize Integration +15. Event Detail Integration +16. Activity Command Integration +17. Milestone Read-Only Integration +18. Empty-State Parity +19. Optimistic Projection Reconciliation +20. Theme Token Integration +21. Dependency Compatibility Report +22. Accessibility Review +23. Responsive Browser Review +24. Obsolete Custom Code Removal +25. Feature Flag and Rollback Plan +26. Upstream License and Attribution Record +27. Visual Parity Report +28. Regression Report + +--- + +# Visual Parity Requirement + +Compare ALLA OS Calendar with upstream using equivalent: + +- selected date +- selected view +- event data +- working hours +- users +- viewport +- light mode +- dark mode + +Verify: + +- header composition +- toolbar +- navigation +- view switching +- Day grid +- Week grid +- Month grid +- Agenda layout +- Year layout when adopted +- event positioning +- overlapping events +- multi-day events +- event overflow +- current-time indicator +- Today highlight +- drag behavior +- resize behavior +- responsive behavior + +Minor theme-token differences are acceptable. + +Structural differences caused by reimplementation are not acceptable. + +--- + +# Constraints + +Must preserve: + +- Activity ownership +- Opportunity ownership +- Quotation ownership +- Approval ownership +- Calendar Projection +- Business Events +- Transactional Outbox +- Projection Runtime +- CRM authorization +- Calendar API +- Timeline behavior +- My Day behavior +- existing source-domain services + +Must not: + +- recreate Calendar views +- retain active custom Day/Week/Month renderers +- retain the custom Agenda renderer +- create local Calendar event storage +- mutate projection rows directly +- bypass Activity APIs +- trust client-side permission flags +- import upstream demo backend +- import upstream demo database +- import upstream mock users +- import upstream app shell +- duplicate Activity logic +- duplicate Calendar business logic + +No schema changes are expected unless an upstream integration requirement is reviewed and explicitly approved. + +--- + +# Testing Requirements + +## Visual Tests + +- Day view parity +- Week view parity +- Month view parity +- Agenda view parity +- Year view parity when adopted +- light mode +- dark mode +- desktop +- tablet +- mobile + +## Interaction Tests + +- Today +- Previous +- Next +- view switching +- date selection +- user filter +- event selection +- Activity drag +- Activity resize +- milestone drag prevention +- milestone resize prevention +- API error rollback +- projection reconciliation + +## Layout Tests + +- overlapping events +- multi-day events +- all-day events +- empty Day +- empty Week +- empty Month +- empty Agenda +- long event titles +- many events in one day +- events outside working hours + +## Permission Tests + +- own Activity +- assigned Activity +- manager-permitted Activity +- unauthorized Activity +- internal-only Activity +- pricing-sensitive milestone +- read-only milestone +- completed Activity +- cancelled Activity + +## Regression Tests + +- Calendar API unchanged +- Calendar Projection unchanged +- Activity API unchanged +- Timeline unchanged +- My Day unchanged +- Dashboard unchanged +- Reports unchanged +- Notification unchanged +- Projection Worker unchanged +- no duplicate Activity event publication +- no direct projection mutation + +## Quality Checks + +- `npm run typecheck` +- targeted Oxlint +- Calendar unit tests +- interaction tests +- browser tests +- accessibility checks + +--- + +# Acceptance Criteria + +- The current reimplemented Calendar UI is removed from the active route. +- ALLA OS uses actual upstream `lramos33/big-calendar` source components. +- Month, Week, Day, Agenda, header, toolbar, navigation, event layout, drag, and resize are not recreated locally. +- Calendar structure and interaction reach upstream parity, except for approved ALLA OS theme-token differences. +- Calendar data continues to pass through `CalendarProjectionItem` and the adapter layer. +- Editable operations continue through Activity APIs. +- Read-only milestones cannot be moved or resized. +- CRM permission enforcement remains server authoritative. +- Day, Week, and Month render genuine upstream Calendar grids when empty. +- Agenda uses the upstream list presentation. +- Optimistic drag and resize reconcile safely with asynchronous projection updates. +- Obsolete custom Calendar code is removed. +- Upstream MIT license and pinned commit are documented. +- Existing Activity, Calendar Projection, Timeline, My Day, Dashboard, Reports, Approval, Notification, and Projection Runtime behavior remains unchanged. +- Visual parity, browser, responsive, interaction, accessibility, and regression validation pass. + +--- + +# Success Criteria + +ALLA OS uses Big Calendar itself—not a locally recreated approximation. + +The Calendar Workspace preserves the upstream Calendar structure, views, event layout, drag-and-drop, resize, toolbar, navigation, responsive behavior, and dark-mode experience. + +ALLA OS continues to own data, Activity commands, Business Events, Projection Runtime, security, and source-domain business rules. diff --git a/plans/task-ep.1.6.2.md b/plans/task-ep.1.6.2.md new file mode 100644 index 0000000..54e93da --- /dev/null +++ b/plans/task-ep.1.6.2.md @@ -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. diff --git a/src/features/crm/calendar/api/types.ts b/src/features/crm/calendar/api/types.ts index 3f6fbf2..21142d5 100644 --- a/src/features/crm/calendar/api/types.ts +++ b/src/features/crm/calendar/api/types.ts @@ -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; diff --git a/src/features/crm/calendar/components/calendar-adapter.ts b/src/features/crm/calendar/components/calendar-adapter.ts index 07193e6..c35c537 100644 --- a/src/features/crm/calendar/components/calendar-adapter.ts +++ b/src/features/crm/calendar/components/calendar-adapter.ts @@ -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"; +} diff --git a/src/features/crm/calendar/components/calendar-workspace.tsx b/src/features/crm/calendar/components/calendar-workspace.tsx index ca04765..3764afe 100644 --- a/src/features/crm/calendar/components/calendar-workspace.tsx +++ b/src/features/crm/calendar/components/calendar-workspace.tsx @@ -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; } -const VIEW_LABELS: Record = { - day: "Day", - week: "Week", - month: "Month", - agenda: "Agenda", -}; - export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) { - const [view, setView] = useState("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 (
- +
@@ -59,8 +91,7 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) { Calendar Workspace

- Projection workspace for scheduled activities and read-only - business milestones. + Upstream Big Calendar UI powered by ALLA Calendar Projection.

@@ -73,50 +104,23 @@ export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) { className="w-full pl-8 sm:w-[240px]" />
- - Lens: {filters.lens ?? "personal"} - + Lens: {filters.lens ?? "personal"} + {rescheduleMutation.isPending ? ( + Reconciling projection... + ) : null}
- setView(value as CalendarView)} + rescheduleMutation.mutate(event)} > - - {Object.entries(VIEW_LABELS).map(([value, label]) => ( - - {label} - - ))} - - - - - - - - - - - - - - - + + @@ -131,21 +135,17 @@ export function CalendarWorkspaceSkeleton() { ))} - + ); } -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 ; - } +function buildCalendarUsers(events: IEvent[]): IUser[] { + const users = new Map(); - return ( -
- {groupedEvents.map(([date, events]) => ( -
-
-
-

- {formatDate(date)} -

-
-
-
- {events.map((event) => ( - - ))} -
-
- ))} -
- ); -} - -function CompactCalendarGrid({ - events, - view, - anchorDate, -}: { - events: BigCalendarEvent[]; - view: "day" | "week" | "month"; - anchorDate: string; -}) { - const cells = buildCalendarGridCells({ events, view, anchorDate }); - const isMonth = view === "month"; - - return ( -
-
- {cells.map((cell) => ( - - ))} -
- {!events.length ? : null} -
- ); -} - -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 ( -
-
- -

No scheduled work. This {view} is free.

-

- The calendar grid is still available for selecting dates and planning Activities. -

- -
-
- ); -} - -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 ( -
-
-
-
- - {event.category.replaceAll("_", " ")} - - {event.display.overdue ? ( - Overdue - ) : null} - {event.display.hotProject ? ( - Hot Project - ) : null} - {!event.editable ? ( - Read-only - ) : null} -
-

{event.title}

- {event.display.summary ? ( -

- {event.display.summary} -

- ) : null} -

- {formatTime(event.start)} - {formatTime(event.end)} - {event.display.location ? ` · ${event.display.location}` : ""} -

-
-
- - {event.editable ? ( - <> - - - - ) : ( - - )} -
-
-
- ); -} - -function CalendarEventPill({ event }: { event: BigCalendarEvent }) { - return ( -
- {formatTime(event.start)}{" "} - {event.title} -
- ); -} - -function CalendarEmptyState() { - return ( -
- -

No scheduled work in this period

-

- Try changing filters or create an Activity from the owning CRM record. -

-
- ); -} - -function groupEventsByDate( - events: BigCalendarEvent[], -): Array<[string, BigCalendarEvent[]]> { - const grouped = new Map(); for (const event of events) { - const key = event.start.slice(0, 10); - grouped.set(key, [...(grouped.get(key) ?? []), event]); + users.set(event.user.id, event.user); } - return [...grouped.entries()]; -} -type CalendarWorkspaceSummary = { - dueToday: number; - overdue: number; - meetings: number; - visits: number; - milestones: number; - approvalDue: number; - hotProjects: number; -}; + return [...users.values()].toSorted((left, right) => left.name.localeCompare(right.name)); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/LICENSE b/src/features/crm/calendar/upstream-big-calendar/LICENSE new file mode 100644 index 0000000..6e3ce50 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/LICENSE @@ -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. \ No newline at end of file diff --git a/src/features/crm/calendar/upstream-big-calendar/UPSTREAM.md b/src/features/crm/calendar/upstream-big-calendar/UPSTREAM.md new file mode 100644 index 0000000..921111d --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/UPSTREAM.md @@ -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`. diff --git a/src/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-day-group.tsx b/src/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-day-group.tsx new file mode 100644 index 0000000..29c310b --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-day-group.tsx @@ -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 ( +
+
+

{format(date, "EEEE, MMMM d, yyyy")}

+
+ +
+ {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 ; + })} + + {sortedEvents.length > 0 && sortedEvents.map(event => )} +
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-event-card.tsx b/src/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-event-card.tsx new file mode 100644 index 0000000..886cbf5 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/agenda-view/agenda-event-card.tsx @@ -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["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 ( + +
+
+
+ {["mixed", "dot"].includes(badgeVariant) && ( + + + + )} + +

+ {eventCurrentDay && eventTotalDays && ( + + Day {eventCurrentDay} of {eventTotalDays} •{" "} + + )} + {event.title} +

+
+ +
+ +

{event.user.name}

+
+ +
+ +

+ {format(startDate, "h:mm a")} - {format(endDate, "h:mm a")} +

+
+ +
+ +

{event.description}

+
+
+
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/agenda-view/calendar-agenda-view.tsx b/src/features/crm/calendar/upstream-big-calendar/components/agenda-view/calendar-agenda-view.tsx new file mode 100644 index 0000000..3deb626 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/agenda-view/calendar-agenda-view.tsx @@ -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(); + + 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 ( +
+ +
+ {eventsByDay.map(dayGroup => ( + + ))} + + {!hasAnyEvents && ( +
+ +

No events scheduled for the selected month

+
+ )} +
+
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/avatar-group.tsx b/src/features/crm/calendar/upstream-big-calendar/components/avatar-group.tsx new file mode 100644 index 0000000..6cca98a --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/avatar-group.tsx @@ -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
{children}
; +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/change-badge-variant-input.tsx b/src/features/crm/calendar/upstream-big-calendar/components/change-badge-variant-input.tsx new file mode 100644 index 0000000..06f17bc --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/change-badge-variant-input.tsx @@ -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 ( +
+

Change badge variant

+ + +
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/client-container.tsx b/src/features/crm/calendar/upstream-big-calendar/components/client-container.tsx new file mode 100644 index 0000000..af19735 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/client-container.tsx @@ -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 ( +
+ + + {selectedView === "day" && ( + + )} + {selectedView === "month" && ( + + )} + {selectedView === "week" && ( + + )} + {selectedView === "year" && } + {selectedView === "agenda" && ( + + )} + +
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/dialogs/add-event-dialog.tsx b/src/features/crm/calendar/upstream-big-calendar/components/dialogs/add-event-dialog.tsx new file mode 100644 index 0000000..220f133 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/dialogs/add-event-dialog.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/dialogs/edit-event-dialog.tsx b/src/features/crm/calendar/upstream-big-calendar/components/dialogs/edit-event-dialog.tsx new file mode 100644 index 0000000..4931f0f --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/dialogs/edit-event-dialog.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/dialogs/event-details-dialog.tsx b/src/features/crm/calendar/upstream-big-calendar/components/dialogs/event-details-dialog.tsx new file mode 100644 index 0000000..e78cd3b --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/dialogs/event-details-dialog.tsx @@ -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 ( + <> + + {children} + + + + {event.title} + + +
+
+ +
+

Responsible

+

{event.user.name}

+
+
+ +
+ +
+

Start Date

+

{format(startDate, "MMM d, yyyy h:mm a")}

+
+
+ +
+ +
+

End Date

+

{format(endDate, "MMM d, yyyy h:mm a")}

+
+
+ +
+ +
+

Description

+

{event.description}

+
+
+
+ + + + + + +
+
+ + ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/dnd/custom-drag-layer.tsx b/src/features/crm/calendar/upstream-big-calendar/components/dnd/custom-drag-layer.tsx new file mode 100644 index 0000000..e3fb8ce --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/dnd/custom-drag-layer.tsx @@ -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 ( +
+
+ {item.children} +
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/dnd/dnd-provider.tsx b/src/features/crm/calendar/upstream-big-calendar/components/dnd/dnd-provider.tsx new file mode 100644 index 0000000..42cb2fb --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/dnd/dnd-provider.tsx @@ -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 ( + + {children} + + + ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event.tsx b/src/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event.tsx new file mode 100644 index 0000000..43ce4e3 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/dnd/draggable-event.tsx @@ -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(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 ( +
+ {children} +
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-day-cell.tsx b/src/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-day-cell.tsx new file mode 100644 index 0000000..f5509df --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-day-cell.tsx @@ -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 ( +
} className={cn(isOver && canDrop && "bg-accent/50")}> + {children} +
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-time-block.tsx b/src/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-time-block.tsx new file mode 100644 index 0000000..0c077a8 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/dnd/droppable-time-block.tsx @@ -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 ( +
} className={cn("h-[24px]", isOver && canDrop && "bg-accent/50")}> + {children} +
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/header/calendar-header.tsx b/src/features/crm/calendar/upstream-big-calendar/components/header/calendar-header.tsx new file mode 100644 index 0000000..2722437 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/header/calendar-header.tsx @@ -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 ( +
+
+ + +
+ +
+
+
+ {VIEW_BUTTONS.map((item, index) => { + const Icon = item.icon; + + return ( + + ); + })} +
+ + +
+ + + + +
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/header/date-navigator.tsx b/src/features/crm/calendar/upstream-big-calendar/components/header/date-navigator.tsx new file mode 100644 index 0000000..392026f --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/header/date-navigator.tsx @@ -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 ( +
+
+ + {month} {year} + + + {eventCount} events + +
+ +
+ + +

{rangeText(view, selectedDate)}

+ + +
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/header/today-button.tsx b/src/features/crm/calendar/upstream-big-calendar/components/header/today-button.tsx new file mode 100644 index 0000000..3c0c4fd --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/header/today-button.tsx @@ -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 ( + + ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/header/user-select.tsx b/src/features/crm/calendar/upstream-big-calendar/components/header/user-select.tsx new file mode 100644 index 0000000..cef4643 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/header/user-select.tsx @@ -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 ( + + ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/month-view/calendar-month-view.tsx b/src/features/crm/calendar/upstream-big-calendar/components/month-view/calendar-month-view.tsx new file mode 100644 index 0000000..7c8d9ea --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/month-view/calendar-month-view.tsx @@ -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 ( +
+
+ {WEEK_DAYS.map(day => ( +
+ {day} +
+ ))} +
+ +
+ {cells.map(cell => ( + + ))} +
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/month-view/day-cell.tsx b/src/features/crm/calendar/upstream-big-calendar/components/month-view/day-cell.tsx new file mode 100644 index 0000000..d99375e --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/month-view/day-cell.tsx @@ -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; +} + +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 ( + +
+ + +
+ {[0, 1, 2].map(position => { + const event = cellEvents.find(e => e.position === position); + const eventKey = event ? `event-${event.id}-${position}` : `empty-${position}`; + + return ( +
+ {event && ( + <> + + + + )} +
+ ); + })} +
+ + {cellEvents.length > MAX_VISIBLE_EVENTS && ( +

+ +{cellEvents.length - MAX_VISIBLE_EVENTS} + {cellEvents.length - MAX_VISIBLE_EVENTS} more... +

+ )} +
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/month-view/event-bullet.tsx b/src/features/crm/calendar/upstream-big-calendar/components/month-view/event-bullet.tsx new file mode 100644 index 0000000..a66141c --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/month-view/event-bullet.tsx @@ -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
; +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/month-view/month-event-badge.tsx b/src/features/crm/calendar/upstream-big-calendar/components/month-view/month-event-badge.tsx new file mode 100644 index 0000000..6886304 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/month-view/month-event-badge.tsx @@ -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, "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["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 ( + + +
+
+ {!["middle", "last"].includes(position) && ["mixed", "dot"].includes(badgeVariant) && ( + + + + )} + + {renderBadgeText && ( +

+ {eventCurrentDay && ( + + Day {eventCurrentDay} of {eventTotalDays} •{" "} + + )} + {event.title} +

+ )} +
+ + {renderBadgeText && {format(new Date(event.startDate), "h:mm a")}} +
+
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/single-calendar.tsx b/src/features/crm/calendar/upstream-big-calendar/components/single-calendar.tsx new file mode 100644 index 0000000..d65831e --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/single-calendar.tsx @@ -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 ( + + ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-day-view.tsx b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-day-view.tsx new file mode 100644 index 0000000..6512876 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-day-view.tsx @@ -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 ( +
+
+
+ + + {/* Day header */} +
+
+ + {format(selectedDate, "EE")} {format(selectedDate, "d")} + +
+
+ + +
+ {/* Hours column */} +
+ {hours.map((hour, index) => ( +
+
+ {index !== 0 && {format(new Date().setHours(hour, 0, 0, 0), "hh a")}} +
+
+ ))} +
+ + {/* Day grid */} +
+
+ {hours.map((hour, index) => { + const isDisabled = !isWorkingHour(selectedDate, hour, workingHours); + + return ( +
+ {index !== 0 &&
} + + + +
+ + + + + +
+ + + +
+ + + +
+ + + + + +
+ + +
+ ); + })} + + {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 ( +
+ +
+ ); + }) + )} +
+ + +
+
+ +
+ +
+ + +
+ {currentEvents.length > 0 ? ( +
+ + + + + +

Happening now

+
+ ) : ( +

No appointments or consultations at the moment

+ )} + + {currentEvents.length > 0 && ( + +
+ {currentEvents.map(event => { + const user = users.find(user => user.id === event.user.id); + + return ( +
+

{event.title}

+ + {user && ( +
+ + {user.name} +
+ )} + +
+ + {format(new Date(), "MMM d, yyyy")} +
+ +
+ + + {format(parseISO(event.startDate), "h:mm a")} - {format(parseISO(event.endDate), "h:mm a")} + +
+
+ ); + })} +
+
+ )} +
+
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-time-line.tsx b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-time-line.tsx new file mode 100644 index 0000000..2f9082d --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-time-line.tsx @@ -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 ( +
+
+
{formatCurrentTime()}
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-week-view.tsx b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-week-view.tsx new file mode 100644 index 0000000..6ca0c60 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/calendar-week-view.tsx @@ -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 ( + <> +
+

Weekly view is not available on smaller devices.

+

Please switch to daily or monthly view.

+
+ +
+
+ + + {/* Week header */} +
+
+
+ {weekDays.map((day, index) => ( + + {format(day, "EE")} {format(day, "d")} + + ))} +
+
+
+ + +
+ {/* Hours column */} +
+ {hours.map((hour, index) => ( +
+
+ {index !== 0 && {format(new Date().setHours(hour, 0, 0, 0), "hh a")}} +
+
+ ))} +
+ + {/* Week grid */} +
+
+ {weekDays.map((day, dayIndex) => { + const dayEvents = singleDayEvents.filter(event => isSameDay(parseISO(event.startDate), day) || isSameDay(parseISO(event.endDate), day)); + const groupedEvents = groupEvents(dayEvents); + + return ( +
+ {hours.map((hour, index) => { + const isDisabled = !isWorkingHour(day, hour, workingHours); + + return ( +
+ {index !== 0 &&
} + + + +
+ + + + + +
+ + + +
+ + + +
+ + + + + +
+ + +
+ ); + })} + + {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 ( +
+ +
+ ); + }) + )} +
+ ); + })} +
+ + +
+
+ +
+ + ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/day-view-multi-day-events-row.tsx b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/day-view-multi-day-events-row.tsx new file mode 100644 index 0000000..f340a59 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/day-view-multi-day-events-row.tsx @@ -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 ( +
+
+
+ {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 ; + })} +
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/event-block.tsx b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/event-block.tsx new file mode 100644 index 0000000..a2c8e6e --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/event-block.tsx @@ -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, Omit, "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["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 ( + + +
+
+ {["mixed", "dot"].includes(badgeVariant) && ( + + + + )} + +

{event.title}

+
+ + {durationInMinutes > 25 && ( +

+ {format(start, "h:mm a")} - {format(end, "h:mm a")} +

+ )} +
+
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/week-view-multi-day-events-row.tsx b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/week-view-multi-day-events-row.tsx new file mode 100644 index 0000000..d3cc303 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/week-and-day-view/week-view-multi-day-events-row.tsx @@ -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 ( +
+
+
+ {weekDays.map((day, dayIndex) => ( +
+ {eventRows.map((row, rowIndex) => { + const event = row.find(e => e.startIndex <= dayIndex && e.endIndex >= dayIndex); + + if (!event) { + return
; + } + + 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 ; + })} +
+ ))} +
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/year-view/calendar-year-view.tsx b/src/features/crm/calendar/upstream-big-calendar/components/year-view/calendar-year-view.tsx new file mode 100644 index 0000000..6b2491c --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/year-view/calendar-year-view.tsx @@ -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 ( +
+
+ {months.map(month => ( + + ))} +
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-day-cell.tsx b/src/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-day-cell.tsx new file mode 100644 index 0000000..85e5258 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-day-cell.tsx @@ -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 ( + + ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-month.tsx b/src/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-month.tsx new file mode 100644 index 0000000..f7222fb --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/components/year-view/year-view-month.tsx @@ -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 ( +
+ + +
+
+ {weekDays.map((day, index) => ( +
+ {day} +
+ ))} +
+ +
+ {daysInMonth.map((day, index) => { + if (day === null) return
; + + 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 ; + })} +
+
+
+ ); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/contexts/calendar-context.tsx b/src/features/crm/calendar/upstream-big-calendar/contexts/calendar-context.tsx new file mode 100644 index 0000000..74cf640 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/contexts/calendar-context.tsx @@ -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>; + visibleHours: TVisibleHours; + setVisibleHours: Dispatch>; + events: IEvent[]; + setLocalEvents: Dispatch>; + onEventChange?: (event: IEvent) => void; +} + +const CalendarContext = createContext(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("colored"); + const [visibleHours, setVisibleHours] = useState(VISIBLE_HOURS); + const [workingHours, setWorkingHours] = useState(WORKING_HOURS); + const [selectedDate, setSelectedDateState] = useState(() => + initialDate ? new Date(initialDate) : new Date() + ); + const [selectedView, setSelectedView] = useState(initialView); + const [selectedUserId, setSelectedUserId] = useState("all"); + const [localEvents, setLocalEvents] = useState(events); + + useEffect(() => { + setLocalEvents(events); + }, [events]); + + const setSelectedDate = (date: Date | undefined) => { + if (date) setSelectedDateState(date); + }; + + return ( + + {children} + + ); +} + +export function useCalendar() { + const context = useContext(CalendarContext); + if (!context) throw new Error("useCalendar must be used within CalendarProvider."); + return context; +} diff --git a/src/features/crm/calendar/upstream-big-calendar/helpers.ts b/src/features/crm/calendar/upstream-big-calendar/helpers.ts new file mode 100644 index 0000000..b9b2091 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/helpers.ts @@ -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) { + 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; + }); +} diff --git a/src/features/crm/calendar/upstream-big-calendar/hooks/use-update-event.ts b/src/features/crm/calendar/upstream-big-calendar/hooks/use-update-event.ts new file mode 100644 index 0000000..86c9b4a --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/hooks/use-update-event.ts @@ -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 }; +} diff --git a/src/features/crm/calendar/upstream-big-calendar/icons.tsx b/src/features/crm/calendar/upstream-big-calendar/icons.tsx new file mode 100644 index 0000000..9e051f5 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/icons.tsx @@ -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; diff --git a/src/features/crm/calendar/upstream-big-calendar/interfaces.ts b/src/features/crm/calendar/upstream-big-calendar/interfaces.ts new file mode 100644 index 0000000..fcd08a8 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/interfaces.ts @@ -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; +} diff --git a/src/features/crm/calendar/upstream-big-calendar/types.ts b/src/features/crm/calendar/upstream-big-calendar/types.ts new file mode 100644 index 0000000..032a862 --- /dev/null +++ b/src/features/crm/calendar/upstream-big-calendar/types.ts @@ -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 };