@autobusal/operator-routes 1.3.3 → 1.4.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.
- package/Locations/Manage.tsx +16 -1
- package/Locations/Propose/Propose.tsx +210 -0
- package/Locations/Propose/styles.ts +155 -0
- package/Locations/services.ts +70 -1
- package/Locations/utilities.tsx +17 -3
- package/Manage.tsx +17 -1
- package/package.json +1 -1
- package/utilities.tsx +26 -0
package/Locations/Manage.tsx
CHANGED
|
@@ -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
|
+
`;
|
package/Locations/services.ts
CHANGED
|
@@ -110,4 +110,73 @@ 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
|
+
);
|
package/Locations/utilities.tsx
CHANGED
|
@@ -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 }
|
|
39
|
+
<option key={ value } value={ value } disabled={ pending }>
|
|
40
|
+
{ city.name } › { 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
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' }),
|