@dhiraj0720/report1chart 3.0.5 → 3.0.6
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/package.json
CHANGED
package/src/index.jsx
CHANGED
|
@@ -6,6 +6,7 @@ import Report1Screen from './screens/Report1Screen';
|
|
|
6
6
|
import Report2Screen from './screens/Report2Screen';
|
|
7
7
|
import Report2ModernScreen from './screens/Report2ModernScreen';
|
|
8
8
|
import Report3Screen from './screens/Report3Screen';
|
|
9
|
+
import Report3ModernScreen from './screens/Report3ModernScreen';
|
|
9
10
|
import Report1AScreen from './screens/Report1AScreen';
|
|
10
11
|
import Report2AScreen from './screens/Report2AScreen';
|
|
11
12
|
import Report3AScreen from './screens/Report3AScreen';
|
|
@@ -98,6 +99,18 @@ if (active === '2N1') {
|
|
|
98
99
|
);
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
if (active === '3N1') {
|
|
103
|
+
return (
|
|
104
|
+
<SafeScreen>
|
|
105
|
+
<Report3ModernScreen
|
|
106
|
+
api={config.report3}
|
|
107
|
+
token={config.token}
|
|
108
|
+
onBack={() => setActive(null)}
|
|
109
|
+
/>
|
|
110
|
+
</SafeScreen>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
101
114
|
|
|
102
115
|
// 👉 REPORT 2
|
|
103
116
|
if (active === 2) {
|
|
@@ -23,6 +23,13 @@ const toPercent = (current, previous) => {
|
|
|
23
23
|
return ((current - previous) / Math.abs(previous)) * 100;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
const compactAmount = (value) => {
|
|
27
|
+
const num = toNumber(value);
|
|
28
|
+
if (Math.abs(num) >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
|
29
|
+
if (Math.abs(num) >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
|
30
|
+
return `${num}`;
|
|
31
|
+
};
|
|
32
|
+
|
|
26
33
|
const TrendBadge = ({ value }) => {
|
|
27
34
|
const positive = value >= 0;
|
|
28
35
|
return (
|
|
@@ -80,15 +87,35 @@ const ModernBars = ({ rows, metric }) => {
|
|
|
80
87
|
const pct = toPercent(value2025, value2024);
|
|
81
88
|
const bar2024Height = Math.max(6, (value2024 / maxValue) * 122);
|
|
82
89
|
const bar2025Height = Math.max(6, (value2025 / maxValue) * 122);
|
|
90
|
+
const showInside2024 = bar2024Height > 48;
|
|
91
|
+
const showInside2025 = bar2025Height > 48;
|
|
83
92
|
|
|
84
93
|
return (
|
|
85
94
|
<View key={row.monthLabel} style={styles.chartGroup}>
|
|
86
95
|
<View style={styles.barPair}>
|
|
87
96
|
<View style={styles.barTrack}>
|
|
88
|
-
|
|
97
|
+
{!showInside2024 ? (
|
|
98
|
+
<Text style={styles.valueTopText}>{compactAmount(value2024)}</Text>
|
|
99
|
+
) : null}
|
|
100
|
+
<View style={[styles.barFill2024, { height: bar2024Height }]}>
|
|
101
|
+
{showInside2024 ? (
|
|
102
|
+
<View style={styles.valueInsideWrap}>
|
|
103
|
+
<Text style={styles.valueInsideText}>{formatNumber(value2024)}</Text>
|
|
104
|
+
</View>
|
|
105
|
+
) : null}
|
|
106
|
+
</View>
|
|
89
107
|
</View>
|
|
90
108
|
<View style={styles.barTrack}>
|
|
91
|
-
|
|
109
|
+
{!showInside2025 ? (
|
|
110
|
+
<Text style={styles.valueTopText}>{compactAmount(value2025)}</Text>
|
|
111
|
+
) : null}
|
|
112
|
+
<View style={[styles.barFill2025, { height: bar2025Height }]}>
|
|
113
|
+
{showInside2025 ? (
|
|
114
|
+
<View style={styles.valueInsideWrap}>
|
|
115
|
+
<Text style={styles.valueInsideText}>{formatNumber(value2025)}</Text>
|
|
116
|
+
</View>
|
|
117
|
+
) : null}
|
|
118
|
+
</View>
|
|
92
119
|
</View>
|
|
93
120
|
</View>
|
|
94
121
|
<Text style={styles.chartMonthText}>{row.monthLabel.slice(0, 3)}</Text>
|
|
@@ -644,21 +671,46 @@ const styles = StyleSheet.create({
|
|
|
644
671
|
marginBottom: 7,
|
|
645
672
|
},
|
|
646
673
|
barTrack: {
|
|
647
|
-
width:
|
|
648
|
-
height:
|
|
674
|
+
width: 14,
|
|
675
|
+
height: 130,
|
|
649
676
|
borderRadius: 10,
|
|
650
677
|
justifyContent: 'flex-end',
|
|
651
678
|
backgroundColor: '#eef3fa',
|
|
652
679
|
marginHorizontal: 2,
|
|
653
|
-
overflow: '
|
|
680
|
+
overflow: 'visible',
|
|
654
681
|
},
|
|
655
682
|
barFill2024: {
|
|
656
683
|
backgroundColor: '#f19a54',
|
|
657
684
|
borderRadius: 10,
|
|
685
|
+
overflow: 'hidden',
|
|
658
686
|
},
|
|
659
687
|
barFill2025: {
|
|
660
688
|
backgroundColor: '#2f7cca',
|
|
661
689
|
borderRadius: 10,
|
|
690
|
+
overflow: 'hidden',
|
|
691
|
+
},
|
|
692
|
+
valueInsideWrap: {
|
|
693
|
+
...StyleSheet.absoluteFillObject,
|
|
694
|
+
justifyContent: 'center',
|
|
695
|
+
alignItems: 'center',
|
|
696
|
+
},
|
|
697
|
+
valueInsideText: {
|
|
698
|
+
color: '#fff',
|
|
699
|
+
fontSize: 8,
|
|
700
|
+
fontWeight: '700',
|
|
701
|
+
transform: [{ rotate: '-90deg' }],
|
|
702
|
+
width: 84,
|
|
703
|
+
textAlign: 'center',
|
|
704
|
+
},
|
|
705
|
+
valueTopText: {
|
|
706
|
+
position: 'absolute',
|
|
707
|
+
top: -15,
|
|
708
|
+
left: -18,
|
|
709
|
+
width: 48,
|
|
710
|
+
textAlign: 'center',
|
|
711
|
+
fontSize: 8,
|
|
712
|
+
fontWeight: '700',
|
|
713
|
+
color: '#405268',
|
|
662
714
|
},
|
|
663
715
|
chartMonthText: {
|
|
664
716
|
fontSize: 10,
|
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
RefreshControl,
|
|
5
|
+
ScrollView,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
TouchableOpacity,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
import Svg, { Circle, Line, Polyline } from 'react-native-svg';
|
|
12
|
+
import { fetchReport3Table } from '../api/report3Fetcher';
|
|
13
|
+
import MonthFilterModal from '../components/MonthFilterModal';
|
|
14
|
+
import { formatNumber } from '../utils/formatNumber';
|
|
15
|
+
|
|
16
|
+
const toNumber = (value) => {
|
|
17
|
+
const numeric = Number(value);
|
|
18
|
+
return Number.isFinite(numeric) ? numeric : 0;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const percentChange = (current, previous) => {
|
|
22
|
+
if (!previous) return 0;
|
|
23
|
+
return ((current - previous) / Math.abs(previous)) * 100;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const percentOf = (part, total) => {
|
|
27
|
+
if (!total) return 0;
|
|
28
|
+
return (part / total) * 100;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const compactNumber = (value) => {
|
|
32
|
+
const num = toNumber(value);
|
|
33
|
+
if (Math.abs(num) >= 1000000000) return `${(num / 1000000000).toFixed(2)}B`;
|
|
34
|
+
if (Math.abs(num) >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
|
35
|
+
if (Math.abs(num) >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
|
36
|
+
return `${num}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const StatTile = ({ label, value, hint, tone }) => (
|
|
40
|
+
<View style={[styles.statTile, tone === 'mint' ? styles.statMint : tone === 'sun' ? styles.statSun : styles.statSea]}>
|
|
41
|
+
<Text style={styles.statLabel}>{label}</Text>
|
|
42
|
+
<Text style={styles.statValue}>{value}</Text>
|
|
43
|
+
<Text style={styles.statHint}>{hint}</Text>
|
|
44
|
+
</View>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const RingGauge = ({ progress }) => {
|
|
48
|
+
const size = 60;
|
|
49
|
+
const stroke = 8;
|
|
50
|
+
const radius = (size - stroke) / 2;
|
|
51
|
+
const circumference = 2 * Math.PI * radius;
|
|
52
|
+
const clamped = Math.max(0, Math.min(100, progress));
|
|
53
|
+
const offset = circumference - (circumference * clamped) / 100;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Svg width={size} height={size}>
|
|
57
|
+
<Circle
|
|
58
|
+
cx={size / 2}
|
|
59
|
+
cy={size / 2}
|
|
60
|
+
r={radius}
|
|
61
|
+
stroke="#d8ece8"
|
|
62
|
+
strokeWidth={stroke}
|
|
63
|
+
fill="none"
|
|
64
|
+
/>
|
|
65
|
+
<Circle
|
|
66
|
+
cx={size / 2}
|
|
67
|
+
cy={size / 2}
|
|
68
|
+
r={radius}
|
|
69
|
+
stroke="#179b78"
|
|
70
|
+
strokeWidth={stroke}
|
|
71
|
+
strokeLinecap="round"
|
|
72
|
+
strokeDasharray={`${circumference} ${circumference}`}
|
|
73
|
+
strokeDashoffset={offset}
|
|
74
|
+
fill="none"
|
|
75
|
+
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
76
|
+
/>
|
|
77
|
+
</Svg>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const SignalChart = ({ rows, mode }) => {
|
|
82
|
+
if (!rows.length) return null;
|
|
83
|
+
|
|
84
|
+
const values2024 = rows.map((row) =>
|
|
85
|
+
mode === 'loads' ? toNumber(row.loadCount2024) : toNumber(row.revenueTl2024),
|
|
86
|
+
);
|
|
87
|
+
const values2025 = rows.map((row) =>
|
|
88
|
+
mode === 'loads' ? toNumber(row.loadCount2025) : toNumber(row.revenueTl2025),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const width = Math.max(360, rows.length * 72);
|
|
92
|
+
const height = 190;
|
|
93
|
+
const left = 18;
|
|
94
|
+
const right = 12;
|
|
95
|
+
const top = 16;
|
|
96
|
+
const bottom = 38;
|
|
97
|
+
const chartHeight = height - top - bottom;
|
|
98
|
+
|
|
99
|
+
const all = [...values2024, ...values2025];
|
|
100
|
+
const min = Math.min(...all);
|
|
101
|
+
const max = Math.max(...all);
|
|
102
|
+
const pad = Math.max(1, (max - min) * 0.14);
|
|
103
|
+
const yMin = min - pad;
|
|
104
|
+
const yMax = max + pad;
|
|
105
|
+
|
|
106
|
+
const stepX = rows.length > 1 ? (width - left - right) / (rows.length - 1) : width / 2;
|
|
107
|
+
const toY = (value) => top + ((yMax - value) / Math.max(1, yMax - yMin)) * chartHeight;
|
|
108
|
+
const toX = (index) => left + index * stepX;
|
|
109
|
+
|
|
110
|
+
const points2024 = values2024.map((value, i) => `${toX(i)},${toY(value)}`).join(' ');
|
|
111
|
+
const points2025 = values2025.map((value, i) => `${toX(i)},${toY(value)}`).join(' ');
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<View style={styles.signalCard}>
|
|
115
|
+
<View style={styles.signalHeader}>
|
|
116
|
+
<Text style={styles.signalTitle}>
|
|
117
|
+
{mode === 'loads' ? 'Load Movement Signal' : 'Revenue Movement Signal'}
|
|
118
|
+
</Text>
|
|
119
|
+
<View style={styles.signalLegend}>
|
|
120
|
+
<Text style={styles.legend2024}>2024</Text>
|
|
121
|
+
<Text style={styles.legend2025}>2025</Text>
|
|
122
|
+
</View>
|
|
123
|
+
</View>
|
|
124
|
+
|
|
125
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
126
|
+
<Svg width={width} height={height}>
|
|
127
|
+
{[0, 0.5, 1].map((ratio) => {
|
|
128
|
+
const y = top + ratio * chartHeight;
|
|
129
|
+
return (
|
|
130
|
+
<Line
|
|
131
|
+
key={ratio}
|
|
132
|
+
x1={left}
|
|
133
|
+
y1={y}
|
|
134
|
+
x2={width - right}
|
|
135
|
+
y2={y}
|
|
136
|
+
stroke="#e7e3d9"
|
|
137
|
+
strokeDasharray="4 5"
|
|
138
|
+
strokeWidth={1}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
})}
|
|
142
|
+
|
|
143
|
+
<Polyline
|
|
144
|
+
points={points2024}
|
|
145
|
+
fill="none"
|
|
146
|
+
stroke="#f59f40"
|
|
147
|
+
strokeWidth={3}
|
|
148
|
+
strokeLinecap="round"
|
|
149
|
+
strokeLinejoin="round"
|
|
150
|
+
strokeDasharray="8 6"
|
|
151
|
+
/>
|
|
152
|
+
<Polyline
|
|
153
|
+
points={points2025}
|
|
154
|
+
fill="none"
|
|
155
|
+
stroke="#168a73"
|
|
156
|
+
strokeWidth={3}
|
|
157
|
+
strokeLinecap="round"
|
|
158
|
+
strokeLinejoin="round"
|
|
159
|
+
/>
|
|
160
|
+
|
|
161
|
+
{rows.map((row, index) => (
|
|
162
|
+
<React.Fragment key={`${row.monthLabel}-${index}`}>
|
|
163
|
+
<Circle cx={toX(index)} cy={toY(values2024[index])} r={4.8} fill="#f59f40" />
|
|
164
|
+
<Circle cx={toX(index)} cy={toY(values2025[index])} r={4.8} fill="#168a73" />
|
|
165
|
+
</React.Fragment>
|
|
166
|
+
))}
|
|
167
|
+
</Svg>
|
|
168
|
+
</ScrollView>
|
|
169
|
+
|
|
170
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.signalMonthStrip}>
|
|
171
|
+
{rows.map((row) => (
|
|
172
|
+
<View key={`month-${row.monthLabel}`} style={styles.signalMonthChip}>
|
|
173
|
+
<Text style={styles.signalMonthText}>{row.monthLabel}</Text>
|
|
174
|
+
</View>
|
|
175
|
+
))}
|
|
176
|
+
</ScrollView>
|
|
177
|
+
</View>
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const Report3ModernScreen = ({ api, token, onBack }) => {
|
|
182
|
+
const [loading, setLoading] = useState(true);
|
|
183
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
184
|
+
const [monthsModal, setMonthsModal] = useState(false);
|
|
185
|
+
const [table, setTable] = useState(null);
|
|
186
|
+
const [selectedMonths, setSelectedMonths] = useState([]);
|
|
187
|
+
const [focusMonth, setFocusMonth] = useState('ALL');
|
|
188
|
+
const [mode, setMode] = useState('loads');
|
|
189
|
+
|
|
190
|
+
const loadData = useCallback(async () => {
|
|
191
|
+
const response = await fetchReport3Table(api.table, token);
|
|
192
|
+
setTable(response);
|
|
193
|
+
const months = (response?.rows || [])
|
|
194
|
+
.filter((row) => row.month !== 99 && row.monthLabel !== 'Total')
|
|
195
|
+
.map((row) => row.monthLabel);
|
|
196
|
+
setSelectedMonths(months);
|
|
197
|
+
setFocusMonth('ALL');
|
|
198
|
+
}, [api.table, token]);
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
setLoading(true);
|
|
202
|
+
loadData()
|
|
203
|
+
.catch(() => {})
|
|
204
|
+
.finally(() => setLoading(false));
|
|
205
|
+
}, [loadData]);
|
|
206
|
+
|
|
207
|
+
const onRefresh = useCallback(async () => {
|
|
208
|
+
setRefreshing(true);
|
|
209
|
+
try {
|
|
210
|
+
await loadData();
|
|
211
|
+
} finally {
|
|
212
|
+
setRefreshing(false);
|
|
213
|
+
}
|
|
214
|
+
}, [loadData]);
|
|
215
|
+
|
|
216
|
+
const baseRows = useMemo(() => {
|
|
217
|
+
return (table?.rows || []).filter((row) => row.month !== 99 && row.monthLabel !== 'Total');
|
|
218
|
+
}, [table]);
|
|
219
|
+
|
|
220
|
+
const rows = useMemo(() => {
|
|
221
|
+
let filtered = baseRows;
|
|
222
|
+
if (selectedMonths.length) {
|
|
223
|
+
filtered = filtered.filter((row) => selectedMonths.includes(row.monthLabel));
|
|
224
|
+
}
|
|
225
|
+
if (focusMonth !== 'ALL') {
|
|
226
|
+
filtered = filtered.filter((row) => row.monthLabel === focusMonth);
|
|
227
|
+
}
|
|
228
|
+
return filtered;
|
|
229
|
+
}, [baseRows, selectedMonths, focusMonth]);
|
|
230
|
+
|
|
231
|
+
const quickMonths = useMemo(() => ['ALL', ...selectedMonths.slice(0, 6)], [selectedMonths]);
|
|
232
|
+
|
|
233
|
+
const load2024Total = useMemo(
|
|
234
|
+
() => rows.reduce((sum, row) => sum + toNumber(row.loadCount2024), 0),
|
|
235
|
+
[rows],
|
|
236
|
+
);
|
|
237
|
+
const load2025Total = useMemo(
|
|
238
|
+
() => rows.reduce((sum, row) => sum + toNumber(row.loadCount2025), 0),
|
|
239
|
+
[rows],
|
|
240
|
+
);
|
|
241
|
+
const revenue2024Total = useMemo(
|
|
242
|
+
() => rows.reduce((sum, row) => sum + toNumber(row.revenueTl2024), 0),
|
|
243
|
+
[rows],
|
|
244
|
+
);
|
|
245
|
+
const revenue2025Total = useMemo(
|
|
246
|
+
() => rows.reduce((sum, row) => sum + toNumber(row.revenueTl2025), 0),
|
|
247
|
+
[rows],
|
|
248
|
+
);
|
|
249
|
+
const budget2025Total = useMemo(
|
|
250
|
+
() => rows.reduce((sum, row) => sum + toNumber(row.budgetRevenueTl2025), 0),
|
|
251
|
+
[rows],
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const loadYoY = percentChange(load2025Total, load2024Total);
|
|
255
|
+
const revenueYoY = percentChange(revenue2025Total, revenue2024Total);
|
|
256
|
+
const budgetCoverage = percentOf(revenue2025Total, budget2025Total);
|
|
257
|
+
|
|
258
|
+
const topMonth = useMemo(() => {
|
|
259
|
+
if (!rows.length) return null;
|
|
260
|
+
return rows.reduce((best, row) => {
|
|
261
|
+
if (!best) return row;
|
|
262
|
+
const bestValue = mode === 'loads' ? toNumber(best.loadCount2025) : toNumber(best.revenueTl2025);
|
|
263
|
+
const rowValue = mode === 'loads' ? toNumber(row.loadCount2025) : toNumber(row.revenueTl2025);
|
|
264
|
+
return rowValue > bestValue ? row : best;
|
|
265
|
+
}, null);
|
|
266
|
+
}, [rows, mode]);
|
|
267
|
+
|
|
268
|
+
const maxLaneValue = useMemo(() => {
|
|
269
|
+
if (!rows.length) return 1;
|
|
270
|
+
const values = rows.flatMap((row) => (
|
|
271
|
+
mode === 'loads'
|
|
272
|
+
? [toNumber(row.loadCount2024), toNumber(row.loadCount2025)]
|
|
273
|
+
: [toNumber(row.revenueTl2024), toNumber(row.revenueTl2025), toNumber(row.budgetRevenueTl2025)]
|
|
274
|
+
));
|
|
275
|
+
return Math.max(1, ...values);
|
|
276
|
+
}, [rows, mode]);
|
|
277
|
+
|
|
278
|
+
if (loading && !table) {
|
|
279
|
+
return (
|
|
280
|
+
<View style={styles.loaderWrap}>
|
|
281
|
+
<ActivityIndicator size="large" color="#13866f" />
|
|
282
|
+
</View>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<View style={styles.screen}>
|
|
288
|
+
<View style={styles.hero}>
|
|
289
|
+
<View style={styles.heroBlobLeft} />
|
|
290
|
+
<View style={styles.heroBlobRight} />
|
|
291
|
+
<TouchableOpacity style={styles.backButton} onPress={onBack}>
|
|
292
|
+
<Text style={styles.backIcon}>‹</Text>
|
|
293
|
+
</TouchableOpacity>
|
|
294
|
+
<Text style={styles.heroTitle}>Transportation Command Center</Text>
|
|
295
|
+
<Text style={styles.heroSubtitle}>Operational pulse for load and revenue movement</Text>
|
|
296
|
+
</View>
|
|
297
|
+
|
|
298
|
+
<ScrollView
|
|
299
|
+
style={styles.content}
|
|
300
|
+
showsVerticalScrollIndicator={false}
|
|
301
|
+
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
|
302
|
+
>
|
|
303
|
+
<View style={styles.topControlRow}>
|
|
304
|
+
<TouchableOpacity
|
|
305
|
+
style={styles.controlChip}
|
|
306
|
+
onPress={() => setMonthsModal(true)}
|
|
307
|
+
activeOpacity={0.9}
|
|
308
|
+
>
|
|
309
|
+
<Text style={styles.controlChipLabel}>Months</Text>
|
|
310
|
+
<Text style={styles.controlChipValue}>
|
|
311
|
+
{selectedMonths.length}/{baseRows.length || selectedMonths.length}
|
|
312
|
+
</Text>
|
|
313
|
+
</TouchableOpacity>
|
|
314
|
+
|
|
315
|
+
<View style={styles.modeToggle}>
|
|
316
|
+
<TouchableOpacity
|
|
317
|
+
style={[styles.modeButton, mode === 'loads' && styles.modeButtonActive]}
|
|
318
|
+
onPress={() => setMode('loads')}
|
|
319
|
+
>
|
|
320
|
+
<Text style={[styles.modeButtonText, mode === 'loads' && styles.modeButtonTextActive]}>
|
|
321
|
+
Loads
|
|
322
|
+
</Text>
|
|
323
|
+
</TouchableOpacity>
|
|
324
|
+
<TouchableOpacity
|
|
325
|
+
style={[styles.modeButton, mode === 'revenue' && styles.modeButtonActive]}
|
|
326
|
+
onPress={() => setMode('revenue')}
|
|
327
|
+
>
|
|
328
|
+
<Text style={[styles.modeButtonText, mode === 'revenue' && styles.modeButtonTextActive]}>
|
|
329
|
+
Revenue
|
|
330
|
+
</Text>
|
|
331
|
+
</TouchableOpacity>
|
|
332
|
+
</View>
|
|
333
|
+
</View>
|
|
334
|
+
|
|
335
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.quickMonthRow}>
|
|
336
|
+
{quickMonths.map((month) => (
|
|
337
|
+
<TouchableOpacity
|
|
338
|
+
key={month}
|
|
339
|
+
style={[styles.monthChip, focusMonth === month && styles.monthChipActive]}
|
|
340
|
+
onPress={() => setFocusMonth(month)}
|
|
341
|
+
>
|
|
342
|
+
<Text style={[styles.monthChipText, focusMonth === month && styles.monthChipTextActive]}>
|
|
343
|
+
{month}
|
|
344
|
+
</Text>
|
|
345
|
+
</TouchableOpacity>
|
|
346
|
+
))}
|
|
347
|
+
</ScrollView>
|
|
348
|
+
|
|
349
|
+
<View style={styles.statRow}>
|
|
350
|
+
<StatTile
|
|
351
|
+
label="Load YoY"
|
|
352
|
+
value={`${loadYoY >= 0 ? '+' : ''}${loadYoY.toFixed(1)}%`}
|
|
353
|
+
hint={`${compactNumber(load2025Total)} vs ${compactNumber(load2024Total)}`}
|
|
354
|
+
tone="mint"
|
|
355
|
+
/>
|
|
356
|
+
<StatTile
|
|
357
|
+
label="Revenue YoY"
|
|
358
|
+
value={`${revenueYoY >= 0 ? '+' : ''}${revenueYoY.toFixed(1)}%`}
|
|
359
|
+
hint={`${compactNumber(revenue2025Total)} vs ${compactNumber(revenue2024Total)}`}
|
|
360
|
+
tone="sun"
|
|
361
|
+
/>
|
|
362
|
+
</View>
|
|
363
|
+
<View style={styles.statRow}>
|
|
364
|
+
<StatTile
|
|
365
|
+
label="Budget Coverage"
|
|
366
|
+
value={`${budgetCoverage.toFixed(1)}%`}
|
|
367
|
+
hint={`${compactNumber(revenue2025Total)} / ${compactNumber(budget2025Total)}`}
|
|
368
|
+
tone="sea"
|
|
369
|
+
/>
|
|
370
|
+
<StatTile
|
|
371
|
+
label="Peak Month"
|
|
372
|
+
value={topMonth?.monthLabel || '-'}
|
|
373
|
+
hint={mode === 'loads' ? compactNumber(topMonth?.loadCount2025) : compactNumber(topMonth?.revenueTl2025)}
|
|
374
|
+
tone="mint"
|
|
375
|
+
/>
|
|
376
|
+
</View>
|
|
377
|
+
|
|
378
|
+
<SignalChart rows={rows} mode={mode} />
|
|
379
|
+
|
|
380
|
+
<Text style={styles.sectionTitle}>Operational lanes</Text>
|
|
381
|
+
{rows.map((row) => {
|
|
382
|
+
const current = mode === 'loads' ? toNumber(row.loadCount2025) : toNumber(row.revenueTl2025);
|
|
383
|
+
const previous = mode === 'loads' ? toNumber(row.loadCount2024) : toNumber(row.revenueTl2024);
|
|
384
|
+
const target = mode === 'loads' ? previous : toNumber(row.budgetRevenueTl2025);
|
|
385
|
+
const delta = percentChange(current, previous);
|
|
386
|
+
const progress = percentOf(current, Math.max(1, target));
|
|
387
|
+
const previousWidth = `${Math.min(100, (previous / maxLaneValue) * 100)}%`;
|
|
388
|
+
const currentWidth = `${Math.min(100, (current / maxLaneValue) * 100)}%`;
|
|
389
|
+
|
|
390
|
+
return (
|
|
391
|
+
<View key={`lane-${row.monthLabel}`} style={styles.laneCard}>
|
|
392
|
+
<View style={styles.laneHeader}>
|
|
393
|
+
<Text style={styles.laneTitle}>{row.monthLabel}</Text>
|
|
394
|
+
<Text style={[styles.deltaTag, delta >= 0 ? styles.deltaUp : styles.deltaDown]}>
|
|
395
|
+
{delta >= 0 ? '+' : ''}
|
|
396
|
+
{delta.toFixed(1)}%
|
|
397
|
+
</Text>
|
|
398
|
+
</View>
|
|
399
|
+
|
|
400
|
+
<View style={styles.laneBarsArea}>
|
|
401
|
+
<View style={styles.laneBars}>
|
|
402
|
+
<Text style={styles.laneLabel}>2024</Text>
|
|
403
|
+
<View style={styles.track}>
|
|
404
|
+
<View style={[styles.bar2024, { width: previousWidth }]} />
|
|
405
|
+
</View>
|
|
406
|
+
<Text style={styles.laneValue}>{compactNumber(previous)}</Text>
|
|
407
|
+
</View>
|
|
408
|
+
<View style={styles.laneBars}>
|
|
409
|
+
<Text style={styles.laneLabel}>2025</Text>
|
|
410
|
+
<View style={styles.track}>
|
|
411
|
+
<View style={[styles.bar2025, { width: currentWidth }]} />
|
|
412
|
+
</View>
|
|
413
|
+
<Text style={styles.laneValue}>{compactNumber(current)}</Text>
|
|
414
|
+
</View>
|
|
415
|
+
</View>
|
|
416
|
+
|
|
417
|
+
<View style={styles.gaugeRow}>
|
|
418
|
+
<RingGauge progress={progress} />
|
|
419
|
+
<View style={styles.gaugeTextWrap}>
|
|
420
|
+
<Text style={styles.gaugeTitle}>
|
|
421
|
+
{mode === 'loads' ? '2025 vs 2024 efficiency' : '2025 budget delivery'}
|
|
422
|
+
</Text>
|
|
423
|
+
<Text style={styles.gaugeValue}>{progress.toFixed(1)}%</Text>
|
|
424
|
+
</View>
|
|
425
|
+
</View>
|
|
426
|
+
</View>
|
|
427
|
+
);
|
|
428
|
+
})}
|
|
429
|
+
|
|
430
|
+
{!rows.length ? (
|
|
431
|
+
<View style={styles.emptyWrap}>
|
|
432
|
+
<Text style={styles.emptyText}>No data for selected filters.</Text>
|
|
433
|
+
</View>
|
|
434
|
+
) : null}
|
|
435
|
+
</ScrollView>
|
|
436
|
+
|
|
437
|
+
<MonthFilterModal
|
|
438
|
+
visible={monthsModal}
|
|
439
|
+
months={baseRows.map((row) => row.monthLabel)}
|
|
440
|
+
selected={selectedMonths}
|
|
441
|
+
onApply={setSelectedMonths}
|
|
442
|
+
onClose={() => setMonthsModal(false)}
|
|
443
|
+
/>
|
|
444
|
+
</View>
|
|
445
|
+
);
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const styles = StyleSheet.create({
|
|
449
|
+
screen: {
|
|
450
|
+
flex: 1,
|
|
451
|
+
backgroundColor: '#faf7ef',
|
|
452
|
+
},
|
|
453
|
+
loaderWrap: {
|
|
454
|
+
flex: 1,
|
|
455
|
+
justifyContent: 'center',
|
|
456
|
+
alignItems: 'center',
|
|
457
|
+
backgroundColor: '#faf7ef',
|
|
458
|
+
},
|
|
459
|
+
hero: {
|
|
460
|
+
backgroundColor: '#0b7a61',
|
|
461
|
+
paddingHorizontal: 16,
|
|
462
|
+
paddingTop: 14,
|
|
463
|
+
paddingBottom: 18,
|
|
464
|
+
overflow: 'hidden',
|
|
465
|
+
},
|
|
466
|
+
heroBlobLeft: {
|
|
467
|
+
position: 'absolute',
|
|
468
|
+
width: 160,
|
|
469
|
+
height: 160,
|
|
470
|
+
borderRadius: 80,
|
|
471
|
+
left: -55,
|
|
472
|
+
top: -90,
|
|
473
|
+
backgroundColor: '#12a07f',
|
|
474
|
+
},
|
|
475
|
+
heroBlobRight: {
|
|
476
|
+
position: 'absolute',
|
|
477
|
+
width: 180,
|
|
478
|
+
height: 180,
|
|
479
|
+
borderRadius: 90,
|
|
480
|
+
right: -70,
|
|
481
|
+
top: -40,
|
|
482
|
+
backgroundColor: '#0f8d70',
|
|
483
|
+
},
|
|
484
|
+
backButton: {
|
|
485
|
+
width: 42,
|
|
486
|
+
height: 42,
|
|
487
|
+
borderRadius: 22,
|
|
488
|
+
justifyContent: 'center',
|
|
489
|
+
alignItems: 'center',
|
|
490
|
+
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
491
|
+
},
|
|
492
|
+
backIcon: {
|
|
493
|
+
fontSize: 28,
|
|
494
|
+
fontWeight: '700',
|
|
495
|
+
color: '#fff',
|
|
496
|
+
marginTop: -2,
|
|
497
|
+
},
|
|
498
|
+
heroTitle: {
|
|
499
|
+
marginTop: 10,
|
|
500
|
+
color: '#fff',
|
|
501
|
+
fontSize: 22,
|
|
502
|
+
fontWeight: '800',
|
|
503
|
+
},
|
|
504
|
+
heroSubtitle: {
|
|
505
|
+
marginTop: 5,
|
|
506
|
+
fontSize: 13,
|
|
507
|
+
color: '#d4fff4',
|
|
508
|
+
fontWeight: '500',
|
|
509
|
+
},
|
|
510
|
+
content: {
|
|
511
|
+
flex: 1,
|
|
512
|
+
paddingHorizontal: 14,
|
|
513
|
+
paddingTop: 14,
|
|
514
|
+
},
|
|
515
|
+
topControlRow: {
|
|
516
|
+
flexDirection: 'row',
|
|
517
|
+
marginBottom: 10,
|
|
518
|
+
},
|
|
519
|
+
controlChip: {
|
|
520
|
+
width: 118,
|
|
521
|
+
borderRadius: 14,
|
|
522
|
+
paddingVertical: 10,
|
|
523
|
+
paddingHorizontal: 12,
|
|
524
|
+
backgroundColor: '#fffdf8',
|
|
525
|
+
borderWidth: 1,
|
|
526
|
+
borderColor: '#dfd6c3',
|
|
527
|
+
marginRight: 10,
|
|
528
|
+
},
|
|
529
|
+
controlChipLabel: {
|
|
530
|
+
fontSize: 11,
|
|
531
|
+
color: '#736b57',
|
|
532
|
+
marginBottom: 2,
|
|
533
|
+
},
|
|
534
|
+
controlChipValue: {
|
|
535
|
+
fontSize: 14,
|
|
536
|
+
fontWeight: '700',
|
|
537
|
+
color: '#2e2a21',
|
|
538
|
+
},
|
|
539
|
+
modeToggle: {
|
|
540
|
+
flex: 1,
|
|
541
|
+
flexDirection: 'row',
|
|
542
|
+
backgroundColor: '#ede6d8',
|
|
543
|
+
borderRadius: 12,
|
|
544
|
+
padding: 4,
|
|
545
|
+
borderWidth: 1,
|
|
546
|
+
borderColor: '#d8cfbf',
|
|
547
|
+
},
|
|
548
|
+
modeButton: {
|
|
549
|
+
flex: 1,
|
|
550
|
+
borderRadius: 10,
|
|
551
|
+
alignItems: 'center',
|
|
552
|
+
justifyContent: 'center',
|
|
553
|
+
paddingVertical: 9,
|
|
554
|
+
},
|
|
555
|
+
modeButtonActive: {
|
|
556
|
+
backgroundColor: '#1c6f5a',
|
|
557
|
+
},
|
|
558
|
+
modeButtonText: {
|
|
559
|
+
fontSize: 13,
|
|
560
|
+
color: '#544b38',
|
|
561
|
+
fontWeight: '700',
|
|
562
|
+
},
|
|
563
|
+
modeButtonTextActive: {
|
|
564
|
+
color: '#fff',
|
|
565
|
+
},
|
|
566
|
+
quickMonthRow: {
|
|
567
|
+
marginBottom: 12,
|
|
568
|
+
},
|
|
569
|
+
monthChip: {
|
|
570
|
+
backgroundColor: '#fffdf8',
|
|
571
|
+
borderWidth: 1,
|
|
572
|
+
borderColor: '#ddd2be',
|
|
573
|
+
borderRadius: 999,
|
|
574
|
+
paddingVertical: 7,
|
|
575
|
+
paddingHorizontal: 12,
|
|
576
|
+
marginRight: 8,
|
|
577
|
+
},
|
|
578
|
+
monthChipActive: {
|
|
579
|
+
backgroundColor: '#e3912f',
|
|
580
|
+
borderColor: '#e3912f',
|
|
581
|
+
},
|
|
582
|
+
monthChipText: {
|
|
583
|
+
fontSize: 12,
|
|
584
|
+
color: '#635640',
|
|
585
|
+
fontWeight: '700',
|
|
586
|
+
},
|
|
587
|
+
monthChipTextActive: {
|
|
588
|
+
color: '#fff',
|
|
589
|
+
},
|
|
590
|
+
statRow: {
|
|
591
|
+
flexDirection: 'row',
|
|
592
|
+
marginBottom: 10,
|
|
593
|
+
},
|
|
594
|
+
statTile: {
|
|
595
|
+
flex: 1,
|
|
596
|
+
borderRadius: 15,
|
|
597
|
+
paddingHorizontal: 12,
|
|
598
|
+
paddingVertical: 11,
|
|
599
|
+
borderWidth: 1,
|
|
600
|
+
marginRight: 10,
|
|
601
|
+
},
|
|
602
|
+
statMint: {
|
|
603
|
+
backgroundColor: '#edfdf7',
|
|
604
|
+
borderColor: '#bcefe0',
|
|
605
|
+
},
|
|
606
|
+
statSun: {
|
|
607
|
+
backgroundColor: '#fff6eb',
|
|
608
|
+
borderColor: '#ffd6a8',
|
|
609
|
+
},
|
|
610
|
+
statSea: {
|
|
611
|
+
backgroundColor: '#edf8ff',
|
|
612
|
+
borderColor: '#b8dcf5',
|
|
613
|
+
},
|
|
614
|
+
statLabel: {
|
|
615
|
+
fontSize: 11,
|
|
616
|
+
color: '#5d5a52',
|
|
617
|
+
},
|
|
618
|
+
statValue: {
|
|
619
|
+
marginTop: 6,
|
|
620
|
+
fontSize: 17,
|
|
621
|
+
color: '#1f2f2d',
|
|
622
|
+
fontWeight: '800',
|
|
623
|
+
},
|
|
624
|
+
statHint: {
|
|
625
|
+
marginTop: 4,
|
|
626
|
+
fontSize: 11,
|
|
627
|
+
color: '#6b6860',
|
|
628
|
+
},
|
|
629
|
+
signalCard: {
|
|
630
|
+
backgroundColor: '#fffdf8',
|
|
631
|
+
borderWidth: 1,
|
|
632
|
+
borderColor: '#e0d7c6',
|
|
633
|
+
borderRadius: 16,
|
|
634
|
+
padding: 12,
|
|
635
|
+
marginBottom: 12,
|
|
636
|
+
},
|
|
637
|
+
signalHeader: {
|
|
638
|
+
flexDirection: 'row',
|
|
639
|
+
justifyContent: 'space-between',
|
|
640
|
+
alignItems: 'center',
|
|
641
|
+
marginBottom: 8,
|
|
642
|
+
},
|
|
643
|
+
signalTitle: {
|
|
644
|
+
fontSize: 14,
|
|
645
|
+
fontWeight: '800',
|
|
646
|
+
color: '#2c2a22',
|
|
647
|
+
},
|
|
648
|
+
signalLegend: {
|
|
649
|
+
flexDirection: 'row',
|
|
650
|
+
},
|
|
651
|
+
legend2024: {
|
|
652
|
+
marginRight: 10,
|
|
653
|
+
color: '#d78835',
|
|
654
|
+
fontWeight: '700',
|
|
655
|
+
fontSize: 12,
|
|
656
|
+
},
|
|
657
|
+
legend2025: {
|
|
658
|
+
color: '#15896f',
|
|
659
|
+
fontWeight: '700',
|
|
660
|
+
fontSize: 12,
|
|
661
|
+
},
|
|
662
|
+
signalMonthStrip: {
|
|
663
|
+
marginTop: 6,
|
|
664
|
+
},
|
|
665
|
+
signalMonthChip: {
|
|
666
|
+
backgroundColor: '#f5efe2',
|
|
667
|
+
borderRadius: 999,
|
|
668
|
+
paddingVertical: 5,
|
|
669
|
+
paddingHorizontal: 10,
|
|
670
|
+
marginRight: 6,
|
|
671
|
+
},
|
|
672
|
+
signalMonthText: {
|
|
673
|
+
color: '#635742',
|
|
674
|
+
fontSize: 11,
|
|
675
|
+
fontWeight: '700',
|
|
676
|
+
},
|
|
677
|
+
sectionTitle: {
|
|
678
|
+
fontSize: 15,
|
|
679
|
+
fontWeight: '800',
|
|
680
|
+
color: '#2b2a22',
|
|
681
|
+
marginBottom: 8,
|
|
682
|
+
},
|
|
683
|
+
laneCard: {
|
|
684
|
+
backgroundColor: '#fffdf8',
|
|
685
|
+
borderRadius: 16,
|
|
686
|
+
borderWidth: 1,
|
|
687
|
+
borderColor: '#e0d7c6',
|
|
688
|
+
padding: 12,
|
|
689
|
+
marginBottom: 10,
|
|
690
|
+
},
|
|
691
|
+
laneHeader: {
|
|
692
|
+
flexDirection: 'row',
|
|
693
|
+
justifyContent: 'space-between',
|
|
694
|
+
marginBottom: 10,
|
|
695
|
+
alignItems: 'center',
|
|
696
|
+
},
|
|
697
|
+
laneTitle: {
|
|
698
|
+
fontSize: 15,
|
|
699
|
+
fontWeight: '800',
|
|
700
|
+
color: '#28261f',
|
|
701
|
+
},
|
|
702
|
+
deltaTag: {
|
|
703
|
+
fontSize: 12,
|
|
704
|
+
fontWeight: '700',
|
|
705
|
+
paddingHorizontal: 8,
|
|
706
|
+
paddingVertical: 4,
|
|
707
|
+
borderRadius: 999,
|
|
708
|
+
},
|
|
709
|
+
deltaUp: {
|
|
710
|
+
color: '#0f8a6d',
|
|
711
|
+
backgroundColor: '#ddf8ef',
|
|
712
|
+
},
|
|
713
|
+
deltaDown: {
|
|
714
|
+
color: '#b6494c',
|
|
715
|
+
backgroundColor: '#ffe7e7',
|
|
716
|
+
},
|
|
717
|
+
laneBarsArea: {
|
|
718
|
+
marginBottom: 10,
|
|
719
|
+
},
|
|
720
|
+
laneBars: {
|
|
721
|
+
flexDirection: 'row',
|
|
722
|
+
alignItems: 'center',
|
|
723
|
+
marginBottom: 8,
|
|
724
|
+
},
|
|
725
|
+
laneLabel: {
|
|
726
|
+
width: 34,
|
|
727
|
+
fontSize: 11,
|
|
728
|
+
fontWeight: '700',
|
|
729
|
+
color: '#675a43',
|
|
730
|
+
},
|
|
731
|
+
track: {
|
|
732
|
+
flex: 1,
|
|
733
|
+
height: 10,
|
|
734
|
+
borderRadius: 999,
|
|
735
|
+
overflow: 'hidden',
|
|
736
|
+
backgroundColor: '#efe8da',
|
|
737
|
+
marginHorizontal: 8,
|
|
738
|
+
},
|
|
739
|
+
bar2024: {
|
|
740
|
+
height: '100%',
|
|
741
|
+
backgroundColor: '#f4a04a',
|
|
742
|
+
borderRadius: 999,
|
|
743
|
+
},
|
|
744
|
+
bar2025: {
|
|
745
|
+
height: '100%',
|
|
746
|
+
backgroundColor: '#179a79',
|
|
747
|
+
borderRadius: 999,
|
|
748
|
+
},
|
|
749
|
+
laneValue: {
|
|
750
|
+
width: 74,
|
|
751
|
+
textAlign: 'right',
|
|
752
|
+
fontSize: 11,
|
|
753
|
+
fontWeight: '700',
|
|
754
|
+
color: '#464238',
|
|
755
|
+
},
|
|
756
|
+
gaugeRow: {
|
|
757
|
+
flexDirection: 'row',
|
|
758
|
+
alignItems: 'center',
|
|
759
|
+
marginTop: 2,
|
|
760
|
+
},
|
|
761
|
+
gaugeTextWrap: {
|
|
762
|
+
marginLeft: 10,
|
|
763
|
+
flex: 1,
|
|
764
|
+
},
|
|
765
|
+
gaugeTitle: {
|
|
766
|
+
fontSize: 11,
|
|
767
|
+
color: '#686152',
|
|
768
|
+
marginBottom: 3,
|
|
769
|
+
},
|
|
770
|
+
gaugeValue: {
|
|
771
|
+
fontSize: 17,
|
|
772
|
+
fontWeight: '800',
|
|
773
|
+
color: '#165a4a',
|
|
774
|
+
},
|
|
775
|
+
emptyWrap: {
|
|
776
|
+
paddingVertical: 24,
|
|
777
|
+
alignItems: 'center',
|
|
778
|
+
},
|
|
779
|
+
emptyText: {
|
|
780
|
+
fontSize: 13,
|
|
781
|
+
color: '#6d675a',
|
|
782
|
+
},
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
export default Report3ModernScreen;
|
|
@@ -51,6 +51,11 @@ const NEW_REPORTS = [
|
|
|
51
51
|
title: 'Gross Profit by Company & Division',
|
|
52
52
|
desc: 'Modern interactive layout',
|
|
53
53
|
},
|
|
54
|
+
{
|
|
55
|
+
id: '3N1',
|
|
56
|
+
title: 'Transportation Business Analysis',
|
|
57
|
+
desc: 'Modern command center layout',
|
|
58
|
+
},
|
|
54
59
|
];
|
|
55
60
|
|
|
56
61
|
const ReportListScreen = ({ onSelect, onExit }) => {
|