@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.
@@ -1,13 +1,13 @@
1
1
  import { useState } from 'react';
2
2
  import { TFunction } from 'i18next';
3
- import { FiAlertTriangle, FiCheck, FiCopy, FiUsers } from 'react-icons/fi';
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 the route's
34
- * own language and shown here to be read and edited; sending it happens in
35
- * the broadcast composer, which the operator opens deliberately. Only they
36
- * know what is replacing the stop and whether a given passenger is being
37
- * moved or refunded, so a system that mailed people the moment a stop was
38
- * withdrawn would be writing an apology in their name.
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
- const copy = (): void => {
50
- const text = `${ subject ?? draft?.subject ?? '' }\n\n${ message ?? draft?.message ?? '' }`;
72
+ return;
73
+ }
51
74
 
52
- navigator.clipboard?.writeText(text).then(
53
- () => success(t('locations_manage.withdraw.draft.copied', { ns: 'common' })),
54
- () => notify(t('locations_manage.withdraw.draft.copy_failed', { ns: 'common' }))
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
- <button type="button" onClick={ copy }>
136
- <FiCopy />
137
- { t('locations_manage.withdraw.draft.copy', { ns: 'common' }) }
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;
@@ -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 - one broadcast per date */
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
- export const useWithdrawCheck = (routeId: number, locationId: number, enabled: boolean): UseQueryResult<{ passengers: AffectedPassenger[], draft: WithdrawDraft | null }> => (
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
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/operator-routes",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"