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",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"image-size": "^2.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^1.28.0",
|
||||
@@ -31,6 +32,7 @@
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
"nodemailer": "^8.0.11",
|
||||
"react": "^19.2.8",
|
||||
"react-big-calendar": "^1.20.0",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.84.0",
|
||||
"server-only": "^0.0.1",
|
||||
@@ -46,6 +48,7 @@
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"dotenv": "^17.4.2",
|
||||
@@ -327,6 +330,15 @@
|
||||
"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": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -2108,6 +2120,16 @@
|
||||
"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": {
|
||||
"version": "7.9.1",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
|
||||
@@ -3845,6 +3879,13 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -3912,16 +3953,34 @@
|
||||
"@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": {
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"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": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
||||
@@ -3932,6 +3991,12 @@
|
||||
"@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": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
@@ -5445,6 +5510,11 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -5526,7 +5596,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
@@ -5728,6 +5797,28 @@
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -5824,6 +5915,15 @@
|
||||
"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": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
@@ -5854,6 +5954,16 @@
|
||||
"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": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
@@ -7114,6 +7224,15 @@
|
||||
"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": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
|
||||
@@ -7413,6 +7532,15 @@
|
||||
"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": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -7893,7 +8021,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -8361,7 +8488,12 @@
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"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"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -8382,7 +8514,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -8480,6 +8611,12 @@
|
||||
"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": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -8532,6 +8669,27 @@
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -8782,7 +8940,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -9349,7 +9506,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -9444,6 +9600,34 @@
|
||||
"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": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
@@ -9476,9 +9660,44 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
@@ -10664,6 +10883,21 @@
|
||||
"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": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
@@ -11244,6 +11478,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"image-size": "^2.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^1.28.0",
|
||||
@@ -49,6 +50,7 @@
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
"nodemailer": "^8.0.11",
|
||||
"react": "^19.2.8",
|
||||
"react-big-calendar": "^1.20.0",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.84.0",
|
||||
"server-only": "^0.0.1",
|
||||
@@ -64,6 +66,7 @@
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-big-calendar": "^1.16.3",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"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." />
|
||||
</div>
|
||||
) : (
|
||||
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} appointmentHrefBase="/owner/appointments" />
|
||||
<DayCalendar
|
||||
columns={columns}
|
||||
date={date}
|
||||
currentTimeMinutes={currentTimeMinutes}
|
||||
appointmentHrefBase="/owner/appointments"
|
||||
reschedule="owner"
|
||||
/>
|
||||
)
|
||||
) : appointments.length === 0 ? (
|
||||
<div className="px-6">
|
||||
|
||||
@@ -187,7 +187,7 @@ export default async function StaffCalendarPage({
|
||||
description={wholeDayOff.reason ?? "Approved time off."}
|
||||
/>
|
||||
) : view === "day" ? (
|
||||
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} />
|
||||
<DayCalendar columns={columns} date={date} currentTimeMinutes={currentTimeMinutes} reschedule="staff" />
|
||||
) : calendarAppointments.length === 0 ? (
|
||||
<div className="px-6 lg:px-0">
|
||||
<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 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 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 type { AppointmentStatus } from "@/types/domain";
|
||||
import { moveCalendarAppointmentAction } from "../actions/calendar-move";
|
||||
import {
|
||||
floatingDateToMinutes,
|
||||
floatingDisplayDate,
|
||||
floatingNotToday,
|
||||
minutesToFloatingDate,
|
||||
} from "./calendar-time";
|
||||
|
||||
const START_HOUR = 7;
|
||||
const END_HOUR = 21;
|
||||
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;
|
||||
|
||||
function topFor(minutes: number): number {
|
||||
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}`;
|
||||
}
|
||||
/** The grid is drawn full height and the PAGE scrolls — one scroll area, not a scrollbox inside the page. */
|
||||
const CALENDAR_HEIGHT = HEADER_HEIGHT + (END_HOUR - START_HOUR) * PX_PER_HOUR + 8;
|
||||
|
||||
export interface CalendarAppointment {
|
||||
id: string;
|
||||
@@ -70,6 +64,30 @@ export interface CalendarColumn {
|
||||
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> = {
|
||||
pending_approval: Clock,
|
||||
confirmed: CheckCheck,
|
||||
@@ -80,222 +98,350 @@ const STATUS_BADGE_ICON: Record<AppointmentStatus, LucideIcon> = {
|
||||
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 left edge (via `sticky`, so it never scrolls out of view), one column
|
||||
* per staff member snapping into place as the row is swiped horizontally
|
||||
* (CSS scroll-snap — no touch/gesture JS needed, matching this app's
|
||||
* "reliable tap/scroll over fragile custom gestures" preference). A single
|
||||
* staff member's own view (staff/page.tsx) is just the one-column case of
|
||||
* the same component. Deliberately no drag-and-drop — every interactive
|
||||
* element is a plain Link (open details / start a reschedule from there).
|
||||
* Pure presentation: every minute value here is already computed by the
|
||||
* page from real WorkingHours/Break/TimeOff/Appointment rows.
|
||||
* The day grid for one date — one time axis, one column per staff member.
|
||||
* A single staff member's own view is just the one-column case.
|
||||
*
|
||||
* Built on react-big-calendar, but everything it draws is restyled to match
|
||||
* the app (see day-calendar.css) and it never sees a real time zone: times
|
||||
* are handed over as "floating" dates (see calendar-time.ts).
|
||||
*
|
||||
* `reschedule` turns on drag-to-move. A drop never saves by itself — it opens
|
||||
* a confirmation first, because a reschedule emails the customer. "owner"
|
||||
* 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({
|
||||
columns,
|
||||
date,
|
||||
currentTimeMinutes,
|
||||
appointmentHrefBase = "/staff/appointments",
|
||||
reschedule,
|
||||
}: {
|
||||
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. */
|
||||
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;
|
||||
reschedule?: "owner" | "staff";
|
||||
}) {
|
||||
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
|
||||
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
|
||||
const showCurrentTime =
|
||||
currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
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 (
|
||||
<div className="flex gap-2 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 className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}>
|
||||
<div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
|
||||
<div className="relative" style={{ height: gridHeight }}>
|
||||
{hourMarks.map((hour) =>
|
||||
hour === END_HOUR ? null : (
|
||||
<div key={hour} className="absolute inset-x-0" style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}>
|
||||
<span className="text-muted absolute -top-2 right-0 text-[10px] font-bold">{formatHour(hour)}</span>
|
||||
{[15, 30, 45].map((minute) => (
|
||||
<span
|
||||
key={minute}
|
||||
className="text-muted/70 absolute right-0 text-[9px]"
|
||||
style={{ top: (minute / MINUTES_PER_HOUR) * PX_PER_HOUR - 6 }}
|
||||
>
|
||||
{minute}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{showCurrentTime && (
|
||||
<div
|
||||
className="absolute inset-x-0 z-40 flex items-center justify-end gap-1"
|
||||
style={{ top: topFor(currentTimeMinutes!) }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="bg-danger h-2 w-2 shrink-0 -translate-y-1/2 rounded-full" />
|
||||
<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">
|
||||
{formatCurrentTimeLabel(currentTimeMinutes!)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={rootRef} className="day-cal px-6 pb-8" data-testid="calendar-grid">
|
||||
<div style={{ height: CALENDAR_HEIGHT }}>
|
||||
<DnDCalendar
|
||||
localizer={localizer}
|
||||
culture="en-GB"
|
||||
toolbar={false}
|
||||
view="day"
|
||||
views={["day"]}
|
||||
date={floatingDisplayDate()}
|
||||
onNavigate={() => {}}
|
||||
onView={() => {}}
|
||||
getNow={() => (currentTimeMinutes === null ? floatingNotToday() : minutesToFloatingDate(currentTimeMinutes))}
|
||||
events={events}
|
||||
backgroundEvents={backgroundEvents}
|
||||
resources={resources}
|
||||
resourceIdAccessor={(r) => r.id}
|
||||
resourceTitleAccessor={(r) => r.column.name}
|
||||
step={15}
|
||||
timeslots={4}
|
||||
min={minutesToFloatingDate(START_HOUR * 60)}
|
||||
max={minutesToFloatingDate(END_HOUR * 60)}
|
||||
scrollToTime={minutesToFloatingDate(scrollMinutes)}
|
||||
dayLayoutAlgorithm="no-overlap"
|
||||
selectable={false}
|
||||
resizable={false}
|
||||
draggableAccessor={canMove}
|
||||
onEventDrop={handleDrop}
|
||||
onSelectEvent={(event) => {
|
||||
if (event.kind === "appointment") router.push(`${appointmentHrefBase}/${event.id}`);
|
||||
}}
|
||||
eventPropGetter={(event) => ({
|
||||
className: cn("day-cal-appointment", canMove(event) && "day-cal-movable"),
|
||||
})}
|
||||
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>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="snap-x snap-mandatory flex flex-1 gap-3 overflow-x-auto">
|
||||
{columns.map((column) => (
|
||||
<div key={column.id} className={COLUMN_WIDTH_CLASS}>
|
||||
<div className="flex items-center gap-2 pb-3" style={{ height: HEADER_HEIGHT }}>
|
||||
{column.avatarUrl ? (
|
||||
<Image
|
||||
src={column.avatarUrl}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover"
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{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]">{column.hoursLabel ?? "Off today"}</p>
|
||||
</div>
|
||||
<Modal
|
||||
open={!!pendingMove}
|
||||
onClose={() => {
|
||||
if (!isPending) setPendingMove(null);
|
||||
}}
|
||||
title={reassigning ? "Move to another barber?" : "Move this appointment?"}
|
||||
>
|
||||
{pendingMove && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-bold">{pendingMove.event.appointment.customerName}</p>
|
||||
<p className="text-muted">{pendingMove.event.appointment.serviceNames}</p>
|
||||
<p className="pt-2">
|
||||
<span className="text-muted">From </span>
|
||||
{formatClock(pendingMove.event.appointment.startMinutes)}
|
||||
{fromName ? ` with ${fromName}` : ""}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted">To </span>
|
||||
<strong>{formatClock(pendingMove.startMinutes)}</strong>
|
||||
{toName ? ` with ${toName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-line relative border-l" style={{ height: gridHeight }}>
|
||||
{/* Hour gridlines */}
|
||||
{hourMarks.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="border-line absolute inset-x-0 border-t"
|
||||
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{column.wholeDayOff ? (
|
||||
<div className="bg-line/10 absolute inset-0 flex items-center justify-center">
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p className="text-muted text-xs">The customer will be notified of the change.</p>
|
||||
{moveError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{moveError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" disabled={isPending} onClick={() => setPendingMove(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="flex-1" isLoading={isPending} onClick={confirmMove}>
|
||||
Move
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const HATCH_CLASS =
|
||||
"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)]";
|
||||
function EventContent({ event }: { event: CalendarEvent }) {
|
||||
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 {
|
||||
if (hour === 0 || hour === 24) return "12:00";
|
||||
if (hour === 12) return "12:00";
|
||||
return `${hour > 12 ? hour - 12 : hour}:00`;
|
||||
const { appointment } = event;
|
||||
const BadgeIcon = STATUS_BADGE_ICON[appointment.status];
|
||||
const height = ((appointment.endMinutes - appointment.startMinutes) / 60) * PX_PER_HOUR;
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user