@autobusal/operator-routes 1.4.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,3 +1,4 @@
1
+ import { useState } from 'react';
1
2
  import { TFunction } from 'i18next';
2
3
  import { useForm } from 'react-hook-form';
3
4
  import { Validate, Display, success } from '@autobusal/utilities';
@@ -7,6 +8,7 @@ import { Data } from './styles';
7
8
  import { LocationForm } from '../types';
8
9
  import { LocationData } from '@autobusal/providers/types/locations';
9
10
  import { usePostMove, useDeleteLocation, usePostUpdate } from '../services';
11
+ import Withdraw from '../Withdraw/Withdraw';
10
12
 
11
13
  interface Props {
12
14
  routeId: number
@@ -38,11 +40,33 @@ const Item = ({ routeId, item, t, onReload }: Props): JSX.Element => {
38
40
  });
39
41
  };
40
42
 
43
+ /*
44
+ * Edited: Claude - Date: 2026-08-21
45
+ *
46
+ * Removing a stop opens the withdrawal panel rather than deleting
47
+ * outright. obtapi cannot delete a location anything was ever sold
48
+ * through - an invoice or a refund still has to describe that sale years
49
+ * later - so it WITHDRAWS instead, and refuses while anybody holds a live
50
+ * ticket through the stop.
51
+ *
52
+ * The panel is where that refusal becomes actionable: it lists who is
53
+ * affected, offers each of them another stop, and drafts the message.
54
+ * Going straight to Delete would surface a 409 as a generic failure and
55
+ * leave the operator with no idea who was in the way.
56
+ */
57
+ const [ withdrawing, setWithdrawing ] = useState<boolean>(false);
58
+
41
59
  const onDelete = (): void => {
60
+ setWithdrawing(true);
61
+ };
62
+
63
+ const onWithdraw = (): void => {
42
64
  Delete({}, {
43
65
  onSuccess: () => {
44
66
  success(t('locations_manage.messages.deleted', { ns: 'common' }));
45
67
 
68
+ setWithdrawing(false);
69
+
46
70
  onReload();
47
71
  }
48
72
  });
@@ -50,6 +74,17 @@ const Item = ({ routeId, item, t, onReload }: Props): JSX.Element => {
50
74
 
51
75
  return (
52
76
  <div className="box">
77
+ { withdrawing && (
78
+ <Withdraw
79
+ routeId={ routeId }
80
+ location={ item }
81
+ t={ t }
82
+ onClose={ () => setWithdrawing(false) }
83
+ onWithdraw={ onWithdraw }
84
+ onReload={ onReload }
85
+ />
86
+ ) }
87
+
53
88
  <h4>{ item.city.country?.name } &rsaquo; { item.city.name } &rsaquo; { item.stop?.name }</h4>
54
89
 
55
90
  <form onSubmit={ handleSubmit(onSubmit) }>
@@ -0,0 +1,287 @@
1
+ import { useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { FiAlertTriangle, FiCheck, FiSend, FiUsers } from 'react-icons/fi';
4
+ import { Button } from '@autobusal/common';
5
+ import { success, notify } from '@autobusal/utilities';
6
+ import { LocationData } from '@autobusal/providers/types/locations';
7
+ import { useWithdrawCheck, useMoveOptions, useMovePassenger, useNotifyAffected, AffectedPassenger } from '../services';
8
+ import {
9
+ Overlay, Panel, Title, Lead, Table, Row, Cell, MoveOptions, Draft, DraftField,
10
+ Actions, Empty, Close, Channels
11
+ } from './styles';
12
+
13
+ interface Props {
14
+ routeId: number
15
+ location: LocationData
16
+ t: TFunction<'normal'>
17
+ onClose: () => void
18
+ onWithdraw: () => void
19
+ onReload: () => void
20
+ }
21
+
22
+ /**
23
+ * Everything that has to happen before a stop can come off a route.
24
+ *
25
+ * Claude - Date: 2026-08-21
26
+ *
27
+ * obtapi refuses the withdrawal while anybody still holds a paid ticket
28
+ * through the stop for a journey that has not departed. That refusal is not
29
+ * a dead end - it comes back with the list, and this is where the operator
30
+ * works through it: move each passenger to another stop, or cancel them, and
31
+ * then withdraw.
32
+ *
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.
47
+ */
48
+ const Withdraw = ({ routeId, location, t, onClose, onWithdraw, onReload }: Props): JSX.Element => {
49
+ const { data, isLoading, refetch } = useWithdrawCheck(routeId, location.id, true);
50
+
51
+ const [ subject, setSubject ] = useState<string | null>(null);
52
+ const [ message, setMessage ] = useState<string | null>(null);
53
+ const [ channels, setChannels ] = useState<string[]>([]);
54
+ const [ notified, setNotified ] = useState<boolean>(false);
55
+
56
+ const passengers = data?.passengers ?? [];
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' }));
71
+
72
+ return;
73
+ }
74
+
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
+ });
89
+ };
90
+
91
+ return (
92
+ <Overlay onClick={ onClose }>
93
+ <Panel className="box" onClick={ event => event.stopPropagation() }>
94
+ <Close type="button" onClick={ onClose }>&times;</Close>
95
+
96
+ <Title>
97
+ { t('locations_manage.withdraw.title', {
98
+ ns: 'common',
99
+ city: location.city?.name,
100
+ stop: location.stop?.name ?? ''
101
+ }) }
102
+ </Title>
103
+
104
+ { isLoading && <Lead>{ t('locations_manage.withdraw.checking', { ns: 'common' }) }</Lead> }
105
+
106
+ { (!isLoading && passengers.length === 0) && (
107
+ <>
108
+ <Empty>
109
+ <FiCheck />
110
+ { t('locations_manage.withdraw.clear', { ns: 'common' }) }
111
+ </Empty>
112
+
113
+ <Actions>
114
+ <Button text={ t('locations_manage.withdraw.action', { ns: 'common' }) } onClick={ onWithdraw } noMargin />
115
+ </Actions>
116
+ </>
117
+ ) }
118
+
119
+ { (!isLoading && passengers.length > 0) && (
120
+ <>
121
+ <Lead>
122
+ <FiAlertTriangle />
123
+ { t('locations_manage.withdraw.blocked', { ns: 'common', count: passengers.length }) }
124
+ </Lead>
125
+
126
+ <Table>
127
+ <Row $head>
128
+ <Cell>{ t('locations_manage.withdraw.columns.booking', { ns: 'common' }) }</Cell>
129
+ <Cell>{ t('locations_manage.withdraw.columns.passenger', { ns: 'common' }) }</Cell>
130
+ <Cell>{ t('locations_manage.withdraw.columns.journey', { ns: 'common' }) }</Cell>
131
+ <Cell>{ t('locations_manage.withdraw.columns.resolve', { ns: 'common' }) }</Cell>
132
+ </Row>
133
+
134
+ { passengers.map(passenger => (
135
+ <Passenger
136
+ key={ passenger.id }
137
+ routeId={ routeId }
138
+ passenger={ passenger }
139
+ t={ t }
140
+ onMoved={ () => { refetch(); onReload(); } }
141
+ />
142
+ )) }
143
+ </Table>
144
+
145
+ { draft && (
146
+ <Draft>
147
+ <h5><FiUsers />{ t('locations_manage.withdraw.draft.title', { ns: 'common' }) }</h5>
148
+ <p>{ t('locations_manage.withdraw.draft.hint', { ns: 'common' }) }</p>
149
+
150
+ <DraftField>
151
+ <label>{ t('locations_manage.withdraw.draft.subject', { ns: 'common' }) }</label>
152
+ <input
153
+ type="text"
154
+ value={ subject ?? draft.subject }
155
+ onChange={ event => setSubject(event.target.value) }
156
+ />
157
+ </DraftField>
158
+
159
+ <DraftField>
160
+ <label>{ t('locations_manage.withdraw.draft.message', { ns: 'common' }) }</label>
161
+ <textarea
162
+ rows={ 8 }
163
+ value={ message ?? draft.message }
164
+ onChange={ event => setMessage(event.target.value) }
165
+ />
166
+ </DraftField>
167
+
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 }) }
190
+ </button>
191
+
192
+ <span>
193
+ { t('locations_manage.withdraw.draft.dates', { ns: 'common', dates: draft.dates.join(', ') }) }
194
+ </span>
195
+ </Draft>
196
+ ) }
197
+ </>
198
+ ) }
199
+ </Panel>
200
+ </Overlay>
201
+ );
202
+ };
203
+
204
+ /**
205
+ * One affected booking, and the stops it could move to instead.
206
+ *
207
+ * The options are fetched only when the operator opens this row - a route
208
+ * with a dozen affected bookings would otherwise fire a dozen requests to
209
+ * draw a table nobody has looked at yet.
210
+ */
211
+ const Passenger = ({ routeId, passenger, t, onMoved }: {
212
+ routeId: number
213
+ passenger: AffectedPassenger
214
+ t: TFunction<'normal'>
215
+ onMoved: () => void
216
+ }): JSX.Element => {
217
+ const [ open, setOpen ] = useState<boolean>(false);
218
+
219
+ const { data: options } = useMoveOptions(routeId, passenger.id, passenger.affects, open);
220
+
221
+ const { mutate: Move, isPending } = useMovePassenger(routeId);
222
+
223
+ const move = (priceId: number): void => {
224
+ Move({ order_id: passenger.id, price_id: priceId }, {
225
+ onSuccess: () => {
226
+ success(t('locations_manage.withdraw.moved', { ns: 'common' }));
227
+
228
+ onMoved();
229
+ }
230
+ });
231
+ };
232
+
233
+ return (
234
+ <Row>
235
+ <Cell>
236
+ <strong>{ passenger.order_number }</strong>
237
+ <span>{ passenger.date_travel }</span>
238
+ </Cell>
239
+
240
+ <Cell>
241
+ <strong>{ passenger.name ?? '—' }</strong>
242
+ <span>{ passenger.email ?? passenger.phone ?? '' }</span>
243
+ { /* an agency booking is a different conversation - the operator
244
+ talks to the agent, who talks to the traveller */ }
245
+ { passenger.booked_by && (
246
+ <span>{ t('locations_manage.withdraw.booked_by', { ns: 'common', who: passenger.booked_by }) }</span>
247
+ ) }
248
+ </Cell>
249
+
250
+ <Cell>
251
+ { passenger.from } &rsaquo; { passenger.to }
252
+ <span>
253
+ { t(`locations_manage.withdraw.affects.${ passenger.affects }`, { ns: 'common' }) }
254
+ </span>
255
+ </Cell>
256
+
257
+ <Cell>
258
+ { !open && (
259
+ <button type="button" onClick={ () => setOpen(true) }>
260
+ { t('locations_manage.withdraw.move', { ns: 'common' }) }
261
+ </button>
262
+ ) }
263
+
264
+ { open && (
265
+ <MoveOptions>
266
+ { (options ?? []).length === 0 && (
267
+ <span>{ t('locations_manage.withdraw.nowhere', { ns: 'common' }) }</span>
268
+ ) }
269
+
270
+ { (options ?? []).map(option => (
271
+ <button
272
+ key={ option.price_id }
273
+ type="button"
274
+ disabled={ isPending }
275
+ onClick={ () => move(option.price_id) }
276
+ >
277
+ { option.city } { option.time && `· ${ option.time }` }
278
+ </button>
279
+ )) }
280
+ </MoveOptions>
281
+ ) }
282
+ </Cell>
283
+ </Row>
284
+ );
285
+ };
286
+
287
+ export default Withdraw;
@@ -0,0 +1,230 @@
1
+ import styled from 'styled-components';
2
+
3
+ export const Overlay = styled.div`
4
+ position: fixed;
5
+ inset: 0;
6
+ z-index: 900;
7
+ display: flex;
8
+ align-items: flex-start;
9
+ justify-content: center;
10
+ padding: 30px 15px;
11
+ overflow-y: auto;
12
+ background: rgba(0, 0, 0, .45);
13
+ `;
14
+
15
+ export const Panel = styled.div`
16
+ position: relative;
17
+ width: 100%;
18
+ max-width: 880px;
19
+ `;
20
+
21
+ export const Close = styled.button`
22
+ position: absolute;
23
+ top: 10px;
24
+ right: 14px;
25
+ background: none;
26
+ border: 0;
27
+ font-size: 26px;
28
+ line-height: 1;
29
+ cursor: pointer;
30
+ opacity: .6;
31
+ `;
32
+
33
+ export const Title = styled.h4`
34
+ margin-bottom: 10px;
35
+ padding-right: 30px;
36
+ `;
37
+
38
+ export const Lead = styled.p`
39
+ margin-bottom: 15px;
40
+ font-size: ${ props => props.theme.size.s };
41
+
42
+ svg {
43
+ margin-right: 6px;
44
+ vertical-align: -2px;
45
+ }
46
+ `;
47
+
48
+ export const Empty = styled.p`
49
+ margin-bottom: 15px;
50
+ font-size: ${ props => props.theme.size.s };
51
+
52
+ svg {
53
+ margin-right: 6px;
54
+ vertical-align: -2px;
55
+ }
56
+ `;
57
+
58
+ export const Table = styled.div`
59
+ border: 1px solid ${ props => props.theme.background.neutral };
60
+ border-radius: 4px;
61
+ overflow: hidden;
62
+ `;
63
+
64
+ export const Row = styled.div<{ $head?: boolean }>`
65
+ display: grid;
66
+ grid-template-columns: 1fr;
67
+ gap: 4px;
68
+ padding: 10px 12px;
69
+ border-bottom: 1px solid ${ props => props.theme.background.neutral };
70
+ background: ${ props => props.$head ? props.theme.background.neutral : 'transparent' };
71
+ font-weight: ${ props => props.$head ? 700 : 400 };
72
+
73
+ &:last-child {
74
+ border-bottom: 0;
75
+ }
76
+
77
+ @media (min-width: 720px) {
78
+ grid-template-columns: 1.1fr 1.4fr 1.4fr 1.3fr;
79
+ gap: 12px;
80
+ align-items: start;
81
+ }
82
+ `;
83
+
84
+ export const Cell = styled.div`
85
+ font-size: ${ props => props.theme.size.xs };
86
+ min-width: 0;
87
+
88
+ strong {
89
+ display: block;
90
+ }
91
+
92
+ span {
93
+ display: block;
94
+ opacity: .7;
95
+ word-break: break-word;
96
+ }
97
+
98
+ > button {
99
+ background: none;
100
+ border: 1px solid ${ props => props.theme.background.neutral };
101
+ border-radius: 3px;
102
+ padding: 4px 10px;
103
+ cursor: pointer;
104
+ font-size: ${ props => props.theme.size.xs };
105
+ }
106
+ `;
107
+
108
+ export const MoveOptions = styled.div`
109
+ display: flex;
110
+ flex-wrap: wrap;
111
+ gap: 6px;
112
+
113
+ button {
114
+ background: none;
115
+ border: 1px solid ${ props => props.theme.background.neutral };
116
+ border-radius: 3px;
117
+ padding: 4px 8px;
118
+ cursor: pointer;
119
+ font-size: ${ props => props.theme.size.xs };
120
+
121
+ &:disabled {
122
+ opacity: .5;
123
+ cursor: default;
124
+ }
125
+ }
126
+
127
+ span {
128
+ font-size: ${ props => props.theme.size.xs };
129
+ opacity: .7;
130
+ }
131
+ `;
132
+
133
+ /*
134
+ * The message, shown to be read and edited - never sent from here. Styled as
135
+ * a working document rather than a warning: the operator is meant to rewrite
136
+ * it, not just approve it.
137
+ */
138
+ export const Draft = styled.div`
139
+ margin-top: 20px;
140
+ padding: 14px 16px;
141
+ border: 1px solid ${ props => props.theme.background.neutral };
142
+ border-radius: 4px;
143
+
144
+ h5 {
145
+ margin-bottom: 4px;
146
+
147
+ svg {
148
+ margin-right: 6px;
149
+ vertical-align: -2px;
150
+ }
151
+ }
152
+
153
+ > p {
154
+ margin-bottom: 12px;
155
+ font-size: ${ props => props.theme.size.xs };
156
+ opacity: .8;
157
+ }
158
+
159
+ > button {
160
+ background: none;
161
+ border: 1px solid ${ props => props.theme.background.neutral };
162
+ border-radius: 3px;
163
+ padding: 5px 12px;
164
+ cursor: pointer;
165
+ font-size: ${ props => props.theme.size.xs };
166
+
167
+ svg {
168
+ margin-right: 6px;
169
+ vertical-align: -2px;
170
+ }
171
+ }
172
+
173
+ > span {
174
+ display: block;
175
+ margin-top: 8px;
176
+ font-size: ${ props => props.theme.size.xs };
177
+ opacity: .7;
178
+ }
179
+ `;
180
+
181
+ export const DraftField = styled.div`
182
+ margin-bottom: 12px;
183
+
184
+ label {
185
+ display: block;
186
+ margin-bottom: 4px;
187
+ font-size: ${ props => props.theme.size.xs };
188
+ opacity: .8;
189
+ }
190
+
191
+ input,
192
+ textarea {
193
+ width: 100%;
194
+ }
195
+ `;
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
+
225
+ export const Actions = styled.div`
226
+ display: flex;
227
+ align-items: center;
228
+ gap: 12px;
229
+ margin-top: 15px;
230
+ `;
@@ -180,3 +180,116 @@ export const useProposeStop = (): UseMutationResult<ProposeResponse, Error, Prop
180
180
  )
181
181
  })
182
182
  );
183
+
184
+ /**
185
+ * The withdrawal flow.
186
+ *
187
+ * Claude - Date: 2026-08-21
188
+ *
189
+ * A stop is WITHDRAWN, not deleted - obtapi cannot delete one that anything
190
+ * was ever sold through, because an invoice or a refund still has to
191
+ * describe that sale years later. The visible behaviour is the same: it
192
+ * stops being sold, searched and mapped.
193
+ */
194
+ export interface AffectedPassenger {
195
+ id: number
196
+ order_number: string
197
+ date_travel: string | null
198
+ departure: string | null
199
+ tickets: number
200
+ name: string | null
201
+ email: string | null
202
+ phone: string | null
203
+ booked_by: string | null
204
+ from: string | null
205
+ to: string | null
206
+ /** which end of THEIR journey this stop is */
207
+ affects: 'departure' | 'arrival'
208
+ }
209
+
210
+ export interface WithdrawDraft {
211
+ subject: string
212
+ message: string
213
+ /** travel dates the affected bookings fall on */
214
+ dates: string[]
215
+ recipients: { order_id: number, order_number: string, name: string | null, email: string | null, phone: string | null, date_travel: string | null }[]
216
+ }
217
+
218
+ export interface MoveOption {
219
+ price_id: number
220
+ location_id: number
221
+ city: string | null
222
+ time: string | null
223
+ fare: string | number
224
+ }
225
+
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 }> => (
230
+ useQuery({
231
+ queryKey: ['operator-location-passengers', { routeId, locationId }],
232
+ enabled,
233
+ queryFn: async () => (
234
+ await apiClient
235
+ .get('/api/locations/operator/passengers', { params: { id: routeId, location_id: locationId } })
236
+ .then(response => response.data)
237
+ )
238
+ })
239
+ );
240
+
241
+ export const useMoveOptions = (routeId: number, orderId: number, end: string, enabled: boolean): UseQueryResult<MoveOption[]> => (
242
+ useQuery({
243
+ queryKey: ['operator-move-options', { routeId, orderId, end }],
244
+ enabled,
245
+ queryFn: async () => (
246
+ await apiClient
247
+ .get('/api/locations/operator/move/options', { params: { id: routeId, order_id: orderId, end } })
248
+ .then(response => response.data?.options ?? [])
249
+ )
250
+ })
251
+ );
252
+
253
+ export const useMovePassenger = (routeId: number): UseMutationResult<void, Error, { order_id: number, price_id: number }, unknown> => (
254
+ useMutation({
255
+ mutationKey: ['operator-move-passenger', { routeId }],
256
+ mutationFn: async (data) => (
257
+ await apiClient
258
+ .post('/api/locations/operator/move/passenger', { id: routeId, ...data })
259
+ .then(response => response.data)
260
+ )
261
+ })
262
+ );
263
+
264
+ export const useRestoreLocation = (routeId: number): UseMutationResult<void, Error, number, unknown> => (
265
+ useMutation({
266
+ mutationKey: ['operator-location-restore', { routeId }],
267
+ mutationFn: async (locationId) => (
268
+ await apiClient
269
+ .post('/api/locations/operator/restore', { id: routeId, location_id: locationId })
270
+ .then(response => response.data)
271
+ )
272
+ })
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.4.0",
3
+ "version": "1.5.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"