payload-reserve 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1145 -0
  3. package/dist/collections/Reservations.d.ts +3 -0
  4. package/dist/collections/Reservations.js +124 -0
  5. package/dist/collections/Reservations.js.map +1 -0
  6. package/dist/collections/Resources.d.ts +3 -0
  7. package/dist/collections/Resources.js +53 -0
  8. package/dist/collections/Resources.js.map +1 -0
  9. package/dist/collections/Schedules.d.ts +3 -0
  10. package/dist/collections/Schedules.js +182 -0
  11. package/dist/collections/Schedules.js.map +1 -0
  12. package/dist/collections/Services.d.ts +3 -0
  13. package/dist/collections/Services.js +75 -0
  14. package/dist/collections/Services.js.map +1 -0
  15. package/dist/components/AvailabilityOverview/AvailabilityOverview.module.css +103 -0
  16. package/dist/components/AvailabilityOverview/index.d.ts +2 -0
  17. package/dist/components/AvailabilityOverview/index.js +277 -0
  18. package/dist/components/AvailabilityOverview/index.js.map +1 -0
  19. package/dist/components/CalendarView/CalendarView.module.css +283 -0
  20. package/dist/components/CalendarView/index.d.ts +3 -0
  21. package/dist/components/CalendarView/index.js +508 -0
  22. package/dist/components/CalendarView/index.js.map +1 -0
  23. package/dist/components/DashboardWidget/DashboardWidget.module.css +53 -0
  24. package/dist/components/DashboardWidget/DashboardWidgetServer.d.ts +2 -0
  25. package/dist/components/DashboardWidget/DashboardWidgetServer.js +126 -0
  26. package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -0
  27. package/dist/defaults.d.ts +12 -0
  28. package/dist/defaults.js +29 -0
  29. package/dist/defaults.js.map +1 -0
  30. package/dist/exports/client.d.ts +2 -0
  31. package/dist/exports/client.js +4 -0
  32. package/dist/exports/client.js.map +1 -0
  33. package/dist/exports/rsc.d.ts +1 -0
  34. package/dist/exports/rsc.js +3 -0
  35. package/dist/exports/rsc.js.map +1 -0
  36. package/dist/hooks/index.d.ts +4 -0
  37. package/dist/hooks/index.js +6 -0
  38. package/dist/hooks/index.js.map +1 -0
  39. package/dist/hooks/reservations/calculateEndTime.d.ts +3 -0
  40. package/dist/hooks/reservations/calculateEndTime.js +22 -0
  41. package/dist/hooks/reservations/calculateEndTime.js.map +1 -0
  42. package/dist/hooks/reservations/validateCancellation.d.ts +3 -0
  43. package/dist/hooks/reservations/validateCancellation.js +38 -0
  44. package/dist/hooks/reservations/validateCancellation.js.map +1 -0
  45. package/dist/hooks/reservations/validateConflicts.d.ts +3 -0
  46. package/dist/hooks/reservations/validateConflicts.js +86 -0
  47. package/dist/hooks/reservations/validateConflicts.js.map +1 -0
  48. package/dist/hooks/reservations/validateStatusTransition.d.ts +2 -0
  49. package/dist/hooks/reservations/validateStatusTransition.js +54 -0
  50. package/dist/hooks/reservations/validateStatusTransition.js.map +1 -0
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.js +3 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/plugin.d.ts +3 -0
  55. package/dist/plugin.js +106 -0
  56. package/dist/plugin.js.map +1 -0
  57. package/dist/translations/en.json +86 -0
  58. package/dist/translations/index.d.ts +3 -0
  59. package/dist/translations/index.js +8 -0
  60. package/dist/translations/index.js.map +1 -0
  61. package/dist/types.d.ts +51 -0
  62. package/dist/types.js +16 -0
  63. package/dist/types.js.map +1 -0
  64. package/dist/utilities/scheduleUtils.d.ts +54 -0
  65. package/dist/utilities/scheduleUtils.js +87 -0
  66. package/dist/utilities/scheduleUtils.js.map +1 -0
  67. package/dist/utilities/slotUtils.d.ts +21 -0
  68. package/dist/utilities/slotUtils.js +28 -0
  69. package/dist/utilities/slotUtils.js.map +1 -0
  70. package/package.json +108 -0
package/README.md ADDED
@@ -0,0 +1,1145 @@
1
+ # payload-reserve - Reservation Plugin for Payload CMS 3.x
2
+
3
+ A full-featured, reusable reservation/booking plugin for Payload CMS 3.x. Designed for salons, clinics, consultants, and any business that needs appointment scheduling with conflict prevention, status workflows, and admin UI components.
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+
9
+ - [Features](#features)
10
+ - [Installation](#installation)
11
+ - [Quick Start](#quick-start)
12
+ - [Configuration](#configuration)
13
+ - [Collections](#collections)
14
+ - [Services](#services)
15
+ - [Resources](#resources)
16
+ - [Schedules](#schedules)
17
+ - [User Collection Extension (Customers)](#user-collection-extension-customers)
18
+ - [Reservations](#reservations)
19
+ - [Business Logic Hooks](#business-logic-hooks)
20
+ - [Auto End Time Calculation](#auto-end-time-calculation)
21
+ - [Conflict Detection](#conflict-detection)
22
+ - [Status Transition Enforcement](#status-transition-enforcement)
23
+ - [Common Workflows](#common-workflows)
24
+ - [Cancellation Policy](#cancellation-policy)
25
+ - [Escape Hatch](#escape-hatch)
26
+ - [Integration Patterns](#integration-patterns)
27
+ - [Admin UI Components](#admin-ui-components)
28
+ - [Dashboard Widget](#dashboard-widget)
29
+ - [Calendar View](#calendar-view)
30
+ - [Availability Overview](#availability-overview)
31
+ - [Utilities](#utilities)
32
+ - [Development](#development)
33
+ - [Project Structure](#project-structure)
34
+ - [API Reference](#api-reference)
35
+
36
+ ---
37
+
38
+ ## Features
39
+
40
+ - **4 Collections + User Extension** - Services, Resources, Schedules, and Reservations, plus customer fields added to your existing Users collection
41
+ - **Double-Booking Prevention** - Automatic conflict detection with configurable buffer times
42
+ - **Status State Machine** - Enforced workflow: pending -> confirmed -> completed/cancelled/no-show
43
+ - **Auto End Time** - Automatically calculates reservation end time from service duration
44
+ - **Cancellation Policy** - Configurable notice period enforcement
45
+ - **Admin Quick-Confirm** - Authenticated admin users can create reservations directly as "confirmed" for walk-ins
46
+ - **Calendar View** - Month/week/day calendar replacing the default reservations list view
47
+ - **Click-to-Create** - Click any calendar cell to create a reservation with the start time pre-filled
48
+ - **Event Tooltips** - Hover events to see full details (service, time range, customer, resource, status)
49
+ - **Status Legend** - Color key displayed in the calendar explaining each status color
50
+ - **Current Time Indicator** - Red line in week/day views marking the current time
51
+ - **Dashboard Widget** - Server component showing today's booking stats at a glance
52
+ - **Availability Grid** - Weekly overview of resource availability vs. booked slots
53
+ - **Recurring & Manual Schedules** - Flexible schedule types with exception dates
54
+ - **Fully Configurable** - Override slugs, access control, buffer times, and admin grouping
55
+ - **Type-Safe** - Full TypeScript support with exported types
56
+
57
+ ---
58
+
59
+ ## Installation
60
+
61
+ ```bash
62
+ # Install the plugin as a dependency
63
+ pnpm add payload-reserve
64
+
65
+ # Or if developing locally, link it
66
+ pnpm link ./plugins/payload-reserve
67
+ ```
68
+
69
+ **Peer Dependency:** Requires `payload ^3.69.0` (modular dashboard widget API).
70
+
71
+ ---
72
+
73
+ ## Quick Start
74
+
75
+ Add the plugin to your `payload.config.ts`:
76
+
77
+ ```typescript
78
+ import { buildConfig } from 'payload'
79
+ import { reservationPlugin } from 'payload-reserve'
80
+
81
+ export default buildConfig({
82
+ // ... your existing config
83
+ plugins: [
84
+ reservationPlugin(),
85
+ ],
86
+ })
87
+ ```
88
+
89
+ That's it. The plugin registers 4 collections, extends your existing Users collection with customer fields (`name`, `phone`, `notes`, `bookings`), and adds a dashboard widget, a calendar list view, and an availability admin view automatically. All plugin collections appear under the **"Reservations"** group in the admin panel.
90
+
91
+ > **Important:** Your Payload config must explicitly define a `users` collection (or whichever collection you specify via `userCollection`). The plugin finds it in `config.collections` and appends customer fields to it.
92
+
93
+ ---
94
+
95
+ ## Configuration
96
+
97
+ All options are optional. The plugin works out of the box with sensible defaults.
98
+
99
+ ```typescript
100
+ import { reservationPlugin } from 'payload-reserve'
101
+ import type { ReservationPluginConfig } from 'payload-reserve'
102
+
103
+ const config: ReservationPluginConfig = {
104
+ // Disable the plugin entirely (collections are still registered for schema consistency)
105
+ disabled: false,
106
+
107
+ // Override collection slugs
108
+ slugs: {
109
+ services: 'reservation-services', // default
110
+ resources: 'reservation-resources', // default
111
+ schedules: 'reservation-schedules', // default
112
+ reservations: 'reservations', // default
113
+ },
114
+
115
+ // Slug of the existing auth collection to extend with customer fields
116
+ userCollection: 'users', // default
117
+
118
+ // Admin panel group name
119
+ adminGroup: 'Reservations', // default
120
+
121
+ // Default buffer time (minutes) between reservations
122
+ // Applied when a service doesn't define its own buffer times
123
+ defaultBufferTime: 0, // default
124
+
125
+ // Minimum hours of notice required before cancellation
126
+ cancellationNoticePeriod: 24, // default (hours)
127
+
128
+ // Override access control per collection
129
+ access: {
130
+ services: {
131
+ read: () => true,
132
+ create: ({ req }) => !!req.user,
133
+ update: ({ req }) => !!req.user,
134
+ delete: ({ req }) => !!req.user,
135
+ },
136
+ resources: { /* ... */ },
137
+ schedules: { /* ... */ },
138
+ reservations: { /* ... */ },
139
+ },
140
+ }
141
+
142
+ reservationPlugin(config)
143
+ ```
144
+
145
+ ### Configuration Defaults
146
+
147
+ | Option | Default | Description |
148
+ |--------|---------|-------------|
149
+ | `disabled` | `false` | Disable plugin functionality |
150
+ | `slugs.services` | `'reservation-services'` | Services collection slug |
151
+ | `slugs.resources` | `'reservation-resources'` | Resources collection slug |
152
+ | `slugs.schedules` | `'reservation-schedules'` | Schedules collection slug |
153
+ | `slugs.reservations` | `'reservations'` | Reservations collection slug |
154
+ | `userCollection` | `'users'` | Existing auth collection to extend with customer fields |
155
+ | `adminGroup` | `'Reservations'` | Admin panel group name |
156
+ | `defaultBufferTime` | `0` | Default buffer (minutes) between bookings |
157
+ | `cancellationNoticePeriod` | `24` | Minimum hours notice for cancellation |
158
+
159
+ ---
160
+
161
+ ## Collections
162
+
163
+ ### Services
164
+
165
+ **Slug:** `reservation-services`
166
+
167
+ Defines what can be booked (e.g., "Haircut", "Consultation", "Massage").
168
+
169
+ | Field | Type | Required | Description |
170
+ |-------|------|----------|-------------|
171
+ | `name` | Text | Yes | Service name (max 200 chars, used as title) |
172
+ | `description` | Textarea | No | Service description |
173
+ | `duration` | Number | Yes | Duration in minutes (min: 1) |
174
+ | `price` | Number | No | Price (min: 0, step: 0.01) |
175
+ | `bufferTimeBefore` | Number | No | Buffer minutes before appointment (default: 0) |
176
+ | `bufferTimeAfter` | Number | No | Buffer minutes after appointment (default: 0) |
177
+ | `active` | Checkbox | No | Whether service is active (default: true, sidebar) |
178
+
179
+ **Example:**
180
+ ```typescript
181
+ await payload.create({
182
+ collection: 'reservation-services',
183
+ data: {
184
+ name: 'Haircut',
185
+ description: 'Standard haircut service',
186
+ duration: 30,
187
+ price: 35.00,
188
+ bufferTimeBefore: 5,
189
+ bufferTimeAfter: 10,
190
+ active: true,
191
+ },
192
+ })
193
+ ```
194
+
195
+ ### Resources
196
+
197
+ **Slug:** `reservation-resources`
198
+
199
+ Who or what performs the service (e.g., a stylist, a room, a consultant).
200
+
201
+ | Field | Type | Required | Description |
202
+ |-------|------|----------|-------------|
203
+ | `name` | Text | Yes | Resource name (max 200 chars, used as title) |
204
+ | `description` | Textarea | No | Resource description |
205
+ | `services` | Relationship | Yes | Services this resource can perform (hasMany) |
206
+ | `active` | Checkbox | No | Whether resource is active (default: true, sidebar) |
207
+
208
+ **Example:**
209
+ ```typescript
210
+ await payload.create({
211
+ collection: 'reservation-resources',
212
+ data: {
213
+ name: 'Alice Johnson',
214
+ description: 'Senior Stylist',
215
+ services: [haircutId, coloringId],
216
+ active: true,
217
+ },
218
+ })
219
+ ```
220
+
221
+ ### Schedules
222
+
223
+ **Slug:** `reservation-schedules`
224
+
225
+ Defines when a resource is available. Supports **recurring** (weekly pattern) and **manual** (specific dates) modes, plus exception dates.
226
+
227
+ | Field | Type | Required | Description |
228
+ |-------|------|----------|-------------|
229
+ | `name` | Text | Yes | Schedule name (used as title) |
230
+ | `resource` | Relationship | Yes | Which resource this schedule belongs to |
231
+ | `scheduleType` | Select | No | `'recurring'` or `'manual'` (default: `'recurring'`) |
232
+ | `recurringSlots` | Array | No | Weekly slots (shown when type is recurring) |
233
+ | `recurringSlots.day` | Select | Yes | Day of week (mon-sun) |
234
+ | `recurringSlots.startTime` | Text | Yes | Start time (HH:mm format) |
235
+ | `recurringSlots.endTime` | Text | Yes | End time (HH:mm format) |
236
+ | `manualSlots` | Array | No | Specific date slots (shown when type is manual) |
237
+ | `manualSlots.date` | Date | Yes | Specific date (day only) |
238
+ | `manualSlots.startTime` | Text | Yes | Start time (HH:mm format) |
239
+ | `manualSlots.endTime` | Text | Yes | End time (HH:mm format) |
240
+ | `exceptions` | Array | No | Dates when the resource is unavailable |
241
+ | `exceptions.date` | Date | Yes | Exception date (day only) |
242
+ | `exceptions.reason` | Text | No | Reason for unavailability |
243
+ | `active` | Checkbox | No | Whether schedule is active (default: true, sidebar) |
244
+
245
+ **Example - Recurring Schedule:**
246
+ ```typescript
247
+ await payload.create({
248
+ collection: 'reservation-schedules',
249
+ data: {
250
+ name: 'Alice - Weekdays',
251
+ resource: aliceId,
252
+ scheduleType: 'recurring',
253
+ recurringSlots: [
254
+ { day: 'mon', startTime: '09:00', endTime: '17:00' },
255
+ { day: 'tue', startTime: '09:00', endTime: '17:00' },
256
+ { day: 'wed', startTime: '09:00', endTime: '17:00' },
257
+ { day: 'thu', startTime: '09:00', endTime: '17:00' },
258
+ { day: 'fri', startTime: '09:00', endTime: '15:00' },
259
+ ],
260
+ exceptions: [
261
+ { date: '2025-12-25', reason: 'Christmas' },
262
+ ],
263
+ active: true,
264
+ },
265
+ })
266
+ ```
267
+
268
+ ### User Collection Extension (Customers)
269
+
270
+ Instead of a standalone Customers collection, the plugin extends your **existing auth-enabled Users collection** with customer fields. This means customers are real users who can log in to your site.
271
+
272
+ The plugin finds the collection specified by `userCollection` (default: `'users'`) and appends these fields if they don't already exist:
273
+
274
+ | Field | Type | Description |
275
+ |-------|------|-------------|
276
+ | `name` | Text | Customer name (max 200 chars) |
277
+ | `phone` | Text | Phone number (max 50 chars) |
278
+ | `notes` | Textarea | Internal notes |
279
+ | `bookings` | Join | Virtual field: all reservations for this customer |
280
+
281
+ The `bookings` field is a **join** field — it shows all reservations linked to this user without storing anything on the user document itself. Fields that already exist on the collection (e.g., if your Users collection already has a `name` field) are skipped to avoid duplicates.
282
+
283
+ **Example:**
284
+ ```typescript
285
+ await payload.create({
286
+ collection: 'users',
287
+ data: {
288
+ name: 'Jane Doe',
289
+ email: 'jane@example.com',
290
+ password: 'securepassword',
291
+ phone: '555-0101',
292
+ notes: 'Prefers morning appointments',
293
+ },
294
+ })
295
+ ```
296
+
297
+ > **Note:** Since users is an auth collection, you must provide `email` and `password` when creating customers. The `email` field comes from Payload's built-in auth — the plugin does not add it.
298
+
299
+ ### Reservations
300
+
301
+ **Slug:** `reservations`
302
+
303
+ The core booking records. Each reservation links a customer to a service performed by a resource at a specific time.
304
+
305
+ | Field | Type | Required | Description |
306
+ |-------|------|----------|-------------|
307
+ | `service` | Relationship | Yes | Which service is being booked |
308
+ | `resource` | Relationship | Yes | Which resource performs the service |
309
+ | `customer` | Relationship | Yes | Who is booking (references the user collection) |
310
+ | `startTime` | Date | Yes | Appointment start (date + time picker) |
311
+ | `endTime` | Date | No | Auto-calculated, read-only (date + time picker) |
312
+ | `status` | Select | No | Workflow status (default: `'pending'`) |
313
+ | `cancellationReason` | Textarea | No | Shown only when status is `'cancelled'` |
314
+ | `notes` | Textarea | No | Additional notes |
315
+
316
+ **Status Options:** `pending`, `confirmed`, `completed`, `cancelled`, `no-show`
317
+
318
+ #### Status Definitions
319
+
320
+ | Status | Meaning | Terminal? |
321
+ |--------|---------|-----------|
322
+ | `pending` | Reservation created but not yet confirmed. Awaiting admin review, payment, or other verification. This is the default status for all new public reservations. | No |
323
+ | `confirmed` | Reservation is locked in. Payment received, admin approved, or created as a walk-in by staff. The time slot is committed. | No |
324
+ | `completed` | Appointment took place successfully. Set by admin after the service is delivered. | Yes |
325
+ | `cancelled` | Reservation was cancelled before the appointment (by customer or admin). Subject to the cancellation notice period. | Yes |
326
+ | `no-show` | Customer did not show up for a confirmed appointment. Set by admin after the scheduled time passes. | Yes |
327
+
328
+ Terminal statuses cannot transition to any other status. Once a reservation is `completed`, `cancelled`, or `no-show`, it is permanently closed.
329
+
330
+ **Example:**
331
+ ```typescript
332
+ // Create a reservation (endTime is auto-calculated)
333
+ const reservation = await payload.create({
334
+ collection: 'reservations',
335
+ data: {
336
+ service: haircutId,
337
+ resource: aliceId,
338
+ customer: janeUserId,
339
+ startTime: '2025-06-15T10:00:00.000Z',
340
+ status: 'pending',
341
+ },
342
+ })
343
+
344
+ // endTime is automatically set to 10:30 (30 min haircut)
345
+ console.log(reservation.endTime) // '2025-06-15T10:30:00.000Z'
346
+ ```
347
+
348
+ ---
349
+
350
+ ## Business Logic Hooks
351
+
352
+ Four `beforeChange` hooks are applied to the Reservations collection, executing in order:
353
+
354
+ ### Auto End Time Calculation
355
+
356
+ **Hook:** `calculateEndTime`
357
+
358
+ Automatically computes `endTime` from `startTime + service.duration` on every create and update. The end time field is read-only in the admin UI.
359
+
360
+ ```
361
+ endTime = startTime + service.duration (minutes)
362
+ ```
363
+
364
+ **Why this matters:** Prevents manual calculation errors and ensures the calendar view and conflict detection always have accurate time ranges. Without auto-calculation, admins could accidentally enter wrong end times, causing invisible scheduling gaps or double-bookings that conflict detection wouldn't catch.
365
+
366
+ ### Conflict Detection
367
+
368
+ **Hook:** `validateConflicts`
369
+
370
+ Prevents double-booking on the same resource. Checks for overlapping time ranges considering buffer times.
371
+
372
+ **How it works:**
373
+ 1. Loads the service's `bufferTimeBefore` and `bufferTimeAfter` (falls back to `defaultBufferTime` from plugin config)
374
+ 2. Computes the **blocked window**: `[startTime - bufferBefore, endTime + bufferAfter]`
375
+ 3. Queries existing reservations for the same resource where status is not `cancelled` or `no-show`
376
+ 4. On updates, excludes the current reservation from the conflict check
377
+ 5. Throws a `ValidationError` if any overlap is found
378
+
379
+ **Example scenario:**
380
+ ```
381
+ Service: Haircut (30 min, 5 min buffer before, 10 min buffer after)
382
+ Reservation: 10:00 - 10:30
383
+ Blocked window: 09:55 - 10:40
384
+
385
+ Another booking at 10:20 on the same resource -> CONFLICT ERROR
386
+ Another booking at 10:45 on the same resource -> OK
387
+ Another booking at 10:20 on a DIFFERENT resource -> OK
388
+ ```
389
+
390
+ **Why this matters:** Protects against double-booking even when multiple users book simultaneously or when frontend data is stale. The server-side check is the single source of truth. Buffer times account for real-world setup and cleanup between appointments — a stylist needs time to clean their station, a room needs to be prepared, etc.
391
+
392
+ ### Status Transition Enforcement
393
+
394
+ **Hook:** `validateStatusTransition`
395
+
396
+ Enforces a strict status state machine:
397
+
398
+ ```
399
+ +-> confirmed --+-> completed
400
+ | |
401
+ pending ------+ +-> cancelled
402
+ | |
403
+ +-> cancelled +-> no-show
404
+ ```
405
+
406
+ **Rules:**
407
+ - **On create (public/unauthenticated):** Status must be `pending` (or not set, defaults to `pending`)
408
+ - **On create (authenticated admin):** Status can be `pending` or `confirmed` — this allows staff to create walk-in reservations that are already confirmed without a second step
409
+ - **On update:** Only valid transitions are allowed:
410
+ - `pending` -> `confirmed`, `cancelled`
411
+ - `confirmed` -> `completed`, `cancelled`, `no-show`
412
+ - `completed`, `cancelled`, `no-show` -> *(terminal, no transitions allowed)*
413
+
414
+ Invalid transitions throw a `ValidationError`.
415
+
416
+ **Why this matters:** The state machine ensures data integrity by preventing nonsensical transitions (e.g., marking a cancelled reservation as completed). Terminal states (`completed`, `cancelled`, `no-show`) prevent accidental reopening of closed reservations. The admin quick-confirm feature supports walk-in workflows without bypassing the state machine — staff can create a reservation as `confirmed` in one step, but they still cannot skip directly to `completed`.
417
+
418
+ ### Common Workflows
419
+
420
+ These workflows show how the status lifecycle and hooks work together in real-world scenarios.
421
+
422
+ **1. Online Booking (standard)**
423
+ ```
424
+ Customer visits booking page → selects service, resource, time slot
425
+ → payload.create({ status: 'pending' }) [hooks: endTime calculated, conflicts checked]
426
+ → Admin reviews in admin panel
427
+ → payload.update({ status: 'confirmed' }) [hooks: transition validated]
428
+ → Appointment takes place
429
+ → payload.update({ status: 'completed' }) [hooks: transition validated]
430
+ ```
431
+
432
+ **2. Walk-In Booking**
433
+ ```
434
+ Customer walks in, staff creates booking in admin panel
435
+ → payload.create({ status: 'confirmed' }) [admin user, hooks: endTime + conflicts]
436
+ → Appointment takes place
437
+ → payload.update({ status: 'completed' })
438
+ ```
439
+ Skips the `pending` step entirely — authenticated admin users can create directly as `confirmed`.
440
+
441
+ **3. Payment-Gated Booking**
442
+ ```
443
+ Customer selects time slot on your frontend
444
+ → payload.create({ status: 'pending' }) [slot is now held]
445
+ → Your app creates a Stripe Checkout Session
446
+ → Customer completes payment
447
+ → Stripe webhook fires → payload.update({ status: 'confirmed' })
448
+ → Appointment takes place → 'completed'
449
+ ```
450
+ The `pending → confirmed` transition fits payment flows naturally. The slot is held from the moment of creation (conflict detection ran on create), so no one else can book the same time while payment processes. See [Integration Patterns](#integration-patterns) for a full code example.
451
+
452
+ **4. Customer Cancellation**
453
+ ```
454
+ Customer requests cancellation (from 'pending' or 'confirmed')
455
+ → payload.update({ status: 'cancelled', cancellationReason: '...' })
456
+ → Hook checks: hours_until_appointment >= cancellationNoticePeriod
457
+ → If enough notice: cancellation succeeds
458
+ → If too late: ValidationError thrown, reservation unchanged
459
+ ```
460
+
461
+ **5. No-Show Handling**
462
+ ```
463
+ Appointment time passes, customer does not arrive
464
+ → Admin marks: payload.update({ status: 'no-show' }) [only from 'confirmed']
465
+ → Reservation is terminal — cannot be reopened
466
+ ```
467
+ No-shows can only be marked on `confirmed` reservations. A `pending` reservation that nobody showed up for should be `cancelled` instead, since it was never confirmed.
468
+
469
+ ### Cancellation Policy
470
+
471
+ **Hook:** `validateCancellation`
472
+
473
+ Enforces a minimum notice period for cancellations. When transitioning to `cancelled`, the hook checks that:
474
+
475
+ ```
476
+ hours_until_appointment >= cancellationNoticePeriod
477
+ ```
478
+
479
+ With the default `cancellationNoticePeriod: 24`, you cannot cancel a reservation that starts within the next 24 hours. The hook throws a `ValidationError` with details about how many hours remain.
480
+
481
+ **Why this matters:** Protects the business from last-minute cancellations that leave empty time slots that can't be filled by other customers. The configurable notice period lets each business set their own policy — a busy salon might require 48 hours, while a consultant might only need 2. Automated cleanup tasks can use the escape hatch to bypass this check when cancelling stale pending reservations.
482
+
483
+ ### Escape Hatch
484
+
485
+ All four hooks check for `context.skipReservationHooks` and skip validation if it's truthy. This lets you bypass hooks for administrative operations, data migrations, or seeding:
486
+
487
+ ```typescript
488
+ await payload.create({
489
+ collection: 'reservations',
490
+ data: {
491
+ service: serviceId,
492
+ resource: resourceId,
493
+ customer: customerId,
494
+ startTime: '2025-06-15T10:00:00.000Z',
495
+ status: 'completed', // Normally would fail (only pending/confirmed allowed on create)
496
+ },
497
+ context: {
498
+ skipReservationHooks: true,
499
+ },
500
+ })
501
+ ```
502
+
503
+ > **Note:** Authenticated admin users can create reservations with `'confirmed'` status without the escape hatch. The escape hatch is only needed for statuses that are never allowed on create (e.g., `'completed'`, `'cancelled'`, `'no-show'`) or to bypass conflict detection and cancellation policy checks.
504
+
505
+ ### Integration Patterns
506
+
507
+ #### Payment Integration (Stripe)
508
+
509
+ The `pending → confirmed` transition is a natural fit for payment-gated bookings. The reservation holds the time slot while payment processes, and the conflict detection hook has already validated availability on create.
510
+
511
+ **Flow:**
512
+ 1. Customer creates a reservation → status is `pending`, slot is held
513
+ 2. Your app creates a Stripe Checkout Session with the reservation ID in metadata
514
+ 3. Customer completes payment on Stripe's hosted page
515
+ 4. Stripe sends a `checkout.session.completed` webhook to your app
516
+ 5. Your webhook handler updates the reservation to `confirmed`
517
+ 6. If payment fails or expires, the reservation stays `pending` for cleanup
518
+
519
+ **Webhook handler example:**
520
+
521
+ ```ts
522
+ // app/api/stripe-webhook/route.ts
523
+ import { getPayload } from 'payload'
524
+ import config from '@payload-config'
525
+ import Stripe from 'stripe'
526
+
527
+ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
528
+
529
+ export async function POST(req: Request) {
530
+ const body = await req.text()
531
+ const sig = req.headers.get('stripe-signature')!
532
+
533
+ const event = stripe.webhooks.constructEvent(
534
+ body,
535
+ sig,
536
+ process.env.STRIPE_WEBHOOK_SECRET!,
537
+ )
538
+
539
+ if (event.type === 'checkout.session.completed') {
540
+ const session = event.data.object as Stripe.Checkout.Session
541
+ const reservationId = session.metadata?.reservationId
542
+
543
+ if (reservationId) {
544
+ const payload = await getPayload({ config })
545
+ await payload.update({
546
+ collection: 'reservations',
547
+ id: reservationId,
548
+ data: { status: 'confirmed' },
549
+ })
550
+ }
551
+ }
552
+
553
+ return new Response('OK', { status: 200 })
554
+ }
555
+ ```
556
+
557
+ > **Slot protection:** The `validateConflicts` hook runs on create, so the time slot is already reserved while the customer pays. No other booking can claim the same slot, even if the payment takes several minutes.
558
+
559
+ #### Notification Integration
560
+
561
+ Use Payload's `afterChange` hook on the Reservations collection (in your app config, outside the plugin) to trigger notifications when status changes. The plugin does not send notifications itself, giving you full control over messaging.
562
+
563
+ **Example scenarios:**
564
+ - **Confirmed** — send a confirmation email with appointment details
565
+ - **Upcoming reminder** — trigger a reminder 24 hours before the appointment (via a scheduled task)
566
+ - **Cancelled** — notify the customer and optionally alert staff about the freed slot
567
+ - **No-show** — notify staff for internal tracking
568
+
569
+ ```ts
570
+ // In your payload.config.ts, add an afterChange hook to the reservations collection
571
+ // using Payload's collections override or a separate plugin
572
+
573
+ const notifyOnStatusChange: CollectionAfterChangeHook = async ({
574
+ doc,
575
+ previousDoc,
576
+ operation,
577
+ }) => {
578
+ if (operation === 'update' && doc.status !== previousDoc.status) {
579
+ switch (doc.status) {
580
+ case 'confirmed':
581
+ await sendConfirmationEmail(doc)
582
+ break
583
+ case 'cancelled':
584
+ await sendCancellationEmail(doc)
585
+ break
586
+ }
587
+ }
588
+ }
589
+ ```
590
+
591
+ #### Scheduled Cleanup
592
+
593
+ Reservations that stay `pending` indefinitely (e.g., abandoned payment flows) hold time slots that could be used by other customers. Set up a scheduled task to cancel stale pending reservations:
594
+
595
+ - Query for `pending` reservations older than your threshold (e.g., 30 minutes)
596
+ - Update them to `cancelled` using the escape hatch to bypass the cancellation notice period
597
+ - Optionally notify the customer that their hold expired
598
+
599
+ ```ts
600
+ // Example: cron job or scheduled task
601
+ const payload = await getPayload({ config })
602
+
603
+ const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000)
604
+
605
+ const { docs: staleReservations } = await payload.find({
606
+ collection: 'reservations',
607
+ where: {
608
+ status: { equals: 'pending' },
609
+ createdAt: { less_than: thirtyMinutesAgo.toISOString() },
610
+ },
611
+ })
612
+
613
+ for (const reservation of staleReservations) {
614
+ await payload.update({
615
+ collection: 'reservations',
616
+ id: reservation.id,
617
+ data: {
618
+ status: 'cancelled',
619
+ cancellationReason: 'Automatically cancelled — payment not completed',
620
+ },
621
+ context: { skipReservationHooks: true }, // bypass cancellation notice period
622
+ })
623
+ }
624
+ ```
625
+
626
+ ---
627
+
628
+ ## Admin UI Components
629
+
630
+ ### Dashboard Widget
631
+
632
+ **Type:** React Server Component (RSC)
633
+ **Location:** Modular Dashboard Widget (`admin.dashboard.widgets`)
634
+ **Widget slug:** `reservation-todays-reservations`
635
+ **Default size:** medium–large
636
+
637
+ Uses Payload's modular dashboard widget system (v3.69.0+), which supports configurable sizing, drag-and-drop layout, and add/remove functionality. Displays a summary of today's reservations:
638
+
639
+ - **Total** reservations today
640
+ - **Upcoming** reservations (not yet completed/cancelled, start time in future)
641
+ - **Completed** reservations
642
+ - **Cancelled** reservations
643
+ - **Next Appointment** details (time and status)
644
+
645
+ The widget queries the database directly via the Payload Local API (server-side, no HTTP requests).
646
+
647
+ ### Calendar View
648
+
649
+ **Type:** Client Component
650
+ **Location:** Replaces the Reservations list view
651
+
652
+ A CSS Grid-based calendar (no external dependencies) with three view modes:
653
+
654
+ - **Month View** - 6-week grid showing all days with reservation chips
655
+ - **Week View** - 7-day grid with hourly rows (7am-6pm)
656
+ - **Day View** - Single day with hourly rows (7am-8pm)
657
+
658
+ **Features:**
659
+ - Navigation (previous/next, "Today" button)
660
+ - Color-coded by status:
661
+ - Pending: Yellow
662
+ - Confirmed: Blue
663
+ - Completed: Green
664
+ - Cancelled: Gray
665
+ - No-show: Red
666
+ - **Status legend** displayed below the header explaining what each color means
667
+ - **Click-to-create:** Click any calendar cell to open a new reservation drawer with the start time pre-filled
668
+ - Month view: clicking a day cell pre-fills start time at 9:00 AM on that date
669
+ - Week/day views: clicking a time cell pre-fills the exact hour for that slot
670
+ - Click any existing reservation to open a Payload document drawer for editing
671
+ - **Enhanced event display:**
672
+ - Month view (compact): shows time + service name
673
+ - Week/day views (full): shows time + service name + customer name
674
+ - **Tooltips:** Hover any reservation event to see full details (service, time range, customer, resource, status) via native browser tooltip
675
+ - **Current time indicator:** A red horizontal line in week/day views showing the current time position within the matching hour cell
676
+ - Fetches data via REST API for the visible date range
677
+
678
+ ### Availability Overview
679
+
680
+ **Type:** Client Component
681
+ **Location:** Custom admin view at `/admin/reservation-availability`
682
+
683
+ A weekly grid showing resource availability:
684
+
685
+ - **Rows** = Active resources
686
+ - **Columns** = Days of the week
687
+ - **Green slots** = Available time ranges (from schedules)
688
+ - **Blue slots** = Booked times (from reservations)
689
+ - **Gray slots** = Exception dates (unavailable)
690
+
691
+ Navigate between weeks with previous/next buttons or jump to "This Week".
692
+
693
+ ---
694
+
695
+ ## Utilities
696
+
697
+ ### slotUtils.ts
698
+
699
+ Time math helpers used by hooks:
700
+
701
+ | Function | Description |
702
+ |----------|-------------|
703
+ | `addMinutes(date, minutes)` | Add minutes to a date, returns new Date |
704
+ | `doRangesOverlap(startA, endA, startB, endB)` | Check if two time ranges overlap (half-open intervals) |
705
+ | `computeBlockedWindow(start, end, bufferBefore, bufferAfter)` | Compute effective blocked window with buffers |
706
+ | `hoursUntil(futureDate, now?)` | Calculate hours between now and a future date |
707
+
708
+ ### scheduleUtils.ts
709
+
710
+ Schedule resolution helpers used by admin components:
711
+
712
+ | Function | Description |
713
+ |----------|-------------|
714
+ | `getDayOfWeek(date)` | Get the DayOfWeek value for a Date |
715
+ | `dateMatchesDay(date, day)` | Check if a date matches a DayOfWeek |
716
+ | `parseTime(time)` | Parse "HH:mm" string to hours/minutes |
717
+ | `combineDateAndTime(date, time)` | Merge a date with a "HH:mm" time string |
718
+ | `isExceptionDate(date, exceptions)` | Check if a date is an exception |
719
+ | `resolveScheduleForDate(schedule, date)` | Resolve concrete time ranges for a date |
720
+
721
+ ---
722
+
723
+ ## Frontend Reservation Guide
724
+
725
+ The plugin is backend-only — it adds collections, hooks, and admin UI to Payload but does not include any customer-facing pages. However, you can build a full booking flow using Payload's built-in Local API (from Server Components / Server Actions) or REST API. No custom endpoints are needed.
726
+
727
+ ### Step 1: Configure Access Control
728
+
729
+ By default all collections use Payload's default access control (authenticated users only). To allow public booking, pass `access` overrides in your plugin config:
730
+
731
+ ```ts
732
+ import { reservationPlugin } from 'payload-reserve'
733
+
734
+ reservationPlugin({
735
+ access: {
736
+ services: {
737
+ read: () => true, // anyone can browse services
738
+ },
739
+ resources: {
740
+ read: () => true, // anyone can browse resources
741
+ },
742
+ schedules: {
743
+ read: () => true, // anyone can check availability
744
+ },
745
+ reservations: {
746
+ create: () => true, // guests can book
747
+ read: ({ req }) => {
748
+ // customers can only read their own reservations
749
+ if (req.user) return true
750
+ return false
751
+ },
752
+ },
753
+ },
754
+ })
755
+ ```
756
+
757
+ > **Note:** Access control for the Users collection itself is defined on your Users collection config, not via the plugin's `access` option. The plugin only extends the Users collection with fields — it doesn't control its access.
758
+
759
+ > **Important:** Always use `overrideAccess: false` in your frontend queries so these rules are enforced. Without it, Payload bypasses access control entirely.
760
+
761
+ ### Step 2: Fetch Available Services
762
+
763
+ Use a React Server Component to list active services:
764
+
765
+ ```tsx
766
+ // app/book/page.tsx
767
+ import { getPayload } from 'payload'
768
+ import config from '@payload-config'
769
+
770
+ export default async function BookingPage() {
771
+ const payload = await getPayload({ config })
772
+
773
+ const { docs: services } = await payload.find({
774
+ collection: 'reservation-services',
775
+ overrideAccess: false,
776
+ where: { active: { equals: true } },
777
+ })
778
+
779
+ return (
780
+ <ul>
781
+ {services.map((service) => (
782
+ <li key={service.id}>
783
+ {service.name} — {service.duration} min — ${service.price}
784
+ </li>
785
+ ))}
786
+ </ul>
787
+ )
788
+ }
789
+ ```
790
+
791
+ ### Step 3: Fetch Resources for a Service
792
+
793
+ Once a customer picks a service, load the resources that offer it:
794
+
795
+ ```ts
796
+ const { docs: resources } = await payload.find({
797
+ collection: 'reservation-resources',
798
+ overrideAccess: false,
799
+ where: {
800
+ services: { contains: selectedServiceId },
801
+ active: { equals: true },
802
+ },
803
+ })
804
+ ```
805
+
806
+ ### Step 4: Check Availability
807
+
808
+ Fetch the resource's schedule and existing reservations for the target date, then compute open slots:
809
+
810
+ ```ts
811
+ import {
812
+ resolveScheduleForDate,
813
+ addMinutes,
814
+ doRangesOverlap,
815
+ computeBlockedWindow,
816
+ } from 'payload-reserve'
817
+
818
+ // 1. Get the resource's active schedule
819
+ const { docs: schedules } = await payload.find({
820
+ collection: 'reservation-schedules',
821
+ overrideAccess: false,
822
+ where: {
823
+ resource: { equals: resourceId },
824
+ active: { equals: true },
825
+ },
826
+ })
827
+
828
+ // 2. Resolve available time ranges for the target date
829
+ const targetDate = new Date('2025-03-15')
830
+ const availableRanges = schedules.flatMap((schedule) =>
831
+ resolveScheduleForDate(schedule, targetDate),
832
+ )
833
+
834
+ // 3. Fetch existing reservations for that resource on that date
835
+ const dayStart = new Date(targetDate)
836
+ dayStart.setHours(0, 0, 0, 0)
837
+ const dayEnd = new Date(targetDate)
838
+ dayEnd.setHours(23, 59, 59, 999)
839
+
840
+ const { docs: existingReservations } = await payload.find({
841
+ collection: 'reservations',
842
+ overrideAccess: false,
843
+ where: {
844
+ resource: { equals: resourceId },
845
+ startTime: { greater_than_equal: dayStart.toISOString() },
846
+ startTime: { less_than: dayEnd.toISOString() },
847
+ status: { not_equals: 'cancelled' },
848
+ },
849
+ })
850
+
851
+ // 4. Generate slots by stepping through available ranges
852
+ // and filtering out conflicts with existing reservations
853
+ const slots = []
854
+ for (const range of availableRanges) {
855
+ let cursor = range.start
856
+ while (addMinutes(cursor, serviceDuration) <= range.end) {
857
+ const slotEnd = addMinutes(cursor, serviceDuration)
858
+ const blocked = existingReservations.some((res) => {
859
+ const window = computeBlockedWindow(
860
+ new Date(res.startTime),
861
+ new Date(res.endTime),
862
+ service.bufferTimeBefore ?? 0,
863
+ service.bufferTimeAfter ?? 0,
864
+ )
865
+ return doRangesOverlap(cursor, slotEnd, window.start, window.end)
866
+ })
867
+ if (!blocked) slots.push(cursor)
868
+ cursor = addMinutes(cursor, 15) // 15-minute step
869
+ }
870
+ }
871
+ ```
872
+
873
+ ### Step 5: Create the Reservation
874
+
875
+ Use a Server Action to create the reservation. The plugin's `beforeChange` hooks automatically calculate the end time, validate conflicts, and enforce status transitions:
876
+
877
+ ```ts
878
+ 'use server'
879
+
880
+ import { getPayload } from 'payload'
881
+ import config from '@payload-config'
882
+
883
+ export async function createReservation(data: {
884
+ service: string
885
+ resource: string
886
+ customerName: string
887
+ customerEmail: string
888
+ startTime: string
889
+ notes?: string
890
+ }) {
891
+ const payload = await getPayload({ config })
892
+
893
+ // Find or create the customer (customers are users)
894
+ const existing = await payload.find({
895
+ collection: 'users',
896
+ overrideAccess: false,
897
+ where: { email: { equals: data.customerEmail } },
898
+ })
899
+
900
+ let customerId: string
901
+ if (existing.docs.length > 0) {
902
+ customerId = String(existing.docs[0].id)
903
+ } else {
904
+ const customer = await payload.create({
905
+ collection: 'users',
906
+ overrideAccess: false,
907
+ data: {
908
+ name: data.customerName,
909
+ email: data.customerEmail,
910
+ password: generateSecurePassword(), // auth collection requires a password
911
+ },
912
+ })
913
+ customerId = String(customer.id)
914
+ }
915
+
916
+ // Create the reservation — hooks handle endTime calculation and conflict checks
917
+ const reservation = await payload.create({
918
+ collection: 'reservations',
919
+ overrideAccess: false,
920
+ data: {
921
+ service: data.service,
922
+ resource: data.resource,
923
+ customer: customerId,
924
+ startTime: data.startTime,
925
+ notes: data.notes,
926
+ // status defaults to 'pending'
927
+ },
928
+ })
929
+
930
+ return reservation
931
+ }
932
+ ```
933
+
934
+ If the time slot is already booked, the `validateConflicts` hook will throw a `ValidationError` — catch it in your UI and prompt the user to pick a different slot.
935
+
936
+ ### Security Notes
937
+
938
+ - **Always pass `overrideAccess: false`** — without it, Payload skips access control and any user can read/write anything.
939
+ - Validate and sanitize all user input before passing it to `payload.create`.
940
+ - Consider adding rate limiting to your Server Actions or API routes to prevent abuse.
941
+ - The plugin's hooks enforce conflict detection server-side, so double-bookings are prevented even if the frontend has stale data.
942
+
943
+ ### Alternative: REST API
944
+
945
+ If you prefer a client-side-only approach (e.g., a separate SPA), use Payload's auto-generated REST API:
946
+
947
+ ```ts
948
+ // Fetch services
949
+ const res = await fetch('https://your-site.com/api/reservation-services?where[active][equals]=true')
950
+ const { docs: services } = await res.json()
951
+
952
+ // Create a reservation (customer is a user ID)
953
+ const res = await fetch('https://your-site.com/api/reservations', {
954
+ method: 'POST',
955
+ headers: { 'Content-Type': 'application/json' },
956
+ body: JSON.stringify({
957
+ service: serviceId,
958
+ resource: resourceId,
959
+ customer: userId,
960
+ startTime: '2025-03-15T10:00:00.000Z',
961
+ }),
962
+ })
963
+ ```
964
+
965
+ The same hooks and access control rules apply to REST API requests.
966
+
967
+ ---
968
+
969
+ ## Development
970
+
971
+ ### Prerequisites
972
+
973
+ - Node.js ^18.20.2 or >=20.9.0
974
+ - pnpm ^9 or ^10
975
+ - MongoDB (or the in-memory server used for testing)
976
+
977
+ ### Commands
978
+
979
+ ```bash
980
+ # Start dev server (Next.js + Payload admin panel)
981
+ pnpm dev
982
+
983
+ # Generate Payload types after schema changes
984
+ pnpm dev:generate-types
985
+
986
+ # Generate import map after adding/removing components
987
+ pnpm dev:generate-importmap
988
+
989
+ # Run integration tests (Vitest)
990
+ pnpm test:int
991
+
992
+ # Run end-to-end tests (Playwright)
993
+ pnpm test:e2e
994
+
995
+ # Run all tests
996
+ pnpm test
997
+
998
+ # Lint source code
999
+ pnpm lint
1000
+
1001
+ # Auto-fix lint issues
1002
+ pnpm lint:fix
1003
+
1004
+ # Build for production
1005
+ pnpm build
1006
+
1007
+ # Clean build artifacts
1008
+ pnpm clean
1009
+ ```
1010
+
1011
+ ### Dev Environment
1012
+
1013
+ The `dev/` directory contains a complete Payload CMS app for testing:
1014
+
1015
+ - **`dev/payload.config.ts`** - Payload config with the plugin installed
1016
+ - **`dev/seed.ts`** - Seeds sample data: 3 services, 2 resources, 2 schedules, 2 customer users, 3 reservations
1017
+ - **`dev/int.spec.ts`** - Integration tests covering all hooks and CRUD operations
1018
+
1019
+ The dev environment uses `mongodb-memory-server` for testing, so no external MongoDB instance is required.
1020
+
1021
+ ### Seed Data
1022
+
1023
+ When you run `pnpm dev`, the seed script creates:
1024
+
1025
+ **Services:**
1026
+ - Haircut (30 min, $35, 5 min buffer before, 10 min after)
1027
+ - Hair Coloring (90 min, $120, 10 min buffer before, 15 min after)
1028
+ - Consultation (15 min, free, 0 min buffer before, 5 min after)
1029
+
1030
+ **Resources:**
1031
+ - Alice Johnson (Senior Stylist) - performs all 3 services
1032
+ - Bob Smith (Junior Stylist) - performs Haircut and Consultation
1033
+
1034
+ **Schedules:**
1035
+ - Alice: Mon-Thu 9am-5pm, Fri 9am-3pm
1036
+ - Bob: Mon/Wed/Fri 10am-6pm, Sat 9am-2pm
1037
+
1038
+ **Customers (created as users):**
1039
+ - Jane Doe (jane@example.com)
1040
+ - John Public (john@example.com)
1041
+
1042
+ **Reservations (today):**
1043
+ - 9:00 AM - Haircut with Alice (confirmed)
1044
+ - 10:00 AM - Consultation with Bob (pending)
1045
+ - 2:00 PM - Hair Coloring with Alice (pending)
1046
+
1047
+ ---
1048
+
1049
+ ## Project Structure
1050
+
1051
+ ```
1052
+ src/
1053
+ index.ts # Public API: re-exports plugin + types
1054
+ plugin.ts # Main plugin factory function
1055
+ types.ts # All TypeScript types + status transitions
1056
+ defaults.ts # Default config values + resolver
1057
+
1058
+ collections/
1059
+ Services.ts # Service definitions
1060
+ Resources.ts # Providers/resources
1061
+ Schedules.ts # Availability schedules
1062
+ Reservations.ts # Bookings with hooks
1063
+
1064
+ hooks/
1065
+ reservations/
1066
+ calculateEndTime.ts # Auto-compute endTime
1067
+ validateConflicts.ts # Double-booking prevention
1068
+ validateStatusTransition.ts # Status state machine
1069
+ validateCancellation.ts # Cancellation notice period
1070
+ index.ts # Barrel export
1071
+
1072
+ utilities/
1073
+ slotUtils.ts # Time math helpers
1074
+ scheduleUtils.ts # Schedule resolution helpers
1075
+
1076
+ components/
1077
+ CalendarView/
1078
+ index.tsx # Client: calendar for reservations
1079
+ CalendarView.module.css
1080
+ DashboardWidget/
1081
+ DashboardWidgetServer.tsx # RSC: today's booking stats
1082
+ DashboardWidget.module.css
1083
+ AvailabilityOverview/
1084
+ index.tsx # Client: weekly availability grid
1085
+ AvailabilityOverview.module.css
1086
+
1087
+ exports/
1088
+ client.ts # CalendarView, AvailabilityOverview
1089
+ rsc.ts # DashboardWidgetServer
1090
+ ```
1091
+
1092
+ ---
1093
+
1094
+ ## API Reference
1095
+
1096
+ ### Plugin Export
1097
+
1098
+ ```typescript
1099
+ import { reservationPlugin } from 'payload-reserve'
1100
+ import type { ReservationPluginConfig } from 'payload-reserve'
1101
+ ```
1102
+
1103
+ ### Client Exports
1104
+
1105
+ ```typescript
1106
+ import { CalendarView, AvailabilityOverview } from 'reservation-plugin/client'
1107
+ ```
1108
+
1109
+ ### RSC Exports
1110
+
1111
+ ```typescript
1112
+ import { DashboardWidgetServer } from 'reservation-plugin/rsc'
1113
+ ```
1114
+
1115
+ ### Type Exports
1116
+
1117
+ ```typescript
1118
+ import type {
1119
+ ReservationPluginConfig,
1120
+ ResolvedReservationPluginConfig,
1121
+ } from 'reservation-plugin'
1122
+ ```
1123
+
1124
+ ### Integration Test Coverage
1125
+
1126
+ The plugin ships with 16 integration tests covering:
1127
+
1128
+ | Test | What It Verifies |
1129
+ |------|-----------------|
1130
+ | Collections registered | All 4 plugin collections exist after plugin init |
1131
+ | User collection extended | Users collection has phone, notes, bookings fields |
1132
+ | Create service | Service CRUD with all fields |
1133
+ | Create resource | Resource with service relationship |
1134
+ | Create schedule | Recurring schedule with slots |
1135
+ | Create user with customer fields | User with name, phone, email |
1136
+ | Auto endTime | endTime = startTime + duration |
1137
+ | Conflict: same resource | Double-booking is rejected |
1138
+ | Conflict: different resource | Same time on different resource is allowed |
1139
+ | Status: must start pending | Creating with non-pending status fails (public context) |
1140
+ | Status: valid transition | pending -> confirmed succeeds |
1141
+ | Status: admin create confirmed | Admin user can create reservation as confirmed |
1142
+ | Status: admin create completed | Admin user cannot create reservation as completed |
1143
+ | Status: invalid transition | completed -> pending fails |
1144
+ | Cancel: too late | Cancellation within notice period fails |
1145
+ | Cancel: sufficient notice | Cancellation with enough notice succeeds |