@autobusal/operator-routes 1.3.3 → 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) }>
@@ -3,8 +3,9 @@ import { useParams } from 'react-router-dom';
3
3
  import { Meta, BackWithTitle, Report } from '@autobusal/common';
4
4
  import { usePage, useBreadcrumbs } from '@autobusal/hooks';
5
5
  import Add from './Add/Add';
6
+ import Propose from './Propose/Propose';
6
7
  import Browse from './Browse/Browse';
7
- import { useGetLocations } from './services';
8
+ import { useGetLocations, useGetStops } from './services';
8
9
 
9
10
  interface Props {
10
11
  url: string
@@ -23,6 +24,10 @@ const Manage = ({ url, t }: Props): JSX.Element => {
23
24
 
24
25
  const { data, refetch } = useGetLocations(id);
25
26
 
27
+ /* Claude - 2026-08-21: the same city/stop tree the Add picker uses, reused
28
+ by Propose so it can offer the city list without a second request. */
29
+ const { data: locations, refetch: refetchStops } = useGetStops(id);
30
+
26
31
  const title = t('locations_manage.title', { name: data?.name, ns: 'common' });
27
32
 
28
33
  useBreadcrumbs(title, [{
@@ -35,6 +40,10 @@ const Manage = ({ url, t }: Props): JSX.Element => {
35
40
 
36
41
  const onReload = (): void => {
37
42
  refetch();
43
+
44
+ // a newly proposed stop has to appear in the picker (disabled, marked
45
+ // pending) without a page reload
46
+ refetchStops();
38
47
  };
39
48
 
40
49
  return (
@@ -43,6 +52,12 @@ const Manage = ({ url, t }: Props): JSX.Element => {
43
52
 
44
53
  <Add routeId={ id } t={ t } onReload={ onReload } />
45
54
 
55
+ { /* Claude - 2026-08-21: the way out when the station simply is not
56
+ there. 57 of 101 cities have no stop at all and are therefore
57
+ invisible in the picker above - before this there was no
58
+ in-product path to fix that. */ }
59
+ <Propose countries={ locations } t={ t } onReload={ onReload } />
60
+
46
61
  <Report margin={ 15 } question={ t('locations_manage.report', { ns: 'common' }) } type="location" t={ t } />
47
62
 
48
63
  <Browse routeId={ id } data={ data?.locations } t={ t } onReload={ onReload } />
@@ -0,0 +1,210 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { FiPlus, FiSearch, FiAlertTriangle, FiClock } from 'react-icons/fi';
4
+ import { Button } from '@autobusal/common';
5
+ import { success } from '@autobusal/utilities';
6
+ import { CountryData } from '@autobusal/providers/types/locations';
7
+ import {
8
+ useLookupStations, useProposeStop, StationData, SimilarStop
9
+ } from '../services';
10
+ import {
11
+ Container, Title, Hint, Field, Results, Result, Chosen,
12
+ Similar, SimilarList, Pending, Actions
13
+ } from './styles';
14
+
15
+ interface Props {
16
+ countries?: CountryData[]
17
+ t: TFunction<'normal'>
18
+ onReload: () => void
19
+ }
20
+
21
+ /**
22
+ * Propose a station this platform does not have yet.
23
+ *
24
+ * Claude - Date: 2026-08-21
25
+ *
26
+ * Until now an operator who needed a stop that did not exist had no path at
27
+ * all: the role is read-only on countries, cities and stops, and the picker
28
+ * beside this one is a <select> of what already exists. A city with no stop
29
+ * produces no options and is therefore invisible - 57 of the 101 cities are
30
+ * in exactly that state. The only recourse was to phone somebody, and
31
+ * nothing recorded that the request had been made.
32
+ *
33
+ * The operator enters it, because they are the one who knows the station.
34
+ * It is created UNVERIFIED: passengers never see it and the route builder
35
+ * refuses it until an admin approves, and this says so plainly rather than
36
+ * letting them find out when the stop will not attach.
37
+ *
38
+ * THE DUPLICATE CHECK ASKS, IT DOES NOT REFUSE. Near-identical names and
39
+ * coordinates a few hundred metres apart are usually one terminal spelled
40
+ * two ways ("Terminali Lindor" / "Terminali Lindor i Autobuseve") - but
41
+ * occasionally they really are two stands, and only the operator knows
42
+ * which. So the server answers with what it found, this shows it, and going
43
+ * ahead is a second deliberate press.
44
+ */
45
+ const Propose = ({ countries, t, onReload }: Props): JSX.Element => {
46
+ const [ cityId, setCityId ] = useState<number>(0);
47
+ const [ q, setQ ] = useState<string>('');
48
+ const [ term, setTerm ] = useState<string>('');
49
+
50
+ const [ picked, setPicked ] = useState<StationData | null>(null);
51
+ const [ similar, setSimilar ] = useState<SimilarStop[] | null>(null);
52
+ const [ pending, setPending ] = useState<string | null>(null);
53
+
54
+ useEffect(() => {
55
+ const timer = setTimeout(() => setTerm(q), 350);
56
+
57
+ return () => clearTimeout(timer);
58
+ }, [ q ]);
59
+
60
+ const { data: stations, isFetching } = useLookupStations(cityId, term);
61
+
62
+ const { mutate: Propose, isPending } = useProposeStop();
63
+
64
+ const reset = (): void => {
65
+ setPicked(null);
66
+ setSimilar(null);
67
+ setQ('');
68
+ setTerm('');
69
+ };
70
+
71
+ const submit = (confirmed: boolean): void => {
72
+ if (!picked || cityId === 0) {
73
+ return;
74
+ }
75
+
76
+ Propose({
77
+ city_id: cityId,
78
+ name: picked.name,
79
+ latitude: picked.latitude,
80
+ longitude: picked.longitude,
81
+ confirmed
82
+ }, {
83
+ onSuccess: (response) => {
84
+ // the server found something like it - show what, and let them decide
85
+ if (response?.status === 'similar') {
86
+ setSimilar(response.similar ?? []);
87
+
88
+ return;
89
+ }
90
+
91
+ success(t('locations_manage.propose.messages.sent', { ns: 'common' }));
92
+
93
+ setPending(response?.stop?.name ?? picked.name);
94
+
95
+ reset();
96
+ onReload();
97
+ }
98
+ });
99
+ };
100
+
101
+ return (
102
+ <Container className="box">
103
+ <Title>{ t('locations_manage.propose.title', { ns: 'common' }) }</Title>
104
+ <Hint>{ t('locations_manage.propose.hint', { ns: 'common' }) }</Hint>
105
+
106
+ <select value={ cityId } onChange={ event => { setCityId(Number(event.target.value)); reset(); } }>
107
+ <option value={ 0 }>{ t('locations_manage.propose.choose_city', { ns: 'common' }) }</option>
108
+
109
+ { countries?.map(country => (
110
+ <optgroup key={ country.id } label={ country.name }>
111
+ { country.cities?.map(city => (
112
+ <option key={ city.id } value={ city.id }>{ city.name }</option>
113
+ )) }
114
+ </optgroup>
115
+ )) }
116
+ </select>
117
+
118
+ { cityId > 0 && (
119
+ <Field>
120
+ <FiSearch />
121
+
122
+ <input
123
+ type="text"
124
+ value={ q }
125
+ placeholder={ t('locations_manage.propose.placeholder', { ns: 'common' }) }
126
+ onChange={ event => { setQ(event.target.value); setSimilar(null); } }
127
+ />
128
+ </Field>
129
+ ) }
130
+
131
+ { isFetching && <Hint>{ t('locations_manage.propose.searching', { ns: 'common' }) }</Hint> }
132
+
133
+ { (!picked && (stations ?? []).length > 0) && (
134
+ <Results>
135
+ { (stations ?? []).map((station, index) => (
136
+ <Result key={ `${ station.name }-${ index }` }>
137
+ <button type="button" onClick={ () => { setPicked(station); setSimilar(null); } }>
138
+ <strong>{ station.name }</strong>
139
+ <span>
140
+ { station.description ?? '' }
141
+ { station.distance !== null && ` · ${ t('locations_manage.propose.away', { ns: 'common', distance: station.distance }) }` }
142
+ </span>
143
+ </button>
144
+ </Result>
145
+ )) }
146
+ </Results>
147
+ ) }
148
+
149
+ { (!isFetching && !picked && term.length > 1 && (stations ?? []).length === 0) && (
150
+ <Hint>{ t('locations_manage.propose.empty', { ns: 'common' }) }</Hint>
151
+ ) }
152
+
153
+ { picked && (
154
+ <>
155
+ <Chosen>
156
+ <strong>{ picked.name }</strong>
157
+ <span>{ picked.latitude }, { picked.longitude }</span>
158
+ </Chosen>
159
+
160
+ { similar && (
161
+ <Similar>
162
+ <p><FiAlertTriangle />{ t('locations_manage.propose.similar.title', { ns: 'common' }) }</p>
163
+
164
+ <SimilarList>
165
+ { similar.map(stop => (
166
+ <li key={ stop.id }>
167
+ { stop.name }
168
+ { stop.distance !== null && ` · ${ t('locations_manage.propose.away', { ns: 'common', distance: stop.distance }) }` }
169
+ { !stop.verified && ` · ${ t('locations_manage.propose.similar.pending', { ns: 'common' }) }` }
170
+ </li>
171
+ )) }
172
+ </SimilarList>
173
+
174
+ <span>{ t('locations_manage.propose.similar.hint', { ns: 'common' }) }</span>
175
+ </Similar>
176
+ ) }
177
+
178
+ <Actions>
179
+ <Button
180
+ loading={ isPending }
181
+ text={
182
+ <>
183
+ <FiPlus />
184
+ { similar
185
+ ? t('locations_manage.propose.similar.anyway', { ns: 'common' })
186
+ : t('locations_manage.propose.action', { ns: 'common' }) }
187
+ </>
188
+ }
189
+ onClick={ () => submit(similar !== null) }
190
+ noMargin
191
+ />
192
+
193
+ <button type="button" onClick={ reset }>
194
+ { t('locations_manage.propose.cancel', { ns: 'common' }) }
195
+ </button>
196
+ </Actions>
197
+ </>
198
+ ) }
199
+
200
+ { pending && (
201
+ <Pending>
202
+ <FiClock />
203
+ { t('locations_manage.propose.pending', { ns: 'common', name: pending }) }
204
+ </Pending>
205
+ ) }
206
+ </Container>
207
+ );
208
+ };
209
+
210
+ export default Propose;
@@ -0,0 +1,155 @@
1
+ import styled from 'styled-components';
2
+
3
+ export const Container = styled.div`
4
+ margin-bottom: 25px;
5
+
6
+ select {
7
+ width: 100%;
8
+ }
9
+ `;
10
+
11
+ export const Title = styled.h5`
12
+ margin-bottom: 10px;
13
+ padding-bottom: 15px;
14
+ font-size: ${ props => props.theme.size.m };
15
+ border-bottom: 1px solid ${ props => props.theme.background.neutral };
16
+ `;
17
+
18
+ export const Hint = styled.p`
19
+ margin: 0 0 12px;
20
+ font-size: ${ props => props.theme.size.xs };
21
+ opacity: .75;
22
+ `;
23
+
24
+ export const Field = styled.div`
25
+ position: relative;
26
+ margin-top: 15px;
27
+
28
+ > svg {
29
+ position: absolute;
30
+ top: 50%;
31
+ left: 10px;
32
+ transform: translateY(-50%);
33
+ opacity: .5;
34
+ }
35
+
36
+ input {
37
+ width: 100%;
38
+ padding-left: 32px;
39
+ }
40
+ `;
41
+
42
+ export const Results = styled.ul`
43
+ margin: 10px 0 0;
44
+ padding: 0;
45
+ list-style: none;
46
+ max-height: 260px;
47
+ overflow-y: auto;
48
+ `;
49
+
50
+ export const Result = styled.li`
51
+ button {
52
+ display: block;
53
+ width: 100%;
54
+ padding: 8px 10px;
55
+ text-align: left;
56
+ background: none;
57
+ border: 0;
58
+ border-bottom: 1px solid ${ props => props.theme.background.neutral };
59
+ cursor: pointer;
60
+
61
+ &:hover {
62
+ background: ${ props => props.theme.background.neutral };
63
+ }
64
+ }
65
+
66
+ strong {
67
+ display: block;
68
+ font-size: ${ props => props.theme.size.s };
69
+ }
70
+
71
+ span {
72
+ font-size: ${ props => props.theme.size.xs };
73
+ opacity: .7;
74
+ }
75
+ `;
76
+
77
+ export const Chosen = styled.div`
78
+ margin-top: 15px;
79
+ padding: 10px 12px;
80
+ background: ${ props => props.theme.background.neutral };
81
+ border-radius: 4px;
82
+
83
+ strong {
84
+ display: block;
85
+ font-size: ${ props => props.theme.size.s };
86
+ }
87
+
88
+ span {
89
+ font-size: ${ props => props.theme.size.xs };
90
+ opacity: .7;
91
+ }
92
+ `;
93
+
94
+ /*
95
+ * The duplicate warning. Deliberately not styled as an error - it is a
96
+ * question, and the operator may well be right that this is a second stand
97
+ * in the same terminal.
98
+ */
99
+ export const Similar = styled.div`
100
+ margin-top: 15px;
101
+ padding: 12px 14px;
102
+ border: 1px solid ${ props => props.theme.font.warning ?? '#e0a800' };
103
+ border-radius: 4px;
104
+
105
+ p {
106
+ margin: 0 0 8px;
107
+ font-weight: 700;
108
+
109
+ svg {
110
+ margin-right: 6px;
111
+ vertical-align: -2px;
112
+ }
113
+ }
114
+
115
+ span {
116
+ display: block;
117
+ margin-top: 8px;
118
+ font-size: ${ props => props.theme.size.xs };
119
+ opacity: .8;
120
+ }
121
+ `;
122
+
123
+ export const SimilarList = styled.ul`
124
+ margin: 0;
125
+ padding-left: 18px;
126
+
127
+ li {
128
+ font-size: ${ props => props.theme.size.xs };
129
+ }
130
+ `;
131
+
132
+ export const Actions = styled.div`
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 12px;
136
+ margin-top: 15px;
137
+
138
+ button[type="button"] {
139
+ background: none;
140
+ border: 0;
141
+ cursor: pointer;
142
+ font-size: ${ props => props.theme.size.xs };
143
+ opacity: .7;
144
+ }
145
+ `;
146
+
147
+ export const Pending = styled.p`
148
+ margin-top: 15px;
149
+ font-size: ${ props => props.theme.size.xs };
150
+
151
+ svg {
152
+ margin-right: 6px;
153
+ vertical-align: -2px;
154
+ }
155
+ `;
@@ -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
+ `;
@@ -110,4 +110,161 @@ export const usePostUpdate = (id: number, location_id: number): UseMutationResul
110
110
  ))
111
111
  )
112
112
  })
113
- );
113
+ );
114
+ /**
115
+ * A station the geocoder found, and a stop that already exists.
116
+ *
117
+ * Claude - Date: 2026-08-21
118
+ */
119
+ export interface StationData {
120
+ name: string
121
+ latitude: string
122
+ longitude: string
123
+ description: string | null
124
+ kind: string | null
125
+ distance: number | null
126
+ }
127
+
128
+ export interface SimilarStop {
129
+ id: number
130
+ name: string
131
+ verified: boolean
132
+ distance: number | null
133
+ }
134
+
135
+ export interface ProposeForm {
136
+ city_id: number
137
+ name: string
138
+ latitude: string
139
+ longitude: string
140
+ confirmed?: boolean
141
+ }
142
+
143
+ export interface ProposeResponse {
144
+ /** `similar` means "we found something like this, look before you commit" */
145
+ status?: 'similar' | 'pending'
146
+ similar?: SimilarStop[]
147
+ stop?: { id: number, name: string, verified: boolean }
148
+ }
149
+
150
+ /**
151
+ * Candidate stations for the name field, from OpenStreetMap via obtapi.
152
+ */
153
+ export const useLookupStations = (cityId: number, q: string): UseQueryResult<StationData[]> => (
154
+ useQuery({
155
+ queryKey: ['operator-stations-lookup', { cityId, q }],
156
+ enabled: cityId > 0 && q.trim().length > 1,
157
+ queryFn: async () => (
158
+ await apiClient
159
+ .get('/api/stops/operator/lookup', { params: { q: q.trim(), city_id: cityId } })
160
+ .then(response => response.data?.results ?? [])
161
+ )
162
+ })
163
+ );
164
+
165
+ /**
166
+ * Propose a station that does not exist yet.
167
+ *
168
+ * Two-step by design: the first call answers `status: 'similar'` when
169
+ * something close already exists, so the operator can look before adding a
170
+ * duplicate. Sending `confirmed` goes ahead anyway. Either way the stop is
171
+ * created UNVERIFIED and cannot be put on a route until an admin approves.
172
+ */
173
+ export const useProposeStop = (): UseMutationResult<ProposeResponse, Error, ProposeForm, unknown> => (
174
+ useMutation({
175
+ mutationKey: ['operator-stop-propose'],
176
+ mutationFn: async (data) => (
177
+ await apiClient
178
+ .post('/api/stops/operator/propose', data)
179
+ .then(response => response.data)
180
+ )
181
+ })
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
+ );
@@ -9,7 +9,7 @@ export const getStops = (t: TFunction<'normal'>, countries?: CountryData[]): JSX
9
9
  countries?.forEach(country => {
10
10
  options.push(
11
11
  <optgroup key={ country.id } label={ country.name }>
12
- { getOptions(country.cities) }
12
+ { getOptions(country.cities, t) }
13
13
  </optgroup>
14
14
  );
15
15
  });
@@ -17,15 +17,29 @@ export const getStops = (t: TFunction<'normal'>, countries?: CountryData[]): JSX
17
17
  return options;
18
18
  };
19
19
 
20
- const getOptions = (cities?: CityData[]): JSX.Element[] => {
20
+ const getOptions = (cities?: CityData[], t?: TFunction<'normal'>): JSX.Element[] => {
21
21
  const options: JSX.Element[] = [];
22
22
 
23
23
  cities?.forEach(city => {
24
24
  city.stops?.forEach(stop => {
25
25
  const value = `${ city.id }#${ stop.id }`;
26
26
 
27
+ /*
28
+ * Edited: Claude - Date: 2026-08-21
29
+ *
30
+ * A stop an operator proposed is listed but DISABLED until an admin
31
+ * approves it. Leaving it out entirely would look like the proposal
32
+ * had been lost - and obtapi refuses it anyway (Locations\
33
+ * OperatorController::add scopes to verified()), so an enabled option
34
+ * would just be an error waiting to happen.
35
+ */
36
+ const pending = stop.verified === false;
37
+
27
38
  options.push(
28
- <option key={ value } value={ value }>{ city.name } &rsaquo; { stop.name }</option>
39
+ <option key={ value } value={ value } disabled={ pending }>
40
+ { city.name } &rsaquo; { stop.name }
41
+ { pending && t ? ` (${ t('locations_manage.add_city.pending', { ns: 'common' }) })` : '' }
42
+ </option>
29
43
  );
30
44
  });
31
45
  });
package/Manage.tsx CHANGED
@@ -11,7 +11,7 @@ import { success } from '@autobusal/utilities';
11
11
  import Template from './Template';
12
12
  import Options from './Options';
13
13
  import Seating from './Seating';
14
- import { getType, getLanguagesExtended, getTemplates, getTransiting } from './utilities';
14
+ import { getType, getLanguagesExtended, getTemplates, getTicketDelivery, getTransiting } from './utilities';
15
15
  import { RouteData } from '@autobusal/providers/types/routes';
16
16
  import { SeatingOptionsData } from './types';
17
17
  import { useGetRoute, usePostRoute, useDeleteRoute } from './services';
@@ -94,6 +94,22 @@ const Manage = ({ url, t }: Props): JSX.Element => {
94
94
  type: 'component',
95
95
  name: 'seating',
96
96
  value: <Seating trip={ data?.trip } t={ t } onUpdated={ onSeatingUpdated } />
97
+ }, {
98
+ /*
99
+ * Edited: Claude (BusMagus mobile app) - Date: 2026-08-10
100
+ *
101
+ * Whether this route accepts an e-ticket (the PDF every passenger
102
+ * already gets, no printing needed) at boarding, or the driver
103
+ * requires a physical printout. Unset/null means e-ticket accepted
104
+ * - the behaviour every route already had before this field
105
+ * existed - so a route saved without ever touching this control
106
+ * keeps working exactly as before.
107
+ */
108
+ label: t('operator_routes.manage.ticket_delivery.title', { ns: 'common', defaultValue: 'Ticket delivery' }),
109
+ name: 'ticket_delivery',
110
+ type: 'select',
111
+ values: getTicketDelivery(t),
112
+ value: data?.trip?.ticket_delivery ?? 'e_ticket'
97
113
  }, {} , {
98
114
  label: t('operator_routes.manage.schedule.title', { ns: 'common' }),
99
115
  name: 'schedule',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/operator-routes",
3
- "version": "1.3.3",
3
+ "version": "1.5.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
package/utilities.tsx CHANGED
@@ -67,6 +67,32 @@ export const getLanguagesExtended = (t: TFunction<'normal'>): DropdownData[] =>
67
67
  }))
68
68
  );
69
69
 
70
+ /**
71
+ * Edited: Claude (BusMagus mobile app) - Date: 2026-08-10
72
+ *
73
+ * Whether the operator accepts an e-ticket (the PDF every passenger
74
+ * already gets emailed/downloads) at boarding, or requires a physical
75
+ * printout. NULL/unset = e-ticket accepted, the existing de facto
76
+ * behaviour on every route today - this field only ever tightens that,
77
+ * it never changes what a passenger receives.
78
+ *
79
+ * Uses `defaultValue` rather than real translation keys for now - this
80
+ * is a brand-new field, and `public/languages/*\/common.json` (where
81
+ * this package's own translations live) had unrelated in-progress
82
+ * changes sitting uncommitted at the time this was built, so adding real
83
+ * translations for all 15 languages was left for a follow-up rather than
84
+ * risking a conflict with that other work. English-only until then.
85
+ */
86
+ export const getTicketDelivery = (t: TFunction<'common'>): DropdownData[] => ([
87
+ {
88
+ name: t('operator_routes.manage.ticket_delivery.e_ticket', { ns: 'common', defaultValue: 'e-Ticket accepted' }),
89
+ id: 'e_ticket'
90
+ }, {
91
+ name: t('operator_routes.manage.ticket_delivery.printed_only', { ns: 'common', defaultValue: 'Printed ticket only' }),
92
+ id: 'printed_only'
93
+ }
94
+ ]);
95
+
70
96
  export const getTemplates = (t: TFunction<'normal'>): DropdownData[] => ([
71
97
  {
72
98
  name: t('operator_routes.manage.templates.standard', { ns: 'common' }),