The calendar is now react-big-calendar.
This commit is contained in:
10 files changed
+956
-230
No files matched your search
@@ -0,0 +1,55 @@
|
|||||||
|
# Calendar
|
||||||
|
|
||||||
|
A living reference for the day calendar on `/staff` (a barber's own day) and
|
||||||
|
`/owner/calendar` (everyone, one column per barber). Both render
|
||||||
|
`src/features/staff/components/day-calendar.tsx`.
|
||||||
|
|
||||||
|
## What it's built on
|
||||||
|
|
||||||
|
[react-big-calendar](https://github.com/jquense/react-big-calendar) (MIT) with
|
||||||
|
its drag-and-drop addon, restyled to match the app in `day-calendar.css`
|
||||||
|
(scoped under `.day-cal`). It was chosen over DayPilot Lite mainly because
|
||||||
|
per-barber columns, a current-time line and sideways scrolling on a phone all
|
||||||
|
work out of the box; FullCalendar and Schedule-X put per-resource columns
|
||||||
|
behind a paid licence.
|
||||||
|
|
||||||
|
Only the day view is used. The agenda (list) view is a separate component.
|
||||||
|
|
||||||
|
## Time zones — read this before touching it
|
||||||
|
|
||||||
|
The library works in JavaScript `Date`s, which carry the **browser's** time
|
||||||
|
zone; the app's scheduling data is **business-local minutes since midnight on
|
||||||
|
a business-local date**. So the calendar never sees a real instant: times go in
|
||||||
|
as "floating" dates (minutes laid onto one fixed day — `calendar-time.ts`) and
|
||||||
|
a drop comes back out as plain minutes. The real date and the business time
|
||||||
|
zone are applied on the server in `moveCalendarAppointmentAction`.
|
||||||
|
|
||||||
|
## Drag to reschedule
|
||||||
|
|
||||||
|
Turned on per page with the `reschedule` prop (`"owner"` or `"staff"`).
|
||||||
|
|
||||||
|
- A drop **never saves on its own.** It opens a confirmation ("From 11:00 AM
|
||||||
|
with Leo → To 1:00 PM with Leo… the customer will be notified"), because a
|
||||||
|
reschedule emails the customer.
|
||||||
|
- `"owner"`: any appointment in the business, and it can be dropped into
|
||||||
|
another barber's column (a reassignment). `"staff"`: their own bookings, along
|
||||||
|
the time axis only.
|
||||||
|
- Only `pending_approval`, `confirmed` and `arrived` bookings can be moved —
|
||||||
|
the same statuses `rescheduleBooking` accepts. Breaks, time off, and finished
|
||||||
|
or in-progress bookings are not draggable. Resizing (changing the length) is
|
||||||
|
off: duration comes from the services.
|
||||||
|
- **The server is the gate.** `moveCalendarAppointmentAction` delegates to the
|
||||||
|
existing `ownerRescheduleAppointmentAction` / `rescheduleStaffAppointmentAction`,
|
||||||
|
so working hours, breaks, overlaps, the staff member's services, the business's
|
||||||
|
minimum-notice rule, the audit log, notifications, reminders and Google
|
||||||
|
Calendar sync all behave exactly as they do from the appointment screen. A
|
||||||
|
rejected move shows the reason inside the dialog and nothing is saved.
|
||||||
|
- The calendar itself does not stop you dropping onto a break or outside
|
||||||
|
someone's hours — it just fails the server check.
|
||||||
|
|
||||||
|
## Phones
|
||||||
|
|
||||||
|
Columns keep a 190px minimum and the grid scrolls sideways, with the hour
|
||||||
|
labels pinned on the left and the header row scrolling with it. Dragging was
|
||||||
|
verified with a mouse only; the library has touch handlers but this has not been
|
||||||
|
tried on a real touch screen. The appointment screen's Reschedule always works.
|
||||||
Generated
+251
-8
@@ -22,6 +22,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"image-size": "^2.0.2",
|
"image-size": "^2.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"next-auth": "^5.0.0-beta.32",
|
"next-auth": "^5.0.0-beta.32",
|
||||||
"nodemailer": "^8.0.11",
|
"nodemailer": "^8.0.11",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
|
"react-big-calendar": "^1.20.0",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.84.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-big-calendar": "^1.16.3",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
@@ -327,6 +330,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||||
|
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
@@ -2108,6 +2120,16 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
|
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/adapter-mariadb": {
|
"node_modules/@prisma/adapter-mariadb": {
|
||||||
"version": "7.9.1",
|
"version": "7.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/adapter-mariadb/-/adapter-mariadb-7.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/adapter-mariadb/-/adapter-mariadb-7.9.1.tgz",
|
||||||
@@ -3102,6 +3124,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@restart/hooks": {
|
||||||
|
"version": "0.4.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz",
|
||||||
|
"integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
|
||||||
@@ -3845,6 +3879,13 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/date-arithmetic": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/date-arithmetic/-/date-arithmetic-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -3912,16 +3953,34 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/prop-types": {
|
||||||
|
"version": "15.7.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-big-calendar": {
|
||||||
|
"version": "1.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-big-calendar/-/react-big-calendar-1.16.3.tgz",
|
||||||
|
"integrity": "sha512-CR+5BKMhlr/wPgsp+sXOeNKNkoU1h/+6H1XoWuL7xnurvzGRQv/EnM8jPS9yxxBvXI8pjQBaJcI7RTSGiewG/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/date-arithmetic": "*",
|
||||||
|
"@types/prop-types": "*",
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
@@ -3932,6 +3991,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/warning": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/web-push": {
|
"node_modules/@types/web-push": {
|
||||||
"version": "3.6.4",
|
"version": "3.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
@@ -5445,6 +5510,11 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cldrjs": {
|
||||||
|
"version": "0.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cldrjs/-/cldrjs-0.5.5.tgz",
|
||||||
|
"integrity": "sha512-KDwzwbmLIPfCgd8JERVDpQKrUUM1U4KpFJJg2IROv89rF172lLufoJnqJ/Wea6fXL5bO6WjuLMzY8V52UWPvkA=="
|
||||||
|
},
|
||||||
"node_modules/client-only": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@@ -5526,7 +5596,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
@@ -5728,6 +5797,28 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-arithmetic": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-arithmetic/-/date-arithmetic-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||||
|
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -5824,6 +5915,15 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/destr": {
|
"node_modules/destr": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
@@ -5854,6 +5954,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-helpers": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-IKySryuFwseGkrCA/pIqlwUPOD50w1Lj/B2Yief3vBOP18k5y4t+hTqKh55gULDVeJMRitcozve+g/wVFf4sFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.27.1",
|
||||||
|
"csstype": "^3.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.2",
|
"version": "17.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
@@ -7114,6 +7224,15 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/globalize": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/globalize/-/globalize-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-PFymRL0PtitFOlSniuwwwNfkooi3cLQJo9Uke1+j1DsGfUkkHkwneImqVtGcqKI0TuzhAlHt7hAcgK324902HA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cldrjs": "^0.5.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "16.4.0",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
|
||||||
@@ -7413,6 +7532,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/invariant": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
|
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -7893,7 +8021,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -8361,7 +8488,12 @@
|
|||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"devOptional": true,
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
@@ -8382,7 +8514,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -8480,6 +8611,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/memoize-one": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -8532,6 +8669,27 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/moment": {
|
||||||
|
"version": "2.31.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.31.0.tgz",
|
||||||
|
"integrity": "sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/moment-timezone": {
|
||||||
|
"version": "0.5.48",
|
||||||
|
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
|
||||||
|
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"moment": "^2.29.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -8782,7 +8940,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -9349,7 +9506,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -9444,6 +9600,34 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-big-calendar": {
|
||||||
|
"version": "1.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.20.0.tgz",
|
||||||
|
"integrity": "sha512-Lp1mvG34l/9xtb/2LsBb4UAF3iPcUkmDqbmpsvDHzp/n8GA5gtn3+nf8BsULWw08opKDgv38nQi75dQlOOqzkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.7",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-arithmetic": "^4.1.0",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"dom-helpers": "^6.0.1",
|
||||||
|
"globalize": "^1.7.1",
|
||||||
|
"invariant": "^2.2.4",
|
||||||
|
"lodash": "^4.18.1",
|
||||||
|
"lodash-es": "^4.18.1",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
|
"memoize-one": "^6.0.0",
|
||||||
|
"moment": "^2.29.4",
|
||||||
|
"moment-timezone": "^0.5.48",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-overlays": "^5.2.1",
|
||||||
|
"uncontrollable": "^7.2.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.14.0 || ^17 || ^18 || ^19",
|
||||||
|
"react-dom": "^16.14.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.8",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||||
@@ -9476,9 +9660,44 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-lifecycles-compat": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-overlays": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.8",
|
||||||
|
"@popperjs/core": "^2.11.6",
|
||||||
|
"@restart/hooks": "^0.4.7",
|
||||||
|
"@types/warning": "^3.0.0",
|
||||||
|
"dom-helpers": "^5.2.0",
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"uncontrollable": "^7.2.1",
|
||||||
|
"warning": "^4.0.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.3.0",
|
||||||
|
"react-dom": ">=16.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-overlays/node_modules/dom-helpers": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.8.7",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
@@ -10664,6 +10883,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uncontrollable": {
|
||||||
|
"version": "7.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
|
||||||
|
"integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.6.3",
|
||||||
|
"@types/react": ">=16.9.11",
|
||||||
|
"invariant": "^2.2.4",
|
||||||
|
"react-lifecycles-compat": "^3.0.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
@@ -11244,6 +11478,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/warning": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-push": {
|
"node_modules/web-push": {
|
||||||
"version": "3.6.7",
|
"version": "3.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"image-size": "^2.0.2",
|
"image-size": "^2.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"next-auth": "^5.0.0-beta.32",
|
"next-auth": "^5.0.0-beta.32",
|
||||||
"nodemailer": "^8.0.11",
|
"nodemailer": "^8.0.11",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
|
"react-big-calendar": "^1.20.0",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.84.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-big-calendar": "^1.16.3",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
|||||||
@@ -244,7 +244,13 @@ export default async function OwnerCalendarPage({
|
|||||||
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
|
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} appointmentHrefBase="/owner/appointments" />
|
<DayCalendar
|
||||||
|
columns={columns}
|
||||||
|
date={date}
|
||||||
|
currentTimeMinutes={currentTimeMinutes}
|
||||||
|
appointmentHrefBase="/owner/appointments"
|
||||||
|
reschedule="owner"
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
) : appointments.length === 0 ? (
|
) : appointments.length === 0 ? (
|
||||||
<div className="px-6">
|
<div className="px-6">
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export default async function StaffCalendarPage({
|
|||||||
description={wholeDayOff.reason ?? "Approved time off."}
|
description={wholeDayOff.reason ?? "Approved time off."}
|
||||||
/>
|
/>
|
||||||
) : view === "day" ? (
|
) : view === "day" ? (
|
||||||
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} />
|
<DayCalendar columns={columns} date={date} currentTimeMinutes={currentTimeMinutes} reschedule="staff" />
|
||||||
) : calendarAppointments.length === 0 ? (
|
) : calendarAppointments.length === 0 ? (
|
||||||
<div className="px-6 lg:px-0">
|
<div className="px-6 lg:px-0">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
|
import { wallClockToInstant } from "@/lib/time/business-time";
|
||||||
|
import { ownerRescheduleAppointmentAction } from "@/features/owner/actions/appointments";
|
||||||
|
import { rescheduleStaffAppointmentAction } from "./appointment-actions";
|
||||||
|
|
||||||
|
const moveSchema = z.object({
|
||||||
|
appointmentId: z.string().min(1),
|
||||||
|
/** The column it was dropped in — a different one than it started in is a reassignment (owner only). */
|
||||||
|
staffId: z.string().min(1),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
startMinutes: z.number().int().min(0).max(24 * 60 - 1),
|
||||||
|
mode: z.enum(["owner", "staff"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface MoveCalendarAppointmentResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-to-reschedule from the calendar. A thin adapter, not a second
|
||||||
|
* reschedule path: it only turns "this date, these business-local minutes"
|
||||||
|
* into the instant the existing actions already take, then delegates — so
|
||||||
|
* slot validation (working hours, breaks, overlaps, rules), the audit log,
|
||||||
|
* customer notifications, reminder rescheduling and Google Calendar sync all
|
||||||
|
* behave exactly as they do from the appointment screen. Each delegate
|
||||||
|
* enforces its own permission (owner: any appointment in the business;
|
||||||
|
* staff: their own only), and the `mode` here is a request, never trusted.
|
||||||
|
*/
|
||||||
|
export async function moveCalendarAppointmentAction(input: unknown): Promise<MoveCalendarAppointmentResult> {
|
||||||
|
const parsed = moveSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return { success: false, error: "That move isn't valid." };
|
||||||
|
|
||||||
|
const context = await getActingBusinessContext();
|
||||||
|
if (!context) return { success: false, error: "You must be signed in." };
|
||||||
|
|
||||||
|
const { appointmentId, staffId, date, startMinutes, mode } = parsed.data;
|
||||||
|
const startAt = wallClockToInstant(date, startMinutes, context.businessTimezone).toISOString();
|
||||||
|
|
||||||
|
const result =
|
||||||
|
mode === "owner"
|
||||||
|
? await ownerRescheduleAppointmentAction({ appointmentId, staffId, startAt })
|
||||||
|
: // A barber moves their own bookings in time only — never to someone else.
|
||||||
|
await rescheduleStaffAppointmentAction({ appointmentId, startAt });
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
revalidatePath("/owner/calendar");
|
||||||
|
revalidatePath("/staff");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
floatingDateToMinutes,
|
||||||
|
floatingDisplayDate,
|
||||||
|
floatingNotToday,
|
||||||
|
minutesToFloatingDate,
|
||||||
|
} from "../calendar-time";
|
||||||
|
|
||||||
|
describe("calendar floating time", () => {
|
||||||
|
it("round-trips every quarter hour of the day", () => {
|
||||||
|
for (let minutes = 0; minutes < 24 * 60; minutes += 15) {
|
||||||
|
expect(floatingDateToMinutes(minutesToFloatingDate(minutes))).toBe(minutes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts every time on the same single calendar day, regardless of the minute", () => {
|
||||||
|
const day = (m: number) => minutesToFloatingDate(m).toDateString();
|
||||||
|
expect(day(0)).toBe(day(7 * 60));
|
||||||
|
expect(day(0)).toBe(day(23 * 60 + 45));
|
||||||
|
expect(day(0)).toBe(floatingDisplayDate().toDateString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'not today' is a different day than the displayed one", () => {
|
||||||
|
expect(floatingNotToday().toDateString()).not.toBe(floatingDisplayDate().toDateString());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* The calendar library works in JavaScript `Date`s, which carry the
|
||||||
|
* BROWSER's time zone — but this app's scheduling data is in business-local
|
||||||
|
* minutes-since-midnight on a business-local calendar date (see
|
||||||
|
* lib/time/business-time.ts). Mixing the two is how a barbershop in
|
||||||
|
* London shows a 10:00 booking at 05:00 to someone viewing from New York.
|
||||||
|
*
|
||||||
|
* So the calendar never sees a real instant. It's handed "floating" dates —
|
||||||
|
* minutes laid onto one fixed, arbitrary day — and reports a drop back as
|
||||||
|
* plain minutes-since-midnight; the actual date and the business time zone
|
||||||
|
* are applied on the server (see features/staff/actions/calendar-move.ts).
|
||||||
|
* The day is a mid-winter Monday so no time zone's DST change can land on it.
|
||||||
|
*/
|
||||||
|
const BASE_YEAR = 2000;
|
||||||
|
const BASE_MONTH = 0;
|
||||||
|
const BASE_DAY = 3;
|
||||||
|
|
||||||
|
export function minutesToFloatingDate(minutes: number): Date {
|
||||||
|
return new Date(BASE_YEAR, BASE_MONTH, BASE_DAY, Math.floor(minutes / 60), minutes % 60, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function floatingDateToMinutes(date: Date): number {
|
||||||
|
return date.getHours() * 60 + date.getMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The day the calendar displays — any date on the floating day. */
|
||||||
|
export function floatingDisplayDate(): Date {
|
||||||
|
return minutesToFloatingDate(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A moment guaranteed to fall on a different day than the displayed one — passed as "now" when the viewed day isn't today, so the library draws no current-time line. */
|
||||||
|
export function floatingNotToday(): Date {
|
||||||
|
return new Date(BASE_YEAR, BASE_MONTH, BASE_DAY - 2, 12, 0, 0, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/*
|
||||||
|
* Restyles react-big-calendar (see day-calendar.tsx) to match the app: the
|
||||||
|
* library draws its own box, colours and event chrome, so everything here
|
||||||
|
* either removes that or swaps in the app's tokens. Scoped under .day-cal so
|
||||||
|
* nothing leaks to any other page. Colours use the theme variables with a
|
||||||
|
* literal fallback in case Tailwind hasn't emitted one.
|
||||||
|
*/
|
||||||
|
.day-cal {
|
||||||
|
--dc-line: var(--color-line, #e1e7e2);
|
||||||
|
--dc-muted: var(--color-muted, #5c6961);
|
||||||
|
--dc-danger: var(--color-danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dragging a booking must never highlight the text around it. */
|
||||||
|
.day-cal .rbc-calendar {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cal .rbc-calendar,
|
||||||
|
.day-cal .rbc-time-view {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header row: the app's avatar + name + hours, no library chrome. */
|
||||||
|
.day-cal .rbc-time-header {
|
||||||
|
border: 0;
|
||||||
|
margin-right: 0 !important; /* the library reserves scrollbar width; the page scrolls instead */
|
||||||
|
}
|
||||||
|
/* Stays in view under the page's sticky top bar (81px tall) so, halfway down
|
||||||
|
the day, you can still see whose column you're looking at. */
|
||||||
|
.day-cal .rbc-time-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 81px;
|
||||||
|
z-index: 25;
|
||||||
|
background: var(--color-stone, #f4f6f4);
|
||||||
|
}
|
||||||
|
.day-cal .rbc-time-header-content {
|
||||||
|
border-left: 1px solid var(--dc-line);
|
||||||
|
}
|
||||||
|
.day-cal .rbc-header {
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left: 1px solid var(--dc-line);
|
||||||
|
padding: 0;
|
||||||
|
font-weight: inherit;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.day-cal .rbc-header:first-child {
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
/* No all-day row — appointments always have a time. */
|
||||||
|
.day-cal .rbc-allday-cell,
|
||||||
|
.day-cal .rbc-time-header-cell + .rbc-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid. */
|
||||||
|
.day-cal .rbc-time-content {
|
||||||
|
border-top: 1px solid var(--dc-line);
|
||||||
|
overflow-y: visible; /* full-height grid; the page owns the scrollbar */
|
||||||
|
}
|
||||||
|
.day-cal .rbc-time-content > * + * > * {
|
||||||
|
border-left: 1px solid var(--dc-line);
|
||||||
|
}
|
||||||
|
.day-cal .rbc-timeslot-group {
|
||||||
|
min-height: 112px; /* 4 x 28px quarter-hour rows == the app's 112px per hour */
|
||||||
|
border-bottom: 1px solid var(--dc-line);
|
||||||
|
}
|
||||||
|
.day-cal .rbc-time-slot {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.day-cal .rbc-day-slot .rbc-time-slot {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.day-cal .rbc-day-slot .rbc-time-slot:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.day-cal .rbc-today {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Wide enough to read a booking; narrower screens scroll sideways with the
|
||||||
|
hour labels pinned. The header cells must match the column width exactly or
|
||||||
|
a name stops lining up with its column once the row overflows. */
|
||||||
|
.day-cal .rbc-time-view-resources .rbc-day-slot,
|
||||||
|
.day-cal .rbc-time-view-resources .rbc-header,
|
||||||
|
.day-cal .rbc-time-view-resources .rbc-day-bg {
|
||||||
|
min-width: 190px; /* a minimum, not a width — wider screens still stretch the columns to fill */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hour labels. */
|
||||||
|
.day-cal .rbc-time-gutter .rbc-timeslot-group {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.day-cal .rbc-label {
|
||||||
|
padding: 0 8px 0 0;
|
||||||
|
color: var(--dc-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.day-cal .rbc-time-gutter,
|
||||||
|
.day-cal .rbc-time-header-gutter {
|
||||||
|
background: var(--color-stone, #f4f6f4);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current time. */
|
||||||
|
.day-cal .rbc-current-time-indicator {
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--dc-danger);
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outside the working window: the same diagonal hatch as breaks/time off. */
|
||||||
|
.day-cal .day-cal-offhours {
|
||||||
|
background-color: rgba(225, 231, 226, 0.15);
|
||||||
|
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, rgba(0, 0, 0, 0.05) 6px, rgba(0, 0, 0, 0.05) 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Events: the library's coloured box is replaced by the app's own block (EventContent). */
|
||||||
|
.day-cal .rbc-event,
|
||||||
|
.day-cal .rbc-background-event {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.day-cal .rbc-event .rbc-event-content {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.day-cal .rbc-event.rbc-selected {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.day-cal .rbc-background-event {
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.day-cal .rbc-event:focus-visible > .rbc-event-content > div {
|
||||||
|
outline: 2px solid var(--color-ink, #111b15);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
/* The library's own time label is hidden — EventContent draws a compact one. */
|
||||||
|
.day-cal .rbc-event-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.day-cal .day-cal-movable {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.day-cal .day-cal-movable:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.day-cal .rbc-addons-dnd-drag-preview {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
@@ -1,42 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||||
|
import { Calendar, dateFnsLocalizer } from "react-big-calendar";
|
||||||
|
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
|
||||||
|
import type { EventInteractionArgs } from "react-big-calendar/lib/addons/dragAndDrop";
|
||||||
|
import { format, getDay, parse, startOfWeek } from "date-fns";
|
||||||
|
import { enGB } from "date-fns/locale";
|
||||||
import { Ban, CheckCheck, CircleDot, Clock, Gift, LogIn, NotebookText, UserX } from "lucide-react";
|
import { Ban, CheckCheck, CircleDot, Clock, Gift, LogIn, NotebookText, UserX } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||||
|
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
|
||||||
|
import "./day-calendar.css";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
import type { AppointmentStatus } from "@/types/domain";
|
import type { AppointmentStatus } from "@/types/domain";
|
||||||
|
import { moveCalendarAppointmentAction } from "../actions/calendar-move";
|
||||||
|
import {
|
||||||
|
floatingDateToMinutes,
|
||||||
|
floatingDisplayDate,
|
||||||
|
floatingNotToday,
|
||||||
|
minutesToFloatingDate,
|
||||||
|
} from "./calendar-time";
|
||||||
|
|
||||||
const START_HOUR = 7;
|
const START_HOUR = 7;
|
||||||
const END_HOUR = 21;
|
const END_HOUR = 21;
|
||||||
const PX_PER_HOUR = 112;
|
const PX_PER_HOUR = 112;
|
||||||
const MINUTES_PER_HOUR = 60;
|
|
||||||
const RANGE_START_MINUTES = START_HOUR * MINUTES_PER_HOUR;
|
|
||||||
const RANGE_END_MINUTES = END_HOUR * MINUTES_PER_HOUR;
|
|
||||||
const HEADER_HEIGHT = 56;
|
const HEADER_HEIGHT = 56;
|
||||||
|
/** The grid is drawn full height and the PAGE scrolls — one scroll area, not a scrollbox inside the page. */
|
||||||
function topFor(minutes: number): number {
|
const CALENDAR_HEIGHT = HEADER_HEIGHT + (END_HOUR - START_HOUR) * PX_PER_HOUR + 8;
|
||||||
return ((clampToRange(minutes) - RANGE_START_MINUTES) / MINUTES_PER_HOUR) * PX_PER_HOUR;
|
|
||||||
}
|
|
||||||
function clampToRange(minutes: number): number {
|
|
||||||
return Math.min(RANGE_END_MINUTES, Math.max(RANGE_START_MINUTES, minutes));
|
|
||||||
}
|
|
||||||
function heightFor(startMinutes: number, endMinutes: number): number {
|
|
||||||
return Math.max(24, topFor(endMinutes) - topFor(startMinutes));
|
|
||||||
}
|
|
||||||
/** Compact "9:30" / "1:15" clock text — no AM/PM, matching the tight in-block time-range label. */
|
|
||||||
function formatCompactTime(minutes: number): string {
|
|
||||||
const h = Math.floor(minutes / MINUTES_PER_HOUR);
|
|
||||||
const m = minutes % MINUTES_PER_HOUR;
|
|
||||||
const displayHour = h % 12 === 0 ? 12 : h % 12;
|
|
||||||
return `${displayHour}:${String(m).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
/** "2:47 pm" — the current-time indicator's own label, read out next to its dot on the axis. */
|
|
||||||
function formatCurrentTimeLabel(minutes: number): string {
|
|
||||||
const h = Math.floor(minutes / MINUTES_PER_HOUR);
|
|
||||||
const m = minutes % MINUTES_PER_HOUR;
|
|
||||||
const period = h >= 12 ? "pm" : "am";
|
|
||||||
const displayHour = h % 12 === 0 ? 12 : h % 12;
|
|
||||||
return `${displayHour}:${String(m).padStart(2, "0")} ${period}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CalendarAppointment {
|
export interface CalendarAppointment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -70,6 +64,30 @@ export interface CalendarColumn {
|
|||||||
workingWindow: { startMinutes: number; endMinutes: number } | null;
|
workingWindow: { startMinutes: number; endMinutes: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localizer = dateFnsLocalizer({
|
||||||
|
format,
|
||||||
|
parse,
|
||||||
|
getDay,
|
||||||
|
startOfWeek: (date: Date) => startOfWeek(date, { weekStartsOn: 1 }),
|
||||||
|
locales: { "en-GB": enGB },
|
||||||
|
});
|
||||||
|
const EMPTY_MOVES: Record<string, { startMinutes: number; resourceId: string }> = {};
|
||||||
|
const DnDCalendar = withDragAndDrop<CalendarEvent, CalendarResource>(Calendar);
|
||||||
|
|
||||||
|
interface CalendarResource {
|
||||||
|
id: string;
|
||||||
|
column: CalendarColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarEvent =
|
||||||
|
| { kind: "appointment"; id: string; resourceId: string; start: Date; end: Date; title: string; appointment: CalendarAppointment }
|
||||||
|
| { kind: "block"; id: string; resourceId: string; start: Date; end: Date; title: string; block: CalendarBlock };
|
||||||
|
|
||||||
|
type AppointmentEvent = Extract<CalendarEvent, { kind: "appointment" }>;
|
||||||
|
|
||||||
|
/** Statuses the booking service will actually reschedule (see NON_RESCHEDULABLE_STATUSES in booking.service.ts). */
|
||||||
|
const MOVABLE_STATUSES: AppointmentStatus[] = ["pending_approval", "confirmed", "arrived"];
|
||||||
|
|
||||||
const STATUS_BADGE_ICON: Record<AppointmentStatus, LucideIcon> = {
|
const STATUS_BADGE_ICON: Record<AppointmentStatus, LucideIcon> = {
|
||||||
pending_approval: Clock,
|
pending_approval: Clock,
|
||||||
confirmed: CheckCheck,
|
confirmed: CheckCheck,
|
||||||
@@ -80,222 +98,350 @@ const STATUS_BADGE_ICON: Record<AppointmentStatus, LucideIcon> = {
|
|||||||
no_show: UserX,
|
no_show: UserX,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Column width on small screens — under 100% so the next column peeks in at the edge, inviting a swipe (per the reference design). Desktop ignores this and flexes to fill instead. */
|
|
||||||
const COLUMN_WIDTH_CLASS = "w-[86%] shrink-0 snap-start sm:w-[300px] lg:w-auto lg:flex-1";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A shared-axis, multi-column, swipeable day grid — one time axis pinned to
|
* The day grid for one date — one time axis, one column per staff member.
|
||||||
* the left edge (via `sticky`, so it never scrolls out of view), one column
|
* A single staff member's own view is just the one-column case.
|
||||||
* per staff member snapping into place as the row is swiped horizontally
|
*
|
||||||
* (CSS scroll-snap — no touch/gesture JS needed, matching this app's
|
* Built on react-big-calendar, but everything it draws is restyled to match
|
||||||
* "reliable tap/scroll over fragile custom gestures" preference). A single
|
* the app (see day-calendar.css) and it never sees a real time zone: times
|
||||||
* staff member's own view (staff/page.tsx) is just the one-column case of
|
* are handed over as "floating" dates (see calendar-time.ts).
|
||||||
* the same component. Deliberately no drag-and-drop — every interactive
|
*
|
||||||
* element is a plain Link (open details / start a reschedule from there).
|
* `reschedule` turns on drag-to-move. A drop never saves by itself — it opens
|
||||||
* Pure presentation: every minute value here is already computed by the
|
* a confirmation first, because a reschedule emails the customer. "owner"
|
||||||
* page from real WorkingHours/Break/TimeOff/Appointment rows.
|
* lets a booking be dragged to another barber's column (a reassignment);
|
||||||
|
* "staff" only along the time axis of their own column. The server re-checks
|
||||||
|
* everything (see moveCalendarAppointmentAction), so this is a convenience,
|
||||||
|
* not the gate. Omit it for a read-only calendar.
|
||||||
*/
|
*/
|
||||||
export function DayCalendar({
|
export function DayCalendar({
|
||||||
columns,
|
columns,
|
||||||
|
date,
|
||||||
currentTimeMinutes,
|
currentTimeMinutes,
|
||||||
appointmentHrefBase = "/staff/appointments",
|
appointmentHrefBase = "/staff/appointments",
|
||||||
|
reschedule,
|
||||||
}: {
|
}: {
|
||||||
columns: CalendarColumn[];
|
columns: CalendarColumn[];
|
||||||
|
/** The business-local calendar date being shown ("YYYY-MM-DD") — needed only to turn a drop back into a real instant on the server. */
|
||||||
|
date: string;
|
||||||
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
|
/** Minutes-from-midnight right now, only passed when the viewed day is today. */
|
||||||
currentTimeMinutes: number | null;
|
currentTimeMinutes: number | null;
|
||||||
/** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */
|
/** Owner's all-staff calendar links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar". */
|
||||||
appointmentHrefBase?: string;
|
appointmentHrefBase?: string;
|
||||||
|
reschedule?: "owner" | "staff";
|
||||||
}) {
|
}) {
|
||||||
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
|
const router = useRouter();
|
||||||
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
|
const [isPending, startTransition] = useTransition();
|
||||||
const showCurrentTime =
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
|
const [pendingMove, setPendingMove] = useState<{
|
||||||
|
event: AppointmentEvent;
|
||||||
|
resourceId: string;
|
||||||
|
startMinutes: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [moveError, setMoveError] = useState<string | null>(null);
|
||||||
|
/**
|
||||||
|
* Moves that already succeeded, shown in place until the refreshed server
|
||||||
|
* data arrives. Tied to the `columns` they were made against, so a new
|
||||||
|
* `columns` (the refresh landing) discards them with no effect needed.
|
||||||
|
*/
|
||||||
|
const [movedState, setMovedState] = useState<{
|
||||||
|
forColumns: CalendarColumn[];
|
||||||
|
positions: Record<string, { startMinutes: number; resourceId: string }>;
|
||||||
|
}>({ forColumns: columns, positions: {} });
|
||||||
|
const moved = movedState.forColumns === columns ? movedState.positions : EMPTY_MOVES;
|
||||||
|
|
||||||
|
const { events, backgroundEvents, resources } = useMemo(() => {
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
const backgroundEvents: CalendarEvent[] = [];
|
||||||
|
for (const column of columns) {
|
||||||
|
for (const appointment of column.appointments) {
|
||||||
|
const override = moved[appointment.id];
|
||||||
|
const start = override ? override.startMinutes : appointment.startMinutes;
|
||||||
|
const length = appointment.endMinutes - appointment.startMinutes;
|
||||||
|
events.push({
|
||||||
|
kind: "appointment",
|
||||||
|
id: appointment.id,
|
||||||
|
resourceId: override ? override.resourceId : column.id,
|
||||||
|
start: minutesToFloatingDate(start),
|
||||||
|
end: minutesToFloatingDate(start + length),
|
||||||
|
title: appointment.customerName,
|
||||||
|
appointment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const block of column.blocks) {
|
||||||
|
backgroundEvents.push({
|
||||||
|
kind: "block",
|
||||||
|
id: block.id,
|
||||||
|
resourceId: column.id,
|
||||||
|
start: minutesToFloatingDate(block.startMinutes),
|
||||||
|
end: minutesToFloatingDate(block.endMinutes),
|
||||||
|
title: block.label,
|
||||||
|
block,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
backgroundEvents,
|
||||||
|
resources: columns.map<CalendarResource>((column) => ({ id: column.id, column })),
|
||||||
|
};
|
||||||
|
}, [columns, moved]);
|
||||||
|
|
||||||
|
const columnById = useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
|
||||||
|
|
||||||
|
// On today's view, bring the current time into view — the grid is a full
|
||||||
|
// 14 hours tall and the page itself scrolls, so without this a barber
|
||||||
|
// opening the calendar at 3pm lands on 7am.
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTimeMinutes === null) return;
|
||||||
|
// The library draws the line a moment after mount, so wait for it.
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
rootRef.current?.querySelector(".rbc-current-time-indicator")?.scrollIntoView({ block: "center" });
|
||||||
|
}, 150);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
// Once, on first render of the day — not on every refresh after a move.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canMove = (event: CalendarEvent) =>
|
||||||
|
!!reschedule && event.kind === "appointment" && MOVABLE_STATUSES.includes(event.appointment.status);
|
||||||
|
|
||||||
|
const handleDrop = ({ event, start, resourceId }: EventInteractionArgs<CalendarEvent>) => {
|
||||||
|
if (event.kind !== "appointment") return;
|
||||||
|
const startMinutes = floatingDateToMinutes(new Date(start));
|
||||||
|
// A barber moves their own booking in time only.
|
||||||
|
const targetResourceId = reschedule === "owner" && typeof resourceId === "string" ? resourceId : event.resourceId;
|
||||||
|
if (startMinutes === event.appointment.startMinutes && targetResourceId === event.resourceId) return;
|
||||||
|
setMoveError(null);
|
||||||
|
setPendingMove({ event, resourceId: targetResourceId, startMinutes });
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmMove = () => {
|
||||||
|
if (!pendingMove || !reschedule) return;
|
||||||
|
const { event, resourceId, startMinutes } = pendingMove;
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await moveCalendarAppointmentAction({
|
||||||
|
appointmentId: event.id,
|
||||||
|
staffId: resourceId,
|
||||||
|
date,
|
||||||
|
startMinutes,
|
||||||
|
mode: reschedule,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
setMoveError(result.error ?? "Couldn't move this appointment.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMovedState({ forColumns: columns, positions: { ...moved, [event.id]: { startMinutes, resourceId } } });
|
||||||
|
setPendingMove(null);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromName = pendingMove ? columnById.get(pendingMove.event.resourceId)?.name : undefined;
|
||||||
|
const toName = pendingMove ? columnById.get(pendingMove.resourceId)?.name : undefined;
|
||||||
|
const reassigning = !!pendingMove && pendingMove.resourceId !== pendingMove.event.resourceId;
|
||||||
|
|
||||||
|
const scrollMinutes = Math.max(START_HOUR * 60, (currentTimeMinutes ?? 9 * 60) - 60);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 px-6 pb-8" data-testid="calendar-grid">
|
<div ref={rootRef} className="day-cal px-6 pb-8" data-testid="calendar-grid">
|
||||||
{/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */}
|
<div style={{ height: CALENDAR_HEIGHT }}>
|
||||||
<div className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}>
|
<DnDCalendar
|
||||||
<div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
|
localizer={localizer}
|
||||||
<div className="relative" style={{ height: gridHeight }}>
|
culture="en-GB"
|
||||||
{hourMarks.map((hour) =>
|
toolbar={false}
|
||||||
hour === END_HOUR ? null : (
|
view="day"
|
||||||
<div key={hour} className="absolute inset-x-0" style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}>
|
views={["day"]}
|
||||||
<span className="text-muted absolute -top-2 right-0 text-[10px] font-bold">{formatHour(hour)}</span>
|
date={floatingDisplayDate()}
|
||||||
{[15, 30, 45].map((minute) => (
|
onNavigate={() => {}}
|
||||||
<span
|
onView={() => {}}
|
||||||
key={minute}
|
getNow={() => (currentTimeMinutes === null ? floatingNotToday() : minutesToFloatingDate(currentTimeMinutes))}
|
||||||
className="text-muted/70 absolute right-0 text-[9px]"
|
events={events}
|
||||||
style={{ top: (minute / MINUTES_PER_HOUR) * PX_PER_HOUR - 6 }}
|
backgroundEvents={backgroundEvents}
|
||||||
>
|
resources={resources}
|
||||||
{minute}
|
resourceIdAccessor={(r) => r.id}
|
||||||
</span>
|
resourceTitleAccessor={(r) => r.column.name}
|
||||||
))}
|
step={15}
|
||||||
</div>
|
timeslots={4}
|
||||||
),
|
min={minutesToFloatingDate(START_HOUR * 60)}
|
||||||
)}
|
max={minutesToFloatingDate(END_HOUR * 60)}
|
||||||
{showCurrentTime && (
|
scrollToTime={minutesToFloatingDate(scrollMinutes)}
|
||||||
<div
|
dayLayoutAlgorithm="no-overlap"
|
||||||
className="absolute inset-x-0 z-40 flex items-center justify-end gap-1"
|
selectable={false}
|
||||||
style={{ top: topFor(currentTimeMinutes!) }}
|
resizable={false}
|
||||||
aria-hidden="true"
|
draggableAccessor={canMove}
|
||||||
>
|
onEventDrop={handleDrop}
|
||||||
<span className="bg-danger h-2 w-2 shrink-0 -translate-y-1/2 rounded-full" />
|
onSelectEvent={(event) => {
|
||||||
<span className="bg-danger absolute left-full -translate-y-1/2 rounded px-1.5 py-0.5 text-[9px] font-bold whitespace-nowrap text-white">
|
if (event.kind === "appointment") router.push(`${appointmentHrefBase}/${event.id}`);
|
||||||
{formatCurrentTimeLabel(currentTimeMinutes!)}
|
}}
|
||||||
</span>
|
eventPropGetter={(event) => ({
|
||||||
</div>
|
className: cn("day-cal-appointment", canMove(event) && "day-cal-movable"),
|
||||||
)}
|
})}
|
||||||
</div>
|
slotPropGetter={(slotDate, resourceId) =>
|
||||||
|
isOffHours(typeof resourceId === "string" ? columnById.get(resourceId) : undefined, slotDate)
|
||||||
|
? { className: "day-cal-offhours" }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
formats={{
|
||||||
|
timeGutterFormat: (value) => formatHourLabel(value.getHours()),
|
||||||
|
// Hidden visually (the block draws its own compact time) but kept for
|
||||||
|
// the accessible name, which the library builds as "<range>: <title>".
|
||||||
|
eventTimeRangeFormat: ({ start, end }) =>
|
||||||
|
`${formatClock(floatingDateToMinutes(start))} – ${formatClock(floatingDateToMinutes(end))}`,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
event: EventContent,
|
||||||
|
resourceHeader: ({ resource }) => <ColumnHeader column={(resource as CalendarResource).column} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Staff columns — a separately-scrolling strip, so swiping through staff never drags the axis along with it. Each column is a full swipeable "page": its own header, then its own grid body. */}
|
<Modal
|
||||||
<div className="snap-x snap-mandatory flex flex-1 gap-3 overflow-x-auto">
|
open={!!pendingMove}
|
||||||
{columns.map((column) => (
|
onClose={() => {
|
||||||
<div key={column.id} className={COLUMN_WIDTH_CLASS}>
|
if (!isPending) setPendingMove(null);
|
||||||
<div className="flex items-center gap-2 pb-3" style={{ height: HEADER_HEIGHT }}>
|
}}
|
||||||
{column.avatarUrl ? (
|
title={reassigning ? "Move to another barber?" : "Move this appointment?"}
|
||||||
<Image
|
>
|
||||||
src={column.avatarUrl}
|
{pendingMove && (
|
||||||
alt=""
|
<div className="space-y-4">
|
||||||
width={36}
|
<div className="space-y-1 text-sm">
|
||||||
height={36}
|
<p className="font-bold">{pendingMove.event.appointment.customerName}</p>
|
||||||
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover"
|
<p className="text-muted">{pendingMove.event.appointment.serviceNames}</p>
|
||||||
/>
|
<p className="pt-2">
|
||||||
) : (
|
<span className="text-muted">From </span>
|
||||||
<div className="bg-white text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line text-sm font-bold">
|
{formatClock(pendingMove.event.appointment.startMinutes)}
|
||||||
{column.name.charAt(0)}
|
{fromName ? ` with ${fromName}` : ""}
|
||||||
</div>
|
</p>
|
||||||
)}
|
<p>
|
||||||
<div className="min-w-0">
|
<span className="text-muted">To </span>
|
||||||
<p className="truncate text-sm font-bold">{column.name}</p>
|
<strong>{formatClock(pendingMove.startMinutes)}</strong>
|
||||||
<p className="text-muted truncate text-[10px]">{column.hoursLabel ?? "Off today"}</p>
|
{toName ? ` with ${toName}` : ""}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-muted text-xs">The customer will be notified of the change.</p>
|
||||||
<div className="border-line relative border-l" style={{ height: gridHeight }}>
|
{moveError && (
|
||||||
{/* Hour gridlines */}
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
{hourMarks.map((hour) => (
|
{moveError}
|
||||||
<div
|
</p>
|
||||||
key={hour}
|
)}
|
||||||
className="border-line absolute inset-x-0 border-t"
|
<div className="flex gap-3">
|
||||||
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
|
<Button variant="secondary" className="flex-1" disabled={isPending} onClick={() => setPendingMove(null)}>
|
||||||
/>
|
Cancel
|
||||||
))}
|
</Button>
|
||||||
|
<Button className="flex-1" isLoading={isPending} onClick={confirmMove}>
|
||||||
{column.wholeDayOff ? (
|
Move
|
||||||
<div className="bg-line/10 absolute inset-0 flex items-center justify-center">
|
</Button>
|
||||||
<span className="text-muted text-xs font-bold tracking-widest uppercase">Off today</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Outside-working-hours shading — diagonal hatch, same texture as the break/time-off blocks below. */}
|
|
||||||
{column.workingWindow ? (
|
|
||||||
<>
|
|
||||||
{column.workingWindow.startMinutes > RANGE_START_MINUTES && (
|
|
||||||
<div
|
|
||||||
className={HATCH_CLASS}
|
|
||||||
style={{ top: 0, height: topFor(column.workingWindow.startMinutes) }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{column.workingWindow.endMinutes < RANGE_END_MINUTES && (
|
|
||||||
<div
|
|
||||||
className={HATCH_CLASS}
|
|
||||||
style={{ top: topFor(column.workingWindow.endMinutes), bottom: 0 }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className={cn(HATCH_CLASS, "inset-0")} aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Breaks / time off */}
|
|
||||||
{column.blocks.map((block) => (
|
|
||||||
<div
|
|
||||||
key={block.id}
|
|
||||||
className="border-warning/40 bg-warning/10 absolute inset-x-1 z-10 overflow-hidden rounded-lg border bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.04)_6px,rgba(0,0,0,0.04)_12px)] px-2 py-1"
|
|
||||||
style={{
|
|
||||||
top: topFor(block.startMinutes),
|
|
||||||
height: heightFor(block.startMinutes, block.endMinutes),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="text-warning-foreground truncate text-[10px] font-bold tracking-widest uppercase">
|
|
||||||
{block.label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Appointments */}
|
|
||||||
{column.appointments.map((appt) => {
|
|
||||||
const blockHeight = heightFor(appt.startMinutes, appt.endMinutes);
|
|
||||||
const BadgeIcon = STATUS_BADGE_ICON[appt.status];
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={appt.id}
|
|
||||||
href={`${appointmentHrefBase}/${appt.id}`}
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 p-2 shadow-sm transition-shadow hover:shadow-md",
|
|
||||||
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
|
|
||||||
statusBlockClass(appt.status),
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
top: topFor(appt.startMinutes),
|
|
||||||
height: blockHeight,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full",
|
|
||||||
statusBadgeClass(appt.status),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BadgeIcon className="h-2.5 w-2.5" aria-hidden="true" />
|
|
||||||
<span className="sr-only">{statusLabel(appt.status)}</span>
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1 pr-4">
|
|
||||||
<p className="truncate text-[11px] font-bold">
|
|
||||||
{formatCompactTime(appt.startMinutes)} – {formatCompactTime(appt.endMinutes)}
|
|
||||||
</p>
|
|
||||||
{appt.hasNotes && <NotebookText className="text-muted h-2.5 w-2.5 shrink-0" aria-label="Has notes" />}
|
|
||||||
{appt.hasAvailableReward && (
|
|
||||||
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{blockHeight >= 44 && (
|
|
||||||
<p className="truncate text-[11px] opacity-80">
|
|
||||||
{appt.customerName} • {appt.serviceNames}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Current-time indicator */}
|
|
||||||
{showCurrentTime && (
|
|
||||||
<div
|
|
||||||
className="border-danger pointer-events-none absolute inset-x-0 z-30 border-t-2"
|
|
||||||
style={{ top: topFor(currentTimeMinutes!) }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for slots outside a staff member's working window (or the whole column when they're off) — those get the same hatch texture the break/time-off blocks use. */
|
||||||
|
function isOffHours(column: CalendarColumn | undefined, slotDate: Date): boolean {
|
||||||
|
if (!column) return false;
|
||||||
|
const minutes = floatingDateToMinutes(slotDate);
|
||||||
|
return (
|
||||||
|
column.wholeDayOff ||
|
||||||
|
!column.workingWindow ||
|
||||||
|
minutes < column.workingWindow.startMinutes ||
|
||||||
|
minutes >= column.workingWindow.endMinutes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColumnHeader({ column }: { column: CalendarColumn }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 text-left" style={{ height: HEADER_HEIGHT }}>
|
||||||
|
{column.avatarUrl ? (
|
||||||
|
<Image
|
||||||
|
src={column.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="border-line h-9 w-9 shrink-0 rounded-full border object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="border-line text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border bg-white text-sm font-bold">
|
||||||
|
{column.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-bold">{column.name}</p>
|
||||||
|
<p className="text-muted truncate text-[10px] font-normal">
|
||||||
|
{column.wholeDayOff ? "Off today" : (column.hoursLabel ?? "Off today")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const HATCH_CLASS =
|
function EventContent({ event }: { event: CalendarEvent }) {
|
||||||
"bg-line/15 absolute inset-x-0 bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.05)_6px,rgba(0,0,0,0.05)_12px)]";
|
if (event.kind === "block") {
|
||||||
|
return (
|
||||||
|
<div className="border-warning/40 bg-warning/10 h-full overflow-hidden rounded-lg border p-1.5">
|
||||||
|
<p className="text-warning-foreground truncate text-[10px] font-bold tracking-widest uppercase">{event.block.label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatHour(hour: number): string {
|
const { appointment } = event;
|
||||||
if (hour === 0 || hour === 24) return "12:00";
|
const BadgeIcon = STATUS_BADGE_ICON[appointment.status];
|
||||||
if (hour === 12) return "12:00";
|
const height = ((appointment.endMinutes - appointment.startMinutes) / 60) * PX_PER_HOUR;
|
||||||
return `${hour > 12 ? hour - 12 : hour}:00`;
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative h-full overflow-hidden rounded-lg border-l-4 p-2 text-left shadow-sm transition-shadow hover:shadow-md",
|
||||||
|
statusBlockClass(appointment.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full",
|
||||||
|
statusBadgeClass(appointment.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BadgeIcon className="h-2.5 w-2.5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">{statusLabel(appointment.status)}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 pr-4">
|
||||||
|
<p className="truncate text-[11px] font-bold">
|
||||||
|
{formatCompactTime(appointment.startMinutes)} – {formatCompactTime(appointment.endMinutes)}
|
||||||
|
</p>
|
||||||
|
{appointment.hasNotes && <NotebookText className="text-muted h-2.5 w-2.5 shrink-0" aria-label="Has notes" />}
|
||||||
|
{appointment.hasAvailableReward && (
|
||||||
|
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{height >= 44 && (
|
||||||
|
<p className="truncate text-[11px] opacity-80">
|
||||||
|
{appointment.customerName} • {appointment.serviceNames}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "9 AM" / "12 PM" gutter label. */
|
||||||
|
function formatHourLabel(hour: number): string {
|
||||||
|
const period = hour >= 12 ? "PM" : "AM";
|
||||||
|
return `${hour % 12 === 0 ? 12 : hour % 12} ${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact "9:30" / "1:15" clock text — no AM/PM, matching the tight in-block time-range label. */
|
||||||
|
function formatCompactTime(minutes: number): string {
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
const displayHour = h % 12 === 0 ? 12 : h % 12;
|
||||||
|
return `${displayHour}:${String(m).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2:45 PM" for the confirmation dialog. */
|
||||||
|
function formatClock(minutes: number): string {
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, "0")} ${h >= 12 ? "PM" : "AM"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: AppointmentStatus): string {
|
function statusLabel(status: AppointmentStatus): string {
|
||||||
|
|||||||
Reference in new issue
Block a user