@lotics/ui 47.13.0 → 47.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,11 @@
1
1
  import { View, StyleSheet, LayoutChangeEvent } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
- import { useMemo, useState, useCallback } from "react";
5
- import Svg, { Circle, Defs, Line, LinearGradient, Polygon, Polyline, Stop } from "react-native-svg";
4
+ import { Fragment, useMemo, useState, useCallback, useId } from "react";
5
+ import Svg, { Circle, Defs, G, Line, LinearGradient, Polygon, Polyline, Stop } from "react-native-svg";
6
+ import { LegendItem } from "./legend_item";
6
7
  import { useLoticsLocale } from "./locale";
8
+ import { SPACE } from "./spacing";
7
9
  import { axisLabelIndices } from "./axis_label_indices";
8
10
 
9
11
  export interface LineChartPoint {
@@ -11,35 +13,109 @@ export interface LineChartPoint {
11
13
  y: number;
12
14
  }
13
15
 
14
- export interface LineChartProps {
16
+ export interface LineChartSeries {
17
+ key: string;
18
+ /** The series' name — its legend entry and the line's accessible name. */
19
+ label: string;
20
+ /** Omit to take the next colour off the chart's own order. */
21
+ color?: string;
15
22
  points: LineChartPoint[];
23
+ }
24
+
25
+ interface LineChartBaseProps {
16
26
  height?: number;
17
27
  formatNumber?: (n: number) => string;
18
28
  formatXLabel?: (x: string | number) => string;
19
29
  emptyLabel?: string;
20
- lineColor?: string;
30
+ /**
31
+ * Render the series legend above the plot. Defaults to ON once there is more
32
+ * than one series — several lines with no key is a picture nobody can read —
33
+ * and OFF for a single one, which the card around it already names.
34
+ */
35
+ legend?: boolean;
21
36
  }
22
37
 
38
+ /**
39
+ * ONE series or SEVERAL, never both: `points` is the single-line form (with its
40
+ * own `lineColor`), `series` the multi-line one (each entry carries its own
41
+ * colour). Expressed as a union so passing both, or neither, fails to compile
42
+ * rather than resolving to whichever the body happens to read first.
43
+ */
44
+ export type LineChartProps = LineChartBaseProps &
45
+ (
46
+ | { points: LineChartPoint[]; series?: undefined; lineColor?: string }
47
+ | { series: LineChartSeries[]; points?: undefined; lineColor?: undefined }
48
+ );
49
+
50
+ /**
51
+ * The order a series takes its colour in when it names none.
52
+ *
53
+ * Interleaved by hue for the reason `avatarTone`'s palette is: listed
54
+ * spectrally, four consecutive series come out four shades of one blue and the
55
+ * legend stops mapping anything. The first entry IS the single-line default, so
56
+ * one series drawn through `series` looks exactly like one drawn through
57
+ * `points`.
58
+ *
59
+ * Held here rather than shared with `PieChart`'s fallback because the two are
60
+ * different marks: a slice is a FILL and reads at 300, a line is a 2.5px STROKE
61
+ * and disappears at that shade.
62
+ */
63
+ const SERIES_COLORS = [
64
+ colors.blue[500],
65
+ colors.orange[500],
66
+ colors.violet[500],
67
+ colors.emerald[600],
68
+ colors.fuchsia[600],
69
+ colors.teal[600],
70
+ colors.indigo[500],
71
+ colors.pink[600],
72
+ ];
73
+
23
74
  const defaultFormatNumber = (n: number): string =>
24
75
  new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n);
25
76
 
26
77
  const defaultFormatXLabel = (x: string | number): string => String(x);
27
78
 
28
79
  /**
29
- * The canonical SVG line chart over `points: { x, y }[]` `formatNumber`/`formatXLabel` for the
30
- * axes, `lineColor`, `height`. (No recharts.) For a tiny inline trend use `Sparkline`.
80
+ * The canonical SVG line chart `points: { x, y }[]` for one line, `series` for
81
+ * several on ONE SHARED y-scale (this period against last, a measure per site).
82
+ * `formatNumber`/`formatXLabel` for the axes, `height` for the plot. (No
83
+ * recharts.) For a tiny inline trend use `Sparkline`.
31
84
  */
32
85
  export function LineChart(props: LineChartProps) {
33
86
  const locale = useLoticsLocale();
34
87
  const {
35
- points: data,
88
+ points,
89
+ series: seriesProp,
90
+ lineColor,
91
+ legend,
36
92
  height: chartHeight = 160,
37
93
  formatNumber = defaultFormatNumber,
38
94
  formatXLabel = defaultFormatXLabel,
39
95
  emptyLabel = locale.chart.noData,
40
- lineColor = colors.blue[500],
41
96
  } = props;
42
97
 
98
+ // A gradient id is document-global: a fixed one means the second chart on the
99
+ // page paints with the FIRST one's wash, which is invisible until two charts
100
+ // carry different colours and then reads as a rendering bug.
101
+ const areaId = `lineChartArea-${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
102
+
103
+ // One code path from here down: the single-line form is a one-entry series
104
+ // list with no label (its name is the card's, not the line's).
105
+ const series = useMemo<LineChartSeries[]>(
106
+ () =>
107
+ seriesProp ??
108
+ [{ key: "value", label: "", color: lineColor, points: points ?? [] }],
109
+ [seriesProp, points, lineColor],
110
+ );
111
+
112
+ const colored = useMemo(
113
+ () => series.map((s, i) => ({ ...s, color: s.color ?? SERIES_COLORS[i % SERIES_COLORS.length] })),
114
+ [series],
115
+ );
116
+
117
+ const showLegend = legend ?? colored.length > 1;
118
+
43
119
  const [chartWidth, setChartWidth] = useState(0);
44
120
 
45
121
  const handleLayout = useCallback((event: LayoutChangeEvent) => {
@@ -51,11 +127,19 @@ export function LineChart(props: LineChartProps) {
51
127
  const paddingLeft = 6;
52
128
  const paddingRight = 10;
53
129
 
130
+ /** The categorical axis is as long as the LONGEST series; a shorter one ends early. */
131
+ const positionCount = useMemo(
132
+ () => colored.reduce((n, s) => Math.max(n, s.points.length), 0),
133
+ [colored],
134
+ );
135
+
54
136
  // Nice domain AROUND the data — a 16–18% series must not be drawn against
55
137
  // a 0–20 axis (it flattens into a sliver at the top). The baseline snaps
56
- // to 0 only when the data actually lives near 0.
138
+ // to 0 only when the data actually lives near 0. Taken across EVERY series:
139
+ // one scale is what makes several lines comparable, and per-series scales
140
+ // would draw two different quantities as the same height.
57
141
  const yAxisTicks = useMemo(() => {
58
- const ys = data.map((d) => d.y);
142
+ const ys = colored.flatMap((s) => s.points.map((p) => p.y));
59
143
  const lo = Math.min(...ys, 0 === ys.length ? 0 : Infinity);
60
144
  const hi = Math.max(...ys, lo);
61
145
  const span = hi - lo || Math.abs(hi) || 1;
@@ -72,38 +156,42 @@ export function LineChart(props: LineChartProps) {
72
156
  const ticks: number[] = [];
73
157
  for (let t = min; t <= max + step / 1e6; t += step) ticks.push(Number(t.toFixed(10)));
74
158
  return { ticks, min, max };
75
- }, [data]);
159
+ }, [colored]);
76
160
 
77
161
  const { min: minValue, max: maxValue } = yAxisTicks;
78
162
 
79
- const drawnPoints = useMemo(() => {
80
- if (data.length === 0 || chartWidth === 0) return [];
163
+ const plotted = useMemo(() => {
164
+ if (positionCount === 0 || chartWidth === 0) return [];
81
165
 
82
166
  const availableWidth = chartWidth - paddingLeft - paddingRight;
83
167
  const availableHeight = chartHeight - paddingTop - paddingBottom;
84
168
  const range = maxValue - minValue || 1;
85
169
 
86
- return data.map((point, index) => {
87
- const x =
88
- paddingLeft +
89
- (data.length > 1 ? (index / (data.length - 1)) * availableWidth : availableWidth / 2);
90
- const y = paddingTop + availableHeight - ((point.y - minValue) / range) * availableHeight;
91
- return { x, y, raw: point };
92
- });
93
- }, [data, chartWidth, minValue, maxValue, chartHeight]);
94
-
95
- const polylinePoints = drawnPoints.map((p) => `${p.x},${p.y}`).join(" ");
170
+ return colored.map((s) => ({
171
+ ...s,
172
+ drawn: s.points.map((point, index) => ({
173
+ x:
174
+ paddingLeft +
175
+ (positionCount > 1
176
+ ? (index / (positionCount - 1)) * availableWidth
177
+ : availableWidth / 2),
178
+ y: paddingTop + availableHeight - ((point.y - minValue) / range) * availableHeight,
179
+ })),
180
+ }));
181
+ }, [colored, positionCount, chartWidth, minValue, maxValue, chartHeight]);
96
182
 
97
183
  const visibleLabels = useMemo(() => {
98
- if (data.length === 0 || chartWidth === 0) return [];
184
+ if (positionCount === 0 || chartWidth === 0) return [];
99
185
 
100
- return axisLabelIndices(data.length, chartWidth).map((index) => ({
101
- index,
102
- label: formatXLabel(data[index].x),
103
- }));
104
- }, [data, chartWidth, formatXLabel]);
186
+ return axisLabelIndices(positionCount, chartWidth).map((index) => {
187
+ // Whichever series reaches this position names it — the axis is shared,
188
+ // so the label is a fact about the position and not about one line.
189
+ const owner = colored.find((s) => s.points[index] !== undefined);
190
+ return { index, label: owner ? formatXLabel(owner.points[index].x) : "" };
191
+ });
192
+ }, [colored, positionCount, chartWidth, formatXLabel]);
105
193
 
106
- if (data.length === 0) {
194
+ if (positionCount === 0) {
107
195
  return (
108
196
  <View style={styles.chartContainer}>
109
197
  <Text color="muted">{emptyLabel}</Text>
@@ -111,9 +199,21 @@ export function LineChart(props: LineChartProps) {
111
199
  );
112
200
  }
113
201
 
202
+ // The area wash is a SINGLE-line device. Stacked translucent washes muddy
203
+ // into a colour that belongs to no series, and the line a reader is tracking
204
+ // then crosses a field of it — so several lines are drawn bare.
205
+ const single = colored.length === 1;
206
+
114
207
  return (
115
208
  <View style={styles.chartContainer}>
116
209
  <View style={styles.chartWrapper}>
210
+ {showLegend ? (
211
+ <View style={styles.legend}>
212
+ {colored.map((s) => (
213
+ <LegendItem key={s.key} color={s.color} label={s.label} />
214
+ ))}
215
+ </View>
216
+ ) : null}
117
217
  <View style={styles.chartRow}>
118
218
  <View style={styles.yAxis}>
119
219
  {yAxisTicks.ticks
@@ -128,13 +228,15 @@ export function LineChart(props: LineChartProps) {
128
228
  <View style={[styles.svgContainer, { height: chartHeight }]} onLayout={handleLayout}>
129
229
  {chartWidth > 0 && (
130
230
  <Svg width={chartWidth} height={chartHeight}>
131
- <Defs>
132
- {/* soft area wash under the line — depth without decoration */}
133
- <LinearGradient id="lineChartArea" x1="0" y1="0" x2="0" y2="1">
134
- <Stop offset="0" stopColor={lineColor} stopOpacity={0.16} />
135
- <Stop offset="1" stopColor={lineColor} stopOpacity={0.01} />
136
- </LinearGradient>
137
- </Defs>
231
+ {single && (
232
+ <Defs>
233
+ {/* soft area wash under the line — depth without decoration */}
234
+ <LinearGradient id={areaId} x1="0" y1="0" x2="0" y2="1">
235
+ <Stop offset="0" stopColor={plotted[0].color} stopOpacity={0.16} />
236
+ <Stop offset="1" stopColor={plotted[0].color} stopOpacity={0.01} />
237
+ </LinearGradient>
238
+ </Defs>
239
+ )}
138
240
  {yAxisTicks.ticks.map((tick, i) => {
139
241
  const availableHeight = chartHeight - paddingTop - paddingBottom;
140
242
  const range = maxValue - minValue || 1;
@@ -151,30 +253,54 @@ export function LineChart(props: LineChartProps) {
151
253
  />
152
254
  );
153
255
  })}
154
- {drawnPoints.length > 1 && (
155
- <Polygon
156
- points={`${polylinePoints} ${drawnPoints[drawnPoints.length - 1].x},${chartHeight - paddingBottom} ${drawnPoints[0].x},${chartHeight - paddingBottom}`}
157
- fill="url(#lineChartArea)"
158
- stroke="none"
159
- />
160
- )}
161
- {drawnPoints.length > 1 && (
162
- <Polyline
163
- points={polylinePoints}
164
- fill="none"
165
- stroke={lineColor}
166
- strokeWidth={2.5}
167
- strokeLinejoin="round"
168
- strokeLinecap="round"
169
- />
170
- )}
171
- {drawnPoints.map((point, index) => {
172
- const isLast = index === drawnPoints.length - 1;
173
- return isLast ? (
174
- // the latest value is the story — emphasize its point
175
- <Circle key={index} cx={point.x} cy={point.y} r={4.5} fill={lineColor} stroke={colors.white} strokeWidth={2} />
256
+ {plotted.map((s) => {
257
+ const polylinePoints = s.drawn.map((p) => `${p.x},${p.y}`).join(" ");
258
+ const line = (
259
+ <>
260
+ {single && s.drawn.length > 1 && (
261
+ <Polygon
262
+ points={`${polylinePoints} ${s.drawn[s.drawn.length - 1].x},${chartHeight - paddingBottom} ${s.drawn[0].x},${chartHeight - paddingBottom}`}
263
+ fill={`url(#${areaId})`}
264
+ stroke="none"
265
+ />
266
+ )}
267
+ {s.drawn.length > 1 && (
268
+ <Polyline
269
+ points={polylinePoints}
270
+ fill="none"
271
+ stroke={s.color}
272
+ strokeWidth={2.5}
273
+ strokeLinejoin="round"
274
+ strokeLinecap="round"
275
+ />
276
+ )}
277
+ {s.drawn.map((point, index) => {
278
+ const isLast = index === s.drawn.length - 1;
279
+ // the latest value is the story — emphasize its point
280
+ if (isLast) {
281
+ return (
282
+ <Circle key={index} cx={point.x} cy={point.y} r={4.5} fill={s.color} stroke={colors.white} strokeWidth={2} />
283
+ );
284
+ }
285
+ // Intermediate markers multiply by the series count: four
286
+ // lines over twelve months is 48 discs, and the plot reads
287
+ // as a field of dots rather than as lines. Several series
288
+ // keep only the anchor the legend colour maps to.
289
+ return single ? (
290
+ <Circle key={index} cx={point.x} cy={point.y} r={3} fill={colors.white} stroke={s.color} strokeWidth={1.5} />
291
+ ) : null;
292
+ })}
293
+ </>
294
+ );
295
+ // A named series announces itself; the single-line form has no
296
+ // name of its own, so it leaves the naming to its container
297
+ // rather than presenting an image called "".
298
+ return s.label ? (
299
+ <G key={s.key} accessibilityRole="image" accessibilityLabel={s.label}>
300
+ {line}
301
+ </G>
176
302
  ) : (
177
- <Circle key={index} cx={point.x} cy={point.y} r={3} fill={colors.white} stroke={lineColor} strokeWidth={1.5} />
303
+ <Fragment key={s.key}>{line}</Fragment>
178
304
  );
179
305
  })}
180
306
  </Svg>
@@ -183,7 +309,7 @@ export function LineChart(props: LineChartProps) {
183
309
  </View>
184
310
  <View style={styles.labelsContainer}>
185
311
  {visibleLabels.map((item, i) => {
186
- const position = data.length > 1 ? item.index / (data.length - 1) : 0.5;
312
+ const position = positionCount > 1 ? item.index / (positionCount - 1) : 0.5;
187
313
  return (
188
314
  <Text
189
315
  key={i}
@@ -218,6 +344,13 @@ const styles = StyleSheet.create({
218
344
  chartWrapper: {
219
345
  gap: 8,
220
346
  },
347
+ // Above the plot, the same anatomy and spacing `StackedBarChart` gives its
348
+ // legend — two charts in one card must not annotate themselves two ways.
349
+ legend: {
350
+ flexDirection: "row",
351
+ flexWrap: "wrap",
352
+ gap: SPACE.md,
353
+ },
221
354
  chartRow: {
222
355
  flexDirection: "row",
223
356
  gap: 4,
package/src/list_item.tsx CHANGED
@@ -97,6 +97,7 @@ export function ListItem(props: ListItemProps) {
97
97
  ref={ref}
98
98
  testID={testID}
99
99
  containerStyle={containerStyle}
100
+ selected={selected}
100
101
  onPress={handlePress}
101
102
  disabled={disabled}
102
103
  right={right}
@@ -134,12 +135,14 @@ function PressRow(props: {
134
135
  ref?: Ref<View>;
135
136
  testID?: string;
136
137
  containerStyle: StyleProp<ViewStyle>;
138
+ /** Selection outranks hover, so the pointer never erases which row is open. */
139
+ selected?: boolean;
137
140
  onPress: () => void;
138
141
  disabled?: boolean;
139
142
  right?: React.ReactNode;
140
143
  children: React.ReactNode;
141
144
  }) {
142
- const { ref, testID, containerStyle, onPress, disabled, right, children } = props;
145
+ const { ref, testID, containerStyle, selected, onPress, disabled, right, children } = props;
143
146
  const [hovered, setHovered] = useState(false);
144
147
  const [pressed, setPressed] = useState(false);
145
148
 
@@ -147,8 +150,11 @@ function PressRow(props: {
147
150
  <View
148
151
  style={[
149
152
  containerStyle,
153
+ // SELECTION OUTRANKS HOVER. A selected row keeps its own (deeper) ground
154
+ // under the pointer; only an unselected one takes the lighter wash, so
155
+ // reaching for a neighbour never hides which row is open.
150
156
  !disabled && pressed && styles.pressed,
151
- !disabled && !pressed && hovered && styles.hovered,
157
+ !disabled && !pressed && hovered && !selected && styles.hovered,
152
158
  ]}
153
159
  >
154
160
  <FocusRingPressable
@@ -193,13 +199,26 @@ const styles = StyleSheet.create({
193
199
  flex: 1,
194
200
  alignItems: "flex-start",
195
201
  },
202
+ // SELECTION AND HOVER MUST NOT PAINT THE SAME GREY. They did — both
203
+ // `zinc-100` — so on a navigation pane, pointing at any row made it
204
+ // indistinguishable from the row currently open, and the persistent state was
205
+ // erased by the transient one exactly while the reader was choosing between
206
+ // them. Every other probe reads a RESTING screen, which is why it survived: at
207
+ // rest the selected row is the only grey one and looks perfect.
208
+ //
209
+ // The ladder is `PressableRow`'s, verbatim — ONE NEUTRAL STEP apart, hover
210
+ // LIGHTER than selection: zinc-50 hovered, zinc-100 selected, zinc-200
211
+ // pressed. The kit had already reached that answer for the register row and
212
+ // written down why an edge or a brand ground is wrong for a ROW; a second
213
+ // answer here would give one question two selection languages on surfaces the
214
+ // same reader crosses.
196
215
  selected: {
197
216
  backgroundColor: colors.zinc["100"],
198
217
  },
199
218
  // The wash `PressableHighlight` would have painted, moved to the wrapper so it
200
219
  // covers the row rather than stopping at the slot.
201
220
  hovered: {
202
- backgroundColor: colors.zinc["100"],
221
+ backgroundColor: colors.zinc["50"],
203
222
  },
204
223
  pressed: {
205
224
  backgroundColor: colors.zinc["200"],
package/src/locale.tsx CHANGED
@@ -6,6 +6,9 @@ import { type GanttLabels } from "./gantt/types";
6
6
  import { type SocialPostPreviewLabels } from "./social_post_rules";
7
7
  import { type PaginationLabels } from "./pagination";
8
8
  import { type SortHeaderLabels } from "./sort_header";
9
+ import { type MonthStepperLabels } from "./month_stepper";
10
+ import { type StateMatrixLabels } from "./state_matrix";
11
+ import { type TimetableLabels } from "./timetable";
9
12
  import { type ConfidenceLabels } from "./confidence";
10
13
  import { type RemainderMeterLabels } from "./remainder_meter";
11
14
  import { type DateRangeFilterFieldLabels } from "./date_range_filter_field";
@@ -41,6 +44,12 @@ export interface LoticsLocale {
41
44
  pagination: Required<PaginationLabels>;
42
45
  /** `SortHeader` a11y prefix + asc/desc suffixes. */
43
46
  sortHeader: Required<SortHeaderLabels>;
47
+ /** `Timetable`'s three column headers. */
48
+ timetable: Required<TimetableLabels>;
49
+ /** `StateMatrix`'s pinned total header. */
50
+ stateMatrix: Required<StateMatrixLabels>;
51
+ /** `MonthStepper`'s two arrows. */
52
+ monthStepper: Required<MonthStepperLabels>;
44
53
  /** `ReferenceField`'s peek footer — the link verbs (change / clear), the draft
45
54
  * verbs (edit / save / saving / cancel) and the go-to. `openLabel`
46
55
  * stays a per-instance PROP because it names the DESTINATION ("Open
@@ -92,6 +101,10 @@ export interface LoticsLocale {
92
101
  chargeLines: { quantity: (label: string) => string; unitPrice: (label: string) => string; amount: (label: string) => string };
93
102
  /** `Ledger`: the screen-reader name of a peekable row. */
94
103
  ledger: { rowDetails: (label: string) => string };
104
+ /** `SectionNav`: the bar's fallback name when `activeKey` matches no item,
105
+ * and what the attention dot ANNOUNCES — a bare mark names nothing, and it
106
+ * is the only thing saying a section needs looking at. */
107
+ sectionNav: { sections: string; needsAttention: string; hasProblem: string };
95
108
  /** `RunningLedger`: the closing line's label ("Số dư hiện tại", "Tồn kho
96
109
  * hiện tại") — the ledger's one derived, always-current figure. */
97
110
  runningLedger: { currentBalance: string };
@@ -337,6 +350,9 @@ export const en: LoticsLocale = {
337
350
  ascending: ", ascending",
338
351
  descending: ", descending",
339
352
  },
353
+ timetable: { time: "Time", subject: "Scheduled", status: "Status" },
354
+ stateMatrix: { total: "Total" },
355
+ monthStepper: { previous: "Previous month", next: "Next month" },
340
356
  referenceField: { open: "Open", change: "Change", clear: "Clear", edit: "Edit", save: "Save", saving: "Saving…", cancel: "Cancel" },
341
357
  optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…", someSelected: "some selected" },
342
358
  picker: { emptyOption: "None" },
@@ -369,6 +385,7 @@ export const en: LoticsLocale = {
369
385
  clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
370
386
  chargeLines: { quantity: (label) => `Quantity for ${label}`, unitPrice: (label) => `Unit price for ${label}`, amount: (label) => `Amount for ${label}` },
371
387
  ledger: { rowDetails: (label) => `${label} details` },
388
+ sectionNav: { sections: "Sections", needsAttention: "Needs attention", hasProblem: "Has a problem" },
372
389
  runningLedger: { currentBalance: "Current balance" },
373
390
  stepper: { complete: "Complete step", progress: "Progress" },
374
391
  stepProgress: {
@@ -415,6 +432,8 @@ export const en: LoticsLocale = {
415
432
  year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
416
433
  today: "Today", yesterday: "Yesterday", tomorrow: "Tomorrow",
417
434
  thisWeek: "This week", thisMonth: "This month", lastMonth: "Last month",
435
+ thisQuarter: "This quarter", lastQuarter: "Last quarter",
436
+ thisYear: "This year", lastYear: "Last year",
418
437
  from: "From", to: "To",
419
438
  selectDateRange: "Select date range", selectDate: "Select date",
420
439
  clear: "Clear", done: "Done", placeholder: "All time",
@@ -564,6 +583,9 @@ export const vi: LoticsLocale = {
564
583
  ascending: " (tăng dần)",
565
584
  descending: " (giảm dần)",
566
585
  },
586
+ timetable: { time: "Giờ", subject: "Phân công", status: "Tình trạng" },
587
+ stateMatrix: { total: "Tổng" },
588
+ monthStepper: { previous: "Tháng trước", next: "Tháng sau" },
567
589
  referenceField: { open: "Mở", change: "Đổi", clear: "Bỏ chọn", edit: "Sửa", save: "Lưu", saving: "Đang lưu…", cancel: "Hủy" },
568
590
  optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…", someSelected: "một số đã chọn" },
569
591
  picker: { emptyOption: "Không có" },
@@ -596,6 +618,7 @@ export const vi: LoticsLocale = {
596
618
  clarify: { otherPlaceholder: "Hoặc nhập câu trả lời khác…", back: "Quay lại", next: "Tiếp", cancel: "Hủy", submit: "Gửi" },
597
619
  chargeLines: { quantity: (label) => `Số lượng ${label}`, unitPrice: (label) => `Đơn giá ${label}`, amount: (label) => `Số tiền ${label}` },
598
620
  ledger: { rowDetails: (label) => `Chi tiết ${label}` },
621
+ sectionNav: { sections: "Mục", needsAttention: "Cần xem lại", hasProblem: "Đang có lỗi" },
599
622
  runningLedger: { currentBalance: "Số dư hiện tại" },
600
623
  stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
601
624
  stepProgress: {
@@ -642,6 +665,8 @@ export const vi: LoticsLocale = {
642
665
  year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH",
643
666
  today: "Hôm nay", yesterday: "Hôm qua", tomorrow: "Ngày mai",
644
667
  thisWeek: "Tuần này", thisMonth: "Tháng này", lastMonth: "Tháng trước",
668
+ thisQuarter: "Quý này", lastQuarter: "Quý trước",
669
+ thisYear: "Năm nay", lastYear: "Năm trước",
645
670
  from: "Từ", to: "Đến",
646
671
  selectDateRange: "Chọn khoảng ngày", selectDate: "Chọn ngày",
647
672
  clear: "Xóa", done: "Xong", placeholder: "Tất cả thời gian",