@ossy/booking 1.13.2 → 1.14.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.
- package/package.json +7 -7
- package/src/availability-setup.page.jsx +542 -0
- package/src/booking-card.component.jsx +279 -0
- package/src/booking-detail.page.jsx +659 -0
- package/src/bookings.page.jsx +289 -32
- package/src/create-booking.action.js +18 -6
- package/src/get-availability.action.js +52 -0
- package/src/get-available-slots.action.js +23 -7
- package/src/list-bookings.action.js +36 -0
- package/src/save-availability.action.js +73 -0
package/src/bookings.page.jsx
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import {
|
|
3
|
-
import { Definition } from './Definition.js'
|
|
4
|
-
import { moduleStatusTags } from './moduleStatus.js'
|
|
5
|
-
import { BookingList } from './BookingList.jsx'
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { BookingCard } from './booking-card.component.jsx'
|
|
6
3
|
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Tokens
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const C = {
|
|
8
|
+
bg: '#fafafa',
|
|
9
|
+
surface: '#ffffff',
|
|
10
|
+
border: '#e4e4e7',
|
|
11
|
+
primary: '#111111',
|
|
12
|
+
secondary: '#52525b',
|
|
13
|
+
muted: '#a1a1aa',
|
|
14
|
+
tabActive: '#111111',
|
|
15
|
+
tabActiveBg: '#f4f4f5',
|
|
16
|
+
tabText: '#52525b',
|
|
17
|
+
errorText: '#dc2626',
|
|
18
|
+
}
|
|
19
|
+
const FONT = 'system-ui, -apple-system, sans-serif'
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Page metadata
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
7
24
|
export const metadata = {
|
|
8
25
|
id: 'booking/bookings',
|
|
9
26
|
path: {
|
|
@@ -12,31 +29,271 @@ export const metadata = {
|
|
|
12
29
|
},
|
|
13
30
|
}
|
|
14
31
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Tab bar
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const TABS = [
|
|
36
|
+
{ id: 'pending', label: 'Förfrågningar' },
|
|
37
|
+
{ id: 'confirmed', label: 'Bekräftade' },
|
|
38
|
+
{ id: 'all', label: 'Alla' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
function TabBar({ activeId, onChange, counts }) {
|
|
42
|
+
return (
|
|
43
|
+
<div style={{ display: 'flex', gap: 4, borderBottom: `1px solid ${C.border}`, marginBottom: 20 }}>
|
|
44
|
+
{TABS.map(tab => {
|
|
45
|
+
const isActive = tab.id === activeId
|
|
46
|
+
const count = counts?.[tab.id]
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
key={tab.id}
|
|
50
|
+
onClick={() => onChange(tab.id)}
|
|
51
|
+
style={{
|
|
52
|
+
background: 'none',
|
|
53
|
+
border: 'none',
|
|
54
|
+
borderBottom: isActive ? `2px solid ${C.primary}` : '2px solid transparent',
|
|
55
|
+
padding: '10px 14px',
|
|
56
|
+
marginBottom: -1,
|
|
57
|
+
fontFamily: FONT,
|
|
58
|
+
fontSize: 13,
|
|
59
|
+
fontWeight: isActive ? 600 : 400,
|
|
60
|
+
color: isActive ? C.primary : C.secondary,
|
|
61
|
+
cursor: 'pointer',
|
|
62
|
+
display: 'flex',
|
|
63
|
+
alignItems: 'center',
|
|
64
|
+
gap: 6,
|
|
65
|
+
whiteSpace: 'nowrap',
|
|
66
|
+
transition: 'color 0.1s',
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{tab.label}
|
|
70
|
+
{count != null && count > 0 && (
|
|
71
|
+
<span
|
|
72
|
+
style={{
|
|
73
|
+
background: isActive ? C.primary : C.border,
|
|
74
|
+
color: isActive ? '#fff' : C.secondary,
|
|
75
|
+
borderRadius: 999,
|
|
76
|
+
fontSize: 10,
|
|
77
|
+
fontWeight: 700,
|
|
78
|
+
padding: '1px 6px',
|
|
79
|
+
lineHeight: 1.5,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{count}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
</button>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Empty state
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
function EmptyState({ tab }) {
|
|
96
|
+
const messages = {
|
|
97
|
+
pending: { icon: '📭', heading: 'Inga förfrågningar', body: 'Du har inga väntande bokningsförfrågningar just nu.' },
|
|
98
|
+
confirmed: { icon: '📅', heading: 'Inga bekräftade bokningar', body: 'Bekräftade bokningar visas här.' },
|
|
99
|
+
all: { icon: '🗂', heading: 'Inga bokningar', body: 'Bokningar från dina klienter visas här.' },
|
|
100
|
+
}
|
|
101
|
+
const m = messages[tab] ?? messages.all
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
style={{
|
|
106
|
+
display: 'flex',
|
|
107
|
+
flexDirection: 'column',
|
|
108
|
+
alignItems: 'center',
|
|
109
|
+
justifyContent: 'center',
|
|
110
|
+
padding: '48px 24px',
|
|
111
|
+
color: C.muted,
|
|
112
|
+
fontFamily: FONT,
|
|
113
|
+
textAlign: 'center',
|
|
114
|
+
gap: 8,
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<span style={{ fontSize: 32 }}>{m.icon}</span>
|
|
118
|
+
<span style={{ fontSize: 15, fontWeight: 600, color: C.secondary }}>{m.heading}</span>
|
|
119
|
+
<span style={{ fontSize: 13 }}>{m.body}</span>
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Spinner
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
function Spinner() {
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<style href="booking/spinner" precedence="low">
|
|
131
|
+
{`@keyframes booking-spin { to { transform: rotate(360deg) } }`}
|
|
132
|
+
</style>
|
|
133
|
+
<div
|
|
134
|
+
style={{
|
|
135
|
+
width: 22,
|
|
136
|
+
height: 22,
|
|
137
|
+
border: '3px solid #e4e4e7',
|
|
138
|
+
borderTopColor: C.primary,
|
|
139
|
+
borderRadius: '50%',
|
|
140
|
+
animation: 'booking-spin 0.7s linear infinite',
|
|
141
|
+
margin: '40px auto',
|
|
142
|
+
}}
|
|
143
|
+
/>
|
|
144
|
+
</>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Page
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
export default function BookingsPage({ workspaceId }) {
|
|
152
|
+
const [activeTab, setActiveTab] = useState('pending')
|
|
153
|
+
const [bookings, setBookings] = useState([])
|
|
154
|
+
const [loading, setLoading] = useState(true)
|
|
155
|
+
const [error, setError] = useState(null)
|
|
156
|
+
|
|
157
|
+
const loadBookings = useCallback(async () => {
|
|
158
|
+
setLoading(true)
|
|
159
|
+
setError(null)
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch('/actions/booking/list', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({}),
|
|
165
|
+
})
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
const data = await res.json().catch(() => ({}))
|
|
168
|
+
throw new Error(data?.message ?? `Fel ${res.status}`)
|
|
169
|
+
}
|
|
170
|
+
const data = await res.json()
|
|
171
|
+
setBookings(data)
|
|
172
|
+
} catch (err) {
|
|
173
|
+
setError(err.message)
|
|
174
|
+
} finally {
|
|
175
|
+
setLoading(false)
|
|
176
|
+
}
|
|
177
|
+
}, [])
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
loadBookings()
|
|
181
|
+
}, [loadBookings])
|
|
182
|
+
|
|
183
|
+
// Filtered views
|
|
184
|
+
const pending = bookings.filter(b => b.status === 'pending')
|
|
185
|
+
const confirmed = bookings.filter(b => b.status === 'confirmed')
|
|
186
|
+
const visible = activeTab === 'pending' ? pending : activeTab === 'confirmed' ? confirmed : bookings
|
|
187
|
+
|
|
188
|
+
const counts = { pending: pending.length, confirmed: confirmed.length, all: bookings.length }
|
|
189
|
+
|
|
190
|
+
// Actions
|
|
191
|
+
const handleConfirm = async (booking) => {
|
|
192
|
+
const res = await fetch('/actions/booking/confirm', {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'Content-Type': 'application/json' },
|
|
195
|
+
body: JSON.stringify({ bookingId: booking.id }),
|
|
196
|
+
})
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
const data = await res.json().catch(() => ({}))
|
|
199
|
+
throw new Error(data?.message ?? 'Bekräftning misslyckades')
|
|
200
|
+
}
|
|
201
|
+
await loadBookings()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const handleDecline = async (booking) => {
|
|
205
|
+
const res = await fetch('/actions/booking/decline', {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'Content-Type': 'application/json' },
|
|
208
|
+
body: JSON.stringify({ bookingId: booking.id }),
|
|
209
|
+
})
|
|
210
|
+
if (!res.ok) {
|
|
211
|
+
const data = await res.json().catch(() => ({}))
|
|
212
|
+
throw new Error(data?.message ?? 'Avböjning misslyckades')
|
|
213
|
+
}
|
|
214
|
+
await loadBookings()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const handleCancel = async (booking) => {
|
|
218
|
+
const res = await fetch('/actions/booking/cancel', {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: { 'Content-Type': 'application/json' },
|
|
221
|
+
body: JSON.stringify({ bookingId: booking.id }),
|
|
222
|
+
})
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
const data = await res.json().catch(() => ({}))
|
|
225
|
+
throw new Error(data?.message ?? 'Avbokning misslyckades')
|
|
226
|
+
}
|
|
227
|
+
await loadBookings()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const handleView = (booking) => {
|
|
231
|
+
window.location.href = `/bokningar/${booking.id}`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div
|
|
236
|
+
style={{
|
|
237
|
+
fontFamily: FONT,
|
|
238
|
+
height: '100%',
|
|
239
|
+
overflowY: 'auto',
|
|
240
|
+
background: C.bg,
|
|
241
|
+
padding: 'var(--space-m, 16px) var(--space-l, 24px)',
|
|
242
|
+
boxSizing: 'border-box',
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
{/* Page header */}
|
|
246
|
+
<div style={{ marginBottom: 24 }}>
|
|
247
|
+
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700, color: C.primary }}>
|
|
248
|
+
Bokningar
|
|
249
|
+
</h1>
|
|
250
|
+
<p style={{ margin: '4px 0 0', fontSize: 14, color: C.secondary }}>
|
|
251
|
+
Hantera bokningsförfrågningar och bekräftade möten.
|
|
252
|
+
</p>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Content card */}
|
|
256
|
+
<div
|
|
257
|
+
style={{
|
|
258
|
+
background: C.surface,
|
|
259
|
+
border: `1px solid ${C.border}`,
|
|
260
|
+
borderRadius: 12,
|
|
261
|
+
padding: '20px 24px',
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<TabBar activeId={activeTab} onChange={setActiveTab} counts={counts} />
|
|
265
|
+
|
|
266
|
+
{loading ? (
|
|
267
|
+
<Spinner />
|
|
268
|
+
) : error ? (
|
|
269
|
+
<div
|
|
270
|
+
style={{
|
|
271
|
+
padding: '24px',
|
|
272
|
+
fontSize: 14,
|
|
273
|
+
color: C.errorText,
|
|
274
|
+
textAlign: 'center',
|
|
275
|
+
fontFamily: FONT,
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
{error}
|
|
279
|
+
</div>
|
|
280
|
+
) : visible.length === 0 ? (
|
|
281
|
+
<EmptyState tab={activeTab} />
|
|
282
|
+
) : (
|
|
283
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
284
|
+
{visible.map(booking => (
|
|
285
|
+
<BookingCard
|
|
286
|
+
key={booking.id}
|
|
287
|
+
booking={booking}
|
|
288
|
+
onConfirm={booking.status === 'pending' ? handleConfirm : undefined}
|
|
289
|
+
onDecline={booking.status === 'pending' ? handleDecline : undefined}
|
|
290
|
+
onCancel={booking.status === 'confirmed' ? handleCancel : undefined}
|
|
291
|
+
onView={handleView}
|
|
292
|
+
/>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
26
295
|
)}
|
|
27
|
-
</
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
</Text>
|
|
32
|
-
</View>
|
|
33
|
-
|
|
34
|
-
<View gap="s" inset="s">
|
|
35
|
-
<Title variant="secondary">Upcoming</Title>
|
|
36
|
-
{/* TODO: load bookings via booking.get-bookings view, filtered by status=confirmed */}
|
|
37
|
-
<BookingList bookings={[]} />
|
|
38
|
-
</View>
|
|
39
|
-
</View>
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
export default BookingsPage
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
@@ -37,13 +37,25 @@ export async function run({ payload, log, integrations }) {
|
|
|
37
37
|
belongsTo: consultantId,
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
// New format: single resource with weeklyWindows array in content
|
|
41
|
+
const configResource = availabilityResources.find(r => Array.isArray(r.content?.weeklyWindows))
|
|
42
|
+
let weeklyWindows, firstResource
|
|
43
|
+
|
|
44
|
+
if (configResource) {
|
|
45
|
+
weeklyWindows = configResource.content.weeklyWindows ?? []
|
|
46
|
+
firstResource = configResource
|
|
47
|
+
} else {
|
|
48
|
+
// Legacy format: one resource per day
|
|
49
|
+
firstResource = availabilityResources[0]
|
|
50
|
+
weeklyWindows = availabilityResources
|
|
51
|
+
.map(r => ({
|
|
52
|
+
dayOfWeek: r.content?.dayOfWeek,
|
|
53
|
+
startTime: r.content?.startTime ?? '09:00',
|
|
54
|
+
endTime: r.content?.endTime ?? '17:00',
|
|
55
|
+
}))
|
|
56
|
+
.filter(w => w.dayOfWeek != null)
|
|
57
|
+
}
|
|
45
58
|
|
|
46
|
-
const firstResource = availabilityResources[0]
|
|
47
59
|
const availability = {
|
|
48
60
|
weeklyWindows,
|
|
49
61
|
bufferMinutes: firstResource?.content?.bufferMinutes ?? 0,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ResourcesQueries } from '@ossy/resources'
|
|
2
|
+
|
|
3
|
+
export const id = 'booking/get-availability'
|
|
4
|
+
export const access = 'workspace'
|
|
5
|
+
|
|
6
|
+
export async function run({ payload, req, log }) {
|
|
7
|
+
const workspaceId = req?.workspaceId
|
|
8
|
+
|
|
9
|
+
if (!workspaceId) {
|
|
10
|
+
throw Object.assign(new Error('workspaceId is required'), { status: 400 })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
log?.info(`[booking/get-availability] Loading availability for workspace ${workspaceId}`)
|
|
14
|
+
|
|
15
|
+
const resources = await ResourcesQueries.GetResources({
|
|
16
|
+
type: '@ossy/booking/availability',
|
|
17
|
+
belongsTo: workspaceId,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
if (!resources.length) {
|
|
21
|
+
log?.info('[booking/get-availability] No availability configured')
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// New format: single resource with weeklyWindows array
|
|
26
|
+
const configResource = resources.find(r => Array.isArray(r.content?.weeklyWindows))
|
|
27
|
+
if (configResource) {
|
|
28
|
+
return {
|
|
29
|
+
id: configResource.id,
|
|
30
|
+
weeklyWindows: configResource.content.weeklyWindows ?? [],
|
|
31
|
+
sessionDurations: configResource.content.sessionDurations ?? [60],
|
|
32
|
+
bufferMinutes: configResource.content.bufferMinutes ?? 0,
|
|
33
|
+
timezone: configResource.content.timezone ?? 'Europe/Stockholm',
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Legacy format: one resource per day — reconstruct config shape
|
|
38
|
+
const firstResource = resources[0]
|
|
39
|
+
return {
|
|
40
|
+
id: firstResource.id,
|
|
41
|
+
weeklyWindows: resources
|
|
42
|
+
.map(r => ({
|
|
43
|
+
dayOfWeek: r.content?.dayOfWeek,
|
|
44
|
+
startTime: r.content?.startTime ?? '09:00',
|
|
45
|
+
endTime: r.content?.endTime ?? '17:00',
|
|
46
|
+
}))
|
|
47
|
+
.filter(w => w.dayOfWeek != null),
|
|
48
|
+
sessionDurations: [firstResource?.content?.sessionDuration ?? 60],
|
|
49
|
+
bufferMinutes: firstResource?.content?.bufferMinutes ?? 0,
|
|
50
|
+
timezone: firstResource?.content?.timezone ?? 'Europe/Stockholm',
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -26,13 +26,25 @@ export async function run({ payload, log }) {
|
|
|
26
26
|
return []
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// New format: single resource with weeklyWindows array in content
|
|
30
|
+
const configResource = availabilityResources.find(r => Array.isArray(r.content?.weeklyWindows))
|
|
31
|
+
let weeklyWindows, firstResource
|
|
32
|
+
|
|
33
|
+
if (configResource) {
|
|
34
|
+
weeklyWindows = configResource.content.weeklyWindows ?? []
|
|
35
|
+
firstResource = configResource
|
|
36
|
+
} else {
|
|
37
|
+
// Legacy format: one resource per day
|
|
38
|
+
firstResource = availabilityResources[0]
|
|
39
|
+
weeklyWindows = availabilityResources
|
|
40
|
+
.map(r => ({
|
|
41
|
+
dayOfWeek: r.content?.dayOfWeek,
|
|
42
|
+
startTime: r.content?.startTime ?? '09:00',
|
|
43
|
+
endTime: r.content?.endTime ?? '17:00',
|
|
44
|
+
}))
|
|
45
|
+
.filter(w => w.dayOfWeek != null)
|
|
46
|
+
}
|
|
34
47
|
|
|
35
|
-
const firstResource = availabilityResources[0]
|
|
36
48
|
const availability = {
|
|
37
49
|
weeklyWindows,
|
|
38
50
|
bufferMinutes: firstResource?.content?.bufferMinutes ?? 0,
|
|
@@ -58,7 +70,11 @@ export async function run({ payload, log }) {
|
|
|
58
70
|
status: 'confirmed',
|
|
59
71
|
}))
|
|
60
72
|
|
|
61
|
-
const
|
|
73
|
+
const sessionDurations = configResource
|
|
74
|
+
? (configResource.content.sessionDurations ?? [DEFAULT_DURATION])
|
|
75
|
+
: [firstResource?.content?.sessionDuration ?? DEFAULT_DURATION]
|
|
76
|
+
|
|
77
|
+
const slotDuration = duration ?? sessionDurations[0] ?? DEFAULT_DURATION
|
|
62
78
|
|
|
63
79
|
const slots = getAvailableSlots({
|
|
64
80
|
availability,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ResourcesQueries } from '@ossy/resources'
|
|
2
|
+
|
|
3
|
+
export const id = 'booking/list'
|
|
4
|
+
export const access = 'workspace'
|
|
5
|
+
|
|
6
|
+
export async function run({ payload, req, log }) {
|
|
7
|
+
const workspaceId = req?.workspaceId
|
|
8
|
+
const status = payload?.status ?? null
|
|
9
|
+
|
|
10
|
+
log?.info(`[booking/list] Loading bookings for workspace ${workspaceId}`, { status })
|
|
11
|
+
|
|
12
|
+
const resources = await ResourcesQueries.GetResources({
|
|
13
|
+
type: '@ossy/booking/booking',
|
|
14
|
+
belongsTo: workspaceId,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
let bookings = resources.map(r => ({
|
|
18
|
+
id: r.id,
|
|
19
|
+
...r.content,
|
|
20
|
+
createdAt: r.createdAt,
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
if (status) {
|
|
24
|
+
bookings = bookings.filter(b => b.status === status)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
bookings.sort((a, b) => {
|
|
28
|
+
const dateA = new Date(a.startsAt ?? a.createdAt ?? 0).getTime()
|
|
29
|
+
const dateB = new Date(b.startsAt ?? b.createdAt ?? 0).getTime()
|
|
30
|
+
return dateB - dateA
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
log?.info(`[booking/list] Found ${bookings.length} booking(s)`)
|
|
34
|
+
|
|
35
|
+
return bookings
|
|
36
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import { Aggregate } from '@ossy/event-store'
|
|
3
|
+
import { Resource, ResourcesEvents, ResourcesQueries } from '@ossy/resources'
|
|
4
|
+
|
|
5
|
+
export const id = 'booking/save-availability'
|
|
6
|
+
export const access = 'workspace'
|
|
7
|
+
|
|
8
|
+
export async function run({ payload, req, log }) {
|
|
9
|
+
const workspaceId = req?.workspaceId
|
|
10
|
+
|
|
11
|
+
if (!workspaceId) {
|
|
12
|
+
throw Object.assign(new Error('workspaceId is required'), { status: 400 })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { weeklyWindows, sessionDurations, bufferMinutes, timezone } = payload ?? {}
|
|
16
|
+
|
|
17
|
+
log?.info(`[booking/save-availability] Saving availability for workspace ${workspaceId}`)
|
|
18
|
+
|
|
19
|
+
const content = {
|
|
20
|
+
weeklyWindows: weeklyWindows ?? [],
|
|
21
|
+
sessionDurations: Array.isArray(sessionDurations) && sessionDurations.length
|
|
22
|
+
? sessionDurations
|
|
23
|
+
: [60],
|
|
24
|
+
bufferMinutes: bufferMinutes ?? 0,
|
|
25
|
+
timezone: timezone ?? 'Europe/Stockholm',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check if a config resource already exists for this workspace
|
|
29
|
+
const existing = await ResourcesQueries.GetResources({
|
|
30
|
+
type: '@ossy/booking/availability',
|
|
31
|
+
belongsTo: workspaceId,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Identify the single config resource (new format has weeklyWindows array in content)
|
|
35
|
+
const configResource = existing.find(r => Array.isArray(r.content?.weeklyWindows))
|
|
36
|
+
|
|
37
|
+
if (configResource) {
|
|
38
|
+
log?.info(`[booking/save-availability] Updating existing resource ${configResource.id}`)
|
|
39
|
+
|
|
40
|
+
await Aggregate.Of(Resource, configResource.id)
|
|
41
|
+
.then(
|
|
42
|
+
Aggregate.Add(
|
|
43
|
+
ResourcesEvents.ContentUpdated({
|
|
44
|
+
createdBy: req?.userId ?? 'system',
|
|
45
|
+
content,
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
.then(Aggregate.Save())
|
|
50
|
+
|
|
51
|
+
return { id: configResource.id, content }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// No config resource found — create a new one
|
|
55
|
+
const resourceId = nanoid()
|
|
56
|
+
log?.info(`[booking/save-availability] Creating new resource ${resourceId}`)
|
|
57
|
+
|
|
58
|
+
const event = ResourcesEvents.Created({
|
|
59
|
+
aggregateId: resourceId,
|
|
60
|
+
type: '@ossy/booking/availability',
|
|
61
|
+
createdBy: req?.userId ?? 'system',
|
|
62
|
+
belongsTo: workspaceId,
|
|
63
|
+
location: '/availability/',
|
|
64
|
+
name: `availability-config.json`,
|
|
65
|
+
content,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await Aggregate.Of(Resource, event).then(Aggregate.View())
|
|
69
|
+
|
|
70
|
+
log?.info(`[booking/save-availability] Resource ${resourceId} created`)
|
|
71
|
+
|
|
72
|
+
return { id: resourceId, content }
|
|
73
|
+
}
|