@autobusal/operator-routes 1.10.1 → 1.10.3
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/Propose/styles.ts +10 -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/styles.ts +2 -0
- package/package.json +1 -1
- package/Prices/Browse/Item.tsx +0 -106
|
@@ -99,7 +99,16 @@ export const Chosen = styled.div`
|
|
|
99
99
|
export const Similar = styled.div`
|
|
100
100
|
margin-top: 15px;
|
|
101
101
|
padding: 12px 14px;
|
|
102
|
-
|
|
102
|
+
/*
|
|
103
|
+
* Claude - 2026-08-30: the hardcoded '#e0a800' fallback that stood here was
|
|
104
|
+
* a workaround for a token nothing defined - theme.font.warning was styled
|
|
105
|
+
* with in four places and present in no palette, and a missing token
|
|
106
|
+
* silently drops the whole declaration rather than failing. It exists now
|
|
107
|
+
* (obtapi's default-settings.json, floored onto every label by
|
|
108
|
+
* Labels\ThemeColors), so this shows the brand's own amber instead of one
|
|
109
|
+
* hardcoded here.
|
|
110
|
+
*/
|
|
111
|
+
border: 1px solid ${ props => props.theme.font.warning };
|
|
103
112
|
border-radius: 4px;
|
|
104
113
|
|
|
105
114
|
p {
|
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.warning };
|
|
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.warning };
|
|
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
|
package/Prices/utilities.ts
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
import { TFunction } from 'i18next';
|
|
2
2
|
import { BreadcrumbData } from '@autobusal/providers/types/pages';
|
|
3
|
+
import { PriceData } from '@autobusal/providers/types/routes';
|
|
4
|
+
import { FareColumn, GroupForm, OriginGroup } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The six fare columns, in the order the screen reads them.
|
|
8
|
+
*/
|
|
9
|
+
export const COLUMNS: FareColumn[] = [
|
|
10
|
+
'adult', 'child', 'baby', 'adult_return', 'child_return', 'baby_return'
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The route's prices, grouped by where the leg leaves from.
|
|
15
|
+
*
|
|
16
|
+
* Claude - 2026-08-30. Keyed on the LOCATION, not the city: a route may call
|
|
17
|
+
* at the same city twice, and those are two different origins with two
|
|
18
|
+
* different sets of fares. The API returns the rows already ordered by
|
|
19
|
+
* `order_id`, which is travel order, so grouping on first appearance leaves
|
|
20
|
+
* the origins in the order the coach visits them - no sorting needed here,
|
|
21
|
+
* and none that could disagree with the timetable.
|
|
22
|
+
*/
|
|
23
|
+
export const byOrigin = (prices: PriceData[]): OriginGroup[] => {
|
|
24
|
+
const groups: OriginGroup[] = [];
|
|
25
|
+
|
|
26
|
+
for (const price of prices) {
|
|
27
|
+
const existing = groups.find(group => group.from.id === price.from.id);
|
|
28
|
+
|
|
29
|
+
if (existing) {
|
|
30
|
+
existing.prices.push(price);
|
|
31
|
+
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
groups.push({ from: price.from, prices: [ price ] });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return groups;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The form field name for one fare.
|
|
43
|
+
*
|
|
44
|
+
* FLAT, WITH NO DOTS. react-hook-form parses a dotted name as a path, and a
|
|
45
|
+
* segment that is a plain integer becomes an ARRAY INDEX - so `327.adult`
|
|
46
|
+
* would build a 328-long sparse array rather than an entry keyed 327. The
|
|
47
|
+
* ids here are database ids, so that is not hypothetical.
|
|
48
|
+
*/
|
|
49
|
+
export const field = (id: number, column: FareColumn): string => `p${ id }_${ column }`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What the group currently holds, as the API's own shape.
|
|
53
|
+
*/
|
|
54
|
+
export const toPrices = (form: GroupForm, prices: PriceData[]) => (
|
|
55
|
+
prices.map(price => ({
|
|
56
|
+
price_id: price.id,
|
|
57
|
+
|
|
58
|
+
...Object.fromEntries(COLUMNS.map(column => [
|
|
59
|
+
column, form[field(price.id, column)]
|
|
60
|
+
]))
|
|
61
|
+
}))
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The group's fares as the form should start out holding them.
|
|
66
|
+
*
|
|
67
|
+
* Strings, because the inputs hold strings: comparing a typed "45" against a
|
|
68
|
+
* numeric 45 would leave a row marked as changed after the operator had
|
|
69
|
+
* typed the value that was already there.
|
|
70
|
+
*/
|
|
71
|
+
export const toForm = (prices: PriceData[]): GroupForm => (
|
|
72
|
+
Object.fromEntries(prices.flatMap(price => COLUMNS.map(column => [
|
|
73
|
+
field(price.id, column), String(price[column] ?? 0)
|
|
74
|
+
])))
|
|
75
|
+
);
|
|
3
76
|
|
|
4
77
|
export const getBreadcrumbs = (type: string, baseUrl: string, backUrl: string, t: TFunction<'common'>, name?: string): BreadcrumbData[] => {
|
|
5
78
|
const final: BreadcrumbData[] = [];
|
package/Pricing/styles.ts
CHANGED
|
@@ -85,6 +85,8 @@ export const Text = styled.span`
|
|
|
85
85
|
export const Percent = styled.strong<{ $up: boolean }>`
|
|
86
86
|
flex: 0 0 auto;
|
|
87
87
|
font-size: ${ props => props.theme.size.m };
|
|
88
|
+
/* amber against the green of a cut - the pairing this always wanted, and
|
|
89
|
+
could not have while font.warning was a token no palette defined */
|
|
88
90
|
color: ${ props => (props.$up ? props.theme.font.warning : props.theme.font.success) };
|
|
89
91
|
`;
|
|
90
92
|
|
package/package.json
CHANGED
package/Prices/Browse/Item.tsx
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { TFunction } from 'i18next';
|
|
2
|
-
import { useForm } from 'react-hook-form';
|
|
3
|
-
import { BiEditAlt } from 'react-icons/bi';
|
|
4
|
-
import { Button } from '@autobusal/common';
|
|
5
|
-
import { Validate, Display, success } from '@autobusal/utilities';
|
|
6
|
-
import { Prices, Actions } from './styles';
|
|
7
|
-
import { PriceData } from '@autobusal/providers/types/routes';
|
|
8
|
-
import { PriceForm } from '../types';
|
|
9
|
-
import { usePostPrice } from '../services';
|
|
10
|
-
|
|
11
|
-
interface Props {
|
|
12
|
-
id: number
|
|
13
|
-
type: 'normal' | 'alternate'
|
|
14
|
-
item: PriceData
|
|
15
|
-
name: string
|
|
16
|
-
t: TFunction<'normal'>
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const Item = ({ id, type, item, name, t }: Props): JSX.Element => {
|
|
20
|
-
const { register, handleSubmit, formState: { errors } } = useForm<PriceForm>();
|
|
21
|
-
|
|
22
|
-
const { mutate: Save, isPending } = usePostPrice(id, item.id, type, name);
|
|
23
|
-
|
|
24
|
-
const onSubmit = (data: PriceForm): void => {
|
|
25
|
-
Save(data, {
|
|
26
|
-
onSuccess: () => success(t('prices_manage.messages.saved', { ns: 'common' }))
|
|
27
|
-
});
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
<div className="box">
|
|
32
|
-
<h4>
|
|
33
|
-
{ item.from.city.name } ({ item.from.city.country?.name }) › { item.to.city.name } ({ item.to.city.country?.name })
|
|
34
|
-
</h4>
|
|
35
|
-
|
|
36
|
-
<form onSubmit={ handleSubmit(onSubmit) }>
|
|
37
|
-
<Prices>
|
|
38
|
-
<div className="row">
|
|
39
|
-
{ t('prices_manage.prices.adult', { ns: 'common' }) }
|
|
40
|
-
|
|
41
|
-
<input type="number" defaultValue={ item.adult } { ...register('adult', Validate('required', t)) } />
|
|
42
|
-
|
|
43
|
-
{ Display(errors.adult) }
|
|
44
|
-
</div>
|
|
45
|
-
|
|
46
|
-
<div className="row">
|
|
47
|
-
{ t('prices_manage.prices.child', { ns: 'common' }) }
|
|
48
|
-
|
|
49
|
-
<input type="number" defaultValue={ item.child } { ...register('child', Validate('required', t)) } />
|
|
50
|
-
|
|
51
|
-
{ Display(errors.child) }
|
|
52
|
-
</div>
|
|
53
|
-
|
|
54
|
-
<div className="row">
|
|
55
|
-
{ t('prices_manage.prices.baby', { ns: 'common' }) }
|
|
56
|
-
|
|
57
|
-
<input type="number" defaultValue={ item.baby } { ...register('baby', Validate('required', t)) } />
|
|
58
|
-
|
|
59
|
-
{ Display(errors.baby) }
|
|
60
|
-
</div>
|
|
61
|
-
</Prices>
|
|
62
|
-
|
|
63
|
-
<Prices>
|
|
64
|
-
<div className="row">
|
|
65
|
-
{ t('prices_manage.prices.adult_return', { ns: 'common' }) }
|
|
66
|
-
|
|
67
|
-
<input type="number" defaultValue={ item.adult_return } { ...register('adult_return', Validate('required', t)) } />
|
|
68
|
-
|
|
69
|
-
{ Display(errors.adult_return) }
|
|
70
|
-
</div>
|
|
71
|
-
|
|
72
|
-
<div className="row">
|
|
73
|
-
{ t('prices_manage.prices.child_return', { ns: 'common' }) }
|
|
74
|
-
|
|
75
|
-
<input type="number" defaultValue={ item.child_return } { ...register('child_return', Validate('required', t)) } />
|
|
76
|
-
|
|
77
|
-
{ Display(errors.child_return) }
|
|
78
|
-
</div>
|
|
79
|
-
|
|
80
|
-
<div className="row">
|
|
81
|
-
{ t('prices_manage.prices.baby_return', { ns: 'common' }) }
|
|
82
|
-
|
|
83
|
-
<input type="number" defaultValue={ item.baby_return } { ...register('baby_return', Validate('required', t)) } />
|
|
84
|
-
|
|
85
|
-
{ Display(errors.baby_return) }
|
|
86
|
-
</div>
|
|
87
|
-
</Prices>
|
|
88
|
-
|
|
89
|
-
<Actions>
|
|
90
|
-
<Button
|
|
91
|
-
type="submit"
|
|
92
|
-
loading={ isPending }
|
|
93
|
-
text={
|
|
94
|
-
<>
|
|
95
|
-
<BiEditAlt />
|
|
96
|
-
{ t('table.actions.update', { ns: 'common' }) }
|
|
97
|
-
</>
|
|
98
|
-
}
|
|
99
|
-
/>
|
|
100
|
-
</Actions>
|
|
101
|
-
</form>
|
|
102
|
-
</div>
|
|
103
|
-
);
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
export default Item;
|