@autobusal/operator-routes 1.5.0 → 1.5.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/Locations/Withdraw/Withdraw.tsx +70 -18
- package/Locations/Withdraw/styles.ts +28 -0
- package/Locations/services.ts +27 -2
- package/package.json +1 -1
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { TFunction } from 'i18next';
|
|
3
|
-
import { FiAlertTriangle, FiCheck,
|
|
3
|
+
import { FiAlertTriangle, FiCheck, FiSend, FiUsers } from 'react-icons/fi';
|
|
4
4
|
import { Button } from '@autobusal/common';
|
|
5
5
|
import { success, notify } from '@autobusal/utilities';
|
|
6
6
|
import { LocationData } from '@autobusal/providers/types/locations';
|
|
7
|
-
import { useWithdrawCheck, useMoveOptions, useMovePassenger, AffectedPassenger } from '../services';
|
|
7
|
+
import { useWithdrawCheck, useMoveOptions, useMovePassenger, useNotifyAffected, AffectedPassenger } from '../services';
|
|
8
8
|
import {
|
|
9
9
|
Overlay, Panel, Title, Lead, Table, Row, Cell, MoveOptions, Draft, DraftField,
|
|
10
|
-
Actions, Empty, Close
|
|
10
|
+
Actions, Empty, Close, Channels
|
|
11
11
|
} from './styles';
|
|
12
12
|
|
|
13
13
|
interface Props {
|
|
@@ -30,29 +30,62 @@ interface Props {
|
|
|
30
30
|
* works through it: move each passenger to another stop, or cancel them, and
|
|
31
31
|
* then withdraw.
|
|
32
32
|
*
|
|
33
|
-
* THE MESSAGE IS DRAFTED, NOT SENT. The wording is written in
|
|
34
|
-
* own language and shown here to be read and edited;
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* THE MESSAGE IS DRAFTED, NOT SENT AUTOMATICALLY. The wording is written in
|
|
34
|
+
* the route's own language and shown here to be read and edited; it goes out
|
|
35
|
+
* only when the operator presses send. Only they know what is replacing the
|
|
36
|
+
* stop and whether a given passenger is being moved or refunded, so mailing
|
|
37
|
+
* people the moment a stop was withdrawn would be writing an apology in
|
|
38
|
+
* their name.
|
|
39
|
+
*
|
|
40
|
+
* IT GOES TO THE AFFECTED BOOKINGS AND NOBODY ELSE. The recipient list is
|
|
41
|
+
* never posted from here - obtapi resolves it from the stop, so the people
|
|
42
|
+
* told are exactly the people in the table above. Routing this through the
|
|
43
|
+
* broadcast composer instead would address a route and a date, reaching
|
|
44
|
+
* passengers boarding two towns away whose journey has not changed, and
|
|
45
|
+
* telling them a stop had changed would be a support call and doubt about a
|
|
46
|
+
* ticket that was never affected.
|
|
39
47
|
*/
|
|
40
48
|
const Withdraw = ({ routeId, location, t, onClose, onWithdraw, onReload }: Props): JSX.Element => {
|
|
41
49
|
const { data, isLoading, refetch } = useWithdrawCheck(routeId, location.id, true);
|
|
42
50
|
|
|
43
51
|
const [ subject, setSubject ] = useState<string | null>(null);
|
|
44
52
|
const [ message, setMessage ] = useState<string | null>(null);
|
|
53
|
+
const [ channels, setChannels ] = useState<string[]>([]);
|
|
54
|
+
const [ notified, setNotified ] = useState<boolean>(false);
|
|
45
55
|
|
|
46
56
|
const passengers = data?.passengers ?? [];
|
|
47
57
|
const draft = data?.draft ?? null;
|
|
58
|
+
const reach = data?.reach ?? {};
|
|
59
|
+
|
|
60
|
+
const { mutate: Notify, isPending: isSending } = useNotifyAffected(routeId);
|
|
61
|
+
|
|
62
|
+
const toggle = (key: string): void => {
|
|
63
|
+
setChannels(current => current.includes(key)
|
|
64
|
+
? current.filter(item => item !== key)
|
|
65
|
+
: [ ...current, key ]);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const send = (): void => {
|
|
69
|
+
if (channels.length === 0) {
|
|
70
|
+
notify(t('locations_manage.withdraw.draft.pick_channel', { ns: 'common' }));
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
51
74
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
Notify({
|
|
76
|
+
location_id: location.id,
|
|
77
|
+
subject: subject ?? draft?.subject ?? '',
|
|
78
|
+
message: message ?? draft?.message ?? '',
|
|
79
|
+
channels
|
|
80
|
+
}, {
|
|
81
|
+
onSuccess: (results) => {
|
|
82
|
+
const sent = Object.values(results ?? {}).reduce((total, row) => total + (row?.sent ?? 0), 0);
|
|
83
|
+
|
|
84
|
+
success(t('locations_manage.withdraw.draft.sent', { ns: 'common', count: sent }));
|
|
85
|
+
|
|
86
|
+
setNotified(true);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
56
89
|
};
|
|
57
90
|
|
|
58
91
|
return (
|
|
@@ -132,9 +165,28 @@ const Withdraw = ({ routeId, location, t, onClose, onWithdraw, onReload }: Props
|
|
|
132
165
|
/>
|
|
133
166
|
</DraftField>
|
|
134
167
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
168
|
+
{ /* how many of the affected passengers each channel can
|
|
169
|
+
actually reach, so the operator sees that WhatsApp would
|
|
170
|
+
get two of nine BEFORE choosing it, not after */ }
|
|
171
|
+
<Channels>
|
|
172
|
+
{ Object.entries(reach).map(([ key, row ]) => (
|
|
173
|
+
<label key={ key }>
|
|
174
|
+
<input
|
|
175
|
+
type="checkbox"
|
|
176
|
+
checked={ channels.includes(key) }
|
|
177
|
+
onChange={ () => toggle(key) }
|
|
178
|
+
/>
|
|
179
|
+
{ t(`admin_label.manage.options_notifications.${ key }`, { ns: 'common', defaultValue: key }) }
|
|
180
|
+
<span>{ t('locations_manage.withdraw.draft.reach', { ns: 'common', count: row.count }) }</span>
|
|
181
|
+
</label>
|
|
182
|
+
)) }
|
|
183
|
+
</Channels>
|
|
184
|
+
|
|
185
|
+
<button type="button" disabled={ isSending || notified } onClick={ send }>
|
|
186
|
+
<FiSend />
|
|
187
|
+
{ notified
|
|
188
|
+
? t('locations_manage.withdraw.draft.already_sent', { ns: 'common' })
|
|
189
|
+
: t('locations_manage.withdraw.draft.send', { ns: 'common', count: passengers.length }) }
|
|
138
190
|
</button>
|
|
139
191
|
|
|
140
192
|
<span>
|
|
@@ -194,6 +194,34 @@ export const DraftField = styled.div`
|
|
|
194
194
|
}
|
|
195
195
|
`;
|
|
196
196
|
|
|
197
|
+
/*
|
|
198
|
+
* The channel tick boxes, each showing how many of the AFFECTED passengers
|
|
199
|
+
* it can reach - so an operator can see that WhatsApp would get two of nine
|
|
200
|
+
* before choosing it rather than after.
|
|
201
|
+
*/
|
|
202
|
+
export const Channels = styled.div`
|
|
203
|
+
display: flex;
|
|
204
|
+
flex-wrap: wrap;
|
|
205
|
+
gap: 14px;
|
|
206
|
+
margin-bottom: 12px;
|
|
207
|
+
|
|
208
|
+
label {
|
|
209
|
+
display: flex;
|
|
210
|
+
align-items: center;
|
|
211
|
+
gap: 6px;
|
|
212
|
+
font-size: ${ props => props.theme.size.xs };
|
|
213
|
+
cursor: pointer;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
input {
|
|
217
|
+
margin: 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
span {
|
|
221
|
+
opacity: .65;
|
|
222
|
+
}
|
|
223
|
+
`;
|
|
224
|
+
|
|
197
225
|
export const Actions = styled.div`
|
|
198
226
|
display: flex;
|
|
199
227
|
align-items: center;
|
package/Locations/services.ts
CHANGED
|
@@ -210,7 +210,7 @@ export interface AffectedPassenger {
|
|
|
210
210
|
export interface WithdrawDraft {
|
|
211
211
|
subject: string
|
|
212
212
|
message: string
|
|
213
|
-
/** travel dates the affected bookings fall on
|
|
213
|
+
/** travel dates the affected bookings fall on */
|
|
214
214
|
dates: string[]
|
|
215
215
|
recipients: { order_id: number, order_number: string, name: string | null, email: string | null, phone: string | null, date_travel: string | null }[]
|
|
216
216
|
}
|
|
@@ -223,7 +223,10 @@ export interface MoveOption {
|
|
|
223
223
|
fare: string | number
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
/** how many of the affected passengers each channel can actually reach */
|
|
227
|
+
export type ChannelReach = Record<string, { count: number, subject: boolean }>
|
|
228
|
+
|
|
229
|
+
export const useWithdrawCheck = (routeId: number, locationId: number, enabled: boolean): UseQueryResult<{ passengers: AffectedPassenger[], draft: WithdrawDraft | null, reach: ChannelReach }> => (
|
|
227
230
|
useQuery({
|
|
228
231
|
queryKey: ['operator-location-passengers', { routeId, locationId }],
|
|
229
232
|
enabled,
|
|
@@ -268,3 +271,25 @@ export const useRestoreLocation = (routeId: number): UseMutationResult<void, Err
|
|
|
268
271
|
)
|
|
269
272
|
})
|
|
270
273
|
);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Send the approved message to the affected passengers - and only them.
|
|
277
|
+
*
|
|
278
|
+
* Claude - Date: 2026-08-21
|
|
279
|
+
*
|
|
280
|
+
* The recipient list is NOT sent: obtapi resolves it from the stop, so the
|
|
281
|
+
* people told are exactly the people the panel listed when the operator read
|
|
282
|
+
* the wording. Deliberately not the broadcast composer, which addresses a
|
|
283
|
+
* route and a date and would also reach passengers boarding two towns away
|
|
284
|
+
* whose journey has not changed.
|
|
285
|
+
*/
|
|
286
|
+
export const useNotifyAffected = (routeId: number): UseMutationResult<Record<string, { sent: number, failed: number }>, Error, { location_id: number, subject: string, message: string, channels: string[] }, unknown> => (
|
|
287
|
+
useMutation({
|
|
288
|
+
mutationKey: ['operator-location-notify', { routeId }],
|
|
289
|
+
mutationFn: async (data) => (
|
|
290
|
+
await apiClient
|
|
291
|
+
.post('/api/locations/operator/notify', { id: routeId, ...data })
|
|
292
|
+
.then(response => response.data)
|
|
293
|
+
)
|
|
294
|
+
})
|
|
295
|
+
);
|