Updated Calendard
CI / ci (push) Canceled after 0s

The calendar is now react-big-calendar.
This commit is contained in:
Stefan Lazic committed 2026-10-08 11:01:32 +01:00
1 parent 8c267eff25
commit a8a8d1e045
10 files changed
+945 -219

No files matched your search

+55
View File
@@ -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.
+251 -8
View File
@@ -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",
+3
View File
@@ -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",
+7 -1
View File
@@ -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">
+1 -1
View File
@@ -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;
}
+355 -209
View File
@@ -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 }}> }}
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>
<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 ? ( {column.avatarUrl ? (
<Image <Image
src={column.avatarUrl} src={column.avatarUrl}
alt="" alt=""
width={36} width={36}
height={36} height={36}
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover" className="border-line h-9 w-9 shrink-0 rounded-full border 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"> <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)} {column.name.charAt(0)}
</div> </div>
)} )}
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-bold">{column.name}</p> <p className="truncate text-sm font-bold">{column.name}</p>
<p className="text-muted truncate text-[10px]">{column.hoursLabel ?? "Off today"}</p> <p className="text-muted truncate text-[10px] font-normal">
</div> {column.wholeDayOff ? "Off today" : (column.hoursLabel ?? "Off today")}
</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> </p>
</div> </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> </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 {