@autobusal/operator-routes 1.5.0 → 1.5.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.2
4
+
5
+ ### Removed
6
+
7
+ - **The confirm dialog in front of removing a stop.** It asked the question
8
+ the withdrawal panel already asks - the panel names the stop, lists
9
+ everybody still travelling through it and has its own button - and it
10
+ answered one part wrongly, warning that the fares would have to be entered
11
+ again. That stopped being true when calibration began reconciling price
12
+ rows instead of rebuilding them: measured on a ten-stop route, withdrawing
13
+ a stop keeps fourteen of sixteen fares and drops only the two legs through
14
+ the stop itself.
15
+
3
16
  ## 1.3.3
4
17
 
5
18
  ### Fixed
@@ -24,11 +24,29 @@ const Actions = ({ item, loading, t, onMove, onDelete }: Props): JSX.Element =>
24
24
  onMove(order);
25
25
  };
26
26
 
27
- const onAskDelete = (): void => {
28
- if (window.confirm(t('locations_manage.actions.delete', { ns: 'common' }))) {
29
- onDelete();
30
- }
31
- };
27
+ /*
28
+ * Edited: Claude - Date: 2026-08-22
29
+ *
30
+ * No window.confirm here any more. Removing a stop opens the withdrawal
31
+ * panel, which names the stop, lists everybody still travelling through
32
+ * it, and has its own explicit button - a far better confirmation than a
33
+ * browser dialog, and the dialog now stood in front of it saying "YOU WILL
34
+ * NEED TO ADD THE PRICES AGAIN", which stopped being true when
35
+ * Locations\Calibration started reconciling price rows instead of
36
+ * rebuilding them. Measured: withdrawing a stop from a ten-stop route kept
37
+ * fourteen of sixteen fares, losing only the two legs through the stop
38
+ * itself.
39
+ *
40
+ * So it was a second gate that asked the same question twice and got the
41
+ * answer wrong once. The panel asks it properly.
42
+ *
43
+ * `locations_manage.actions.delete` is deliberately still in obtapi's
44
+ * language files. obtapi deploys BEFORE the frontends, so for the length
45
+ * of that window the previous bundle is still calling it - and a missing
46
+ * key renders as the key itself, which is what the dialog showed when it
47
+ * was removed. It now says something true and is simply read by nobody
48
+ * once this bundle lands.
49
+ */
32
50
 
33
51
  return (
34
52
  <ContainerActions>
@@ -80,7 +98,7 @@ const Actions = ({ item, loading, t, onMove, onDelete }: Props): JSX.Element =>
80
98
  </>
81
99
  }
82
100
  noMargin
83
- onClick={ onAskDelete }
101
+ onClick={ onDelete }
84
102
  />
85
103
  </Group>
86
104
  </ContainerActions>
@@ -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.2",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"