@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.
@@ -1,9 +1,26 @@
1
- import React from 'react'
2
- import { View, Title, Text, Tags } from '@ossy/design-system'
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
- const BookingsPage = () => (
16
- <View
17
- gap="m"
18
- surface="primary"
19
- style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
20
- >
21
- <View inset="s" gap="s">
22
- <View layout="row" gap="s" alignItems="center">
23
- <Title>{Definition.title} — Bookings</Title>
24
- {moduleStatusTags(Definition).length > 0 && (
25
- <Tags tags={moduleStatusTags(Definition)} size="s" />
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
- </View>
28
-
29
- <Text style={{ maxWidth: '400px' }}>
30
- Incoming appointments from your clients.
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
- const weeklyWindows = availabilityResources.map(r => ({
41
- dayOfWeek: r.content?.dayOfWeek,
42
- startTime: r.content?.startTime ?? '09:00',
43
- endTime: r.content?.endTime ?? '17:00',
44
- })).filter(w => w.dayOfWeek != null)
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
- const weeklyWindows = availabilityResources.map(r => ({
30
- dayOfWeek: r.content?.dayOfWeek,
31
- startTime: r.content?.startTime ?? '09:00',
32
- endTime: r.content?.endTime ?? '17:00',
33
- })).filter(w => w.dayOfWeek != null)
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 slotDuration = duration ?? firstResource?.content?.sessionDuration ?? DEFAULT_DURATION
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
+ }