@autobusal/operator-routes 1.10.0 → 1.10.2
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/CHANGELOG.md +4 -0
- package/Manage.tsx +20 -1
- package/Prices/Browse/Browse.tsx +48 -15
- package/Prices/Browse/Group.tsx +207 -0
- package/Prices/Browse/styles.ts +236 -8
- package/Prices/Manage.tsx +1 -1
- package/Prices/services.ts +41 -2
- package/Prices/types.ts +23 -0
- package/Prices/utilities.ts +73 -0
- package/Pricing/Manage.tsx +279 -0
- package/Pricing/services.ts +76 -0
- package/Pricing/styles.ts +166 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/types.ts +40 -1
- package/Prices/Browse/Item.tsx +0 -106
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.10.1 - 2026-08-30
|
|
4
|
+
|
|
5
|
+
- Staged dynamic pricing (roadmap P5): a per-route screen for the price rules - the opt-in, the operator's own caps, and the rules as a NUMBERED list read top to bottom, because the first rule whose band matches is the only one that applies. Each row says what it will actually do once the cap has had its say.
|
|
6
|
+
|
|
3
7
|
## 1.10.0 (2026-08-29)
|
|
4
8
|
|
|
5
9
|
- Editors offer Save & Close alongside Save & Stay, so correcting a record no longer bounces you back to its list every time (opt-in per page, editing only).
|
package/Manage.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { TFunction } from 'i18next';
|
|
|
4
4
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
5
5
|
import { RiMoneyEuroCircleLine } from 'react-icons/ri';
|
|
6
6
|
import { FaMapMarkerAlt } from 'react-icons/fa';
|
|
7
|
-
import { IoCalendarClearOutline } from 'react-icons/io5';
|
|
7
|
+
import { IoCalendarClearOutline, IoPricetagsOutline } from 'react-icons/io5';
|
|
8
8
|
import { Meta, BackWithTitle, Viewer } from '@autobusal/common';
|
|
9
9
|
import { usePage, useBreadcrumbs, useRouteFeatures, useOperatorDrivers } from '@autobusal/hooks';
|
|
10
10
|
import { success } from '@autobusal/utilities';
|
|
@@ -136,6 +136,25 @@ const Manage = ({ url, t }: Props): JSX.Element => {
|
|
|
136
136
|
</>
|
|
137
137
|
),
|
|
138
138
|
url: `/operator/routes/unavailable/${ id }`
|
|
139
|
+
}, {
|
|
140
|
+
/*
|
|
141
|
+
* Claude - 2026-08-30 (roadmap P5): staged dynamic pricing.
|
|
142
|
+
*
|
|
143
|
+
* A link, like the schedule and the unavailable dates above it: the
|
|
144
|
+
* rules are an ordered LIST whose order decides which one sets the
|
|
145
|
+
* fare, and a list that means something cannot be squeezed into one
|
|
146
|
+
* row of this form.
|
|
147
|
+
*/
|
|
148
|
+
label: t('operator_routes.manage.pricing.title', { ns: 'common' }),
|
|
149
|
+
name: 'pricing',
|
|
150
|
+
type: 'link',
|
|
151
|
+
value: (
|
|
152
|
+
<>
|
|
153
|
+
<IoPricetagsOutline />
|
|
154
|
+
{ t('operator_routes.manage.pricing.link', { ns: 'common' }) }
|
|
155
|
+
</>
|
|
156
|
+
),
|
|
157
|
+
url: `/operator/routes/pricing/${ id }`
|
|
139
158
|
}, {}, {
|
|
140
159
|
label: t('operator_routes.manage.features', { ns: 'common' }),
|
|
141
160
|
name: 'features',
|
package/Prices/Browse/Browse.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
1
2
|
import { TFunction } from 'i18next';
|
|
2
|
-
import
|
|
3
|
-
import { Items } from './styles';
|
|
3
|
+
import Group from './Group';
|
|
4
|
+
import { Items, Empty } from './styles';
|
|
4
5
|
import { PriceData } from '@autobusal/providers/types/routes';
|
|
6
|
+
import { byOrigin } from '../utilities';
|
|
5
7
|
|
|
6
8
|
interface Props {
|
|
7
9
|
id: number
|
|
@@ -9,25 +11,56 @@ interface Props {
|
|
|
9
11
|
data?: PriceData[]
|
|
10
12
|
name: string
|
|
11
13
|
t: TFunction<'normal'>
|
|
14
|
+
onReload: () => void
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
/**
|
|
18
|
+
* A route's fares, one panel per departure point.
|
|
19
|
+
*
|
|
20
|
+
* Claude - 2026-08-30. The API sends a flat list ordered by `order_id`, which
|
|
21
|
+
* is travel order; grouping it here rather than server-side keeps the payload
|
|
22
|
+
* exactly as it was, so nothing else that reads a route's prices had to
|
|
23
|
+
* change to get this screen.
|
|
24
|
+
*/
|
|
25
|
+
const Browse = ({ id, type, data, name, t, onReload }: Props): JSX.Element => {
|
|
26
|
+
/*
|
|
27
|
+
* MEMOISED, AND NOT AS AN OPTIMISATION. Each group hands its prices to a
|
|
28
|
+
* form that takes them as its baseline and resets when they change - which
|
|
29
|
+
* is how a server-side copy shows up without a stale "unsaved" state. Built
|
|
30
|
+
* fresh on every render, the arrays are new every time, so that reset fired
|
|
31
|
+
* on every keystroke and wiped what was being typed. Measured, not
|
|
32
|
+
* theorised: filling a column down put the old values straight back.
|
|
33
|
+
*
|
|
34
|
+
* react-query hands back the same `data` until it actually refetches, so
|
|
35
|
+
* this changes identity exactly when the fares really did.
|
|
36
|
+
*/
|
|
37
|
+
const groups = useMemo(() => byOrigin(data ?? []), [ data ]);
|
|
38
|
+
|
|
39
|
+
if (!groups.length) {
|
|
40
|
+
return (
|
|
41
|
+
<Items>
|
|
42
|
+
<div className="box">
|
|
43
|
+
<Empty>{ t('prices_manage.empty', { ns: 'common' }) }</Empty>
|
|
44
|
+
</div>
|
|
45
|
+
</Items>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
25
48
|
|
|
26
49
|
return (
|
|
27
50
|
<Items>
|
|
28
|
-
{
|
|
51
|
+
{ groups.map(group => (
|
|
52
|
+
<Group
|
|
53
|
+
key={ group.from.id }
|
|
54
|
+
id={ id }
|
|
55
|
+
type={ type }
|
|
56
|
+
group={ group }
|
|
57
|
+
name={ name }
|
|
58
|
+
t={ t }
|
|
59
|
+
onReload={ onReload }
|
|
60
|
+
/>
|
|
61
|
+
)) }
|
|
29
62
|
</Items>
|
|
30
63
|
);
|
|
31
64
|
};
|
|
32
65
|
|
|
33
|
-
export default Browse;
|
|
66
|
+
export default Browse;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react';
|
|
2
|
+
import { TFunction } from 'i18next';
|
|
3
|
+
import { useForm } from 'react-hook-form';
|
|
4
|
+
import { BiEditAlt } from 'react-icons/bi';
|
|
5
|
+
import { IoArrowDown } from 'react-icons/io5';
|
|
6
|
+
import { FaRegCopy } from 'react-icons/fa';
|
|
7
|
+
import { Button } from '@autobusal/common';
|
|
8
|
+
import { Validate, Display, success } from '@autobusal/utilities';
|
|
9
|
+
import { Group as Container, Header, Origin, Country, Tools, Dirty, Copy, Columns, Column, Fill, Row, Destination, Changed } from './styles';
|
|
10
|
+
import { FareColumn, GroupForm, OriginGroup } from '../types';
|
|
11
|
+
import { COLUMNS, field, toForm, toPrices } from '../utilities';
|
|
12
|
+
import { usePostPrices, usePostCopy } from '../services';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
id: number
|
|
16
|
+
type: 'normal' | 'alternate'
|
|
17
|
+
group: OriginGroup
|
|
18
|
+
name: string
|
|
19
|
+
t: TFunction<'normal'>
|
|
20
|
+
onReload: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Everything the route sells leaving ONE stop.
|
|
25
|
+
*
|
|
26
|
+
* Claude - 2026-08-30 (ruled by Ferjolt: "the save should be per origin, and
|
|
27
|
+
* the copy should be based per origin too").
|
|
28
|
+
*
|
|
29
|
+
* The group is the unit of work because it is the unit an operator thinks in:
|
|
30
|
+
* fares are decided per departure point, not per city pair. So the six labels
|
|
31
|
+
* are said once at the top, each destination is one line, and one Save writes
|
|
32
|
+
* the lot in a single transaction.
|
|
33
|
+
*
|
|
34
|
+
* Save stays disabled until something in THIS group changes, and the rows
|
|
35
|
+
* that changed carry a dot - on a twelve-destination group, pressing Save
|
|
36
|
+
* should not be a guess about what you edited near the top.
|
|
37
|
+
*/
|
|
38
|
+
const Group = ({ id, type, group, name, t, onReload }: Props): JSX.Element => {
|
|
39
|
+
const defaults = useMemo(() => toForm(group.prices), [ group.prices ]);
|
|
40
|
+
|
|
41
|
+
const { register, handleSubmit, setValue, getValues, reset, formState: { errors, isDirty, dirtyFields } } = useForm<GroupForm>({
|
|
42
|
+
defaultValues: defaults
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/*
|
|
46
|
+
* The copy below writes on the server and the page refetches, so the form
|
|
47
|
+
* has to take the new fares as its baseline - otherwise every copied row
|
|
48
|
+
* stays marked as changed, and Save would write the values back over
|
|
49
|
+
* themselves.
|
|
50
|
+
*/
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
reset(defaults);
|
|
53
|
+
}, [ defaults, reset ]);
|
|
54
|
+
|
|
55
|
+
const { mutate: Save, isPending } = usePostPrices(id, type, name);
|
|
56
|
+
const { mutate: CopyPrices, isPending: copying } = usePostCopy(id, type, name);
|
|
57
|
+
|
|
58
|
+
const city = group.from.city;
|
|
59
|
+
|
|
60
|
+
const onSubmit = (data: GroupForm): void => {
|
|
61
|
+
/*
|
|
62
|
+
* Nothing changed, nothing to write. The button is dimmed in that state
|
|
63
|
+
* but the shared Button has no real disabled prop, and pressing Enter in
|
|
64
|
+
* any input submits the form whatever the button looks like - so the
|
|
65
|
+
* guard belongs here rather than on the control.
|
|
66
|
+
*/
|
|
67
|
+
if (!isDirty) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Save({
|
|
72
|
+
location_from: group.from.id,
|
|
73
|
+
prices: toPrices(data, group.prices)
|
|
74
|
+
}, {
|
|
75
|
+
onSuccess: () => {
|
|
76
|
+
success(t('prices_manage.messages.saved_group', { city: city.name, ns: 'common' }));
|
|
77
|
+
|
|
78
|
+
// the values just written are the new baseline, so the group goes
|
|
79
|
+
// clean without a refetch
|
|
80
|
+
reset(data);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const onCopy = (): void => {
|
|
86
|
+
if (!window.confirm(t('prices_manage.group.copy_ask', { city: city.name, ns: 'common' }))) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
CopyPrices({ location_from: group.from.id }, {
|
|
91
|
+
onSuccess: () => {
|
|
92
|
+
success(t('prices_manage.messages.copied', { ns: 'common' }));
|
|
93
|
+
|
|
94
|
+
onReload();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fill one column down the group from its first destination.
|
|
101
|
+
*
|
|
102
|
+
* Marked dirty rather than written: this fills the boxes and leaves them
|
|
103
|
+
* unsaved, so getting it wrong costs nothing but not pressing Save.
|
|
104
|
+
*/
|
|
105
|
+
const onFill = (column: FareColumn): void => {
|
|
106
|
+
const first = getValues(field(group.prices[0].id, column));
|
|
107
|
+
|
|
108
|
+
for (const price of group.prices.slice(1)) {
|
|
109
|
+
setValue(field(price.id, column), first, { shouldDirty: true });
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const alone = group.prices.length < 2;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Container className="box">
|
|
117
|
+
<form onSubmit={ handleSubmit(onSubmit) }>
|
|
118
|
+
<Header>
|
|
119
|
+
<Origin>
|
|
120
|
+
{ t('prices_manage.group.from', { ns: 'common' }) } { city.name }
|
|
121
|
+
<Country>{ city.country?.name }</Country>
|
|
122
|
+
</Origin>
|
|
123
|
+
|
|
124
|
+
<Tools>
|
|
125
|
+
{ isDirty && (
|
|
126
|
+
<Dirty>{ t('prices_manage.group.unsaved', { ns: 'common' }) }</Dirty>
|
|
127
|
+
) }
|
|
128
|
+
|
|
129
|
+
{ !alone && (
|
|
130
|
+
<Copy
|
|
131
|
+
type="button"
|
|
132
|
+
disabled={ copying }
|
|
133
|
+
onClick={ onCopy }
|
|
134
|
+
title={ t('prices_manage.group.copy_hint', { ns: 'common' }) }
|
|
135
|
+
>
|
|
136
|
+
<FaRegCopy />
|
|
137
|
+
{ t('prices_manage.group.copy', { ns: 'common' }) }
|
|
138
|
+
</Copy>
|
|
139
|
+
) }
|
|
140
|
+
|
|
141
|
+
<Button
|
|
142
|
+
type="submit"
|
|
143
|
+
active={ isDirty }
|
|
144
|
+
loading={ isPending }
|
|
145
|
+
text={
|
|
146
|
+
<>
|
|
147
|
+
<BiEditAlt />
|
|
148
|
+
{ t('table.actions.update', { ns: 'common' }) }
|
|
149
|
+
</>
|
|
150
|
+
}
|
|
151
|
+
/>
|
|
152
|
+
</Tools>
|
|
153
|
+
</Header>
|
|
154
|
+
|
|
155
|
+
<Columns>
|
|
156
|
+
<div>{ t('prices_manage.group.to', { ns: 'common' }) }</div>
|
|
157
|
+
|
|
158
|
+
{ COLUMNS.map(column => (
|
|
159
|
+
<Column key={ column }>
|
|
160
|
+
{ t(`prices_manage.columns.${ column }`, { ns: 'common' }) }
|
|
161
|
+
|
|
162
|
+
{ !alone && (
|
|
163
|
+
<Fill
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={ () => onFill(column) }
|
|
166
|
+
title={ t('prices_manage.group.fill', { ns: 'common' }) }
|
|
167
|
+
>
|
|
168
|
+
<IoArrowDown />
|
|
169
|
+
</Fill>
|
|
170
|
+
) }
|
|
171
|
+
</Column>
|
|
172
|
+
)) }
|
|
173
|
+
</Columns>
|
|
174
|
+
|
|
175
|
+
{ group.prices.map(price => {
|
|
176
|
+
const changed = COLUMNS.some(column => dirtyFields[field(price.id, column)]);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<Row key={ price.id } $changed={ changed }>
|
|
180
|
+
<Destination>
|
|
181
|
+
{ changed && <Changed /> }
|
|
182
|
+
|
|
183
|
+
{ price.to.city.name }
|
|
184
|
+
<Country>{ price.to.city.country?.name }</Country>
|
|
185
|
+
</Destination>
|
|
186
|
+
|
|
187
|
+
{ COLUMNS.map(column => (
|
|
188
|
+
<div key={ column }>
|
|
189
|
+
<input
|
|
190
|
+
type="number"
|
|
191
|
+
step="0.01"
|
|
192
|
+
title={ t(`prices_manage.prices.${ column }`, { ns: 'common' }) }
|
|
193
|
+
{ ...register(field(price.id, column), Validate('required', t)) }
|
|
194
|
+
/>
|
|
195
|
+
|
|
196
|
+
{ Display(errors[field(price.id, column)]) }
|
|
197
|
+
</div>
|
|
198
|
+
)) }
|
|
199
|
+
</Row>
|
|
200
|
+
);
|
|
201
|
+
}) }
|
|
202
|
+
</form>
|
|
203
|
+
</Container>
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export default Group;
|
package/Prices/Browse/styles.ts
CHANGED
|
@@ -1,22 +1,250 @@
|
|
|
1
|
-
import styled from 'styled-components';
|
|
1
|
+
import styled, { css } from 'styled-components';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* The route price screen, grouped by where the leg LEAVES FROM.
|
|
5
|
+
*
|
|
6
|
+
* Claude - 2026-08-30 (ruled by Ferjolt: save per origin, copy per origin).
|
|
7
|
+
*
|
|
8
|
+
* What this replaces: one card per city pair, each with its own six labelled
|
|
9
|
+
* inputs and its own Save button. A real route runs to about 150 pairs, so
|
|
10
|
+
* that was 150 cards, 900 inputs, 900 repeated labels and 150 separate saves
|
|
11
|
+
* - a screen you scrolled rather than read.
|
|
12
|
+
*
|
|
13
|
+
* The shape here is a table per origin: the six labels are said ONCE at the
|
|
14
|
+
* top of the group, and every destination below is a single line of numbers.
|
|
15
|
+
* That is roughly half the height, and it lets a column be read as a column -
|
|
16
|
+
* which is how an operator checks their own fares ("is anything out of
|
|
17
|
+
* Shkoder wrong?"), and what makes the fill-down control legible.
|
|
18
|
+
*/
|
|
3
19
|
export const Items = styled.div`
|
|
4
20
|
display: flex;
|
|
5
21
|
flex-direction: column;
|
|
6
22
|
gap: 15px;
|
|
7
23
|
`;
|
|
8
24
|
|
|
9
|
-
export const
|
|
25
|
+
export const Group = styled.div`
|
|
10
26
|
display: flex;
|
|
11
27
|
flex-direction: column;
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The origin, its copy action and its Save button.
|
|
32
|
+
*
|
|
33
|
+
* Sticky because a group runs to a dozen destinations: the button that
|
|
34
|
+
* commits what you are typing should not be somewhere above the fold while
|
|
35
|
+
* you type it.
|
|
36
|
+
*/
|
|
37
|
+
export const Header = styled.div`
|
|
38
|
+
position: sticky;
|
|
39
|
+
top: 0;
|
|
40
|
+
z-index: 2;
|
|
41
|
+
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-wrap: wrap;
|
|
44
|
+
align-items: center;
|
|
45
|
+
justify-content: space-between;
|
|
46
|
+
gap: 10px;
|
|
47
|
+
|
|
48
|
+
padding: 12px 14px;
|
|
49
|
+
|
|
50
|
+
background: ${ props => props.theme.background.neutral };
|
|
51
|
+
border-bottom: 1px solid ${ props => props.theme.inputs.border };
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
export const Origin = styled.h4`
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 8px;
|
|
58
|
+
margin: 0;
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
export const Country = styled.span`
|
|
62
|
+
font-weight: normal;
|
|
63
|
+
color: ${ props => props.theme.font.faded };
|
|
64
|
+
`;
|
|
12
65
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
66
|
+
export const Tools = styled.div`
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-wrap: wrap;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 10px;
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* "This group has unsaved edits", said once where the Save button is.
|
|
75
|
+
*/
|
|
76
|
+
export const Dirty = styled.span`
|
|
77
|
+
font-size: ${ props => props.theme.size.s };
|
|
78
|
+
color: ${ props => props.theme.font.info };
|
|
79
|
+
`;
|
|
80
|
+
|
|
81
|
+
export const Copy = styled.button`
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
gap: 5px;
|
|
85
|
+
|
|
86
|
+
padding: 0;
|
|
87
|
+
|
|
88
|
+
font-size: ${ props => props.theme.size.s };
|
|
89
|
+
color: ${ props => props.theme.font.faded };
|
|
90
|
+
|
|
91
|
+
background: none;
|
|
92
|
+
border: 0;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
|
|
95
|
+
&:hover {
|
|
96
|
+
color: ${ props => props.theme.font.normal };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
&:disabled {
|
|
100
|
+
opacity: 0.5;
|
|
101
|
+
cursor: default;
|
|
16
102
|
}
|
|
17
103
|
`;
|
|
18
104
|
|
|
19
|
-
|
|
105
|
+
/**
|
|
106
|
+
* One line per destination, and one line of labels above them all.
|
|
107
|
+
*
|
|
108
|
+
* The seventh column is the destination itself; the two groups of three are
|
|
109
|
+
* the one-way and return fares, separated by a rule rather than by repeating
|
|
110
|
+
* the word "return" on every input.
|
|
111
|
+
*/
|
|
112
|
+
const grid = css`
|
|
113
|
+
display: grid;
|
|
114
|
+
grid-template-columns: minmax(150px, 1.6fr) repeat(6, minmax(64px, 1fr));
|
|
115
|
+
align-items: center;
|
|
116
|
+
gap: 8px;
|
|
117
|
+
|
|
118
|
+
padding: 8px 14px;
|
|
119
|
+
|
|
120
|
+
/* the first return column opens the second half of the row */
|
|
121
|
+
> *:nth-child(5) {
|
|
122
|
+
border-left: 1px solid ${ props => props.theme.inputs.border };
|
|
123
|
+
padding-left: 8px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@media (max-width: 900px) {
|
|
127
|
+
grid-template-columns: repeat(3, minmax(64px, 1fr));
|
|
128
|
+
|
|
129
|
+
> *:nth-child(5) {
|
|
130
|
+
border-left: 0;
|
|
131
|
+
padding-left: 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
export const Columns = styled.div`
|
|
137
|
+
${ grid };
|
|
138
|
+
|
|
139
|
+
position: sticky;
|
|
140
|
+
top: 47px;
|
|
141
|
+
z-index: 1;
|
|
142
|
+
|
|
143
|
+
font-size: ${ props => props.theme.size.xs };
|
|
144
|
+
color: ${ props => props.theme.font.faded };
|
|
145
|
+
|
|
146
|
+
background: ${ props => props.theme.background.box };
|
|
147
|
+
border-bottom: 1px solid ${ props => props.theme.inputs.border };
|
|
148
|
+
|
|
149
|
+
/*
|
|
150
|
+
* The "To" label takes a line of its own once the six fares fold into two
|
|
151
|
+
* rows of three - the same line the destination name takes on a row below.
|
|
152
|
+
* Without it the labels sit one cell to the right of the inputs they name,
|
|
153
|
+
* which on a price screen is worse than having no labels at all.
|
|
154
|
+
*/
|
|
155
|
+
@media (max-width: 900px) {
|
|
156
|
+
> *:first-child {
|
|
157
|
+
grid-column: 1 / -1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
export const Column = styled.div`
|
|
163
|
+
display: flex;
|
|
164
|
+
align-items: center;
|
|
165
|
+
justify-content: space-between;
|
|
166
|
+
gap: 4px;
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Fills a column down every destination in the group, from the first row.
|
|
171
|
+
*
|
|
172
|
+
* Client-side on purpose: it fills the boxes and leaves them unsaved, so a
|
|
173
|
+
* mistake is undone by not pressing Save. The server-side copy in the group
|
|
174
|
+
* header writes immediately, which is the right tool for "these really are
|
|
175
|
+
* all the same" and the wrong one for "let me try".
|
|
176
|
+
*/
|
|
177
|
+
export const Fill = styled.button`
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
|
|
181
|
+
padding: 0;
|
|
182
|
+
|
|
183
|
+
color: ${ props => props.theme.font.faded };
|
|
184
|
+
|
|
185
|
+
background: none;
|
|
186
|
+
border: 0;
|
|
187
|
+
cursor: pointer;
|
|
188
|
+
|
|
189
|
+
&:hover {
|
|
190
|
+
color: ${ props => props.theme.primary.normal };
|
|
191
|
+
}
|
|
192
|
+
`;
|
|
193
|
+
|
|
194
|
+
export const Row = styled.div<{ $changed: boolean }>`
|
|
195
|
+
${ grid };
|
|
196
|
+
|
|
197
|
+
border-bottom: 1px solid ${ props => props.theme.inputs.border };
|
|
198
|
+
|
|
199
|
+
&:last-child {
|
|
200
|
+
border-bottom: 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
${ props => props.$changed && css`
|
|
204
|
+
background: ${ props.theme.background.neutral };
|
|
205
|
+
` };
|
|
206
|
+
|
|
207
|
+
input {
|
|
208
|
+
text-align: right;
|
|
209
|
+
}
|
|
210
|
+
`;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The destination cell. On a narrow screen the six fare columns fold into
|
|
214
|
+
* two rows of three, so this takes a line of its own above them.
|
|
215
|
+
*/
|
|
216
|
+
export const Destination = styled.div`
|
|
20
217
|
display: flex;
|
|
21
|
-
|
|
22
|
-
|
|
218
|
+
align-items: center;
|
|
219
|
+
gap: 6px;
|
|
220
|
+
|
|
221
|
+
@media (max-width: 900px) {
|
|
222
|
+
grid-column: 1 / -1;
|
|
223
|
+
}
|
|
224
|
+
`;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* A dot on the rows this group is about to write, so pressing Save is not a
|
|
228
|
+
* guess about what you changed ten destinations ago.
|
|
229
|
+
*/
|
|
230
|
+
export const Changed = styled.span`
|
|
231
|
+
flex: 0 0 auto;
|
|
232
|
+
|
|
233
|
+
width: 7px;
|
|
234
|
+
height: 7px;
|
|
235
|
+
|
|
236
|
+
border-radius: 50%;
|
|
237
|
+
background: ${ props => props.theme.font.info };
|
|
238
|
+
`;
|
|
239
|
+
|
|
240
|
+
export const Small = styled.span`
|
|
241
|
+
font-size: ${ props => props.theme.size.xs };
|
|
242
|
+
color: ${ props => props.theme.font.faded };
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
export const Empty = styled.p`
|
|
246
|
+
margin: 0;
|
|
247
|
+
padding: 20px;
|
|
248
|
+
text-align: center;
|
|
249
|
+
color: ${ props => props.theme.font.faded };
|
|
250
|
+
`;
|
package/Prices/Manage.tsx
CHANGED
|
@@ -60,7 +60,7 @@ const Manage = ({ type, url, t }: Props): JSX.Element => {
|
|
|
60
60
|
/>
|
|
61
61
|
) }
|
|
62
62
|
|
|
63
|
-
<Browse id={ id } type={ type } data={ prices } name={ name } t={ t } />
|
|
63
|
+
<Browse id={ id } type={ type } data={ prices } name={ name } t={ t } onReload={ onReload } />
|
|
64
64
|
</div>
|
|
65
65
|
</Meta>
|
|
66
66
|
);
|
package/Prices/services.ts
CHANGED
|
@@ -41,12 +41,51 @@ export const usePostPrice = (id: number, price_id: number, type: ('normal' | 'al
|
|
|
41
41
|
})
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Save every fare leaving one origin, in one request.
|
|
46
|
+
*
|
|
47
|
+
* Claude - 2026-08-30. `usePostPrice` above is untouched and still posts a
|
|
48
|
+
* single row - a tab left open on the previous screen keeps working, and the
|
|
49
|
+
* two endpoints validate identically.
|
|
50
|
+
*
|
|
51
|
+
* A whole route saved a row at a time would be about 150 requests, and
|
|
52
|
+
* `api/prices/` sits in obtapi's 120/min general tier: the save would 429
|
|
53
|
+
* half way through and leave the operator no way to tell which half had been
|
|
54
|
+
* written. One request per group is also one TRANSACTION per group, so a
|
|
55
|
+
* group is saved or it is not.
|
|
56
|
+
*/
|
|
57
|
+
export const usePostPrices = (id: number, type: ('normal' | 'alternate'), name: string): UseMutationResult<void, Error, { location_from: number, prices: unknown[] }, unknown> => (
|
|
58
|
+
useMutation({
|
|
59
|
+
mutationKey: ['operator-prices-save-many', { id, type, name }],
|
|
60
|
+
mutationFn: async (data: { location_from: number, prices: unknown[] }) => (
|
|
61
|
+
await apiClient
|
|
62
|
+
.post('/api/prices/operator/update-many', {
|
|
63
|
+
...data,
|
|
64
|
+
id,
|
|
65
|
+
type,
|
|
66
|
+
name
|
|
67
|
+
})
|
|
68
|
+
.then(response => (
|
|
69
|
+
response.data
|
|
70
|
+
))
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Copy the first fare over the rest.
|
|
77
|
+
*
|
|
78
|
+
* `location_from` narrows that to one origin group, which is the action in
|
|
79
|
+
* each group header; omitted, this is the route-wide copy that has always
|
|
80
|
+
* lived in the Options menu.
|
|
81
|
+
*/
|
|
82
|
+
export const usePostCopy = (id: number, type: ('normal' | 'alternate'), name: string): UseMutationResult<void, Error, { location_from?: number } | undefined, unknown> => (
|
|
45
83
|
useMutation({
|
|
46
84
|
mutationKey: ['operator-prices-copy', { id, type, name }],
|
|
47
|
-
mutationFn: async () => (
|
|
85
|
+
mutationFn: async (data?: { location_from?: number }) => (
|
|
48
86
|
await apiClient
|
|
49
87
|
.post('/api/prices/operator/copy', {
|
|
88
|
+
...data,
|
|
50
89
|
id,
|
|
51
90
|
type,
|
|
52
91
|
name
|
package/Prices/types.ts
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
import { LocationData } from '@autobusal/providers/types/locations';
|
|
2
|
+
import { PriceData } from '@autobusal/providers/types/routes';
|
|
3
|
+
|
|
4
|
+
/** the six fare columns a price row carries */
|
|
5
|
+
export type FareColumn = 'adult' | 'child' | 'baby' | 'adult_return' | 'child_return' | 'baby_return';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Everything the route sells LEAVING one stop.
|
|
9
|
+
*
|
|
10
|
+
* Claude - 2026-08-30. The unit the screen saves and copies in - see
|
|
11
|
+
* utilities.byOrigin for why it is keyed on the location and not the city.
|
|
12
|
+
*/
|
|
13
|
+
export interface OriginGroup {
|
|
14
|
+
from: LocationData
|
|
15
|
+
prices: PriceData[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One origin group's form: flat `p{price_id}_{column}` keys holding strings.
|
|
20
|
+
* See utilities.field for why the names have no dots in them.
|
|
21
|
+
*/
|
|
22
|
+
export type GroupForm = Record<string, string>;
|
|
23
|
+
|
|
1
24
|
export interface PriceForm {
|
|
2
25
|
adult: number
|
|
3
26
|
child: number
|