@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.
- package/Locations/Browse/Item.tsx +35 -0
- package/Locations/Withdraw/Withdraw.tsx +287 -0
- package/Locations/Withdraw/styles.ts +230 -0
- package/Locations/services.ts +113 -0
- package/package.json +1 -1
|
@@ -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 } › { item.city.name } › { 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 }>×</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 } › { 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
|
+
`;
|
package/Locations/services.ts
CHANGED
|
@@ -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
|
+
);
|