@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.
@@ -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
- border: 1px solid ${ props => props.theme.font.warning ?? '#e0a800' };
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 {
@@ -1,7 +1,9 @@
1
+ import { useMemo } from 'react';
1
2
  import { TFunction } from 'i18next';
2
- import Item from './Item';
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
- const Browse = ({ id, type, data, name, t }: Props): JSX.Element | JSX.Element[] | undefined => {
15
- const items = data?.map(item => (
16
- <Item
17
- key={ item.id }
18
- id={ id }
19
- type={ type }
20
- item={ item }
21
- name={ name }
22
- t={ t }
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
- { items }
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;
@@ -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 Prices = styled.div`
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
- @media (min-width: 540px) {
14
- flex-direction: row;
15
- gap: 15px;
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
- export const Actions = styled.div`
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
- justify-content: center;
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
  );
@@ -41,12 +41,51 @@ export const usePostPrice = (id: number, price_id: number, type: ('normal' | 'al
41
41
  })
42
42
  );
43
43
 
44
- export const usePostCopy = (id: number, type: ('normal' | 'alternate'), name: string): UseMutationResult<void> => (
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/operator-routes",
3
- "version": "1.10.1",
3
+ "version": "1.10.3",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
@@ -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 }) &rsaquo; { 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;