@autobusal/operator-routes 1.4.0 → 1.5.0

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,235 @@
1
+ import { useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { FiAlertTriangle, FiCheck, FiCopy, 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, AffectedPassenger } from '../services';
8
+ import {
9
+ Overlay, Panel, Title, Lead, Table, Row, Cell, MoveOptions, Draft, DraftField,
10
+ Actions, Empty, Close
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. 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.
39
+ */
40
+ const Withdraw = ({ routeId, location, t, onClose, onWithdraw, onReload }: Props): JSX.Element => {
41
+ const { data, isLoading, refetch } = useWithdrawCheck(routeId, location.id, true);
42
+
43
+ const [ subject, setSubject ] = useState<string | null>(null);
44
+ const [ message, setMessage ] = useState<string | null>(null);
45
+
46
+ const passengers = data?.passengers ?? [];
47
+ const draft = data?.draft ?? null;
48
+
49
+ const copy = (): void => {
50
+ const text = `${ subject ?? draft?.subject ?? '' }\n\n${ message ?? draft?.message ?? '' }`;
51
+
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
+ );
56
+ };
57
+
58
+ return (
59
+ <Overlay onClick={ onClose }>
60
+ <Panel className="box" onClick={ event => event.stopPropagation() }>
61
+ <Close type="button" onClick={ onClose }>&times;</Close>
62
+
63
+ <Title>
64
+ { t('locations_manage.withdraw.title', {
65
+ ns: 'common',
66
+ city: location.city?.name,
67
+ stop: location.stop?.name ?? ''
68
+ }) }
69
+ </Title>
70
+
71
+ { isLoading && <Lead>{ t('locations_manage.withdraw.checking', { ns: 'common' }) }</Lead> }
72
+
73
+ { (!isLoading && passengers.length === 0) && (
74
+ <>
75
+ <Empty>
76
+ <FiCheck />
77
+ { t('locations_manage.withdraw.clear', { ns: 'common' }) }
78
+ </Empty>
79
+
80
+ <Actions>
81
+ <Button text={ t('locations_manage.withdraw.action', { ns: 'common' }) } onClick={ onWithdraw } noMargin />
82
+ </Actions>
83
+ </>
84
+ ) }
85
+
86
+ { (!isLoading && passengers.length > 0) && (
87
+ <>
88
+ <Lead>
89
+ <FiAlertTriangle />
90
+ { t('locations_manage.withdraw.blocked', { ns: 'common', count: passengers.length }) }
91
+ </Lead>
92
+
93
+ <Table>
94
+ <Row $head>
95
+ <Cell>{ t('locations_manage.withdraw.columns.booking', { ns: 'common' }) }</Cell>
96
+ <Cell>{ t('locations_manage.withdraw.columns.passenger', { ns: 'common' }) }</Cell>
97
+ <Cell>{ t('locations_manage.withdraw.columns.journey', { ns: 'common' }) }</Cell>
98
+ <Cell>{ t('locations_manage.withdraw.columns.resolve', { ns: 'common' }) }</Cell>
99
+ </Row>
100
+
101
+ { passengers.map(passenger => (
102
+ <Passenger
103
+ key={ passenger.id }
104
+ routeId={ routeId }
105
+ passenger={ passenger }
106
+ t={ t }
107
+ onMoved={ () => { refetch(); onReload(); } }
108
+ />
109
+ )) }
110
+ </Table>
111
+
112
+ { draft && (
113
+ <Draft>
114
+ <h5><FiUsers />{ t('locations_manage.withdraw.draft.title', { ns: 'common' }) }</h5>
115
+ <p>{ t('locations_manage.withdraw.draft.hint', { ns: 'common' }) }</p>
116
+
117
+ <DraftField>
118
+ <label>{ t('locations_manage.withdraw.draft.subject', { ns: 'common' }) }</label>
119
+ <input
120
+ type="text"
121
+ value={ subject ?? draft.subject }
122
+ onChange={ event => setSubject(event.target.value) }
123
+ />
124
+ </DraftField>
125
+
126
+ <DraftField>
127
+ <label>{ t('locations_manage.withdraw.draft.message', { ns: 'common' }) }</label>
128
+ <textarea
129
+ rows={ 8 }
130
+ value={ message ?? draft.message }
131
+ onChange={ event => setMessage(event.target.value) }
132
+ />
133
+ </DraftField>
134
+
135
+ <button type="button" onClick={ copy }>
136
+ <FiCopy />
137
+ { t('locations_manage.withdraw.draft.copy', { ns: 'common' }) }
138
+ </button>
139
+
140
+ <span>
141
+ { t('locations_manage.withdraw.draft.dates', { ns: 'common', dates: draft.dates.join(', ') }) }
142
+ </span>
143
+ </Draft>
144
+ ) }
145
+ </>
146
+ ) }
147
+ </Panel>
148
+ </Overlay>
149
+ );
150
+ };
151
+
152
+ /**
153
+ * One affected booking, and the stops it could move to instead.
154
+ *
155
+ * The options are fetched only when the operator opens this row - a route
156
+ * with a dozen affected bookings would otherwise fire a dozen requests to
157
+ * draw a table nobody has looked at yet.
158
+ */
159
+ const Passenger = ({ routeId, passenger, t, onMoved }: {
160
+ routeId: number
161
+ passenger: AffectedPassenger
162
+ t: TFunction<'normal'>
163
+ onMoved: () => void
164
+ }): JSX.Element => {
165
+ const [ open, setOpen ] = useState<boolean>(false);
166
+
167
+ const { data: options } = useMoveOptions(routeId, passenger.id, passenger.affects, open);
168
+
169
+ const { mutate: Move, isPending } = useMovePassenger(routeId);
170
+
171
+ const move = (priceId: number): void => {
172
+ Move({ order_id: passenger.id, price_id: priceId }, {
173
+ onSuccess: () => {
174
+ success(t('locations_manage.withdraw.moved', { ns: 'common' }));
175
+
176
+ onMoved();
177
+ }
178
+ });
179
+ };
180
+
181
+ return (
182
+ <Row>
183
+ <Cell>
184
+ <strong>{ passenger.order_number }</strong>
185
+ <span>{ passenger.date_travel }</span>
186
+ </Cell>
187
+
188
+ <Cell>
189
+ <strong>{ passenger.name ?? '—' }</strong>
190
+ <span>{ passenger.email ?? passenger.phone ?? '' }</span>
191
+ { /* an agency booking is a different conversation - the operator
192
+ talks to the agent, who talks to the traveller */ }
193
+ { passenger.booked_by && (
194
+ <span>{ t('locations_manage.withdraw.booked_by', { ns: 'common', who: passenger.booked_by }) }</span>
195
+ ) }
196
+ </Cell>
197
+
198
+ <Cell>
199
+ { passenger.from } &rsaquo; { passenger.to }
200
+ <span>
201
+ { t(`locations_manage.withdraw.affects.${ passenger.affects }`, { ns: 'common' }) }
202
+ </span>
203
+ </Cell>
204
+
205
+ <Cell>
206
+ { !open && (
207
+ <button type="button" onClick={ () => setOpen(true) }>
208
+ { t('locations_manage.withdraw.move', { ns: 'common' }) }
209
+ </button>
210
+ ) }
211
+
212
+ { open && (
213
+ <MoveOptions>
214
+ { (options ?? []).length === 0 && (
215
+ <span>{ t('locations_manage.withdraw.nowhere', { ns: 'common' }) }</span>
216
+ ) }
217
+
218
+ { (options ?? []).map(option => (
219
+ <button
220
+ key={ option.price_id }
221
+ type="button"
222
+ disabled={ isPending }
223
+ onClick={ () => move(option.price_id) }
224
+ >
225
+ { option.city } { option.time && `· ${ option.time }` }
226
+ </button>
227
+ )) }
228
+ </MoveOptions>
229
+ ) }
230
+ </Cell>
231
+ </Row>
232
+ );
233
+ };
234
+
235
+ export default Withdraw;
@@ -0,0 +1,202 @@
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
+ export const Actions = styled.div`
198
+ display: flex;
199
+ align-items: center;
200
+ gap: 12px;
201
+ margin-top: 15px;
202
+ `;
@@ -180,3 +180,91 @@ 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 - one broadcast per date */
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
+ export const useWithdrawCheck = (routeId: number, locationId: number, enabled: boolean): UseQueryResult<{ passengers: AffectedPassenger[], draft: WithdrawDraft | null }> => (
227
+ useQuery({
228
+ queryKey: ['operator-location-passengers', { routeId, locationId }],
229
+ enabled,
230
+ queryFn: async () => (
231
+ await apiClient
232
+ .get('/api/locations/operator/passengers', { params: { id: routeId, location_id: locationId } })
233
+ .then(response => response.data)
234
+ )
235
+ })
236
+ );
237
+
238
+ export const useMoveOptions = (routeId: number, orderId: number, end: string, enabled: boolean): UseQueryResult<MoveOption[]> => (
239
+ useQuery({
240
+ queryKey: ['operator-move-options', { routeId, orderId, end }],
241
+ enabled,
242
+ queryFn: async () => (
243
+ await apiClient
244
+ .get('/api/locations/operator/move/options', { params: { id: routeId, order_id: orderId, end } })
245
+ .then(response => response.data?.options ?? [])
246
+ )
247
+ })
248
+ );
249
+
250
+ export const useMovePassenger = (routeId: number): UseMutationResult<void, Error, { order_id: number, price_id: number }, unknown> => (
251
+ useMutation({
252
+ mutationKey: ['operator-move-passenger', { routeId }],
253
+ mutationFn: async (data) => (
254
+ await apiClient
255
+ .post('/api/locations/operator/move/passenger', { id: routeId, ...data })
256
+ .then(response => response.data)
257
+ )
258
+ })
259
+ );
260
+
261
+ export const useRestoreLocation = (routeId: number): UseMutationResult<void, Error, number, unknown> => (
262
+ useMutation({
263
+ mutationKey: ['operator-location-restore', { routeId }],
264
+ mutationFn: async (locationId) => (
265
+ await apiClient
266
+ .post('/api/locations/operator/restore', { id: routeId, location_id: locationId })
267
+ .then(response => response.data)
268
+ )
269
+ })
270
+ );
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.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"