payload-reserve 1.1.0 → 1.3.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 (30) hide show
  1. package/README.md +29 -3
  2. package/dist/collections/Schedules.js +49 -4
  3. package/dist/collections/Schedules.js.map +1 -1
  4. package/dist/components/CalendarView/CalendarView.module.css +35 -0
  5. package/dist/components/CalendarView/index.js +94 -9
  6. package/dist/components/CalendarView/index.js.map +1 -1
  7. package/dist/defaults.js +28 -1
  8. package/dist/defaults.js.map +1 -1
  9. package/dist/endpoints/cancelBooking.js +20 -0
  10. package/dist/endpoints/cancelBooking.js.map +1 -1
  11. package/dist/endpoints/checkAvailability.js +11 -1
  12. package/dist/endpoints/checkAvailability.js.map +1 -1
  13. package/dist/endpoints/customerSearch.js +8 -0
  14. package/dist/endpoints/customerSearch.js.map +1 -1
  15. package/dist/endpoints/getSlots.js +1 -0
  16. package/dist/endpoints/getSlots.js.map +1 -1
  17. package/dist/hooks/reservations/onStatusChange.js +35 -16
  18. package/dist/hooks/reservations/onStatusChange.js.map +1 -1
  19. package/dist/hooks/reservations/validateConflicts.js +23 -22
  20. package/dist/hooks/reservations/validateConflicts.js.map +1 -1
  21. package/dist/hooks/reservations/validateStatusTransition.js +16 -6
  22. package/dist/hooks/reservations/validateStatusTransition.js.map +1 -1
  23. package/dist/services/AvailabilityService.d.ts +1 -0
  24. package/dist/services/AvailabilityService.js +30 -4
  25. package/dist/services/AvailabilityService.js.map +1 -1
  26. package/dist/translations/en.json +3 -1
  27. package/dist/utilities/resolveReservationItems.d.ts +2 -1
  28. package/dist/utilities/resolveReservationItems.js +47 -5
  29. package/dist/utilities/resolveReservationItems.js.map +1 -1
  30. package/package.json +5 -2
package/README.md CHANGED
@@ -12,7 +12,7 @@ Designed for salons, clinics, hotels, restaurants, event venues, and any busines
12
12
  - **User Collection Extension** — Optionally extend your existing auth collection with booking fields; set `userCollection: undefined` (default) to use a standalone Customers collection
13
13
  - **Resource Owner Multi-Tenancy** — Opt-in `resourceOwnerMode` wires ownership access control so each resource owner (host) sees only their own listings and reservations
14
14
  - **Configurable Status Machine** — Define your own statuses, transitions, blocking states, and terminal states
15
- - **Double-Booking Prevention** — Server-side conflict detection with configurable buffer times; respects capacity modes
15
+ - **Double-Booking Prevention** — Server-side conflict detection with per-item buffer times; respects capacity modes
16
16
  - **Auto End Time** — Calculates `endTime` from `startTime + service.duration` automatically
17
17
  - **Three Duration Types** — `fixed` (service duration), `flexible` (customer-specified end), and `full-day` bookings
18
18
  - **Multi-Resource Bookings** — Single reservation that spans multiple resources simultaneously via the `items` array
@@ -21,8 +21,8 @@ Designed for salons, clinics, hotels, restaurants, event venues, and any busines
21
21
  - **Extra Reservation Fields** — Inject custom fields into the Reservations collection via `extraReservationFields` without forking the plugin
22
22
  - **Cancellation Policy** — Configurable minimum notice period enforcement
23
23
  - **Plugin Hooks API** — Seven lifecycle hooks (`beforeBookingCreate`, `afterBookingCreate`, `beforeBookingConfirm`, `afterBookingConfirm`, `beforeBookingCancel`, `afterBookingCancel`, `afterStatusChange`) for integrating email, Stripe, and external systems
24
- - **Availability Service** — Pure functions and DB helpers for slot generation and conflict checking
25
- - **Public REST API** — Five pre-built endpoints for availability, slot listing, booking, cancellation, and customer search
24
+ - **Availability Service** — Pure functions and DB helpers for slot generation (15-min step) and conflict checking with guest-count-aware filtering
25
+ - **Public REST API** — Five pre-built endpoints for availability, slot listing, booking, cancellation, and customer search — with ownership enforcement and input validation
26
26
  - **Calendar View** — Month/week/day calendar replacing the default reservations list view
27
27
  - **Dashboard Widget** — Server component showing today's booking stats
28
28
  - **Availability Overview** — Weekly grid of resource availability vs. booked slots
@@ -105,3 +105,29 @@ The `access` override in plugin config always takes precedence over the auto-wir
105
105
  | [Examples](https://github.com/elghaied/payload-reserve/blob/main/docs/examples.md) | Salon, hotel, restaurant, event venue, Stripe, email, multi-tenant (resource owner mode) |
106
106
  | [Advanced](https://github.com/elghaied/payload-reserve/blob/main/docs/advanced.md) | DB indexes, reconciliation job for race condition detection |
107
107
  | [Development](https://github.com/elghaied/payload-reserve/blob/main/docs/development.md) | Prerequisites, commands, project file tree |
108
+ | [v1.2.0 Breaking Changes](https://github.com/elghaied/payload-reserve/blob/main/docs/BREAKING-CHANGES-v1.2.md) | Migration guide for upgrading to v1.2.0 |
109
+
110
+ ---
111
+
112
+ ## Contributing
113
+
114
+ This project uses [Changesets](https://github.com/changesets/changesets) for versioning and changelogs.
115
+
116
+ When making a change that should appear in the release notes, run:
117
+
118
+ ```bash
119
+ pnpm changeset
120
+ ```
121
+
122
+ This prompts for the semver bump type (patch/minor/major) and a summary. Commit the generated changeset file with your PR.
123
+
124
+ **Releasing:**
125
+
126
+ ```bash
127
+ pnpm changeset:version # consume changesets, bump version, update CHANGELOG.md
128
+ git add -A && git commit -m "release v<version>"
129
+ git tag v<version>
130
+ git push && git push --tags
131
+ ```
132
+
133
+ The GitHub Action will create a release with the changelog content and publish to npm.
@@ -1,4 +1,12 @@
1
+ import { ValidationError } from 'payload';
1
2
  import { makeScheduleOwnerAccess } from '../utilities/ownerAccess.js';
3
+ const TIME_REGEX = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
4
+ const validateTime = (value)=>{
5
+ if (!value) {
6
+ return true;
7
+ } // required handles emptiness
8
+ return TIME_REGEX.test(value) || 'Invalid time format. Use HH:mm (e.g., 09:00, 17:30)';
9
+ };
2
10
  export function createSchedulesCollection(config) {
3
11
  const rom = config.resourceOwnerMode;
4
12
  const access = config.access.schedules ?? (rom ? makeScheduleOwnerAccess(rom) : {});
@@ -89,7 +97,8 @@ export function createSchedulesCollection(config) {
89
97
  placeholder: '09:00'
90
98
  },
91
99
  label: ({ t })=>t('reservation:fieldStartTimeHHmm'),
92
- required: true
100
+ required: true,
101
+ validate: validateTime
93
102
  },
94
103
  {
95
104
  name: 'endTime',
@@ -98,7 +107,8 @@ export function createSchedulesCollection(config) {
98
107
  placeholder: '17:00'
99
108
  },
100
109
  label: ({ t })=>t('reservation:fieldEndTimeHHmm'),
101
- required: true
110
+ required: true,
111
+ validate: validateTime
102
112
  }
103
113
  ],
104
114
  label: ({ t })=>t('reservation:fieldRecurringSlots')
@@ -128,7 +138,8 @@ export function createSchedulesCollection(config) {
128
138
  placeholder: '09:00'
129
139
  },
130
140
  label: ({ t })=>t('reservation:fieldStartTimeHHmm'),
131
- required: true
141
+ required: true,
142
+ validate: validateTime
132
143
  },
133
144
  {
134
145
  name: 'endTime',
@@ -137,7 +148,8 @@ export function createSchedulesCollection(config) {
137
148
  placeholder: '17:00'
138
149
  },
139
150
  label: ({ t })=>t('reservation:fieldEndTimeHHmm'),
140
- required: true
151
+ required: true,
152
+ validate: validateTime
141
153
  }
142
154
  ],
143
155
  label: ({ t })=>t('reservation:fieldManualSlots')
@@ -175,6 +187,39 @@ export function createSchedulesCollection(config) {
175
187
  label: ({ t })=>t('reservation:fieldActive')
176
188
  }
177
189
  ],
190
+ hooks: {
191
+ beforeValidate: [
192
+ ({ data })=>{
193
+ const slots = data?.recurringSlots ?? [];
194
+ for (const slot of slots){
195
+ if (slot.startTime && slot.endTime && slot.startTime >= slot.endTime) {
196
+ throw new ValidationError({
197
+ errors: [
198
+ {
199
+ message: 'endTime must be after startTime',
200
+ path: 'recurringSlots'
201
+ }
202
+ ]
203
+ });
204
+ }
205
+ }
206
+ const manual = data?.manualSlots ?? [];
207
+ for (const slot of manual){
208
+ if (slot.startTime && slot.endTime && slot.startTime >= slot.endTime) {
209
+ throw new ValidationError({
210
+ errors: [
211
+ {
212
+ message: 'endTime must be after startTime',
213
+ path: 'manualSlots'
214
+ }
215
+ ]
216
+ });
217
+ }
218
+ }
219
+ return data;
220
+ }
221
+ ]
222
+ },
178
223
  labels: {
179
224
  plural: ({ t })=>t('reservation:collectionSchedules'),
180
225
  singular: ({ t })=>t('reservation:collectionSchedules')
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/collections/Schedules.ts"],"sourcesContent":["import type { CollectionConfig, CollectionSlug } from 'payload'\n\nimport type { PluginT } from '../translations/index.js'\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { makeScheduleOwnerAccess } from '../utilities/ownerAccess.js'\n\nexport function createSchedulesCollection(\n config: ResolvedReservationPluginConfig,\n): CollectionConfig {\n const rom = config.resourceOwnerMode\n const access =\n config.access.schedules ?? (rom ? makeScheduleOwnerAccess(rom) : {})\n\n return {\n slug: config.slugs.schedules,\n access,\n admin: {\n group: config.adminGroup,\n useAsTitle: 'name',\n },\n fields: [\n {\n name: 'name',\n type: 'text',\n label: ({ t }) => (t as PluginT)('reservation:fieldName'),\n required: true,\n },\n {\n name: 'resource',\n type: 'relationship',\n label: ({ t }) => (t as PluginT)('reservation:fieldResource'),\n relationTo: config.slugs.resources as unknown as CollectionSlug,\n required: true,\n },\n {\n name: 'scheduleType',\n type: 'select',\n defaultValue: 'recurring',\n label: ({ t }) => (t as PluginT)('reservation:fieldScheduleType'),\n options: [\n {\n label: ({ t }) => (t as PluginT)('reservation:scheduleTypeRecurring'),\n value: 'recurring',\n },\n {\n label: ({ t }) => (t as PluginT)('reservation:scheduleTypeManual'),\n value: 'manual',\n },\n ],\n },\n {\n name: 'recurringSlots',\n type: 'array',\n admin: {\n condition: (_, siblingData) => siblingData?.scheduleType === 'recurring',\n },\n fields: [\n {\n name: 'day',\n type: 'select',\n label: ({ t }) => (t as PluginT)('reservation:fieldDay'),\n options: [\n { label: ({ t }) => (t as PluginT)('reservation:dayMonday'), value: 'mon' },\n { label: ({ t }) => (t as PluginT)('reservation:dayTuesday'), value: 'tue' },\n { label: ({ t }) => (t as PluginT)('reservation:dayWednesday'), value: 'wed' },\n { label: ({ t }) => (t as PluginT)('reservation:dayThursday'), value: 'thu' },\n { label: ({ t }) => (t as PluginT)('reservation:dayFriday'), value: 'fri' },\n { label: ({ t }) => (t as PluginT)('reservation:daySaturday'), value: 'sat' },\n { label: ({ t }) => (t as PluginT)('reservation:daySunday'), value: 'sun' },\n ],\n required: true,\n },\n {\n name: 'startTime',\n type: 'text',\n admin: {\n placeholder: '09:00',\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldStartTimeHHmm'),\n required: true,\n },\n {\n name: 'endTime',\n type: 'text',\n admin: {\n placeholder: '17:00',\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldEndTimeHHmm'),\n required: true,\n },\n ],\n label: ({ t }) => (t as PluginT)('reservation:fieldRecurringSlots'),\n },\n {\n name: 'manualSlots',\n type: 'array',\n admin: {\n condition: (_, siblingData) => siblingData?.scheduleType === 'manual',\n },\n fields: [\n {\n name: 'date',\n type: 'date',\n admin: {\n date: {\n pickerAppearance: 'dayOnly',\n },\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldDate'),\n required: true,\n },\n {\n name: 'startTime',\n type: 'text',\n admin: {\n placeholder: '09:00',\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldStartTimeHHmm'),\n required: true,\n },\n {\n name: 'endTime',\n type: 'text',\n admin: {\n placeholder: '17:00',\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldEndTimeHHmm'),\n required: true,\n },\n ],\n label: ({ t }) => (t as PluginT)('reservation:fieldManualSlots'),\n },\n {\n name: 'exceptions',\n type: 'array',\n fields: [\n {\n name: 'date',\n type: 'date',\n admin: {\n date: {\n pickerAppearance: 'dayOnly',\n },\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldDate'),\n required: true,\n },\n {\n name: 'reason',\n type: 'text',\n label: ({ t }) => (t as PluginT)('reservation:fieldReason'),\n },\n ],\n label: ({ t }) => (t as PluginT)('reservation:fieldExceptions'),\n },\n {\n name: 'active',\n type: 'checkbox',\n admin: {\n position: 'sidebar',\n },\n defaultValue: true,\n label: ({ t }) => (t as PluginT)('reservation:fieldActive'),\n },\n ],\n labels: {\n plural: ({ t }) => (t as PluginT)('reservation:collectionSchedules'),\n singular: ({ t }) => (t as PluginT)('reservation:collectionSchedules'),\n },\n }\n}\n"],"names":["makeScheduleOwnerAccess","createSchedulesCollection","config","rom","resourceOwnerMode","access","schedules","slug","slugs","admin","group","adminGroup","useAsTitle","fields","name","type","label","t","required","relationTo","resources","defaultValue","options","value","condition","_","siblingData","scheduleType","placeholder","date","pickerAppearance","position","labels","plural","singular"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/collections/Schedules.ts"],"sourcesContent":["import type { CollectionConfig, CollectionSlug } from 'payload'\n\nimport { ValidationError } from 'payload'\n\nimport type { PluginT } from '../translations/index.js'\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { makeScheduleOwnerAccess } from '../utilities/ownerAccess.js'\n\nconst TIME_REGEX = /^(?:[01]\\d|2[0-3]):[0-5]\\d$/\n\nconst validateTime = (value: null | string | undefined): string | true => {\n if (!value) { return true } // required handles emptiness\n return TIME_REGEX.test(value) || 'Invalid time format. Use HH:mm (e.g., 09:00, 17:30)'\n}\n\nexport function createSchedulesCollection(\n config: ResolvedReservationPluginConfig,\n): CollectionConfig {\n const rom = config.resourceOwnerMode\n const access =\n config.access.schedules ?? (rom ? makeScheduleOwnerAccess(rom) : {})\n\n return {\n slug: config.slugs.schedules,\n access,\n admin: {\n group: config.adminGroup,\n useAsTitle: 'name',\n },\n fields: [\n {\n name: 'name',\n type: 'text',\n label: ({ t }) => (t as PluginT)('reservation:fieldName'),\n required: true,\n },\n {\n name: 'resource',\n type: 'relationship',\n label: ({ t }) => (t as PluginT)('reservation:fieldResource'),\n relationTo: config.slugs.resources as unknown as CollectionSlug,\n required: true,\n },\n {\n name: 'scheduleType',\n type: 'select',\n defaultValue: 'recurring',\n label: ({ t }) => (t as PluginT)('reservation:fieldScheduleType'),\n options: [\n {\n label: ({ t }) => (t as PluginT)('reservation:scheduleTypeRecurring'),\n value: 'recurring',\n },\n {\n label: ({ t }) => (t as PluginT)('reservation:scheduleTypeManual'),\n value: 'manual',\n },\n ],\n },\n {\n name: 'recurringSlots',\n type: 'array',\n admin: {\n condition: (_, siblingData) => siblingData?.scheduleType === 'recurring',\n },\n fields: [\n {\n name: 'day',\n type: 'select',\n label: ({ t }) => (t as PluginT)('reservation:fieldDay'),\n options: [\n { label: ({ t }) => (t as PluginT)('reservation:dayMonday'), value: 'mon' },\n { label: ({ t }) => (t as PluginT)('reservation:dayTuesday'), value: 'tue' },\n { label: ({ t }) => (t as PluginT)('reservation:dayWednesday'), value: 'wed' },\n { label: ({ t }) => (t as PluginT)('reservation:dayThursday'), value: 'thu' },\n { label: ({ t }) => (t as PluginT)('reservation:dayFriday'), value: 'fri' },\n { label: ({ t }) => (t as PluginT)('reservation:daySaturday'), value: 'sat' },\n { label: ({ t }) => (t as PluginT)('reservation:daySunday'), value: 'sun' },\n ],\n required: true,\n },\n {\n name: 'startTime',\n type: 'text',\n admin: {\n placeholder: '09:00',\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldStartTimeHHmm'),\n required: true,\n validate: validateTime,\n },\n {\n name: 'endTime',\n type: 'text',\n admin: {\n placeholder: '17:00',\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldEndTimeHHmm'),\n required: true,\n validate: validateTime,\n },\n ],\n label: ({ t }) => (t as PluginT)('reservation:fieldRecurringSlots'),\n },\n {\n name: 'manualSlots',\n type: 'array',\n admin: {\n condition: (_, siblingData) => siblingData?.scheduleType === 'manual',\n },\n fields: [\n {\n name: 'date',\n type: 'date',\n admin: {\n date: {\n pickerAppearance: 'dayOnly',\n },\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldDate'),\n required: true,\n },\n {\n name: 'startTime',\n type: 'text',\n admin: {\n placeholder: '09:00',\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldStartTimeHHmm'),\n required: true,\n validate: validateTime,\n },\n {\n name: 'endTime',\n type: 'text',\n admin: {\n placeholder: '17:00',\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldEndTimeHHmm'),\n required: true,\n validate: validateTime,\n },\n ],\n label: ({ t }) => (t as PluginT)('reservation:fieldManualSlots'),\n },\n {\n name: 'exceptions',\n type: 'array',\n fields: [\n {\n name: 'date',\n type: 'date',\n admin: {\n date: {\n pickerAppearance: 'dayOnly',\n },\n },\n label: ({ t }) => (t as PluginT)('reservation:fieldDate'),\n required: true,\n },\n {\n name: 'reason',\n type: 'text',\n label: ({ t }) => (t as PluginT)('reservation:fieldReason'),\n },\n ],\n label: ({ t }) => (t as PluginT)('reservation:fieldExceptions'),\n },\n {\n name: 'active',\n type: 'checkbox',\n admin: {\n position: 'sidebar',\n },\n defaultValue: true,\n label: ({ t }) => (t as PluginT)('reservation:fieldActive'),\n },\n ],\n hooks: {\n beforeValidate: [\n ({ data }) => {\n const slots = (data?.recurringSlots as Array<{ endTime?: string; startTime?: string }>) ?? []\n for (const slot of slots) {\n if (slot.startTime && slot.endTime && slot.startTime >= slot.endTime) {\n throw new ValidationError({\n errors: [{ message: 'endTime must be after startTime', path: 'recurringSlots' }],\n })\n }\n }\n const manual = (data?.manualSlots as Array<{ endTime?: string; startTime?: string }>) ?? []\n for (const slot of manual) {\n if (slot.startTime && slot.endTime && slot.startTime >= slot.endTime) {\n throw new ValidationError({\n errors: [{ message: 'endTime must be after startTime', path: 'manualSlots' }],\n })\n }\n }\n return data\n },\n ],\n },\n labels: {\n plural: ({ t }) => (t as PluginT)('reservation:collectionSchedules'),\n singular: ({ t }) => (t as PluginT)('reservation:collectionSchedules'),\n },\n }\n}\n"],"names":["ValidationError","makeScheduleOwnerAccess","TIME_REGEX","validateTime","value","test","createSchedulesCollection","config","rom","resourceOwnerMode","access","schedules","slug","slugs","admin","group","adminGroup","useAsTitle","fields","name","type","label","t","required","relationTo","resources","defaultValue","options","condition","_","siblingData","scheduleType","placeholder","validate","date","pickerAppearance","position","hooks","beforeValidate","data","slots","recurringSlots","slot","startTime","endTime","errors","message","path","manual","manualSlots","labels","plural","singular"],"mappings":"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"}
@@ -452,3 +452,38 @@
452
452
  color: var(--theme-elevation-400);
453
453
  font-size: 0.9375rem;
454
454
  }
455
+
456
+ /* Resource filter */
457
+ .filterBar {
458
+ display: flex;
459
+ align-items: center;
460
+ gap: 8px;
461
+ margin-bottom: 12px;
462
+ }
463
+
464
+ .resourceFilter {
465
+ appearance: none;
466
+ background: var(--theme-bg);
467
+ border: none;
468
+ border-radius: 3px;
469
+ box-shadow: inset 0 0 0 1px var(--theme-elevation-250);
470
+ padding: 6px 28px 6px 10px;
471
+ cursor: pointer;
472
+ font-family: inherit;
473
+ font-size: 0.8125rem;
474
+ color: var(--theme-text);
475
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23666'/%3E%3C/svg%3E");
476
+ background-repeat: no-repeat;
477
+ background-position: right 10px center;
478
+ min-width: 160px;
479
+ transition: box-shadow 100ms cubic-bezier(0, 0.2, 0.2, 1);
480
+ }
481
+
482
+ .resourceFilter:hover {
483
+ box-shadow: inset 0 0 0 1px var(--theme-elevation-400);
484
+ }
485
+
486
+ .resourceFilter:focus-visible {
487
+ outline: 2px solid var(--theme-text);
488
+ outline-offset: 2px;
489
+ }
@@ -100,6 +100,9 @@ export const CalendarView = ()=>{
100
100
  const [loading, setLoading] = useState(true);
101
101
  const [drawerDocId, setDrawerDocId] = useState(null);
102
102
  const [initialData, setInitialData] = useState(undefined);
103
+ // Resource filter state
104
+ const [resources, setResources] = useState([]);
105
+ const [selectedResourceId, setSelectedResourceId] = useState('');
103
106
  // Pending tab state
104
107
  const [pendingReservations, setPendingReservations] = useState([]);
105
108
  const [pendingCount, setPendingCount] = useState(0);
@@ -117,6 +120,35 @@ export const CalendarView = ()=>{
117
120
  openDrawer();
118
121
  }
119
122
  });
123
+ // Fetch active resources for filter dropdown
124
+ useEffect(()=>{
125
+ const fetchResources = async ()=>{
126
+ try {
127
+ const resourceSlug = slugs?.resources ?? 'resources';
128
+ const params = new URLSearchParams({
129
+ depth: '0',
130
+ limit: '100',
131
+ sort: 'name',
132
+ 'where[active][equals]': 'true'
133
+ });
134
+ const url = `${config.serverURL ?? ''}${config.routes.api}/${resourceSlug}?${params}`;
135
+ const response = await fetch(url);
136
+ const result = await response.json();
137
+ const docs = result.docs ?? [];
138
+ setResources(docs.map((d)=>({
139
+ id: d.id,
140
+ name: d.name ?? ''
141
+ })));
142
+ } catch {
143
+ setResources([]);
144
+ }
145
+ };
146
+ void fetchResources();
147
+ }, [
148
+ config.routes.api,
149
+ config.serverURL,
150
+ slugs?.resources
151
+ ]);
120
152
  const { rangeEnd, rangeStart } = useMemo(()=>{
121
153
  const start = new Date(currentDate);
122
154
  const end = new Date(currentDate);
@@ -216,7 +248,36 @@ export const CalendarView = ()=>{
216
248
  viewMode,
217
249
  fetchPendingReservations
218
250
  ]);
219
- // Clear selection when leaving pending view
251
+ // Client-side resource filtering
252
+ const matchesResourceFilter = useCallback((r)=>{
253
+ if (!selectedResourceId) {
254
+ return true;
255
+ }
256
+ // Check top-level resource
257
+ const topId = typeof r.resource === 'string' ? r.resource : r.resource?.id;
258
+ if (topId === selectedResourceId) {
259
+ return true;
260
+ }
261
+ // Check items array for multi-resource bookings
262
+ if (r.items && r.items.length > 0) {
263
+ return r.items.some((item)=>{
264
+ const itemId = typeof item.resource === 'string' ? item.resource : item.resource?.id;
265
+ return itemId === selectedResourceId;
266
+ });
267
+ }
268
+ return false;
269
+ }, [
270
+ selectedResourceId
271
+ ]);
272
+ const filteredReservations = useMemo(()=>reservations.filter(matchesResourceFilter), [
273
+ reservations,
274
+ matchesResourceFilter
275
+ ]);
276
+ const filteredPendingReservations = useMemo(()=>pendingReservations.filter(matchesResourceFilter), [
277
+ pendingReservations,
278
+ matchesResourceFilter
279
+ ]);
280
+ // Clear selection when leaving pending view or changing resource filter
220
281
  useEffect(()=>{
221
282
  if (viewMode !== 'pending') {
222
283
  setSelectedIds(new Set());
@@ -225,6 +286,11 @@ export const CalendarView = ()=>{
225
286
  }, [
226
287
  viewMode
227
288
  ]);
289
+ useEffect(()=>{
290
+ setSelectedIds(new Set());
291
+ }, [
292
+ selectedResourceId
293
+ ]);
228
294
  // Auto-clear feedback toast
229
295
  useEffect(()=>{
230
296
  if (!actionFeedback) {
@@ -563,7 +629,7 @@ export const CalendarView = ()=>{
563
629
  const dayStr = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
564
630
  const isToday = dayStr === todayStr;
565
631
  const isOtherMonth = day.getMonth() !== currentDate.getMonth();
566
- const dayReservations = reservations.filter((r)=>{
632
+ const dayReservations = filteredReservations.filter((r)=>{
567
633
  const rDate = new Date(r.startTime);
568
634
  return rDate.getFullYear() === day.getFullYear() && rDate.getMonth() === day.getMonth() && rDate.getDate() === day.getDate();
569
635
  });
@@ -629,7 +695,7 @@ export const CalendarView = ()=>{
629
695
  ]
630
696
  }),
631
697
  weekDays.map((day, di)=>{
632
- const cellReservations = reservations.filter((r)=>{
698
+ const cellReservations = filteredReservations.filter((r)=>{
633
699
  const rDate = new Date(r.startTime);
634
700
  return rDate.getFullYear() === day.getFullYear() && rDate.getMonth() === day.getMonth() && rDate.getDate() === day.getDate() && rDate.getHours() === hour;
635
701
  });
@@ -664,7 +730,7 @@ export const CalendarView = ()=>{
664
730
  return /*#__PURE__*/ _jsx("div", {
665
731
  className: styles.dayView,
666
732
  children: hours.map((hour)=>{
667
- const hourReservations = reservations.filter((r)=>{
733
+ const hourReservations = filteredReservations.filter((r)=>{
668
734
  const rDate = new Date(r.startTime);
669
735
  return rDate.getFullYear() === currentDate.getFullYear() && rDate.getMonth() === currentDate.getMonth() && rDate.getDate() === currentDate.getDate() && rDate.getHours() === hour;
670
736
  });
@@ -701,18 +767,18 @@ export const CalendarView = ()=>{
701
767
  });
702
768
  };
703
769
  const renderPendingView = ()=>{
704
- if (pendingReservations.length === 0) {
770
+ if (filteredPendingReservations.length === 0) {
705
771
  return /*#__PURE__*/ _jsx("div", {
706
772
  className: styles.pendingEmpty,
707
773
  children: t('reservation:pendingEmpty')
708
774
  });
709
775
  }
710
- const allSelected = pendingReservations.length > 0 && pendingReservations.every((r)=>selectedIds.has(r.id));
776
+ const allSelected = filteredPendingReservations.length > 0 && filteredPendingReservations.every((r)=>selectedIds.has(r.id));
711
777
  const toggleSelectAll = ()=>{
712
778
  if (allSelected) {
713
779
  setSelectedIds(new Set());
714
780
  } else {
715
- setSelectedIds(new Set(pendingReservations.map((r)=>r.id)));
781
+ setSelectedIds(new Set(filteredPendingReservations.map((r)=>r.id)));
716
782
  }
717
783
  };
718
784
  const toggleSelect = (id)=>{
@@ -801,7 +867,7 @@ export const CalendarView = ()=>{
801
867
  })
802
868
  }),
803
869
  /*#__PURE__*/ _jsx("tbody", {
804
- children: pendingReservations.map((r)=>{
870
+ children: filteredPendingReservations.map((r)=>{
805
871
  const isConfirming = confirmingIds.has(r.id);
806
872
  // Show all resources from items array if present, else top-level resource
807
873
  const resourceDisplay = getResourceNames(r).join(', ') || t('reservation:calendarUnknownResource');
@@ -986,7 +1052,7 @@ export const CalendarView = ()=>{
986
1052
  label,
987
1053
  key === 'pending' && pendingCount > 0 && /*#__PURE__*/ _jsx("span", {
988
1054
  className: styles.pendingBadge,
989
- children: pendingCount
1055
+ children: selectedResourceId ? filteredPendingReservations.length : pendingCount
990
1056
  })
991
1057
  ]
992
1058
  }, key))
@@ -995,6 +1061,25 @@ export const CalendarView = ()=>{
995
1061
  ]
996
1062
  }),
997
1063
  viewMode !== 'pending' && renderStatusLegend(),
1064
+ resources.length > 1 && /*#__PURE__*/ _jsx("div", {
1065
+ className: styles.filterBar,
1066
+ children: /*#__PURE__*/ _jsxs("select", {
1067
+ "aria-label": t('reservation:filterByResource'),
1068
+ className: styles.resourceFilter,
1069
+ onChange: (e)=>setSelectedResourceId(e.target.value),
1070
+ value: selectedResourceId,
1071
+ children: [
1072
+ /*#__PURE__*/ _jsx("option", {
1073
+ value: "",
1074
+ children: t('reservation:filterAllResources')
1075
+ }),
1076
+ resources.map((r)=>/*#__PURE__*/ _jsx("option", {
1077
+ value: r.id,
1078
+ children: r.name
1079
+ }, r.id))
1080
+ ]
1081
+ })
1082
+ }),
998
1083
  loading && viewMode !== 'pending' ? /*#__PURE__*/ _jsx("div", {
999
1084
  className: styles.loading,
1000
1085
  children: t('reservation:calendarLoading')