@dhiraj0720/report1chart 3.0.7 → 3.0.9
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 +1 -1
- package/src/components/CompactAxisBarLineChart.jsx +5 -5
- package/src/components/CompactAxisLineChart.jsx +3 -3
- package/src/components/ModernDataTable.jsx +276 -0
- package/src/index.jsx +1 -1
- package/src/screens/Report1ModernScreen.jsx +364 -256
- package/src/screens/Report2ModernScreen.jsx +40 -2
- package/src/screens/Report3ModernScreen.jsx +40 -2
- package/src/utils/filterChartByMonths.js +22 -2
package/package.json
CHANGED
|
@@ -36,8 +36,8 @@ const CompactAxisBarLineChart = ({
|
|
|
36
36
|
title,
|
|
37
37
|
legend,
|
|
38
38
|
height = 220,
|
|
39
|
-
minGraphWidth =
|
|
40
|
-
groupSpacing =
|
|
39
|
+
minGraphWidth = 300,
|
|
40
|
+
groupSpacing = 64,
|
|
41
41
|
}) => {
|
|
42
42
|
const labels = data?.labels || [];
|
|
43
43
|
const series = data?.series || [];
|
|
@@ -76,8 +76,8 @@ const CompactAxisBarLineChart = ({
|
|
|
76
76
|
paddingTop: computedPaddingTop,
|
|
77
77
|
chartHeight: computedChartHeight,
|
|
78
78
|
maxValue: peak,
|
|
79
|
-
barWidth:
|
|
80
|
-
barGap:
|
|
79
|
+
barWidth: 20,
|
|
80
|
+
barGap: 4,
|
|
81
81
|
};
|
|
82
82
|
}, [barSeriesA, barSeriesB, groupSpacing, height, labels.length, lineSeries, minGraphWidth]);
|
|
83
83
|
|
|
@@ -251,7 +251,7 @@ const CompactAxisBarLineChart = ({
|
|
|
251
251
|
fontSize="10"
|
|
252
252
|
fill="#4f6076"
|
|
253
253
|
textAnchor="middle"
|
|
254
|
-
transform={`rotate(-
|
|
254
|
+
transform={`rotate(-30 ${cx} ${height - 14})`}
|
|
255
255
|
>
|
|
256
256
|
{label}
|
|
257
257
|
</SvgText>
|
|
@@ -26,8 +26,8 @@ const CompactAxisLineChart = ({
|
|
|
26
26
|
title,
|
|
27
27
|
legend,
|
|
28
28
|
height = 210,
|
|
29
|
-
minGraphWidth =
|
|
30
|
-
pointSpacing =
|
|
29
|
+
minGraphWidth = 300,
|
|
30
|
+
pointSpacing = 56,
|
|
31
31
|
}) => {
|
|
32
32
|
const labels = data?.labels || [];
|
|
33
33
|
const series = data?.series || [];
|
|
@@ -171,7 +171,7 @@ const CompactAxisLineChart = ({
|
|
|
171
171
|
fontSize="10"
|
|
172
172
|
fill="#4f6076"
|
|
173
173
|
textAnchor="middle"
|
|
174
|
-
|
|
174
|
+
transform={`rotate(-30 ${toX(index)} ${height - 14})`}
|
|
175
175
|
>
|
|
176
176
|
{label}
|
|
177
177
|
</SvgText>
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
const normalizeCell = (value) => {
|
|
5
|
+
if (value === null || value === undefined) {
|
|
6
|
+
return { text: '-', color: '#2b3850', weight: '600' };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (typeof value === 'object' && !Array.isArray(value) && value.text !== undefined) {
|
|
10
|
+
return {
|
|
11
|
+
text: value.text,
|
|
12
|
+
color: value.color || '#2b3850',
|
|
13
|
+
weight: value.weight || '600',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return { text: String(value), color: '#2b3850', weight: '600' };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const renderCell = (col, row, rowIndex, compact) => {
|
|
21
|
+
const raw = col.render ? col.render(row) : row[col.key];
|
|
22
|
+
const cell = normalizeCell(raw);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<View
|
|
26
|
+
key={`cell-${rowIndex}-${col.key}`}
|
|
27
|
+
style={[
|
|
28
|
+
styles.cell,
|
|
29
|
+
{ width: col.width || (compact ? 110 : 126) },
|
|
30
|
+
col.align === 'right' && styles.alignRight,
|
|
31
|
+
]}
|
|
32
|
+
>
|
|
33
|
+
<Text
|
|
34
|
+
numberOfLines={1}
|
|
35
|
+
ellipsizeMode="tail"
|
|
36
|
+
style={[
|
|
37
|
+
styles.cellText,
|
|
38
|
+
{ color: cell.color, fontWeight: cell.weight },
|
|
39
|
+
col.align === 'right' && styles.textRight,
|
|
40
|
+
col.align === 'center' && styles.textCenter,
|
|
41
|
+
]}
|
|
42
|
+
>
|
|
43
|
+
{cell.text}
|
|
44
|
+
</Text>
|
|
45
|
+
</View>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ModernDataTable = ({
|
|
50
|
+
title,
|
|
51
|
+
columns = [],
|
|
52
|
+
rows = [],
|
|
53
|
+
compact = false,
|
|
54
|
+
freezeFirstColumn = false,
|
|
55
|
+
}) => {
|
|
56
|
+
const firstColumn = columns[0];
|
|
57
|
+
const scrollColumns = columns.slice(1);
|
|
58
|
+
const shouldFreeze = freezeFirstColumn && columns.length > 1;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View style={styles.card}>
|
|
62
|
+
<Text style={styles.title}>{title}</Text>
|
|
63
|
+
|
|
64
|
+
{!rows.length ? (
|
|
65
|
+
<View style={styles.emptyWrap}>
|
|
66
|
+
<Text style={styles.emptyText}>No table data for selected filters.</Text>
|
|
67
|
+
</View>
|
|
68
|
+
) : shouldFreeze ? (
|
|
69
|
+
<View style={styles.freezeWrap}>
|
|
70
|
+
<View style={styles.frozenSide}>
|
|
71
|
+
<View style={styles.headerRow}>
|
|
72
|
+
<View
|
|
73
|
+
style={[
|
|
74
|
+
styles.cell,
|
|
75
|
+
styles.headerCell,
|
|
76
|
+
styles.frozenCell,
|
|
77
|
+
{ width: firstColumn.width || (compact ? 110 : 126) },
|
|
78
|
+
firstColumn.align === 'right' && styles.alignRight,
|
|
79
|
+
]}
|
|
80
|
+
>
|
|
81
|
+
<Text
|
|
82
|
+
style={[
|
|
83
|
+
styles.headerText,
|
|
84
|
+
firstColumn.align === 'right' && styles.textRight,
|
|
85
|
+
firstColumn.align === 'center' && styles.textCenter,
|
|
86
|
+
]}
|
|
87
|
+
>
|
|
88
|
+
{firstColumn.label}
|
|
89
|
+
</Text>
|
|
90
|
+
</View>
|
|
91
|
+
</View>
|
|
92
|
+
|
|
93
|
+
{rows.map((row, rowIndex) => (
|
|
94
|
+
<View
|
|
95
|
+
key={`frozen-row-${rowIndex}`}
|
|
96
|
+
style={[
|
|
97
|
+
styles.dataRow,
|
|
98
|
+
rowIndex % 2 === 0 ? styles.rowEven : styles.rowOdd,
|
|
99
|
+
]}
|
|
100
|
+
>
|
|
101
|
+
{renderCell(firstColumn, row, rowIndex, compact)}
|
|
102
|
+
</View>
|
|
103
|
+
))}
|
|
104
|
+
</View>
|
|
105
|
+
|
|
106
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
107
|
+
<View>
|
|
108
|
+
<View style={styles.headerRow}>
|
|
109
|
+
{scrollColumns.map((col) => (
|
|
110
|
+
<View
|
|
111
|
+
key={`header-${col.key}`}
|
|
112
|
+
style={[
|
|
113
|
+
styles.cell,
|
|
114
|
+
styles.headerCell,
|
|
115
|
+
{ width: col.width || (compact ? 110 : 126) },
|
|
116
|
+
col.align === 'right' && styles.alignRight,
|
|
117
|
+
]}
|
|
118
|
+
>
|
|
119
|
+
<Text
|
|
120
|
+
style={[
|
|
121
|
+
styles.headerText,
|
|
122
|
+
col.align === 'right' && styles.textRight,
|
|
123
|
+
col.align === 'center' && styles.textCenter,
|
|
124
|
+
]}
|
|
125
|
+
>
|
|
126
|
+
{col.label}
|
|
127
|
+
</Text>
|
|
128
|
+
</View>
|
|
129
|
+
))}
|
|
130
|
+
</View>
|
|
131
|
+
|
|
132
|
+
{rows.map((row, rowIndex) => (
|
|
133
|
+
<View
|
|
134
|
+
key={`row-${rowIndex}`}
|
|
135
|
+
style={[
|
|
136
|
+
styles.dataRow,
|
|
137
|
+
rowIndex % 2 === 0 ? styles.rowEven : styles.rowOdd,
|
|
138
|
+
]}
|
|
139
|
+
>
|
|
140
|
+
{scrollColumns.map((col) => renderCell(col, row, rowIndex, compact))}
|
|
141
|
+
</View>
|
|
142
|
+
))}
|
|
143
|
+
</View>
|
|
144
|
+
</ScrollView>
|
|
145
|
+
</View>
|
|
146
|
+
) : (
|
|
147
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
148
|
+
<View>
|
|
149
|
+
<View style={styles.headerRow}>
|
|
150
|
+
{columns.map((col) => (
|
|
151
|
+
<View
|
|
152
|
+
key={`header-${col.key}`}
|
|
153
|
+
style={[
|
|
154
|
+
styles.cell,
|
|
155
|
+
styles.headerCell,
|
|
156
|
+
{ width: col.width || (compact ? 110 : 126) },
|
|
157
|
+
col.align === 'right' && styles.alignRight,
|
|
158
|
+
]}
|
|
159
|
+
>
|
|
160
|
+
<Text
|
|
161
|
+
style={[
|
|
162
|
+
styles.headerText,
|
|
163
|
+
col.align === 'right' && styles.textRight,
|
|
164
|
+
col.align === 'center' && styles.textCenter,
|
|
165
|
+
]}
|
|
166
|
+
>
|
|
167
|
+
{col.label}
|
|
168
|
+
</Text>
|
|
169
|
+
</View>
|
|
170
|
+
))}
|
|
171
|
+
</View>
|
|
172
|
+
|
|
173
|
+
{rows.map((row, rowIndex) => (
|
|
174
|
+
<View
|
|
175
|
+
key={`row-${rowIndex}`}
|
|
176
|
+
style={[
|
|
177
|
+
styles.dataRow,
|
|
178
|
+
rowIndex % 2 === 0 ? styles.rowEven : styles.rowOdd,
|
|
179
|
+
]}
|
|
180
|
+
>
|
|
181
|
+
{columns.map((col) => renderCell(col, row, rowIndex, compact))}
|
|
182
|
+
</View>
|
|
183
|
+
))}
|
|
184
|
+
</View>
|
|
185
|
+
</ScrollView>
|
|
186
|
+
)}
|
|
187
|
+
</View>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const styles = StyleSheet.create({
|
|
192
|
+
card: {
|
|
193
|
+
backgroundColor: '#ffffff',
|
|
194
|
+
borderWidth: 1,
|
|
195
|
+
borderColor: '#d4deed',
|
|
196
|
+
borderRadius: 14,
|
|
197
|
+
padding: 10,
|
|
198
|
+
marginBottom: 12,
|
|
199
|
+
},
|
|
200
|
+
title: {
|
|
201
|
+
fontSize: 13,
|
|
202
|
+
fontWeight: '800',
|
|
203
|
+
color: '#1c2f47',
|
|
204
|
+
marginBottom: 8,
|
|
205
|
+
},
|
|
206
|
+
freezeWrap: {
|
|
207
|
+
flexDirection: 'row',
|
|
208
|
+
},
|
|
209
|
+
frozenSide: {
|
|
210
|
+
borderRightWidth: 1,
|
|
211
|
+
borderRightColor: '#c7d7ef',
|
|
212
|
+
backgroundColor: '#fff',
|
|
213
|
+
zIndex: 2,
|
|
214
|
+
},
|
|
215
|
+
headerRow: {
|
|
216
|
+
flexDirection: 'row',
|
|
217
|
+
backgroundColor: '#1f385a',
|
|
218
|
+
borderTopLeftRadius: 10,
|
|
219
|
+
borderTopRightRadius: 10,
|
|
220
|
+
overflow: 'hidden',
|
|
221
|
+
},
|
|
222
|
+
headerCell: {
|
|
223
|
+
borderBottomWidth: 1,
|
|
224
|
+
borderBottomColor: '#2d4d78',
|
|
225
|
+
},
|
|
226
|
+
headerText: {
|
|
227
|
+
fontSize: 11,
|
|
228
|
+
color: '#f3f8ff',
|
|
229
|
+
fontWeight: '700',
|
|
230
|
+
},
|
|
231
|
+
dataRow: {
|
|
232
|
+
flexDirection: 'row',
|
|
233
|
+
},
|
|
234
|
+
rowEven: {
|
|
235
|
+
backgroundColor: '#f8fbff',
|
|
236
|
+
},
|
|
237
|
+
rowOdd: {
|
|
238
|
+
backgroundColor: '#ffffff',
|
|
239
|
+
},
|
|
240
|
+
cell: {
|
|
241
|
+
paddingVertical: 10,
|
|
242
|
+
paddingHorizontal: 8,
|
|
243
|
+
borderRightWidth: 1,
|
|
244
|
+
borderRightColor: '#e2ebf7',
|
|
245
|
+
justifyContent: 'center',
|
|
246
|
+
},
|
|
247
|
+
frozenCell: {
|
|
248
|
+
borderRightColor: '#c7d7ef',
|
|
249
|
+
},
|
|
250
|
+
cellText: {
|
|
251
|
+
fontSize: 11.5,
|
|
252
|
+
color: '#2b3850',
|
|
253
|
+
},
|
|
254
|
+
alignRight: {
|
|
255
|
+
alignItems: 'flex-end',
|
|
256
|
+
},
|
|
257
|
+
textRight: {
|
|
258
|
+
textAlign: 'right',
|
|
259
|
+
},
|
|
260
|
+
textCenter: {
|
|
261
|
+
textAlign: 'center',
|
|
262
|
+
},
|
|
263
|
+
emptyWrap: {
|
|
264
|
+
borderWidth: 1,
|
|
265
|
+
borderColor: '#dce5f3',
|
|
266
|
+
borderRadius: 10,
|
|
267
|
+
paddingVertical: 18,
|
|
268
|
+
alignItems: 'center',
|
|
269
|
+
},
|
|
270
|
+
emptyText: {
|
|
271
|
+
color: '#6b7a91',
|
|
272
|
+
fontSize: 12,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
export default ModernDataTable;
|
package/src/index.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useMemo, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
ActivityIndicator,
|
|
4
4
|
ScrollView,
|
|
@@ -7,10 +7,16 @@ import {
|
|
|
7
7
|
TouchableOpacity,
|
|
8
8
|
View,
|
|
9
9
|
} from 'react-native';
|
|
10
|
-
import
|
|
11
|
-
import
|
|
10
|
+
import fetchReport4 from '../api/report4Fetcher';
|
|
11
|
+
import ModernDataTable from '../components/ModernDataTable';
|
|
12
12
|
import { formatNumber } from '../utils/formatNumber';
|
|
13
13
|
|
|
14
|
+
const TABS = [
|
|
15
|
+
{ key: 'kumule', label: 'KÜMÜLE' },
|
|
16
|
+
{ key: 'faaliyet', label: 'FAALİYET KAR/ZARAR' },
|
|
17
|
+
{ key: 'fg', label: 'FG / BRÜT KAR ORANI' },
|
|
18
|
+
];
|
|
19
|
+
|
|
14
20
|
const toNumber = (value) => {
|
|
15
21
|
const numeric = Number(value);
|
|
16
22
|
return Number.isFinite(numeric) ? numeric : 0;
|
|
@@ -21,86 +27,144 @@ const toPercent = (current, previous) => {
|
|
|
21
27
|
return ((current - previous) / Math.abs(previous)) * 100;
|
|
22
28
|
};
|
|
23
29
|
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const asPercentCell = (value) => {
|
|
31
|
+
const numeric = toNumber(value);
|
|
32
|
+
const positive = numeric >= 0;
|
|
33
|
+
return {
|
|
34
|
+
text: `${positive ? '+' : ''}${numeric.toFixed(1)}%`,
|
|
35
|
+
color: positive ? '#15724a' : '#b43c44',
|
|
36
|
+
weight: '700',
|
|
37
|
+
};
|
|
38
|
+
};
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
40
|
+
const mapRowByTab = (row, tabKey) => {
|
|
41
|
+
if (tabKey === 'faaliyet') {
|
|
42
|
+
return {
|
|
43
|
+
name: row.name,
|
|
44
|
+
value2024: toNumber(row.fk2024),
|
|
45
|
+
value2025: toNumber(row.fk2025),
|
|
46
|
+
change: toNumber(row.changePercent),
|
|
47
|
+
budget2025: toNumber(row.budget2025),
|
|
48
|
+
variance: toNumber(row.budgetVariancePercent),
|
|
49
|
+
ratio2024: null,
|
|
50
|
+
ratio2025: null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (tabKey === 'fg') {
|
|
55
|
+
const ratio2024 = toNumber(row.ratio2024);
|
|
56
|
+
const ratio2025 = toNumber(row.ratio2025);
|
|
57
|
+
return {
|
|
58
|
+
name: row.name,
|
|
59
|
+
value2024: toNumber(row.gross2024),
|
|
60
|
+
value2025: toNumber(row.gross2025),
|
|
61
|
+
change: ratio2025 - ratio2024,
|
|
62
|
+
budget2025: toNumber(row.fk2025 || row.budget2025),
|
|
63
|
+
variance: ratio2025 - ratio2024,
|
|
64
|
+
ratio2024,
|
|
65
|
+
ratio2025,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: row.name,
|
|
71
|
+
value2024: toNumber(row.gross2024),
|
|
72
|
+
value2025: toNumber(row.gross2025),
|
|
73
|
+
change: toNumber(row.grossChangePercent),
|
|
74
|
+
budget2025: toNumber(row.budgetGross2025),
|
|
75
|
+
variance: toNumber(row.budgetGrossVariancePercent),
|
|
76
|
+
ratio2024: toNumber(row.ratio2024),
|
|
77
|
+
ratio2025: toNumber(row.ratio2025),
|
|
78
|
+
};
|
|
56
79
|
};
|
|
57
80
|
|
|
58
|
-
const Report1ModernScreen = ({
|
|
59
|
-
const [
|
|
60
|
-
const [loading, setLoading] = useState(true);
|
|
61
|
-
const [viewMode, setViewMode] = useState('yoy');
|
|
81
|
+
const Report1ModernScreen = ({ api, token, onBack }) => {
|
|
82
|
+
const [activeTab, setActiveTab] = useState('kumule');
|
|
62
83
|
const [sortMode, setSortMode] = useState('growth');
|
|
84
|
+
const [rowsByTab, setRowsByTab] = useState({});
|
|
85
|
+
const [loadedByTab, setLoadedByTab] = useState({});
|
|
86
|
+
const [errorByTab, setErrorByTab] = useState({});
|
|
87
|
+
const [loading, setLoading] = useState(false);
|
|
88
|
+
|
|
89
|
+
const endpointByTab = useMemo(() => ({
|
|
90
|
+
kumule: api?.kumule,
|
|
91
|
+
faaliyet: api?.faaliyetKarZarar,
|
|
92
|
+
fg: api?.fgBrutKarOrani,
|
|
93
|
+
}), [api]);
|
|
94
|
+
|
|
95
|
+
const loadTabData = useCallback(async (tabKey, force = false) => {
|
|
96
|
+
const endpoint = endpointByTab[tabKey];
|
|
97
|
+
|
|
98
|
+
if (!endpoint || !token) {
|
|
99
|
+
setErrorByTab((prev) => ({
|
|
100
|
+
...prev,
|
|
101
|
+
[tabKey]: 'API endpoint or token missing.',
|
|
102
|
+
}));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
63
105
|
|
|
64
|
-
useEffect(() => {
|
|
65
106
|
setLoading(true);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
107
|
+
setErrorByTab((prev) => ({ ...prev, [tabKey]: null }));
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const rows = await fetchReport4(endpoint, token);
|
|
111
|
+
const sortedRows = [...rows].sort(
|
|
112
|
+
(a, b) => (a.sortOrder || 0) - (b.sortOrder || 0),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
setRowsByTab((prev) => ({ ...prev, [tabKey]: sortedRows }));
|
|
116
|
+
setLoadedByTab((prev) => ({ ...prev, [tabKey]: true }));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
setErrorByTab((prev) => ({
|
|
119
|
+
...prev,
|
|
120
|
+
[tabKey]: error?.message || 'Failed to load performance data.',
|
|
121
|
+
}));
|
|
122
|
+
} finally {
|
|
123
|
+
setLoading(false);
|
|
124
|
+
}
|
|
125
|
+
}, [endpointByTab, token]);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
loadTabData(activeTab);
|
|
129
|
+
}, [activeTab, loadTabData]);
|
|
130
|
+
|
|
131
|
+
const activeRawRows = rowsByTab[activeTab] || [];
|
|
132
|
+
const activeError = errorByTab[activeTab];
|
|
133
|
+
const activeLoaded = loadedByTab[activeTab];
|
|
134
|
+
|
|
135
|
+
const mappedRows = useMemo(() => {
|
|
136
|
+
return activeRawRows.map((row) => mapRowByTab(row, activeTab));
|
|
137
|
+
}, [activeRawRows, activeTab]);
|
|
138
|
+
|
|
139
|
+
const sortedRows = useMemo(() => {
|
|
140
|
+
const copy = [...mappedRows];
|
|
141
|
+
if (sortMode === 'name') {
|
|
142
|
+
return copy.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
143
|
+
}
|
|
144
|
+
if (sortMode === 'variance') {
|
|
145
|
+
return copy.sort((a, b) => b.variance - a.variance);
|
|
146
|
+
}
|
|
147
|
+
return copy.sort((a, b) => b.change - a.change);
|
|
148
|
+
}, [mappedRows, sortMode]);
|
|
71
149
|
|
|
72
150
|
const totals = useMemo(() => {
|
|
73
|
-
const total2024 =
|
|
74
|
-
const total2025 =
|
|
75
|
-
const budget2025 =
|
|
151
|
+
const total2024 = sortedRows.reduce((sum, row) => sum + row.value2024, 0);
|
|
152
|
+
const total2025 = sortedRows.reduce((sum, row) => sum + row.value2025, 0);
|
|
153
|
+
const budget2025 = sortedRows.reduce((sum, row) => sum + row.budget2025, 0);
|
|
154
|
+
const avgRatio2025 = sortedRows.length
|
|
155
|
+
? sortedRows.reduce((sum, row) => sum + (row.ratio2025 || 0), 0) / sortedRows.length
|
|
156
|
+
: 0;
|
|
157
|
+
|
|
76
158
|
return {
|
|
77
159
|
total2024,
|
|
78
160
|
total2025,
|
|
79
161
|
budget2025,
|
|
80
162
|
yoy: toPercent(total2025, total2024),
|
|
163
|
+
avgRatio2025,
|
|
81
164
|
};
|
|
82
|
-
}, [
|
|
165
|
+
}, [sortedRows]);
|
|
83
166
|
|
|
84
|
-
const
|
|
85
|
-
const copy = [...rows];
|
|
86
|
-
if (sortMode === 'variance') {
|
|
87
|
-
return copy.sort((a, b) => toNumber(b.budgetVariancePercent) - toNumber(a.budgetVariancePercent));
|
|
88
|
-
}
|
|
89
|
-
if (sortMode === 'name') {
|
|
90
|
-
return copy.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
91
|
-
}
|
|
92
|
-
return copy.sort((a, b) => toNumber(b.actualChangePercent) - toNumber(a.actualChangePercent));
|
|
93
|
-
}, [rows, sortMode]);
|
|
94
|
-
|
|
95
|
-
const topItem = sortedRows[0];
|
|
96
|
-
|
|
97
|
-
if (loading) {
|
|
98
|
-
return (
|
|
99
|
-
<View style={styles.loaderWrap}>
|
|
100
|
-
<ActivityIndicator size="large" color="#ffb155" />
|
|
101
|
-
</View>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
167
|
+
const spotlight = sortedRows[0];
|
|
104
168
|
|
|
105
169
|
return (
|
|
106
170
|
<View style={styles.screen}>
|
|
@@ -111,151 +175,185 @@ const Report1ModernScreen = ({ endpoint, token, onBack }) => {
|
|
|
111
175
|
<Text style={styles.backIcon}>‹</Text>
|
|
112
176
|
</TouchableOpacity>
|
|
113
177
|
<Text style={styles.heroTitle}>Performance Studio</Text>
|
|
114
|
-
<Text style={styles.heroSubtitle}>
|
|
178
|
+
<Text style={styles.heroSubtitle}>Three-tab live performance monitor</Text>
|
|
179
|
+
</View>
|
|
180
|
+
|
|
181
|
+
<View style={styles.tabsContainer}>
|
|
182
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
183
|
+
{TABS.map((tab) => {
|
|
184
|
+
const isActive = tab.key === activeTab;
|
|
185
|
+
return (
|
|
186
|
+
<TouchableOpacity
|
|
187
|
+
key={tab.key}
|
|
188
|
+
style={[styles.tabButton, isActive && styles.activeTabButton]}
|
|
189
|
+
onPress={() => setActiveTab(tab.key)}
|
|
190
|
+
>
|
|
191
|
+
<Text style={[styles.tabText, isActive && styles.activeTabText]}>
|
|
192
|
+
{tab.label}
|
|
193
|
+
</Text>
|
|
194
|
+
</TouchableOpacity>
|
|
195
|
+
);
|
|
196
|
+
})}
|
|
197
|
+
</ScrollView>
|
|
115
198
|
</View>
|
|
116
199
|
|
|
117
200
|
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
|
118
|
-
|
|
119
|
-
<View style={
|
|
120
|
-
<
|
|
121
|
-
<Text style={styles.kpiValue}>{formatNumber(totals.total2024)}</Text>
|
|
122
|
-
</View>
|
|
123
|
-
<View style={[styles.kpiCard, styles.kpiBlue]}>
|
|
124
|
-
<Text style={styles.kpiLabel}>2025 Actual</Text>
|
|
125
|
-
<Text style={styles.kpiValue}>{formatNumber(totals.total2025)}</Text>
|
|
126
|
-
</View>
|
|
127
|
-
</View>
|
|
128
|
-
<View style={styles.kpiRow}>
|
|
129
|
-
<View style={[styles.kpiCard, styles.kpiIndigo]}>
|
|
130
|
-
<Text style={styles.kpiLabel}>2025 Budget</Text>
|
|
131
|
-
<Text style={styles.kpiValue}>{formatNumber(totals.budget2025)}</Text>
|
|
132
|
-
</View>
|
|
133
|
-
<View style={[styles.kpiCard, styles.kpiTeal]}>
|
|
134
|
-
<Text style={styles.kpiLabel}>Total YoY</Text>
|
|
135
|
-
<Text style={styles.kpiValue}>
|
|
136
|
-
{totals.yoy >= 0 ? '+' : ''}
|
|
137
|
-
{totals.yoy.toFixed(1)}%
|
|
138
|
-
</Text>
|
|
139
|
-
</View>
|
|
140
|
-
</View>
|
|
141
|
-
|
|
142
|
-
{topItem ? (
|
|
143
|
-
<View style={styles.spotlightCard}>
|
|
144
|
-
<Text style={styles.spotlightLabel}>SPOTLIGHT</Text>
|
|
145
|
-
<Text style={styles.spotlightName}>{topItem.name}</Text>
|
|
146
|
-
<Text style={styles.spotlightValue}>
|
|
147
|
-
{formatNumber(topItem.actual2025)} in 2025 • {toNumber(topItem.actualChangePercent).toFixed(1)}% YoY
|
|
148
|
-
</Text>
|
|
201
|
+
{loading && !activeLoaded ? (
|
|
202
|
+
<View style={styles.center}>
|
|
203
|
+
<ActivityIndicator size="large" color="#ffb155" />
|
|
149
204
|
</View>
|
|
150
205
|
) : null}
|
|
151
206
|
|
|
152
|
-
|
|
153
|
-
<View style={styles.
|
|
154
|
-
<
|
|
155
|
-
style={[styles.segmentBtn, viewMode === 'yoy' && styles.segmentBtnActive]}
|
|
156
|
-
onPress={() => setViewMode('yoy')}
|
|
157
|
-
>
|
|
158
|
-
<Text style={[styles.segmentText, viewMode === 'yoy' && styles.segmentTextActive]}>YoY</Text>
|
|
159
|
-
</TouchableOpacity>
|
|
160
|
-
<TouchableOpacity
|
|
161
|
-
style={[styles.segmentBtn, viewMode === 'budget' && styles.segmentBtnActive]}
|
|
162
|
-
onPress={() => setViewMode('budget')}
|
|
163
|
-
>
|
|
164
|
-
<Text style={[styles.segmentText, viewMode === 'budget' && styles.segmentTextActive]}>Budget</Text>
|
|
165
|
-
</TouchableOpacity>
|
|
207
|
+
{!loading && activeError && !sortedRows.length ? (
|
|
208
|
+
<View style={styles.center}>
|
|
209
|
+
<Text style={styles.errorText}>{activeError}</Text>
|
|
166
210
|
<TouchableOpacity
|
|
167
|
-
style={
|
|
168
|
-
onPress={() =>
|
|
211
|
+
style={styles.retryButton}
|
|
212
|
+
onPress={() => loadTabData(activeTab, true)}
|
|
169
213
|
>
|
|
170
|
-
<Text style={
|
|
214
|
+
<Text style={styles.retryText}>Retry</Text>
|
|
171
215
|
</TouchableOpacity>
|
|
172
216
|
</View>
|
|
217
|
+
) : null}
|
|
173
218
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
{sortedRows.map((item) => {
|
|
197
|
-
const actual2024 = toNumber(item.actual2024);
|
|
198
|
-
const actual2025 = toNumber(item.actual2025);
|
|
199
|
-
const budget2025 = toNumber(item.budget2025);
|
|
200
|
-
const growth = toNumber(item.actualChangePercent);
|
|
201
|
-
const variance = toNumber(item.budgetVariancePercent);
|
|
202
|
-
const efficiency = toNumber(item.opexToGrossProfitPercent);
|
|
203
|
-
const maxForBar = Math.max(1, actual2024, actual2025, budget2025);
|
|
204
|
-
const bar2024Width = `${(actual2024 / maxForBar) * 100}%`;
|
|
205
|
-
const bar2025Width = `${(actual2025 / maxForBar) * 100}%`;
|
|
206
|
-
const budgetWidth = `${(budget2025 / maxForBar) * 100}%`;
|
|
207
|
-
|
|
208
|
-
const badgeValue =
|
|
209
|
-
viewMode === 'budget' ? variance : viewMode === 'efficiency' ? efficiency : growth;
|
|
210
|
-
|
|
211
|
-
return (
|
|
212
|
-
<View key={item.name} style={styles.card}>
|
|
213
|
-
<View style={styles.cardHeader}>
|
|
214
|
-
<Text style={styles.cardTitle}>{item.name}</Text>
|
|
215
|
-
<Text style={[styles.badge, badgeValue >= 0 ? styles.badgeUp : styles.badgeDown]}>
|
|
216
|
-
{badgeValue >= 0 ? '+' : ''}
|
|
217
|
-
{badgeValue.toFixed(1)}%
|
|
219
|
+
{sortedRows.length ? (
|
|
220
|
+
<>
|
|
221
|
+
<View style={styles.kpiRow}>
|
|
222
|
+
<View style={[styles.kpiCard, styles.kpiWarm]}>
|
|
223
|
+
<Text style={styles.kpiLabel}>2024 Value</Text>
|
|
224
|
+
<Text style={styles.kpiValue}>{formatNumber(totals.total2024)}</Text>
|
|
225
|
+
</View>
|
|
226
|
+
<View style={[styles.kpiCard, styles.kpiBlue]}>
|
|
227
|
+
<Text style={styles.kpiLabel}>2025 Value</Text>
|
|
228
|
+
<Text style={styles.kpiValue}>{formatNumber(totals.total2025)}</Text>
|
|
229
|
+
</View>
|
|
230
|
+
</View>
|
|
231
|
+
<View style={styles.kpiRow}>
|
|
232
|
+
<View style={[styles.kpiCard, styles.kpiIndigo]}>
|
|
233
|
+
<Text style={styles.kpiLabel}>2025 Budget</Text>
|
|
234
|
+
<Text style={styles.kpiValue}>{formatNumber(totals.budget2025)}</Text>
|
|
235
|
+
</View>
|
|
236
|
+
<View style={[styles.kpiCard, styles.kpiTeal]}>
|
|
237
|
+
<Text style={styles.kpiLabel}>Total Change</Text>
|
|
238
|
+
<Text style={styles.kpiValue}>
|
|
239
|
+
{totals.yoy >= 0 ? '+' : ''}
|
|
240
|
+
{totals.yoy.toFixed(1)}%
|
|
218
241
|
</Text>
|
|
219
242
|
</View>
|
|
243
|
+
</View>
|
|
220
244
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
<
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
245
|
+
<View style={styles.insightStrip}>
|
|
246
|
+
<View>
|
|
247
|
+
<Text style={styles.insightLabel}>Average Ratio 2025</Text>
|
|
248
|
+
<Text style={styles.insightValue}>{totals.avgRatio2025.toFixed(2)}%</Text>
|
|
249
|
+
</View>
|
|
250
|
+
<View>
|
|
251
|
+
<Text style={styles.insightLabel}>Tab</Text>
|
|
252
|
+
<Text style={styles.insightValue}>{TABS.find((x) => x.key === activeTab)?.label}</Text>
|
|
227
253
|
</View>
|
|
254
|
+
</View>
|
|
228
255
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
256
|
+
{spotlight ? (
|
|
257
|
+
<View style={styles.spotlightCard}>
|
|
258
|
+
<Text style={styles.spotlightLabel}>SPOTLIGHT</Text>
|
|
259
|
+
<Text style={styles.spotlightName}>{spotlight.name}</Text>
|
|
260
|
+
<Text style={styles.spotlightValue}>
|
|
261
|
+
2025: {formatNumber(spotlight.value2025)} • Change: {spotlight.change >= 0 ? '+' : ''}
|
|
262
|
+
{spotlight.change.toFixed(1)}%
|
|
263
|
+
</Text>
|
|
235
264
|
</View>
|
|
265
|
+
) : null}
|
|
236
266
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
267
|
+
<View style={styles.sortRow}>
|
|
268
|
+
<TouchableOpacity
|
|
269
|
+
style={[styles.sortChip, sortMode === 'growth' && styles.sortChipActive]}
|
|
270
|
+
onPress={() => setSortMode('growth')}
|
|
271
|
+
>
|
|
272
|
+
<Text style={[styles.sortChipText, sortMode === 'growth' && styles.sortChipTextActive]}>
|
|
273
|
+
Top Growth
|
|
274
|
+
</Text>
|
|
275
|
+
</TouchableOpacity>
|
|
276
|
+
<TouchableOpacity
|
|
277
|
+
style={[styles.sortChip, sortMode === 'variance' && styles.sortChipActive]}
|
|
278
|
+
onPress={() => setSortMode('variance')}
|
|
279
|
+
>
|
|
280
|
+
<Text style={[styles.sortChipText, sortMode === 'variance' && styles.sortChipTextActive]}>
|
|
281
|
+
Top Variance
|
|
282
|
+
</Text>
|
|
283
|
+
</TouchableOpacity>
|
|
284
|
+
<TouchableOpacity
|
|
285
|
+
style={[styles.sortChip, sortMode === 'name' && styles.sortChipActive]}
|
|
286
|
+
onPress={() => setSortMode('name')}
|
|
287
|
+
>
|
|
288
|
+
<Text style={[styles.sortChipText, sortMode === 'name' && styles.sortChipTextActive]}>
|
|
289
|
+
A-Z
|
|
290
|
+
</Text>
|
|
291
|
+
</TouchableOpacity>
|
|
292
|
+
</View>
|
|
293
|
+
|
|
294
|
+
{sortedRows.map((item) => {
|
|
295
|
+
const maxForBar = Math.max(1, item.value2024, item.value2025, item.budget2025);
|
|
296
|
+
const width2024 = `${(item.value2024 / maxForBar) * 100}%`;
|
|
297
|
+
const width2025 = `${(item.value2025 / maxForBar) * 100}%`;
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<View key={item.name} style={styles.card}>
|
|
301
|
+
<View style={styles.cardHeader}>
|
|
302
|
+
<Text style={styles.cardTitle}>{item.name}</Text>
|
|
303
|
+
<Text style={[styles.badge, item.change >= 0 ? styles.badgeUp : styles.badgeDown]}>
|
|
304
|
+
{item.change >= 0 ? '+' : ''}
|
|
305
|
+
{item.change.toFixed(1)}%
|
|
306
|
+
</Text>
|
|
242
307
|
</View>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
308
|
+
|
|
309
|
+
<View style={styles.barRow}>
|
|
310
|
+
<Text style={styles.barLabel}>2024</Text>
|
|
311
|
+
<View style={styles.track}>
|
|
312
|
+
<View style={[styles.fill2024, { width: width2024 }]} />
|
|
313
|
+
</View>
|
|
314
|
+
<Text style={styles.barValue}>{formatNumber(item.value2024)}</Text>
|
|
315
|
+
</View>
|
|
316
|
+
|
|
317
|
+
<View style={styles.barRow}>
|
|
318
|
+
<Text style={styles.barLabel}>2025</Text>
|
|
319
|
+
<View style={styles.track}>
|
|
320
|
+
<View style={[styles.fill2025, { width: width2025 }]} />
|
|
321
|
+
</View>
|
|
322
|
+
<Text style={styles.barValue}>{formatNumber(item.value2025)}</Text>
|
|
253
323
|
</View>
|
|
254
324
|
</View>
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
325
|
+
);
|
|
326
|
+
})}
|
|
327
|
+
|
|
328
|
+
<ModernDataTable
|
|
329
|
+
title="Performance Table"
|
|
330
|
+
freezeFirstColumn
|
|
331
|
+
columns={[
|
|
332
|
+
{ key: 'name', label: 'Activity', width: 150 },
|
|
333
|
+
{ key: 'value2024', label: '2024', width: 120, align: 'right', render: (row) => formatNumber(row.value2024) },
|
|
334
|
+
{ key: 'value2025', label: '2025', width: 120, align: 'right', render: (row) => formatNumber(row.value2025) },
|
|
335
|
+
{ key: 'change', label: 'Change %', width: 90, align: 'right', render: (row) => asPercentCell(row.change) },
|
|
336
|
+
{ key: 'budget2025', label: 'Budget 2025', width: 130, align: 'right', render: (row) => formatNumber(row.budget2025) },
|
|
337
|
+
{ key: 'variance', label: 'Variance %', width: 95, align: 'right', render: (row) => asPercentCell(row.variance) },
|
|
338
|
+
{
|
|
339
|
+
key: 'ratio2024',
|
|
340
|
+
label: 'Ratio 2024',
|
|
341
|
+
width: 92,
|
|
342
|
+
align: 'right',
|
|
343
|
+
render: (row) => (row.ratio2024 === null ? '-' : `${row.ratio2024.toFixed(2)}%`),
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
key: 'ratio2025',
|
|
347
|
+
label: 'Ratio 2025',
|
|
348
|
+
width: 92,
|
|
349
|
+
align: 'right',
|
|
350
|
+
render: (row) => (row.ratio2025 === null ? '-' : `${row.ratio2025.toFixed(2)}%`),
|
|
351
|
+
},
|
|
352
|
+
]}
|
|
353
|
+
rows={sortedRows}
|
|
354
|
+
/>
|
|
355
|
+
</>
|
|
356
|
+
) : null}
|
|
259
357
|
|
|
260
358
|
<View style={styles.footerSpacing} />
|
|
261
359
|
</ScrollView>
|
|
@@ -268,12 +366,6 @@ const styles = StyleSheet.create({
|
|
|
268
366
|
flex: 1,
|
|
269
367
|
backgroundColor: '#0f1726',
|
|
270
368
|
},
|
|
271
|
-
loaderWrap: {
|
|
272
|
-
flex: 1,
|
|
273
|
-
justifyContent: 'center',
|
|
274
|
-
alignItems: 'center',
|
|
275
|
-
backgroundColor: '#0f1726',
|
|
276
|
-
},
|
|
277
369
|
hero: {
|
|
278
370
|
paddingHorizontal: 16,
|
|
279
371
|
paddingTop: 14,
|
|
@@ -325,11 +417,58 @@ const styles = StyleSheet.create({
|
|
|
325
417
|
fontSize: 13,
|
|
326
418
|
fontWeight: '500',
|
|
327
419
|
},
|
|
420
|
+
tabsContainer: {
|
|
421
|
+
backgroundColor: '#111b2d',
|
|
422
|
+
borderBottomWidth: 1,
|
|
423
|
+
borderBottomColor: '#2f3b57',
|
|
424
|
+
paddingVertical: 8,
|
|
425
|
+
paddingHorizontal: 8,
|
|
426
|
+
},
|
|
427
|
+
tabButton: {
|
|
428
|
+
paddingHorizontal: 14,
|
|
429
|
+
paddingVertical: 9,
|
|
430
|
+
borderRadius: 8,
|
|
431
|
+
marginHorizontal: 4,
|
|
432
|
+
backgroundColor: '#18233a',
|
|
433
|
+
borderWidth: 1,
|
|
434
|
+
borderColor: '#35415e',
|
|
435
|
+
},
|
|
436
|
+
activeTabButton: {
|
|
437
|
+
backgroundColor: '#ffb155',
|
|
438
|
+
borderColor: '#ffb155',
|
|
439
|
+
},
|
|
440
|
+
tabText: {
|
|
441
|
+
fontSize: 12,
|
|
442
|
+
fontWeight: '700',
|
|
443
|
+
color: '#c5d0e8',
|
|
444
|
+
},
|
|
445
|
+
activeTabText: {
|
|
446
|
+
color: '#2e2720',
|
|
447
|
+
},
|
|
328
448
|
content: {
|
|
329
449
|
flex: 1,
|
|
330
450
|
paddingHorizontal: 14,
|
|
331
451
|
paddingTop: 14,
|
|
332
452
|
},
|
|
453
|
+
center: {
|
|
454
|
+
paddingVertical: 24,
|
|
455
|
+
alignItems: 'center',
|
|
456
|
+
},
|
|
457
|
+
errorText: {
|
|
458
|
+
color: '#ffb3bb',
|
|
459
|
+
textAlign: 'center',
|
|
460
|
+
marginBottom: 10,
|
|
461
|
+
},
|
|
462
|
+
retryButton: {
|
|
463
|
+
backgroundColor: '#ffb155',
|
|
464
|
+
borderRadius: 8,
|
|
465
|
+
paddingHorizontal: 14,
|
|
466
|
+
paddingVertical: 8,
|
|
467
|
+
},
|
|
468
|
+
retryText: {
|
|
469
|
+
color: '#2e2720',
|
|
470
|
+
fontWeight: '700',
|
|
471
|
+
},
|
|
333
472
|
kpiRow: {
|
|
334
473
|
flexDirection: 'row',
|
|
335
474
|
marginBottom: 10,
|
|
@@ -368,6 +507,27 @@ const styles = StyleSheet.create({
|
|
|
368
507
|
fontWeight: '800',
|
|
369
508
|
color: '#1d2738',
|
|
370
509
|
},
|
|
510
|
+
insightStrip: {
|
|
511
|
+
backgroundColor: '#1a2640',
|
|
512
|
+
borderColor: '#303d58',
|
|
513
|
+
borderWidth: 1,
|
|
514
|
+
borderRadius: 14,
|
|
515
|
+
paddingHorizontal: 12,
|
|
516
|
+
paddingVertical: 10,
|
|
517
|
+
flexDirection: 'row',
|
|
518
|
+
justifyContent: 'space-between',
|
|
519
|
+
marginBottom: 10,
|
|
520
|
+
},
|
|
521
|
+
insightLabel: {
|
|
522
|
+
fontSize: 11,
|
|
523
|
+
color: '#9eb1d3',
|
|
524
|
+
marginBottom: 4,
|
|
525
|
+
},
|
|
526
|
+
insightValue: {
|
|
527
|
+
color: '#fff',
|
|
528
|
+
fontWeight: '800',
|
|
529
|
+
fontSize: 13,
|
|
530
|
+
},
|
|
371
531
|
spotlightCard: {
|
|
372
532
|
backgroundColor: '#1a2640',
|
|
373
533
|
borderColor: '#303d58',
|
|
@@ -375,7 +535,7 @@ const styles = StyleSheet.create({
|
|
|
375
535
|
borderRadius: 14,
|
|
376
536
|
paddingHorizontal: 12,
|
|
377
537
|
paddingVertical: 12,
|
|
378
|
-
marginBottom:
|
|
538
|
+
marginBottom: 10,
|
|
379
539
|
},
|
|
380
540
|
spotlightLabel: {
|
|
381
541
|
fontSize: 10,
|
|
@@ -394,37 +554,9 @@ const styles = StyleSheet.create({
|
|
|
394
554
|
fontSize: 12,
|
|
395
555
|
color: '#c9d5ed',
|
|
396
556
|
},
|
|
397
|
-
controlsWrap: {
|
|
398
|
-
marginBottom: 12,
|
|
399
|
-
},
|
|
400
|
-
segmentRow: {
|
|
401
|
-
flexDirection: 'row',
|
|
402
|
-
backgroundColor: '#1a2640',
|
|
403
|
-
borderRadius: 12,
|
|
404
|
-
borderWidth: 1,
|
|
405
|
-
borderColor: '#35415e',
|
|
406
|
-
padding: 4,
|
|
407
|
-
marginBottom: 8,
|
|
408
|
-
},
|
|
409
|
-
segmentBtn: {
|
|
410
|
-
flex: 1,
|
|
411
|
-
alignItems: 'center',
|
|
412
|
-
paddingVertical: 9,
|
|
413
|
-
borderRadius: 10,
|
|
414
|
-
},
|
|
415
|
-
segmentBtnActive: {
|
|
416
|
-
backgroundColor: '#ffb155',
|
|
417
|
-
},
|
|
418
|
-
segmentText: {
|
|
419
|
-
color: '#c0cee8',
|
|
420
|
-
fontWeight: '700',
|
|
421
|
-
fontSize: 12,
|
|
422
|
-
},
|
|
423
|
-
segmentTextActive: {
|
|
424
|
-
color: '#2e2720',
|
|
425
|
-
},
|
|
426
557
|
sortRow: {
|
|
427
558
|
flexDirection: 'row',
|
|
559
|
+
marginBottom: 10,
|
|
428
560
|
},
|
|
429
561
|
sortChip: {
|
|
430
562
|
paddingVertical: 6,
|
|
@@ -483,12 +615,12 @@ const styles = StyleSheet.create({
|
|
|
483
615
|
backgroundColor: '#522d34',
|
|
484
616
|
color: '#ff9ca6',
|
|
485
617
|
},
|
|
486
|
-
|
|
618
|
+
barRow: {
|
|
487
619
|
flexDirection: 'row',
|
|
488
620
|
alignItems: 'center',
|
|
489
621
|
marginBottom: 8,
|
|
490
622
|
},
|
|
491
|
-
|
|
623
|
+
barLabel: {
|
|
492
624
|
width: 54,
|
|
493
625
|
color: '#bac8e1',
|
|
494
626
|
fontSize: 11,
|
|
@@ -512,37 +644,13 @@ const styles = StyleSheet.create({
|
|
|
512
644
|
backgroundColor: '#6db2ff',
|
|
513
645
|
borderRadius: 999,
|
|
514
646
|
},
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
backgroundColor: '#c8cfff',
|
|
518
|
-
borderRadius: 999,
|
|
519
|
-
},
|
|
520
|
-
rowValue: {
|
|
521
|
-
width: 86,
|
|
647
|
+
barValue: {
|
|
648
|
+
width: 92,
|
|
522
649
|
textAlign: 'right',
|
|
523
650
|
color: '#e4ecff',
|
|
524
651
|
fontSize: 11,
|
|
525
652
|
fontWeight: '700',
|
|
526
653
|
},
|
|
527
|
-
efficiencyRow: {
|
|
528
|
-
marginTop: 4,
|
|
529
|
-
flexDirection: 'row',
|
|
530
|
-
alignItems: 'center',
|
|
531
|
-
},
|
|
532
|
-
efficiencyTextWrap: {
|
|
533
|
-
marginLeft: 10,
|
|
534
|
-
flex: 1,
|
|
535
|
-
},
|
|
536
|
-
efficiencyLabel: {
|
|
537
|
-
color: '#aebadb',
|
|
538
|
-
fontSize: 11,
|
|
539
|
-
},
|
|
540
|
-
efficiencyValue: {
|
|
541
|
-
marginTop: 3,
|
|
542
|
-
color: '#ffbf72',
|
|
543
|
-
fontSize: 16,
|
|
544
|
-
fontWeight: '800',
|
|
545
|
-
},
|
|
546
654
|
footerSpacing: {
|
|
547
655
|
height: 14,
|
|
548
656
|
},
|
|
@@ -13,6 +13,7 @@ import DivisionFilterModal from '../components/DivisionFilterModal';
|
|
|
13
13
|
import MonthFilterModal from '../components/MonthFilterModal';
|
|
14
14
|
import CompactAxisLineChart from '../components/CompactAxisLineChart';
|
|
15
15
|
import CompactAxisBarLineChart from '../components/CompactAxisBarLineChart';
|
|
16
|
+
import ModernDataTable from '../components/ModernDataTable';
|
|
16
17
|
import { filterChartByMonths } from '../utils/filterChartByMonths';
|
|
17
18
|
import { formatNumber } from '../utils/formatNumber';
|
|
18
19
|
|
|
@@ -26,6 +27,14 @@ const toPercent = (current, previous) => {
|
|
|
26
27
|
return ((current - previous) / Math.abs(previous)) * 100;
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
const toMonthKey = (value) => {
|
|
31
|
+
return String(value ?? '')
|
|
32
|
+
.trim()
|
|
33
|
+
.toLocaleUpperCase('tr-TR')
|
|
34
|
+
.normalize('NFD')
|
|
35
|
+
.replace(/[\u0300-\u036f]/g, '');
|
|
36
|
+
};
|
|
37
|
+
|
|
29
38
|
const TrendBadge = ({ value }) => {
|
|
30
39
|
const positive = value >= 0;
|
|
31
40
|
return (
|
|
@@ -37,6 +46,16 @@ const TrendBadge = ({ value }) => {
|
|
|
37
46
|
);
|
|
38
47
|
};
|
|
39
48
|
|
|
49
|
+
const percentCell = (value) => {
|
|
50
|
+
const numeric = toNumber(value);
|
|
51
|
+
const positive = numeric >= 0;
|
|
52
|
+
return {
|
|
53
|
+
text: `${positive ? '+' : ''}${numeric.toFixed(1)}%`,
|
|
54
|
+
color: positive ? '#15724a' : '#b43c44',
|
|
55
|
+
weight: '700',
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
40
59
|
const KpiCard = ({ label, value, hint, tone }) => {
|
|
41
60
|
const toneStyle = tone === 'warm' ? styles.kpiWarm : tone === 'mint' ? styles.kpiMint : styles.kpiCool;
|
|
42
61
|
return (
|
|
@@ -90,7 +109,8 @@ const Report2ModernScreen = ({ api, token, onBack }) => {
|
|
|
90
109
|
if (!preserveSelection) {
|
|
91
110
|
return monthLabels;
|
|
92
111
|
}
|
|
93
|
-
const
|
|
112
|
+
const monthKeys = new Set(monthLabels.map(toMonthKey));
|
|
113
|
+
const kept = prev.filter((month) => monthKeys.has(toMonthKey(month)));
|
|
94
114
|
return kept.length ? kept : monthLabels;
|
|
95
115
|
});
|
|
96
116
|
},
|
|
@@ -136,7 +156,8 @@ const Report2ModernScreen = ({ api, token, onBack }) => {
|
|
|
136
156
|
|
|
137
157
|
const rows = useMemo(() => {
|
|
138
158
|
if (!selectedMonths.length) return [];
|
|
139
|
-
|
|
159
|
+
const selectedKeys = new Set(selectedMonths.map(toMonthKey));
|
|
160
|
+
return baseRows.filter((row) => selectedKeys.has(toMonthKey(row.monthLabel)));
|
|
140
161
|
}, [baseRows, selectedMonths]);
|
|
141
162
|
|
|
142
163
|
const filteredLine = useMemo(() => {
|
|
@@ -285,6 +306,23 @@ const Report2ModernScreen = ({ api, token, onBack }) => {
|
|
|
285
306
|
</View>
|
|
286
307
|
))}
|
|
287
308
|
|
|
309
|
+
<ModernDataTable
|
|
310
|
+
title="Gross Profit Table"
|
|
311
|
+
freezeFirstColumn
|
|
312
|
+
columns={[
|
|
313
|
+
{ key: 'monthLabel', label: 'Month', width: 110 },
|
|
314
|
+
{ key: 'teu2024', label: '2024 TEU', width: 108, align: 'right', render: (row) => formatNumber(row.teu2024) },
|
|
315
|
+
{ key: 'teu2025', label: '2025 TEU', width: 108, align: 'right', render: (row) => formatNumber(row.teu2025) },
|
|
316
|
+
{ key: 'teuChangePercent', label: 'TEU %', width: 88, align: 'right', render: (row) => percentCell(row.teuChangePercent) },
|
|
317
|
+
{ key: 'profitUsd2024', label: '2024 Profit', width: 120, align: 'right', render: (row) => formatNumber(row.profitUsd2024) },
|
|
318
|
+
{ key: 'profitUsd2025', label: '2025 Profit', width: 120, align: 'right', render: (row) => formatNumber(row.profitUsd2025) },
|
|
319
|
+
{ key: 'profitChangePercent', label: 'Profit %', width: 92, align: 'right', render: (row) => percentCell(row.profitChangePercent) },
|
|
320
|
+
{ key: 'budgetProfitUsd2025', label: '2025 Budget', width: 122, align: 'right', render: (row) => formatNumber(row.budgetProfitUsd2025) },
|
|
321
|
+
{ key: 'budgetChangePercent', label: 'Budget %', width: 96, align: 'right', render: (row) => percentCell(row.budgetChangePercent) },
|
|
322
|
+
]}
|
|
323
|
+
rows={rows}
|
|
324
|
+
/>
|
|
325
|
+
|
|
288
326
|
{!rows.length ? (
|
|
289
327
|
<View style={styles.emptyWrap}>
|
|
290
328
|
<Text style={styles.emptyText}>No months selected. Please update month filter.</Text>
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import MonthFilterModal from '../components/MonthFilterModal';
|
|
18
18
|
import CompactAxisBarLineChart from '../components/CompactAxisBarLineChart';
|
|
19
19
|
import CompactAxisLineChart from '../components/CompactAxisLineChart';
|
|
20
|
+
import ModernDataTable from '../components/ModernDataTable';
|
|
20
21
|
import { filterChartByMonths } from '../utils/filterChartByMonths';
|
|
21
22
|
|
|
22
23
|
const toNumber = (value) => {
|
|
@@ -34,6 +35,14 @@ const percentOf = (part, total) => {
|
|
|
34
35
|
return (part / total) * 100;
|
|
35
36
|
};
|
|
36
37
|
|
|
38
|
+
const toMonthKey = (value) => {
|
|
39
|
+
return String(value ?? '')
|
|
40
|
+
.trim()
|
|
41
|
+
.toLocaleUpperCase('tr-TR')
|
|
42
|
+
.normalize('NFD')
|
|
43
|
+
.replace(/[\u0300-\u036f]/g, '');
|
|
44
|
+
};
|
|
45
|
+
|
|
37
46
|
const compactNumber = (value) => {
|
|
38
47
|
const num = toNumber(value);
|
|
39
48
|
if (Math.abs(num) >= 1000000000) return `${(num / 1000000000).toFixed(2)}B`;
|
|
@@ -50,6 +59,16 @@ const StatTile = ({ label, value, hint, tone }) => (
|
|
|
50
59
|
</View>
|
|
51
60
|
);
|
|
52
61
|
|
|
62
|
+
const percentCell = (value) => {
|
|
63
|
+
const numeric = toNumber(value);
|
|
64
|
+
const positive = numeric >= 0;
|
|
65
|
+
return {
|
|
66
|
+
text: `${positive ? '+' : ''}${numeric.toFixed(1)}%`,
|
|
67
|
+
color: positive ? '#15724a' : '#b43c44',
|
|
68
|
+
weight: '700',
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
53
72
|
const RingGauge = ({ progress }) => {
|
|
54
73
|
const size = 58;
|
|
55
74
|
const stroke = 8;
|
|
@@ -109,7 +128,8 @@ const Report3ModernScreen = ({ api, token, onBack }) => {
|
|
|
109
128
|
const months = lineRes?.labels || [];
|
|
110
129
|
setSelectedMonths((prev) => {
|
|
111
130
|
if (!preserveSelection) return months;
|
|
112
|
-
const
|
|
131
|
+
const monthKeys = new Set(months.map(toMonthKey));
|
|
132
|
+
const kept = prev.filter((month) => monthKeys.has(toMonthKey(month)));
|
|
113
133
|
return kept.length ? kept : months;
|
|
114
134
|
});
|
|
115
135
|
},
|
|
@@ -142,7 +162,8 @@ const Report3ModernScreen = ({ api, token, onBack }) => {
|
|
|
142
162
|
|
|
143
163
|
const rows = useMemo(() => {
|
|
144
164
|
if (!selectedMonths.length) return [];
|
|
145
|
-
|
|
165
|
+
const selectedKeys = new Set(selectedMonths.map(toMonthKey));
|
|
166
|
+
return baseRows.filter((row) => selectedKeys.has(toMonthKey(row.monthLabel)));
|
|
146
167
|
}, [baseRows, selectedMonths]);
|
|
147
168
|
|
|
148
169
|
const filteredLine = useMemo(() => {
|
|
@@ -355,6 +376,23 @@ const Report3ModernScreen = ({ api, token, onBack }) => {
|
|
|
355
376
|
);
|
|
356
377
|
})}
|
|
357
378
|
|
|
379
|
+
<ModernDataTable
|
|
380
|
+
title="Transportation Table"
|
|
381
|
+
freezeFirstColumn
|
|
382
|
+
columns={[
|
|
383
|
+
{ key: 'monthLabel', label: 'Month', width: 110 },
|
|
384
|
+
{ key: 'loadCount2024', label: '2024 Load', width: 110, align: 'right', render: (row) => compactNumber(row.loadCount2024) },
|
|
385
|
+
{ key: 'loadCount2025', label: '2025 Load', width: 110, align: 'right', render: (row) => compactNumber(row.loadCount2025) },
|
|
386
|
+
{ key: 'loadCountChangePercent', label: 'Load %', width: 90, align: 'right', render: (row) => percentCell(row.loadCountChangePercent) },
|
|
387
|
+
{ key: 'revenueTl2024', label: '2024 Revenue', width: 122, align: 'right', render: (row) => compactNumber(row.revenueTl2024) },
|
|
388
|
+
{ key: 'revenueTl2025', label: '2025 Revenue', width: 122, align: 'right', render: (row) => compactNumber(row.revenueTl2025) },
|
|
389
|
+
{ key: 'revenueChangePercent', label: 'Revenue %', width: 95, align: 'right', render: (row) => percentCell(row.revenueChangePercent) },
|
|
390
|
+
{ key: 'budgetRevenueTl2025', label: '2025 Budget', width: 122, align: 'right', render: (row) => compactNumber(row.budgetRevenueTl2025) },
|
|
391
|
+
{ key: 'budgetChangePercent', label: 'Budget %', width: 95, align: 'right', render: (row) => percentCell(row.budgetChangePercent) },
|
|
392
|
+
]}
|
|
393
|
+
rows={rows}
|
|
394
|
+
/>
|
|
395
|
+
|
|
358
396
|
{!rows.length ? (
|
|
359
397
|
<View style={styles.emptyWrap}>
|
|
360
398
|
<Text style={styles.emptyText}>No data for selected month filter.</Text>
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
export const filterChartByMonths = (chart, selectedMonths) => {
|
|
2
|
-
if (!
|
|
2
|
+
if (!chart) return chart;
|
|
3
|
+
if (!selectedMonths) return chart;
|
|
4
|
+
if (selectedMonths.length === 0) {
|
|
5
|
+
return {
|
|
6
|
+
...chart,
|
|
7
|
+
labels: [],
|
|
8
|
+
series: (chart.series || []).map((s) => ({
|
|
9
|
+
...s,
|
|
10
|
+
data: [],
|
|
11
|
+
})),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const toMonthKey = (value) => {
|
|
16
|
+
return String(value ?? '')
|
|
17
|
+
.trim()
|
|
18
|
+
.toLocaleUpperCase('tr-TR')
|
|
19
|
+
.normalize('NFD')
|
|
20
|
+
.replace(/[\u0300-\u036f]/g, '');
|
|
21
|
+
};
|
|
22
|
+
const selectedKeys = new Set(selectedMonths.map(toMonthKey));
|
|
3
23
|
|
|
4
24
|
const indices = chart.labels
|
|
5
|
-
.map((label, i) => (
|
|
25
|
+
.map((label, i) => (selectedKeys.has(toMonthKey(label)) ? i : -1))
|
|
6
26
|
.filter(i => i !== -1);
|
|
7
27
|
|
|
8
28
|
return {
|