@ossy/booking 1.15.7 → 1.16.1
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/README.md +26 -19
- package/package.json +7 -7
- package/src/AvailabilityEditor.jsx +22 -15
- package/src/Definition.js +1 -23
- package/src/SalesSection.jsx +9 -4
- package/src/availability-setup.page.jsx +94 -126
- package/src/booking-card.component.jsx +30 -33
- package/src/booking-detail.page.jsx +65 -204
- package/src/booking-resources.js +10 -0
- package/src/bookings.page.jsx +54 -56
- package/src/cancel-booking.action.js +1 -61
- package/src/cancel-booking.task.js +60 -0
- package/src/confirm-booking.action.js +1 -82
- package/src/confirm-booking.task.js +81 -0
- package/src/create-booking.action.js +1 -168
- package/src/create-booking.task.js +170 -0
- package/src/create-service.action.js +1 -0
- package/src/create-service.task.js +46 -0
- package/src/decline-booking.action.js +1 -68
- package/src/decline-booking.task.js +67 -0
- package/src/delete-service.action.js +1 -0
- package/src/delete-service.task.js +41 -0
- package/src/en.translations.json +246 -0
- package/src/get-availability.action.js +1 -52
- package/src/get-availability.task.js +51 -0
- package/src/get-available-slots.action.js +1 -89
- package/src/get-available-slots.task.js +99 -0
- package/src/get-services.action.js +1 -0
- package/src/get-services.task.js +37 -0
- package/src/home.page.jsx +48 -5
- package/src/index.js +20 -1
- package/src/invoke-error-message.js +10 -0
- package/src/list-bookings.action.js +1 -36
- package/src/list-bookings.task.js +35 -0
- package/src/locations.js +14 -0
- package/src/public-booking.page.jsx +200 -245
- package/src/save-availability.action.js +1 -73
- package/src/save-availability.task.js +74 -0
- package/src/send-booking-reminder.task.js +2 -2
- package/src/services.page.jsx +167 -46
- package/src/sv.translations.json +246 -0
- package/src/update-service.action.js +1 -0
- package/src/update-service.task.js +52 -0
- package/src/BookingForm.jsx +0 -82
- package/src/availability.page.jsx +0 -41
- package/src/booking-page.page.jsx +0 -42
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import React, { useState } from 'react'
|
|
2
|
-
import { View, Title, Text, Button, Badge, Alert } from '@ossy/design-system'
|
|
2
|
+
import { View, Title, Text, Button, Badge, Alert, useLocale } from '@ossy/design-system'
|
|
3
|
+
import { metadata as ConfirmBooking } from './confirm-booking.action.js'
|
|
4
|
+
import { metadata as DeclineBooking } from './decline-booking.action.js'
|
|
5
|
+
import { metadata as CancelBooking } from './cancel-booking.action.js'
|
|
3
6
|
|
|
4
7
|
export const metadata = { id: 'resource:booking/booking/card' }
|
|
5
8
|
|
|
@@ -9,36 +12,21 @@ const STATUS_VARIANT = {
|
|
|
9
12
|
cancelled: 'neutral',
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
pending: 'Väntar',
|
|
14
|
-
confirmed: 'Bekräftad',
|
|
15
|
-
cancelled: 'Avbokad',
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const SV_MONTHS = [
|
|
19
|
-
'jan', 'feb', 'mar', 'apr', 'maj', 'jun',
|
|
20
|
-
'jul', 'aug', 'sep', 'okt', 'nov', 'dec',
|
|
21
|
-
]
|
|
22
|
-
const SV_WEEKDAYS = ['Sön', 'Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör']
|
|
23
|
-
|
|
24
|
-
function formatDateTime (iso) {
|
|
15
|
+
function formatDateTime (iso, t, language) {
|
|
25
16
|
if (!iso) return '—'
|
|
26
17
|
const d = new Date(iso)
|
|
27
|
-
const
|
|
28
|
-
const day = d.getDate()
|
|
29
|
-
const month = SV_MONTHS[d.getMonth()]
|
|
30
|
-
const hh = String(d.getHours()).padStart(2, '0')
|
|
31
|
-
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
18
|
+
const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
|
|
32
19
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
20
|
+
return new Intl.DateTimeFormat(locale, {
|
|
21
|
+
weekday: 'short', day: 'numeric', month: 'short',
|
|
22
|
+
hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
|
|
23
|
+
}).format(d)
|
|
36
24
|
} catch {
|
|
37
|
-
return
|
|
25
|
+
return d.toLocaleString(locale)
|
|
38
26
|
}
|
|
39
27
|
}
|
|
40
28
|
|
|
41
|
-
function formatDuration (minutes) {
|
|
29
|
+
function formatDuration (minutes, t) {
|
|
42
30
|
if (!minutes) return '—'
|
|
43
31
|
if (minutes < 60) return `${minutes} min`
|
|
44
32
|
const h = Math.floor(minutes / 60)
|
|
@@ -51,21 +39,27 @@ function truncate (text, max = 120) {
|
|
|
51
39
|
return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text
|
|
52
40
|
}
|
|
53
41
|
|
|
54
|
-
/** Booking card view — registered at `resource:booking/booking/card`. */
|
|
55
42
|
export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel, onView }) {
|
|
43
|
+
const { t, language } = useLocale()
|
|
56
44
|
const data = booking ?? resource?.content ?? resource
|
|
57
45
|
if (!data) return null
|
|
58
46
|
|
|
59
47
|
const [loadingAction, setLoadingAction] = useState(null)
|
|
60
48
|
const [error, setError] = useState(null)
|
|
61
49
|
|
|
50
|
+
const statusLabel = {
|
|
51
|
+
pending: t('booking.card.status.pending'),
|
|
52
|
+
confirmed: t('booking.card.status.confirmed'),
|
|
53
|
+
cancelled: t('booking.card.status.cancelled'),
|
|
54
|
+
}
|
|
55
|
+
|
|
62
56
|
const handle = async (actionFn, actionName) => {
|
|
63
57
|
setLoadingAction(actionName)
|
|
64
58
|
setError(null)
|
|
65
59
|
try {
|
|
66
60
|
await actionFn(data)
|
|
67
61
|
} catch (err) {
|
|
68
|
-
setError(err?.message ?? '
|
|
62
|
+
setError(err?.message ?? t('booking.card.errorGeneric'))
|
|
69
63
|
} finally {
|
|
70
64
|
setLoadingAction(null)
|
|
71
65
|
}
|
|
@@ -81,7 +75,7 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
|
|
|
81
75
|
<View layout="row" gap="s" alignItems="center" style={{ flexWrap: 'wrap' }}>
|
|
82
76
|
<Title variant="tertiary">{data.clientName ?? '—'}</Title>
|
|
83
77
|
<Badge
|
|
84
|
-
label={
|
|
78
|
+
label={statusLabel[data.status] ?? data.status}
|
|
85
79
|
variant={STATUS_VARIANT[data.status] ?? 'neutral'}
|
|
86
80
|
size="s"
|
|
87
81
|
/>
|
|
@@ -91,14 +85,14 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
|
|
|
91
85
|
|
|
92
86
|
{onView && (
|
|
93
87
|
<Button variant="link" onClick={() => onView(data)}>
|
|
94
|
-
|
|
88
|
+
{t('booking.card.view')}
|
|
95
89
|
</Button>
|
|
96
90
|
)}
|
|
97
91
|
</View>
|
|
98
92
|
|
|
99
93
|
<View layout="row" gap="l" style={{ flexWrap: 'wrap' }}>
|
|
100
|
-
<Text size="s" color="secondary">{formatDateTime(data.startsAt)}</Text>
|
|
101
|
-
<Text size="s" color="secondary">{formatDuration(data.duration)}</Text>
|
|
94
|
+
<Text size="s" color="secondary">{formatDateTime(data.startsAt, t, language)}</Text>
|
|
95
|
+
<Text size="s" color="secondary">{formatDuration(data.duration, t)}</Text>
|
|
102
96
|
</View>
|
|
103
97
|
|
|
104
98
|
{data.clientMessage && (
|
|
@@ -111,29 +105,32 @@ export function BookingCard ({ booking, resource, onConfirm, onDecline, onCancel
|
|
|
111
105
|
<View layout="row" gap="s" style={{ flexWrap: 'wrap' }}>
|
|
112
106
|
{isPending && onConfirm && (
|
|
113
107
|
<Button
|
|
108
|
+
data-action={ConfirmBooking.id}
|
|
114
109
|
variant="primary"
|
|
115
110
|
disabled={loadingAction === 'confirm'}
|
|
116
111
|
onClick={() => handle(onConfirm, 'confirm')}
|
|
117
112
|
>
|
|
118
|
-
{loadingAction === 'confirm' ? '
|
|
113
|
+
{loadingAction === 'confirm' ? t('booking.card.waiting') : t('booking.card.confirm')}
|
|
119
114
|
</Button>
|
|
120
115
|
)}
|
|
121
116
|
{isPending && onDecline && (
|
|
122
117
|
<Button
|
|
118
|
+
data-action={DeclineBooking.id}
|
|
123
119
|
variant="danger"
|
|
124
120
|
disabled={loadingAction === 'decline'}
|
|
125
121
|
onClick={() => handle(onDecline, 'decline')}
|
|
126
122
|
>
|
|
127
|
-
{loadingAction === 'decline' ? '
|
|
123
|
+
{loadingAction === 'decline' ? t('booking.card.waiting') : t('booking.card.decline')}
|
|
128
124
|
</Button>
|
|
129
125
|
)}
|
|
130
126
|
{isConfirmed && onCancel && (
|
|
131
127
|
<Button
|
|
128
|
+
data-action={CancelBooking.id}
|
|
132
129
|
variant="danger"
|
|
133
130
|
disabled={loadingAction === 'cancel'}
|
|
134
131
|
onClick={() => handle(onCancel, 'cancel')}
|
|
135
132
|
>
|
|
136
|
-
{loadingAction === 'cancel' ? '
|
|
133
|
+
{loadingAction === 'cancel' ? t('booking.card.waiting') : t('booking.card.cancel')}
|
|
137
134
|
</Button>
|
|
138
135
|
)}
|
|
139
136
|
</View>
|
|
@@ -1,57 +1,36 @@
|
|
|
1
|
-
import React, { useState, useEffect, useCallback } from 'react'
|
|
1
|
+
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
|
2
2
|
import { useRouter } from '@ossy/router-react'
|
|
3
|
-
import { View, Title, Text, Button, Badge, Alert, Textarea } from '@ossy/design-system'
|
|
3
|
+
import { View, Title, Text, Button, Badge, Alert, Textarea, useLocale } from '@ossy/design-system'
|
|
4
|
+
import { useSdk } from '@ossy/sdk-react'
|
|
5
|
+
import { metadata as ConfirmBooking } from './confirm-booking.action.js'
|
|
6
|
+
import { metadata as DeclineBooking } from './decline-booking.action.js'
|
|
7
|
+
import { metadata as CancelBooking } from './cancel-booking.action.js'
|
|
8
|
+
import { metadata as ListBookings } from './list-bookings.action.js'
|
|
9
|
+
import { invokeErrorMessage } from './invoke-error-message.js'
|
|
4
10
|
|
|
5
11
|
export const metadata = {
|
|
6
12
|
id: 'booking/booking-detail',
|
|
7
|
-
path: {
|
|
8
|
-
sv: '/bokningar/:bookingId',
|
|
9
|
-
en: '/bookings/:bookingId',
|
|
10
|
-
},
|
|
13
|
+
path: { sv: '/bokningar/:bookingId', en: '/bookings/:bookingId' },
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
const STATUS_VARIANT = {
|
|
14
|
-
pending: 'warning',
|
|
15
|
-
confirmed: 'success',
|
|
16
|
-
cancelled: 'neutral',
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const STATUS_LABEL = {
|
|
20
|
-
pending: 'Väntar på bekräftelse',
|
|
21
|
-
confirmed: 'Bekräftad',
|
|
22
|
-
cancelled: 'Avbokad',
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const SV_MONTHS = [
|
|
26
|
-
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
|
27
|
-
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
|
28
|
-
]
|
|
29
|
-
const SV_WEEKDAYS = ['Söndag', 'Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag']
|
|
16
|
+
const STATUS_VARIANT = { pending: 'warning', confirmed: 'success', cancelled: 'neutral' }
|
|
30
17
|
|
|
31
|
-
function formatDateTime (iso) {
|
|
18
|
+
function formatDateTime (iso, language) {
|
|
32
19
|
if (!iso) return '—'
|
|
33
|
-
const
|
|
34
|
-
const weekday = SV_WEEKDAYS[d.getDay()]
|
|
35
|
-
const day = d.getDate()
|
|
36
|
-
const month = SV_MONTHS[d.getMonth()]
|
|
37
|
-
const year = d.getFullYear()
|
|
38
|
-
const hh = String(d.getHours()).padStart(2, '0')
|
|
39
|
-
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
20
|
+
const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
|
|
40
21
|
try {
|
|
41
|
-
|
|
42
|
-
.formatToParts(d).find(p => p.type === 'timeZoneName')?.value ?? ''
|
|
43
|
-
return `${weekday} ${day} ${month} ${year} kl. ${hh}:${mm}${tz ? ` (${tz})` : ''}`
|
|
22
|
+
return new Intl.DateTimeFormat(locale, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }).format(new Date(iso))
|
|
44
23
|
} catch {
|
|
45
|
-
return
|
|
24
|
+
return new Date(iso).toLocaleString(locale)
|
|
46
25
|
}
|
|
47
26
|
}
|
|
48
27
|
|
|
49
|
-
function formatDuration (minutes) {
|
|
28
|
+
function formatDuration (minutes, t) {
|
|
50
29
|
if (!minutes) return '—'
|
|
51
|
-
if (minutes < 60) return
|
|
30
|
+
if (minutes < 60) return t('booking.bookingDetail.durationMinutes', { minutes })
|
|
52
31
|
const h = Math.floor(minutes / 60)
|
|
53
32
|
const m = minutes % 60
|
|
54
|
-
return m ?
|
|
33
|
+
return m ? t('booking.bookingDetail.durationHoursMinutes', { hours: h, minutes: m }) : t('booking.bookingDetail.durationHours', { hours: h })
|
|
55
34
|
}
|
|
56
35
|
|
|
57
36
|
function DetailRow ({ label, value }) {
|
|
@@ -63,139 +42,66 @@ function DetailRow ({ label, value }) {
|
|
|
63
42
|
)
|
|
64
43
|
}
|
|
65
44
|
|
|
66
|
-
function ConfirmForm ({ booking, onSuccess, onError }) {
|
|
45
|
+
function ConfirmForm ({ booking, onSuccess, onError, t, sdk }) {
|
|
67
46
|
const [loading, setLoading] = useState(false)
|
|
68
|
-
|
|
69
47
|
const handleConfirm = async () => {
|
|
70
48
|
setLoading(true)
|
|
71
49
|
try {
|
|
72
|
-
|
|
73
|
-
method: 'POST',
|
|
74
|
-
headers: { 'Content-Type': 'application/json' },
|
|
75
|
-
body: JSON.stringify({ bookingId: booking.id }),
|
|
76
|
-
})
|
|
77
|
-
if (!res.ok) {
|
|
78
|
-
const data = await res.json().catch(() => ({}))
|
|
79
|
-
throw new Error(data?.message ?? 'Bekräftning misslyckades')
|
|
80
|
-
}
|
|
50
|
+
await sdk.invoke(ConfirmBooking, { bookingId: booking.id })
|
|
81
51
|
onSuccess()
|
|
82
|
-
} catch (err) {
|
|
83
|
-
onError(err.message)
|
|
84
|
-
} finally {
|
|
85
|
-
setLoading(false)
|
|
86
|
-
}
|
|
52
|
+
} catch (err) { onError(await invokeErrorMessage(err, t('booking.bookingDetail.errorConfirm'))) } finally { setLoading(false) }
|
|
87
53
|
}
|
|
88
|
-
|
|
89
54
|
return (
|
|
90
|
-
<Button variant="primary" disabled={loading} onClick={handleConfirm}>
|
|
91
|
-
{loading ? '
|
|
55
|
+
<Button data-action={ConfirmBooking.id} variant="primary" disabled={loading} onClick={handleConfirm}>
|
|
56
|
+
{loading ? t('booking.bookingDetail.confirming') : t('booking.bookingDetail.confirm')}
|
|
92
57
|
</Button>
|
|
93
58
|
)
|
|
94
59
|
}
|
|
95
60
|
|
|
96
|
-
function DeclineForm ({ booking, onSuccess, onError }) {
|
|
61
|
+
function DeclineForm ({ booking, onSuccess, onError, t, sdk }) {
|
|
97
62
|
const [reason, setReason] = useState('')
|
|
98
63
|
const [loading, setLoading] = useState(false)
|
|
99
64
|
const [expanded, setExpanded] = useState(false)
|
|
100
|
-
|
|
101
65
|
const handleDecline = async () => {
|
|
102
66
|
setLoading(true)
|
|
103
67
|
try {
|
|
104
|
-
|
|
105
|
-
method: 'POST',
|
|
106
|
-
headers: { 'Content-Type': 'application/json' },
|
|
107
|
-
body: JSON.stringify({ bookingId: booking.id, reason: reason.trim() || null }),
|
|
108
|
-
})
|
|
109
|
-
if (!res.ok) {
|
|
110
|
-
const data = await res.json().catch(() => ({}))
|
|
111
|
-
throw new Error(data?.message ?? 'Avböjning misslyckades')
|
|
112
|
-
}
|
|
68
|
+
await sdk.invoke(DeclineBooking, { bookingId: booking.id, reason: reason.trim() || null })
|
|
113
69
|
onSuccess()
|
|
114
|
-
} catch (err) {
|
|
115
|
-
onError(err.message)
|
|
116
|
-
} finally {
|
|
117
|
-
setLoading(false)
|
|
118
|
-
}
|
|
70
|
+
} catch (err) { onError(await invokeErrorMessage(err, t('booking.bookingDetail.errorDecline'))) } finally { setLoading(false) }
|
|
119
71
|
}
|
|
120
|
-
|
|
121
|
-
if (!expanded) {
|
|
122
|
-
return (
|
|
123
|
-
<Button variant="danger" onClick={() => setExpanded(true)}>
|
|
124
|
-
Avböj förfrågan
|
|
125
|
-
</Button>
|
|
126
|
-
)
|
|
127
|
-
}
|
|
128
|
-
|
|
72
|
+
if (!expanded) return <Button variant="danger" onClick={() => setExpanded(true)}>{t('booking.bookingDetail.decline')}</Button>
|
|
129
73
|
return (
|
|
130
|
-
<Alert variant="danger" title=
|
|
74
|
+
<Alert variant="danger" title={t('booking.bookingDetail.declineTitle')}>
|
|
131
75
|
<View gap="s">
|
|
132
|
-
<Textarea
|
|
133
|
-
|
|
134
|
-
onChange={e => setReason(e.target.value)}
|
|
135
|
-
rows={3}
|
|
136
|
-
placeholder="T.ex. Tyvärr är den begärda tiden inte längre tillgänglig…"
|
|
137
|
-
/>
|
|
138
|
-
<Text variant="small" color="secondary">
|
|
139
|
-
Anledning (valfritt — skickas till klienten)
|
|
140
|
-
</Text>
|
|
76
|
+
<Textarea value={reason} onChange={e => setReason(e.target.value)} rows={3} placeholder={t('booking.bookingDetail.declinePlaceholder')} />
|
|
77
|
+
<Text variant="small" color="secondary">{t('booking.bookingDetail.declineReasonHint')}</Text>
|
|
141
78
|
<View layout="row" gap="s">
|
|
142
|
-
<Button variant="danger" disabled={loading} onClick={handleDecline}>
|
|
143
|
-
|
|
144
|
-
</Button>
|
|
145
|
-
<Button variant="neutral" onClick={() => setExpanded(false)}>
|
|
146
|
-
Avbryt
|
|
147
|
-
</Button>
|
|
79
|
+
<Button data-action={DeclineBooking.id} variant="danger" disabled={loading} onClick={handleDecline}>{loading ? t('booking.bookingDetail.declining') : t('booking.bookingDetail.declineConfirm')}</Button>
|
|
80
|
+
<Button variant="neutral" onClick={() => setExpanded(false)}>{t('booking.bookingDetail.cancelButton')}</Button>
|
|
148
81
|
</View>
|
|
149
82
|
</View>
|
|
150
83
|
</Alert>
|
|
151
84
|
)
|
|
152
85
|
}
|
|
153
86
|
|
|
154
|
-
function CancelForm ({ booking, onSuccess, onError }) {
|
|
87
|
+
function CancelForm ({ booking, onSuccess, onError, t, sdk }) {
|
|
155
88
|
const [loading, setLoading] = useState(false)
|
|
156
89
|
const [expanded, setExpanded] = useState(false)
|
|
157
|
-
|
|
158
90
|
const handleCancel = async () => {
|
|
159
91
|
setLoading(true)
|
|
160
92
|
try {
|
|
161
|
-
|
|
162
|
-
method: 'POST',
|
|
163
|
-
headers: { 'Content-Type': 'application/json' },
|
|
164
|
-
body: JSON.stringify({ bookingId: booking.id }),
|
|
165
|
-
})
|
|
166
|
-
if (!res.ok) {
|
|
167
|
-
const data = await res.json().catch(() => ({}))
|
|
168
|
-
throw new Error(data?.message ?? 'Avbokning misslyckades')
|
|
169
|
-
}
|
|
93
|
+
await sdk.invoke(CancelBooking, { bookingId: booking.id })
|
|
170
94
|
onSuccess()
|
|
171
|
-
} catch (err) {
|
|
172
|
-
onError(err.message)
|
|
173
|
-
} finally {
|
|
174
|
-
setLoading(false)
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (!expanded) {
|
|
179
|
-
return (
|
|
180
|
-
<Button variant="danger" onClick={() => setExpanded(true)}>
|
|
181
|
-
Avboka möte
|
|
182
|
-
</Button>
|
|
183
|
-
)
|
|
95
|
+
} catch (err) { onError(await invokeErrorMessage(err, t('booking.bookingDetail.errorCancel'))) } finally { setLoading(false) }
|
|
184
96
|
}
|
|
185
|
-
|
|
97
|
+
if (!expanded) return <Button variant="danger" onClick={() => setExpanded(true)}>{t('booking.bookingDetail.cancelMeeting')}</Button>
|
|
186
98
|
return (
|
|
187
|
-
<Alert variant="danger" title=
|
|
99
|
+
<Alert variant="danger" title={t('booking.bookingDetail.cancelTitle')}>
|
|
188
100
|
<View gap="s">
|
|
189
|
-
<Text color="secondary">
|
|
190
|
-
Klienten meddelas via e-post. Åtgärden kan inte ångras.
|
|
191
|
-
</Text>
|
|
101
|
+
<Text color="secondary">{t('booking.bookingDetail.cancelBody')}</Text>
|
|
192
102
|
<View layout="row" gap="s">
|
|
193
|
-
<Button variant="danger" disabled={loading} onClick={handleCancel}>
|
|
194
|
-
|
|
195
|
-
</Button>
|
|
196
|
-
<Button variant="neutral" onClick={() => setExpanded(false)}>
|
|
197
|
-
Avbryt
|
|
198
|
-
</Button>
|
|
103
|
+
<Button data-action={CancelBooking.id} variant="danger" disabled={loading} onClick={handleCancel}>{loading ? t('booking.bookingDetail.cancelling') : t('booking.bookingDetail.cancelConfirm')}</Button>
|
|
104
|
+
<Button variant="neutral" onClick={() => setExpanded(false)}>{t('booking.bookingDetail.cancelButton')}</Button>
|
|
199
105
|
</View>
|
|
200
106
|
</View>
|
|
201
107
|
</Alert>
|
|
@@ -203,67 +109,44 @@ function CancelForm ({ booking, onSuccess, onError }) {
|
|
|
203
109
|
}
|
|
204
110
|
|
|
205
111
|
export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
|
|
112
|
+
const { t, language } = useLocale()
|
|
113
|
+
const sdk = useSdk()
|
|
206
114
|
const router = useRouter()
|
|
207
115
|
const bookingId = bookingIdProp ?? router?.params?.bookingId
|
|
208
|
-
|
|
209
116
|
const [booking, setBooking] = useState(null)
|
|
210
117
|
const [loading, setLoading] = useState(true)
|
|
211
118
|
const [error, setError] = useState(null)
|
|
212
119
|
const [actionError, setActionError] = useState(null)
|
|
213
120
|
|
|
121
|
+
const statusLabel = useMemo(() => ({
|
|
122
|
+
pending: t('booking.bookingDetail.status.pending'),
|
|
123
|
+
confirmed: t('booking.bookingDetail.status.confirmed'),
|
|
124
|
+
cancelled: t('booking.bookingDetail.status.cancelled'),
|
|
125
|
+
}), [t])
|
|
126
|
+
|
|
214
127
|
const loadBooking = useCallback(async () => {
|
|
215
128
|
if (!bookingId) return
|
|
216
|
-
setLoading(true)
|
|
217
|
-
setError(null)
|
|
129
|
+
setLoading(true); setError(null)
|
|
218
130
|
try {
|
|
219
|
-
const
|
|
220
|
-
method: 'POST',
|
|
221
|
-
headers: { 'Content-Type': 'application/json' },
|
|
222
|
-
body: JSON.stringify({}),
|
|
223
|
-
})
|
|
224
|
-
if (!res.ok) {
|
|
225
|
-
const data = await res.json().catch(() => ({}))
|
|
226
|
-
throw new Error(data?.message ?? `Fel ${res.status}`)
|
|
227
|
-
}
|
|
228
|
-
const all = await res.json()
|
|
131
|
+
const all = await sdk.invoke(ListBookings, {})
|
|
229
132
|
const found = all.find(b => b.id === bookingId)
|
|
230
|
-
if (!found) throw new Error('
|
|
133
|
+
if (!found) throw new Error(t('booking.bookingDetail.notFound'))
|
|
231
134
|
setBooking(found)
|
|
232
|
-
} catch (err) {
|
|
233
|
-
|
|
234
|
-
} finally {
|
|
235
|
-
setLoading(false)
|
|
236
|
-
}
|
|
237
|
-
}, [bookingId])
|
|
238
|
-
|
|
239
|
-
useEffect(() => {
|
|
240
|
-
loadBooking()
|
|
241
|
-
}, [loadBooking])
|
|
135
|
+
} catch (err) { setError(err.message) } finally { setLoading(false) }
|
|
136
|
+
}, [bookingId, sdk, t])
|
|
242
137
|
|
|
243
|
-
|
|
244
|
-
setActionError(null)
|
|
245
|
-
loadBooking()
|
|
246
|
-
}
|
|
138
|
+
useEffect(() => { loadBooking() }, [loadBooking])
|
|
247
139
|
|
|
248
140
|
const goBack = () => {
|
|
249
141
|
if (window.history.length > 1) window.history.back()
|
|
250
|
-
else window.location.href = '/
|
|
142
|
+
else window.location.href = router.getHref('booking/bookings')
|
|
251
143
|
}
|
|
252
144
|
|
|
253
145
|
return (
|
|
254
|
-
<View
|
|
255
|
-
|
|
256
|
-
surface="primary"
|
|
257
|
-
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
258
|
-
>
|
|
259
|
-
<Button variant="link" onClick={goBack}>
|
|
260
|
-
← Alla bokningar
|
|
261
|
-
</Button>
|
|
262
|
-
|
|
146
|
+
<View gap="m" surface="primary" style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}>
|
|
147
|
+
<Button variant="link" onClick={goBack}>{t('booking.bookingDetail.back')}</Button>
|
|
263
148
|
{loading ? (
|
|
264
|
-
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>
|
|
265
|
-
Laddar…
|
|
266
|
-
</Text>
|
|
149
|
+
<Text color="secondary" style={{ textAlign: 'center', padding: 'var(--space-l)' }}>{t('booking.bookingDetail.loading')}</Text>
|
|
267
150
|
) : error ? (
|
|
268
151
|
<Alert variant="danger">{error}</Alert>
|
|
269
152
|
) : booking ? (
|
|
@@ -274,18 +157,14 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
|
|
|
274
157
|
<Title variant="secondary">{booking.clientName ?? '—'}</Title>
|
|
275
158
|
{booking.clientEmail && <Text color="secondary">{booking.clientEmail}</Text>}
|
|
276
159
|
</View>
|
|
277
|
-
<Badge
|
|
278
|
-
label={STATUS_LABEL[booking.status] ?? booking.status}
|
|
279
|
-
variant={STATUS_VARIANT[booking.status] ?? 'neutral'}
|
|
280
|
-
/>
|
|
160
|
+
<Badge label={statusLabel[booking.status] ?? booking.status} variant={STATUS_VARIANT[booking.status] ?? 'neutral'} />
|
|
281
161
|
</View>
|
|
282
|
-
|
|
283
162
|
<View gap="s">
|
|
284
|
-
<DetailRow label=
|
|
285
|
-
<DetailRow label=
|
|
163
|
+
<DetailRow label={t('booking.bookingDetail.label.dateTime')} value={formatDateTime(booking.startsAt, language)} />
|
|
164
|
+
<DetailRow label={t('booking.bookingDetail.label.duration')} value={formatDuration(booking.duration, t)} />
|
|
286
165
|
{booking.clientMessage && (
|
|
287
166
|
<View gap="xs">
|
|
288
|
-
<Text weight="medium" color="secondary">
|
|
167
|
+
<Text weight="medium" color="secondary">{t('booking.bookingDetail.label.message')}</Text>
|
|
289
168
|
<View surface="primary" roundness="s" inset="s" style={{ borderLeft: '3px solid var(--separator)' }}>
|
|
290
169
|
<Text style={{ whiteSpace: 'pre-wrap' }}>{booking.clientMessage}</Text>
|
|
291
170
|
</View>
|
|
@@ -293,36 +172,18 @@ export default function BookingDetailPage ({ bookingId: bookingIdProp }) {
|
|
|
293
172
|
)}
|
|
294
173
|
</View>
|
|
295
174
|
</View>
|
|
296
|
-
|
|
297
175
|
{(booking.status === 'pending' || booking.status === 'confirmed') && (
|
|
298
176
|
<View gap="s" surface="secondary" roundness="m" inset="l">
|
|
299
|
-
<Title variant="tertiary">
|
|
300
|
-
{booking.status === 'pending' ? 'Hantera förfrågan' : 'Hantera bokning'}
|
|
301
|
-
</Title>
|
|
302
|
-
|
|
177
|
+
<Title variant="tertiary">{booking.status === 'pending' ? t('booking.bookingDetail.manageRequest') : t('booking.bookingDetail.manageBooking')}</Title>
|
|
303
178
|
{actionError && <Alert variant="danger">{actionError}</Alert>}
|
|
304
|
-
|
|
305
179
|
{booking.status === 'pending' && (
|
|
306
180
|
<View gap="s">
|
|
307
|
-
<ConfirmForm
|
|
308
|
-
|
|
309
|
-
onSuccess={handleSuccess}
|
|
310
|
-
onError={setActionError}
|
|
311
|
-
/>
|
|
312
|
-
<DeclineForm
|
|
313
|
-
booking={booking}
|
|
314
|
-
onSuccess={handleSuccess}
|
|
315
|
-
onError={setActionError}
|
|
316
|
-
/>
|
|
181
|
+
<ConfirmForm booking={booking} sdk={sdk} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
|
|
182
|
+
<DeclineForm booking={booking} sdk={sdk} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
|
|
317
183
|
</View>
|
|
318
184
|
)}
|
|
319
|
-
|
|
320
185
|
{booking.status === 'confirmed' && (
|
|
321
|
-
<CancelForm
|
|
322
|
-
booking={booking}
|
|
323
|
-
onSuccess={handleSuccess}
|
|
324
|
-
onError={setActionError}
|
|
325
|
-
/>
|
|
186
|
+
<CancelForm booking={booking} sdk={sdk} onSuccess={() => { setActionError(null); loadBooking() }} onError={setActionError} t={t} />
|
|
326
187
|
)}
|
|
327
188
|
</View>
|
|
328
189
|
)}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ResourcesQueries } from '@ossy/resources/server'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* List booking resources by type (and optional workspace).
|
|
5
|
+
*/
|
|
6
|
+
export function getBookingResources ({ type, belongsTo }) {
|
|
7
|
+
const query = { type }
|
|
8
|
+
if (belongsTo) query.belongsTo = belongsTo
|
|
9
|
+
return ResourcesQueries.GetResources(query)
|
|
10
|
+
}
|