@suflon/rnmd-reporting 0.0.1
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/App.tsx +69 -0
- package/README.md +97 -0
- package/app.json +4 -0
- package/babel.config.js +18 -0
- package/global.css +143 -0
- package/index.js +9 -0
- package/metro.config.js +209 -0
- package/nativewind-env.d.ts +1 -0
- package/package.json +106 -0
- package/patches/@react-navigation+stack+7.6.16.patch +11 -0
- package/patches/@suflon+native-ui+0.0.18.patch +26020 -0
- package/patches/react-native+0.83.1.patch +52 -0
- package/react-native.config.js +27 -0
- package/scripts/fix-suflon-native-ui.js +25 -0
- package/scripts/link-react-native-pnpm.js +42 -0
- package/src/config/index.ts +10 -0
- package/src/context/ConnectionI18nContext.tsx +61 -0
- package/src/modules/Reporting/component/README.md +3 -0
- package/src/modules/Reporting/component/ReportChart.tsx +882 -0
- package/src/modules/Reporting/component/ReportFilterModal.tsx +403 -0
- package/src/modules/Reporting/component/ReportingDetail.tsx +481 -0
- package/src/modules/Reporting/index.tsx +239 -0
- package/src/modules/Reporting/utils.tsx +14 -0
- package/src/navigation/index.tsx +31 -0
- package/src/screens/ConnectionListScreen.tsx +471 -0
- package/src/screens/DevToolsCorner.tsx +810 -0
- package/src/screens/NewConnectionModal.tsx +282 -0
- package/src/services/ApiService.ts +22 -0
- package/src/services/ConnectionService.ts +81 -0
- package/src/services/api.ts +83 -0
- package/src/stores/connection.store.ts +3 -0
- package/src/stores/language.store.ts +54 -0
- package/src/theme/colors.ts +56 -0
- package/src/types/connection.ts +69 -0
- package/src/utils/AsyncStorageUtils.ts +56 -0
- package/src/utils/connectionStrings.ts +158 -0
- package/src/utils/errorMessage.ts +11 -0
- package/tailwind.config.js +196 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import React, { useState, useMemo } from 'react';
|
|
2
|
+
import { View, Text, Dimensions, TouchableOpacity, ScrollView, PanResponder } from 'react-native';
|
|
3
|
+
import Svg, { Path, Circle, G, Line as SvgLine, Text as SvgText } from 'react-native-svg';
|
|
4
|
+
import { themeColors } from '@/theme/colors';
|
|
5
|
+
import { Icon } from '@suflon/native-ui';
|
|
6
|
+
|
|
7
|
+
interface ChartData {
|
|
8
|
+
label: string;
|
|
9
|
+
value: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ReportChartProps {
|
|
13
|
+
data: ChartData[];
|
|
14
|
+
records?: any[];
|
|
15
|
+
visibleKeys?: string[];
|
|
16
|
+
type?: 'BAR' | 'LINE' | 'AREA';
|
|
17
|
+
title?: string;
|
|
18
|
+
subtitle?: string;
|
|
19
|
+
color?: string;
|
|
20
|
+
height?: number;
|
|
21
|
+
isDarkMode?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const formatKeyLabel = (key: string) => {
|
|
25
|
+
return key
|
|
26
|
+
.replace(/_/g, ' ')
|
|
27
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Format full date YYYY-MM-DD to clean Month name (e.g. Jul '26)
|
|
31
|
+
const formatMonthLabel = (label: string) => {
|
|
32
|
+
if (!label) return '';
|
|
33
|
+
if (/^\d{4}-\d{2}(-\d{2})?/.test(label)) {
|
|
34
|
+
try {
|
|
35
|
+
const parts = label.split('-');
|
|
36
|
+
const year = parts[0];
|
|
37
|
+
const monthNum = parseInt(parts[1], 10);
|
|
38
|
+
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
39
|
+
if (monthNum >= 1 && monthNum <= 12) {
|
|
40
|
+
return `${monthNames[monthNum - 1]} '${year.slice(2)}`;
|
|
41
|
+
}
|
|
42
|
+
} catch (e) {}
|
|
43
|
+
}
|
|
44
|
+
return label;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const STATUS_CONFIG: Record<string, { key: string; label: string; color: string; bgLight: string; borderLight: string }> = {
|
|
48
|
+
completed: { key: 'completed', label: 'Completed', color: '#10B981', bgLight: '#ECFDF5', borderLight: '#A7F3D0' },
|
|
49
|
+
scheduled: { key: 'scheduled', label: 'Scheduled', color: '#F59E0B', bgLight: '#FEF3C7', borderLight: '#FDE68A' },
|
|
50
|
+
cancelled: { key: 'cancelled', label: 'Cancelled', color: '#EF4444', bgLight: '#FEE2E2', borderLight: '#FCA5A5' },
|
|
51
|
+
noshow: { key: 'noshow', label: 'No-Show', color: '#8B5CF6', bgLight: '#F3E8FF', borderLight: '#DDD6FE' },
|
|
52
|
+
rescheduled: { key: 'rescheduled', label: 'Rescheduled', color: '#3B82F6', bgLight: '#EFF6FF', borderLight: '#BFDBFE' },
|
|
53
|
+
upcoming: { key: 'upcoming', label: 'Upcoming', color: '#06B6D4', bgLight: '#CFFAFE', borderLight: '#A5F3FC' },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const MAX_VISIBLE_POINTS = 15;
|
|
57
|
+
|
|
58
|
+
const ReportChart: React.FC<ReportChartProps> = ({
|
|
59
|
+
data = [],
|
|
60
|
+
records = [],
|
|
61
|
+
visibleKeys = [],
|
|
62
|
+
type: initialType = 'LINE',
|
|
63
|
+
title = 'Top 10 Diagnoses',
|
|
64
|
+
subtitle = 'GRAPH VISUALIZATION',
|
|
65
|
+
color = themeColors.brand[600],
|
|
66
|
+
height = 240,
|
|
67
|
+
isDarkMode = false,
|
|
68
|
+
}) => {
|
|
69
|
+
const [chartType, setChartType] = useState<'BAR' | 'LINE' | 'AREA'>(initialType);
|
|
70
|
+
const [viewMode, setViewMode] = useState<'ALL_STATUS' | 'COMBINED'>('ALL_STATUS');
|
|
71
|
+
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
|
72
|
+
|
|
73
|
+
// Active status legend toggles
|
|
74
|
+
const [activeStatusMap, setActiveStatusMap] = useState<Record<string, boolean>>({
|
|
75
|
+
completed: true,
|
|
76
|
+
scheduled: true,
|
|
77
|
+
cancelled: true,
|
|
78
|
+
noshow: true,
|
|
79
|
+
rescheduled: true,
|
|
80
|
+
upcoming: true,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const toggleStatus = (statusKey: string) => {
|
|
84
|
+
setActiveStatusMap(prev => ({
|
|
85
|
+
...prev,
|
|
86
|
+
[statusKey]: !prev[statusKey]
|
|
87
|
+
}));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Dark mode colors
|
|
91
|
+
const colors = useMemo(() => ({
|
|
92
|
+
cardBg: isDarkMode ? '#1C1C1E' : themeColors.white,
|
|
93
|
+
cardBorder: isDarkMode ? '#2C2C2E' : themeColors.slate[200],
|
|
94
|
+
textPrimary: isDarkMode ? '#FFFFFF' : themeColors.slate[900],
|
|
95
|
+
textSecondary: isDarkMode ? '#9CA3AF' : themeColors.slate[500],
|
|
96
|
+
textMuted: isDarkMode ? '#94A3B8' : '#94A3B8',
|
|
97
|
+
gridLine: isDarkMode ? '#2C2C2E' : '#F1F5F9',
|
|
98
|
+
pillBg: isDarkMode ? '#2C2C2E' : '#F1F5F9',
|
|
99
|
+
pillActiveBg: '#7C3AED',
|
|
100
|
+
pillActiveText: '#FFFFFF',
|
|
101
|
+
pillText: isDarkMode ? '#9CA3AF' : themeColors.slate[600],
|
|
102
|
+
progressBg: isDarkMode ? '#2C2C2E' : themeColors.slate[100],
|
|
103
|
+
listBg: isDarkMode ? '#141518' : themeColors.slate[50],
|
|
104
|
+
}), [isDarkMode]);
|
|
105
|
+
|
|
106
|
+
const screenWidth = Dimensions.get('window').width - 32;
|
|
107
|
+
|
|
108
|
+
// Detect if series labels are date strings
|
|
109
|
+
const isDateSeries = useMemo(() => {
|
|
110
|
+
if (records && records.length > 0) {
|
|
111
|
+
return records.some(r => /^\d{4}-\d{2}/.test(String(r.date || r.label || r.group_key || '')));
|
|
112
|
+
}
|
|
113
|
+
return data.some(d => /^\d{4}-\d{2}/.test(d.label));
|
|
114
|
+
}, [records, data]);
|
|
115
|
+
|
|
116
|
+
// Dedicated Y-Axis Margin & Bottom space for labels
|
|
117
|
+
const paddingLeft = 45;
|
|
118
|
+
const paddingRight = 20;
|
|
119
|
+
const paddingTop = 25;
|
|
120
|
+
const paddingBottom = isDateSeries ? 36 : 52;
|
|
121
|
+
|
|
122
|
+
// Detect if records have status columns (completed, scheduled, cancelled, noshow, etc.)
|
|
123
|
+
const availableStatusKeys = useMemo(() => {
|
|
124
|
+
if (!records || records.length === 0) return [];
|
|
125
|
+
const firstRow = records[0];
|
|
126
|
+
return Object.keys(firstRow).filter(k => STATUS_CONFIG[k.toLowerCase()]);
|
|
127
|
+
}, [records]);
|
|
128
|
+
|
|
129
|
+
const isMultiStatusAvailable = availableStatusKeys.length > 0;
|
|
130
|
+
|
|
131
|
+
// Prepare multi-status items list
|
|
132
|
+
const multiStatusItems = useMemo(() => {
|
|
133
|
+
if (!records || records.length === 0) return [];
|
|
134
|
+
return records.map((r, idx) => {
|
|
135
|
+
const keys = Object.keys(r);
|
|
136
|
+
const labelKey = keys.find(k => ['diagnosis_title', 'group_key', 'label', 'name', 'title', 'status', 'date'].includes(k)) || keys[0] || `Item ${idx + 1}`;
|
|
137
|
+
const label = String(r[labelKey] ?? `Item ${idx + 1}`);
|
|
138
|
+
|
|
139
|
+
const statusValues: Record<string, number> = {};
|
|
140
|
+
availableStatusKeys.forEach(sk => {
|
|
141
|
+
statusValues[sk] = Number(r[sk]) || 0;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const total = Object.values(statusValues).reduce((sum, v) => sum + v, 0);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
label,
|
|
148
|
+
total,
|
|
149
|
+
statusValues,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}, [records, availableStatusKeys]);
|
|
153
|
+
|
|
154
|
+
// Sort items chronologically if date strings (e.g. 2026-05-01 -> 2026-06-01 -> 2026-07-01 -> 2026-08-01)
|
|
155
|
+
const sortedMultiStatusItems = useMemo(() => {
|
|
156
|
+
if (!multiStatusItems || multiStatusItems.length === 0) return [];
|
|
157
|
+
|
|
158
|
+
if (isDateSeries) {
|
|
159
|
+
return [...multiStatusItems].sort((a, b) => a.label.localeCompare(b.label));
|
|
160
|
+
}
|
|
161
|
+
return multiStatusItems;
|
|
162
|
+
}, [multiStatusItems, isDateSeries]);
|
|
163
|
+
|
|
164
|
+
// Limit visible points for chart to avoid crowding
|
|
165
|
+
const hasOverflow = sortedMultiStatusItems.length > MAX_VISIBLE_POINTS || data.length > MAX_VISIBLE_POINTS;
|
|
166
|
+
const visibleDataItems = useMemo(() => {
|
|
167
|
+
if (isMultiStatusAvailable) {
|
|
168
|
+
return hasOverflow ? sortedMultiStatusItems.slice(0, MAX_VISIBLE_POINTS) : sortedMultiStatusItems;
|
|
169
|
+
} else {
|
|
170
|
+
const raw = data.map(d => ({ label: d.label, total: d.value, statusValues: {} }));
|
|
171
|
+
const isRawDateSeries = raw.every(item => /^\d{4}-\d{2}/.test(item.label));
|
|
172
|
+
const sortedRaw = isRawDateSeries ? [...raw].sort((a, b) => a.label.localeCompare(b.label)) : raw;
|
|
173
|
+
return hasOverflow ? sortedRaw.slice(0, MAX_VISIBLE_POINTS) : sortedRaw;
|
|
174
|
+
}
|
|
175
|
+
}, [isMultiStatusAvailable, sortedMultiStatusItems, data, hasOverflow]);
|
|
176
|
+
|
|
177
|
+
const totalCases = useMemo(() => {
|
|
178
|
+
if (isMultiStatusAvailable) {
|
|
179
|
+
return multiStatusItems.reduce((sum, item) => sum + item.total, 0);
|
|
180
|
+
}
|
|
181
|
+
return data.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
|
182
|
+
}, [isMultiStatusAvailable, multiStatusItems, data]);
|
|
183
|
+
|
|
184
|
+
// Dynamic table columns
|
|
185
|
+
const tableColumns = useMemo(() => {
|
|
186
|
+
if (visibleKeys && visibleKeys.length > 0) return visibleKeys;
|
|
187
|
+
if (records && records.length > 0) {
|
|
188
|
+
return Object.keys(records[0]).filter(
|
|
189
|
+
k => !['id', 'region_id', 'company_id', 'staff_id', 'patient_id'].includes(k)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return [];
|
|
193
|
+
}, [visibleKeys, records]);
|
|
194
|
+
|
|
195
|
+
const tableRows = records;
|
|
196
|
+
|
|
197
|
+
// SVG Layout Dimensions
|
|
198
|
+
const effectiveChartWidth = hasOverflow
|
|
199
|
+
? Math.max(visibleDataItems.length * 60, screenWidth - paddingLeft - paddingRight)
|
|
200
|
+
: screenWidth - paddingLeft - paddingRight;
|
|
201
|
+
const totalSvgWidth = effectiveChartWidth + paddingLeft + paddingRight;
|
|
202
|
+
const chartHeight = height - paddingTop - paddingBottom;
|
|
203
|
+
|
|
204
|
+
// Calculate maximum Y value considering active status lines / bars
|
|
205
|
+
const maxValue = useMemo(() => {
|
|
206
|
+
let maxVal = 10;
|
|
207
|
+
if (isMultiStatusAvailable && viewMode === 'ALL_STATUS') {
|
|
208
|
+
visibleDataItems.forEach(item => {
|
|
209
|
+
availableStatusKeys.forEach(sk => {
|
|
210
|
+
if (activeStatusMap[sk]) {
|
|
211
|
+
maxVal = Math.max(maxVal, item.statusValues[sk] || 0);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
visibleDataItems.forEach(item => {
|
|
217
|
+
maxVal = Math.max(maxVal, item.total || 0);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return Math.ceil(maxVal * 1.2) || 10;
|
|
221
|
+
}, [isMultiStatusAvailable, viewMode, visibleDataItems, availableStatusKeys, activeStatusMap]);
|
|
222
|
+
|
|
223
|
+
// Precise Dot-to-Dot point coordinates starting right at number side
|
|
224
|
+
const xPoints = useMemo(() => {
|
|
225
|
+
if (visibleDataItems.length <= 1) {
|
|
226
|
+
return [paddingLeft + effectiveChartWidth / 2];
|
|
227
|
+
}
|
|
228
|
+
const startX = paddingLeft + 16;
|
|
229
|
+
const availableWidth = effectiveChartWidth - 32;
|
|
230
|
+
return visibleDataItems.map((_, i) => {
|
|
231
|
+
return startX + (i / (visibleDataItems.length - 1)) * availableWidth;
|
|
232
|
+
});
|
|
233
|
+
}, [visibleDataItems, paddingLeft, effectiveChartWidth]);
|
|
234
|
+
|
|
235
|
+
// Snap Dot-to-Dot Scrubbing
|
|
236
|
+
const handleTouch = (xPos: number) => {
|
|
237
|
+
if (!xPoints || xPoints.length === 0) return;
|
|
238
|
+
let closestIdx = 0;
|
|
239
|
+
let minDistance = Infinity;
|
|
240
|
+
|
|
241
|
+
xPoints.forEach((px, idx) => {
|
|
242
|
+
const dist = Math.abs(px - xPos);
|
|
243
|
+
if (dist < minDistance) {
|
|
244
|
+
minDistance = dist;
|
|
245
|
+
closestIdx = idx;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
setSelectedIndex(closestIdx);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const panResponder = useMemo(
|
|
253
|
+
() =>
|
|
254
|
+
PanResponder.create({
|
|
255
|
+
onStartShouldSetPanResponder: () => true,
|
|
256
|
+
onStartShouldSetPanResponderCapture: () => true,
|
|
257
|
+
onMoveShouldSetPanResponder: () => true,
|
|
258
|
+
onMoveShouldSetPanResponderCapture: () => true,
|
|
259
|
+
onPanResponderGrant: (evt) => {
|
|
260
|
+
handleTouch(evt.nativeEvent.locationX);
|
|
261
|
+
},
|
|
262
|
+
onPanResponderMove: (evt) => {
|
|
263
|
+
handleTouch(evt.nativeEvent.locationX);
|
|
264
|
+
},
|
|
265
|
+
onPanResponderRelease: () => {},
|
|
266
|
+
}),
|
|
267
|
+
[xPoints]
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (visibleDataItems.length === 0) {
|
|
271
|
+
return (
|
|
272
|
+
<View style={{
|
|
273
|
+
backgroundColor: colors.cardBg,
|
|
274
|
+
borderRadius: 20,
|
|
275
|
+
padding: 30,
|
|
276
|
+
borderWidth: 1,
|
|
277
|
+
borderColor: colors.cardBorder,
|
|
278
|
+
alignItems: 'center',
|
|
279
|
+
justifyContent: 'center',
|
|
280
|
+
}}>
|
|
281
|
+
<Text style={{ color: colors.textSecondary, fontSize: 13, fontWeight: '600' }}>
|
|
282
|
+
No report data available for visualization
|
|
283
|
+
</Text>
|
|
284
|
+
</View>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Render Multi-Line Curves with Dot-to-Dot nodes (No shadow / fill area)
|
|
289
|
+
const renderLineCurves = () => {
|
|
290
|
+
if (isMultiStatusAvailable && viewMode === 'ALL_STATUS') {
|
|
291
|
+
return availableStatusKeys.map((statusKey) => {
|
|
292
|
+
if (!activeStatusMap[statusKey]) return null;
|
|
293
|
+
const statusCfg = STATUS_CONFIG[statusKey] || { color: color, label: statusKey };
|
|
294
|
+
|
|
295
|
+
const linePoints = visibleDataItems.map((item, i) => {
|
|
296
|
+
const val = item.statusValues[statusKey] || 0;
|
|
297
|
+
const y = paddingTop + chartHeight - (val / maxValue) * chartHeight;
|
|
298
|
+
return { x: xPoints[i], y, value: val };
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (linePoints.length < 1) return null;
|
|
302
|
+
|
|
303
|
+
let pathData = `M ${linePoints[0].x} ${linePoints[0].y}`;
|
|
304
|
+
for (let i = 0; i < linePoints.length - 1; i++) {
|
|
305
|
+
const p0 = linePoints[i];
|
|
306
|
+
const p1 = linePoints[i + 1];
|
|
307
|
+
const cp1x = p0.x + (p1.x - p0.x) / 2;
|
|
308
|
+
pathData += ` C ${cp1x} ${p0.y}, ${cp1x} ${p1.y}, ${p1.x} ${p1.y}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<G key={statusKey}>
|
|
313
|
+
<Path
|
|
314
|
+
d={pathData}
|
|
315
|
+
fill="none"
|
|
316
|
+
stroke={statusCfg.color}
|
|
317
|
+
strokeWidth="3.5"
|
|
318
|
+
strokeLinecap="round"
|
|
319
|
+
/>
|
|
320
|
+
{/* Dot to Dot Circles */}
|
|
321
|
+
{linePoints.map((p, i) => (
|
|
322
|
+
<Circle
|
|
323
|
+
key={i}
|
|
324
|
+
cx={p.x}
|
|
325
|
+
cy={p.y}
|
|
326
|
+
r={i === selectedIndex ? "7" : "5"}
|
|
327
|
+
fill={statusCfg.color}
|
|
328
|
+
stroke={colors.cardBg}
|
|
329
|
+
strokeWidth="2"
|
|
330
|
+
/>
|
|
331
|
+
))}
|
|
332
|
+
</G>
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
} else {
|
|
336
|
+
// Combined single clean stroke line (No shadow / gradient area under curve)
|
|
337
|
+
const combinedPoints = visibleDataItems.map((item, i) => {
|
|
338
|
+
const val = item.total;
|
|
339
|
+
const y = paddingTop + chartHeight - (val / maxValue) * chartHeight;
|
|
340
|
+
return { x: xPoints[i], y, value: val };
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (combinedPoints.length < 1) return null;
|
|
344
|
+
|
|
345
|
+
let pathData = `M ${combinedPoints[0].x} ${combinedPoints[0].y}`;
|
|
346
|
+
for (let i = 0; i < combinedPoints.length - 1; i++) {
|
|
347
|
+
const p0 = combinedPoints[i];
|
|
348
|
+
const p1 = combinedPoints[i + 1];
|
|
349
|
+
const cp1x = p0.x + (p1.x - p0.x) / 2;
|
|
350
|
+
pathData += ` C ${cp1x} ${p0.y}, ${cp1x} ${p1.y}, ${p1.x} ${p1.y}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<G>
|
|
355
|
+
<Path d={pathData} fill="none" stroke={themeColors.brand[600]} strokeWidth="3.5" strokeLinecap="round" />
|
|
356
|
+
{combinedPoints.map((p, i) => (
|
|
357
|
+
<Circle
|
|
358
|
+
key={i}
|
|
359
|
+
cx={p.x}
|
|
360
|
+
cy={p.y}
|
|
361
|
+
r={i === selectedIndex ? "7" : "5"}
|
|
362
|
+
fill={i === selectedIndex ? "#7C3AED" : themeColors.brand[600]}
|
|
363
|
+
stroke={colors.cardBg}
|
|
364
|
+
strokeWidth="2"
|
|
365
|
+
/>
|
|
366
|
+
))}
|
|
367
|
+
</G>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Render Side-by-Side Grouped Bars
|
|
373
|
+
const renderBarChart = () => {
|
|
374
|
+
const stepWidth = visibleDataItems.length > 1 ? (effectiveChartWidth - 32) / (visibleDataItems.length - 1) : 40;
|
|
375
|
+
|
|
376
|
+
if (isMultiStatusAvailable && viewMode === 'ALL_STATUS') {
|
|
377
|
+
const activeKeys = availableStatusKeys.filter(sk => activeStatusMap[sk]);
|
|
378
|
+
const numBars = activeKeys.length || 1;
|
|
379
|
+
const totalGroupWidth = Math.min(stepWidth * 0.75, 40);
|
|
380
|
+
const singleBarWidth = Math.max(totalGroupWidth / numBars - 2, 4);
|
|
381
|
+
|
|
382
|
+
return visibleDataItems.map((item, i) => {
|
|
383
|
+
const groupCenterX = xPoints[i];
|
|
384
|
+
const startX = groupCenterX - (numBars * (singleBarWidth + 2)) / 2;
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<G key={i}>
|
|
388
|
+
{activeKeys.map((statusKey, bIdx) => {
|
|
389
|
+
const val = item.statusValues[statusKey] || 0;
|
|
390
|
+
const barH = Math.max((val / maxValue) * chartHeight, 3);
|
|
391
|
+
const bx = startX + bIdx * (singleBarWidth + 2);
|
|
392
|
+
const by = paddingTop + chartHeight - barH;
|
|
393
|
+
const statusCfg = STATUS_CONFIG[statusKey] || { color: color };
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<Path
|
|
397
|
+
key={statusKey}
|
|
398
|
+
d={`M ${bx} ${paddingTop + chartHeight} L ${bx} ${by + 3} Q ${bx} ${by} ${bx + 3} ${by} L ${bx + singleBarWidth - 3} ${by} Q ${bx + singleBarWidth} ${by} ${bx + singleBarWidth} ${by + 3} L ${bx + singleBarWidth} ${paddingTop + chartHeight} Z`}
|
|
399
|
+
fill={statusCfg.color}
|
|
400
|
+
opacity={i === selectedIndex ? 1 : 0.85}
|
|
401
|
+
/>
|
|
402
|
+
);
|
|
403
|
+
})}
|
|
404
|
+
</G>
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
} else {
|
|
408
|
+
// Combined single bar chart
|
|
409
|
+
const maxBarWidth = 26;
|
|
410
|
+
const barWidth = Math.min(stepWidth * 0.55, maxBarWidth);
|
|
411
|
+
|
|
412
|
+
return visibleDataItems.map((item, i) => {
|
|
413
|
+
const val = item.total;
|
|
414
|
+
const x = xPoints[i] - barWidth / 2;
|
|
415
|
+
const barH = Math.max((val / maxValue) * chartHeight, 4);
|
|
416
|
+
const y = paddingTop + chartHeight - barH;
|
|
417
|
+
const isSelected = i === selectedIndex;
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<G key={i}>
|
|
421
|
+
<Path
|
|
422
|
+
d={`M ${x} ${paddingTop + chartHeight} L ${x} ${y + 4} Q ${x} ${y} ${x + 4} ${y} L ${x + barWidth - 4} ${y} Q ${x + barWidth} ${y} ${x + barWidth} ${y + 4} L ${x + barWidth} ${paddingTop + chartHeight} Z`}
|
|
423
|
+
fill={isSelected ? '#6D28D9' : color}
|
|
424
|
+
opacity={isSelected ? 1 : 0.85}
|
|
425
|
+
/>
|
|
426
|
+
</G>
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const selectedItem = selectedIndex !== null && selectedIndex < visibleDataItems.length ? visibleDataItems[selectedIndex] : null;
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<View style={{ gap: 14 }}>
|
|
436
|
+
{/* Graph Visualization Card (Clean flat surface, no shadow) */}
|
|
437
|
+
<View style={{
|
|
438
|
+
backgroundColor: colors.cardBg,
|
|
439
|
+
borderRadius: 20,
|
|
440
|
+
padding: 16,
|
|
441
|
+
borderWidth: 1,
|
|
442
|
+
borderColor: colors.cardBorder,
|
|
443
|
+
}}>
|
|
444
|
+
{/* Title Row on Top (Full Width) */}
|
|
445
|
+
<View style={{ marginBottom: 12 }}>
|
|
446
|
+
<Text style={{ fontSize: 16, fontWeight: '800', color: colors.textPrimary }}>
|
|
447
|
+
{title}
|
|
448
|
+
</Text>
|
|
449
|
+
</View>
|
|
450
|
+
|
|
451
|
+
{/* Mode Switcher Group: Multi-Line View / Combined & Chart Type Toggle */}
|
|
452
|
+
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
453
|
+
{isMultiStatusAvailable ? (
|
|
454
|
+
<View style={{ flexDirection: 'row', backgroundColor: colors.pillBg, padding: 3, borderRadius: 10 }}>
|
|
455
|
+
<TouchableOpacity
|
|
456
|
+
onPress={() => setViewMode('ALL_STATUS')}
|
|
457
|
+
style={{
|
|
458
|
+
paddingHorizontal: 12,
|
|
459
|
+
paddingVertical: 6,
|
|
460
|
+
borderRadius: 8,
|
|
461
|
+
backgroundColor: viewMode === 'ALL_STATUS' ? '#7C3AED' : 'transparent',
|
|
462
|
+
}}
|
|
463
|
+
>
|
|
464
|
+
<Text style={{ fontSize: 11, fontWeight: '800', color: viewMode === 'ALL_STATUS' ? '#FFFFFF' : colors.pillText }}>
|
|
465
|
+
All Status Lines
|
|
466
|
+
</Text>
|
|
467
|
+
</TouchableOpacity>
|
|
468
|
+
<TouchableOpacity
|
|
469
|
+
onPress={() => setViewMode('COMBINED')}
|
|
470
|
+
style={{
|
|
471
|
+
paddingHorizontal: 12,
|
|
472
|
+
paddingVertical: 6,
|
|
473
|
+
borderRadius: 8,
|
|
474
|
+
backgroundColor: viewMode === 'COMBINED' ? '#7C3AED' : 'transparent',
|
|
475
|
+
}}
|
|
476
|
+
>
|
|
477
|
+
<Text style={{ fontSize: 11, fontWeight: '800', color: viewMode === 'COMBINED' ? '#FFFFFF' : colors.pillText }}>
|
|
478
|
+
Combined
|
|
479
|
+
</Text>
|
|
480
|
+
</TouchableOpacity>
|
|
481
|
+
</View>
|
|
482
|
+
) : <View />}
|
|
483
|
+
|
|
484
|
+
<View style={{ flexDirection: 'row', backgroundColor: colors.pillBg, padding: 3, borderRadius: 8, gap: 2 }}>
|
|
485
|
+
<TouchableOpacity
|
|
486
|
+
onPress={() => setChartType('LINE')}
|
|
487
|
+
style={{
|
|
488
|
+
paddingHorizontal: 8,
|
|
489
|
+
paddingVertical: 6,
|
|
490
|
+
borderRadius: 6,
|
|
491
|
+
backgroundColor: chartType === 'LINE' || chartType === 'AREA' ? colors.pillActiveBg : 'transparent',
|
|
492
|
+
}}
|
|
493
|
+
>
|
|
494
|
+
<Icon
|
|
495
|
+
name="trending-up"
|
|
496
|
+
type="Feather"
|
|
497
|
+
size={14}
|
|
498
|
+
color={chartType === 'LINE' || chartType === 'AREA' ? '#FFFFFF' : colors.pillText}
|
|
499
|
+
/>
|
|
500
|
+
</TouchableOpacity>
|
|
501
|
+
<TouchableOpacity
|
|
502
|
+
onPress={() => setChartType('BAR')}
|
|
503
|
+
style={{
|
|
504
|
+
paddingHorizontal: 8,
|
|
505
|
+
paddingVertical: 6,
|
|
506
|
+
borderRadius: 6,
|
|
507
|
+
backgroundColor: chartType === 'BAR' ? colors.pillActiveBg : 'transparent',
|
|
508
|
+
}}
|
|
509
|
+
>
|
|
510
|
+
<Icon
|
|
511
|
+
name="bar-chart-2"
|
|
512
|
+
type="Feather"
|
|
513
|
+
size={14}
|
|
514
|
+
color={chartType === 'BAR' ? '#FFFFFF' : colors.pillText}
|
|
515
|
+
/>
|
|
516
|
+
</TouchableOpacity>
|
|
517
|
+
</View>
|
|
518
|
+
</View>
|
|
519
|
+
|
|
520
|
+
{/* Status Legend Pills Row */}
|
|
521
|
+
{isMultiStatusAvailable && viewMode === 'ALL_STATUS' && (
|
|
522
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
|
523
|
+
{availableStatusKeys.map(sk => {
|
|
524
|
+
const cfg = STATUS_CONFIG[sk] || { label: formatKeyLabel(sk), color: '#6B7280', bgLight: '#F3F4F6', borderLight: '#E5E7EB' };
|
|
525
|
+
const isActive = activeStatusMap[sk];
|
|
526
|
+
return (
|
|
527
|
+
<TouchableOpacity
|
|
528
|
+
key={sk}
|
|
529
|
+
onPress={() => toggleStatus(sk)}
|
|
530
|
+
style={{
|
|
531
|
+
flexDirection: 'row',
|
|
532
|
+
alignItems: 'center',
|
|
533
|
+
gap: 5,
|
|
534
|
+
paddingHorizontal: 11,
|
|
535
|
+
paddingVertical: 5,
|
|
536
|
+
borderRadius: 20,
|
|
537
|
+
borderWidth: 1.5,
|
|
538
|
+
backgroundColor: isActive ? (isDarkMode ? `${cfg.color}25` : cfg.bgLight) : (isDarkMode ? '#2C2C2E' : '#F8FAFC'),
|
|
539
|
+
borderColor: isActive ? cfg.color : (isDarkMode ? '#3A3A3C' : '#E2E8F0'),
|
|
540
|
+
opacity: isActive ? 1 : 0.4,
|
|
541
|
+
}}
|
|
542
|
+
>
|
|
543
|
+
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: cfg.color }} />
|
|
544
|
+
<Text style={{ fontSize: 11, fontWeight: '800', color: isActive ? (isDarkMode ? '#FFFFFF' : cfg.color) : colors.textMuted }}>
|
|
545
|
+
{cfg.label}
|
|
546
|
+
</Text>
|
|
547
|
+
</TouchableOpacity>
|
|
548
|
+
);
|
|
549
|
+
})}
|
|
550
|
+
</View>
|
|
551
|
+
)}
|
|
552
|
+
|
|
553
|
+
{/* Selected Point Floating Scrub Tooltip Banner */}
|
|
554
|
+
{selectedItem ? (
|
|
555
|
+
<View style={{
|
|
556
|
+
backgroundColor: colors.listBg,
|
|
557
|
+
paddingHorizontal: 12,
|
|
558
|
+
paddingVertical: 8,
|
|
559
|
+
borderRadius: 12,
|
|
560
|
+
marginBottom: 10,
|
|
561
|
+
borderWidth: 1,
|
|
562
|
+
borderColor: colors.cardBorder,
|
|
563
|
+
}}>
|
|
564
|
+
<Text style={{ fontSize: 11, fontWeight: '900', color: colors.textPrimary, marginBottom: 4 }}>
|
|
565
|
+
{isDateSeries ? formatMonthLabel(selectedItem.label) : selectedItem.label}
|
|
566
|
+
</Text>
|
|
567
|
+
|
|
568
|
+
{isMultiStatusAvailable && viewMode === 'ALL_STATUS' ? (
|
|
569
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
570
|
+
{availableStatusKeys.map(sk => {
|
|
571
|
+
if (!activeStatusMap[sk]) return null;
|
|
572
|
+
const cfg = STATUS_CONFIG[sk];
|
|
573
|
+
const val = selectedItem.statusValues[sk] || 0;
|
|
574
|
+
return (
|
|
575
|
+
<View key={sk} style={{ flexDirection: 'row', alignItems: 'center', gap: 3 }}>
|
|
576
|
+
<View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: cfg?.color || '#6B7280' }} />
|
|
577
|
+
<Text style={{ fontSize: 10, fontWeight: '700', color: colors.textSecondary }}>
|
|
578
|
+
{cfg?.label || sk}: <Text style={{ fontWeight: '900', color: colors.textPrimary }}>{val}</Text>
|
|
579
|
+
</Text>
|
|
580
|
+
</View>
|
|
581
|
+
);
|
|
582
|
+
})}
|
|
583
|
+
</View>
|
|
584
|
+
) : (
|
|
585
|
+
<Text style={{ fontSize: 11, fontWeight: '800', color: themeColors.brand[600] }}>
|
|
586
|
+
Total: {selectedItem.total} Cases
|
|
587
|
+
</Text>
|
|
588
|
+
)}
|
|
589
|
+
</View>
|
|
590
|
+
) : null}
|
|
591
|
+
|
|
592
|
+
{/* SVG Chart Canvas with PanResponder Scrubbing */}
|
|
593
|
+
<ScrollView horizontal={hasOverflow} showsHorizontalScrollIndicator={false}>
|
|
594
|
+
<View
|
|
595
|
+
{...panResponder.panHandlers}
|
|
596
|
+
style={{ width: hasOverflow ? totalSvgWidth : '100%', position: 'relative' }}
|
|
597
|
+
>
|
|
598
|
+
<Svg height={height} width={hasOverflow ? totalSvgWidth : screenWidth}>
|
|
599
|
+
{/* Horizontal Grid lines */}
|
|
600
|
+
{[0, 0.25, 0.5, 0.75, 1].map((p, i) => {
|
|
601
|
+
const yPos = paddingTop + p * chartHeight;
|
|
602
|
+
return (
|
|
603
|
+
<SvgLine
|
|
604
|
+
key={i}
|
|
605
|
+
x1={paddingLeft}
|
|
606
|
+
y1={yPos}
|
|
607
|
+
x2={hasOverflow ? totalSvgWidth - paddingRight : screenWidth - paddingRight}
|
|
608
|
+
y2={yPos}
|
|
609
|
+
stroke={colors.gridLine}
|
|
610
|
+
strokeDasharray="4,4"
|
|
611
|
+
strokeWidth="1"
|
|
612
|
+
/>
|
|
613
|
+
);
|
|
614
|
+
})}
|
|
615
|
+
|
|
616
|
+
{/* Left Y-axis divider line */}
|
|
617
|
+
<SvgLine
|
|
618
|
+
x1={paddingLeft}
|
|
619
|
+
y1={paddingTop - 10}
|
|
620
|
+
x2={paddingLeft}
|
|
621
|
+
y2={paddingTop + chartHeight}
|
|
622
|
+
stroke={colors.gridLine}
|
|
623
|
+
strokeWidth="1"
|
|
624
|
+
/>
|
|
625
|
+
|
|
626
|
+
{/* Render Curves / Bars */}
|
|
627
|
+
{chartType === 'BAR' ? renderBarChart() : renderLineCurves()}
|
|
628
|
+
|
|
629
|
+
{/* Scrub Reticle Crosshair Line (CoinDCX / Groww snap indicator) */}
|
|
630
|
+
{selectedIndex !== null && xPoints[selectedIndex] && (
|
|
631
|
+
<G pointerEvents="none">
|
|
632
|
+
<SvgLine
|
|
633
|
+
x1={xPoints[selectedIndex]}
|
|
634
|
+
y1={paddingTop - 5}
|
|
635
|
+
x2={xPoints[selectedIndex]}
|
|
636
|
+
y2={paddingTop + chartHeight}
|
|
637
|
+
stroke="#7C3AED"
|
|
638
|
+
strokeWidth="1.5"
|
|
639
|
+
strokeDasharray="4,4"
|
|
640
|
+
/>
|
|
641
|
+
</G>
|
|
642
|
+
)}
|
|
643
|
+
|
|
644
|
+
{/* X-Axis Labels: Straight for Dates, Angled -35 deg for Text Diagnoses so they NEVER collide! */}
|
|
645
|
+
{visibleDataItems.map((d, i) => {
|
|
646
|
+
const px = xPoints[i];
|
|
647
|
+
if (isDateSeries) {
|
|
648
|
+
const displayMonth = formatMonthLabel(d.label);
|
|
649
|
+
return (
|
|
650
|
+
<SvgText
|
|
651
|
+
key={`xlabel-${i}`}
|
|
652
|
+
x={px}
|
|
653
|
+
y={paddingTop + chartHeight + 18}
|
|
654
|
+
textAnchor="middle"
|
|
655
|
+
fontSize="10"
|
|
656
|
+
fontWeight="700"
|
|
657
|
+
fill={i === selectedIndex ? '#7C3AED' : colors.textMuted}
|
|
658
|
+
>
|
|
659
|
+
{displayMonth}
|
|
660
|
+
</SvgText>
|
|
661
|
+
);
|
|
662
|
+
} else {
|
|
663
|
+
// For Diagnoses / Text categories, truncate and angle cleanly so words never overlap
|
|
664
|
+
const truncLabel = d.label.length > 10 ? d.label.substring(0, 9) + '…' : d.label;
|
|
665
|
+
return (
|
|
666
|
+
<SvgText
|
|
667
|
+
key={`xlabel-${i}`}
|
|
668
|
+
x={px}
|
|
669
|
+
y={paddingTop + chartHeight + 14}
|
|
670
|
+
textAnchor="end"
|
|
671
|
+
fontSize="9"
|
|
672
|
+
fontWeight="600"
|
|
673
|
+
fill={i === selectedIndex ? '#7C3AED' : colors.textMuted}
|
|
674
|
+
transform={`rotate(-35, ${px}, ${paddingTop + chartHeight + 14})`}
|
|
675
|
+
>
|
|
676
|
+
{truncLabel}
|
|
677
|
+
</SvgText>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
})}
|
|
681
|
+
</Svg>
|
|
682
|
+
|
|
683
|
+
{/* Y-Axis values column on left (Cleanly aligned with grid) */}
|
|
684
|
+
<View
|
|
685
|
+
pointerEvents="none"
|
|
686
|
+
style={{
|
|
687
|
+
position: 'absolute',
|
|
688
|
+
left: 0,
|
|
689
|
+
top: paddingTop - 6,
|
|
690
|
+
height: chartHeight + 12,
|
|
691
|
+
justifyContent: 'space-between',
|
|
692
|
+
width: 40,
|
|
693
|
+
}}
|
|
694
|
+
>
|
|
695
|
+
{[1, 0.8, 0.6, 0.4, 0.2, 0].map((p, i) => (
|
|
696
|
+
<Text key={i} style={{ fontSize: 9, fontWeight: '700', color: colors.textMuted, textAlign: 'right', paddingRight: 6 }}>
|
|
697
|
+
{Math.round(maxValue * p)}
|
|
698
|
+
</Text>
|
|
699
|
+
))}
|
|
700
|
+
</View>
|
|
701
|
+
</View>
|
|
702
|
+
</ScrollView>
|
|
703
|
+
|
|
704
|
+
{/* Overflow notice */}
|
|
705
|
+
{hasOverflow && (
|
|
706
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 4, marginTop: 8 }}>
|
|
707
|
+
<Icon name="info" type="Feather" size={12} color={colors.textMuted} />
|
|
708
|
+
<Text style={{ fontSize: 10, color: colors.textMuted }}>
|
|
709
|
+
Showing {visibleDataItems.length} of {multiStatusItems.length || data.length} items • Scroll chart to see more
|
|
710
|
+
</Text>
|
|
711
|
+
</View>
|
|
712
|
+
)}
|
|
713
|
+
</View>
|
|
714
|
+
|
|
715
|
+
{/* Data Breakdown Table (Clean flat surface, no shadow) */}
|
|
716
|
+
<View style={{
|
|
717
|
+
backgroundColor: colors.cardBg,
|
|
718
|
+
borderRadius: 20,
|
|
719
|
+
borderWidth: 1,
|
|
720
|
+
borderColor: colors.cardBorder,
|
|
721
|
+
overflow: 'hidden',
|
|
722
|
+
}}>
|
|
723
|
+
<View style={{
|
|
724
|
+
flexDirection: 'row',
|
|
725
|
+
justifyContent: 'space-between',
|
|
726
|
+
alignItems: 'center',
|
|
727
|
+
paddingHorizontal: 16,
|
|
728
|
+
paddingVertical: 12,
|
|
729
|
+
backgroundColor: colors.listBg,
|
|
730
|
+
borderBottomWidth: 1,
|
|
731
|
+
borderBottomColor: colors.cardBorder,
|
|
732
|
+
}}>
|
|
733
|
+
<Text style={{ fontSize: 13, fontWeight: '800', color: colors.textPrimary }}>
|
|
734
|
+
Data Breakdown Table
|
|
735
|
+
</Text>
|
|
736
|
+
<Text style={{ fontSize: 11, fontWeight: '700', color: themeColors.brand[600] }}>
|
|
737
|
+
Total: {totalCases} Cases
|
|
738
|
+
</Text>
|
|
739
|
+
</View>
|
|
740
|
+
|
|
741
|
+
{tableColumns.length > 2 && tableRows.length > 0 ? (
|
|
742
|
+
/* Multi-Column Horizontal Scroll Table */
|
|
743
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
744
|
+
<View>
|
|
745
|
+
{/* Table Header Row */}
|
|
746
|
+
<View style={{
|
|
747
|
+
flexDirection: 'row',
|
|
748
|
+
backgroundColor: isDarkMode ? '#2C2C2E' : themeColors.slate[100],
|
|
749
|
+
paddingHorizontal: 16,
|
|
750
|
+
paddingVertical: 10,
|
|
751
|
+
borderBottomWidth: 1,
|
|
752
|
+
borderBottomColor: colors.cardBorder,
|
|
753
|
+
}}>
|
|
754
|
+
{tableColumns.map((colKey) => (
|
|
755
|
+
<Text
|
|
756
|
+
key={colKey}
|
|
757
|
+
style={{
|
|
758
|
+
width: 110,
|
|
759
|
+
fontSize: 10,
|
|
760
|
+
fontWeight: '800',
|
|
761
|
+
color: colors.textSecondary,
|
|
762
|
+
letterSpacing: 0.5,
|
|
763
|
+
}}
|
|
764
|
+
>
|
|
765
|
+
{formatKeyLabel(colKey).toUpperCase()}
|
|
766
|
+
</Text>
|
|
767
|
+
))}
|
|
768
|
+
</View>
|
|
769
|
+
|
|
770
|
+
{/* Table Rows */}
|
|
771
|
+
{tableRows.map((rowItem: any, rIdx: number) => (
|
|
772
|
+
<View
|
|
773
|
+
key={rIdx}
|
|
774
|
+
style={{
|
|
775
|
+
flexDirection: 'row',
|
|
776
|
+
paddingHorizontal: 16,
|
|
777
|
+
paddingVertical: 10,
|
|
778
|
+
borderBottomWidth: 1,
|
|
779
|
+
borderBottomColor: colors.gridLine,
|
|
780
|
+
alignItems: 'center',
|
|
781
|
+
}}
|
|
782
|
+
>
|
|
783
|
+
{tableColumns.map((colKey) => {
|
|
784
|
+
const rawCell = rowItem[colKey];
|
|
785
|
+
const displayCell = (rawCell === null || rawCell === undefined || rawCell === '' || String(rawCell).trim() === '') ? '-' : String(rawCell);
|
|
786
|
+
return (
|
|
787
|
+
<Text
|
|
788
|
+
key={colKey}
|
|
789
|
+
style={{
|
|
790
|
+
width: 110,
|
|
791
|
+
fontSize: 11,
|
|
792
|
+
fontWeight: '600',
|
|
793
|
+
color: colors.textPrimary,
|
|
794
|
+
}}
|
|
795
|
+
numberOfLines={1}
|
|
796
|
+
>
|
|
797
|
+
{displayCell}
|
|
798
|
+
</Text>
|
|
799
|
+
);
|
|
800
|
+
})}
|
|
801
|
+
</View>
|
|
802
|
+
))}
|
|
803
|
+
</View>
|
|
804
|
+
</ScrollView>
|
|
805
|
+
) : (
|
|
806
|
+
/* Clean 2-Column Table (LABEL on left, VALUE on right with ZERO overlap) */
|
|
807
|
+
<View>
|
|
808
|
+
{/* 2-Column Header */}
|
|
809
|
+
<View style={{
|
|
810
|
+
flexDirection: 'row',
|
|
811
|
+
justifyContent: 'space-between',
|
|
812
|
+
backgroundColor: isDarkMode ? '#2C2C2E' : themeColors.slate[100],
|
|
813
|
+
paddingHorizontal: 16,
|
|
814
|
+
paddingVertical: 10,
|
|
815
|
+
borderBottomWidth: 1,
|
|
816
|
+
borderBottomColor: colors.cardBorder,
|
|
817
|
+
}}>
|
|
818
|
+
<Text style={{ fontSize: 10, fontWeight: '800', color: colors.textSecondary, letterSpacing: 0.5 }}>
|
|
819
|
+
{tableColumns[0] ? formatKeyLabel(tableColumns[0]).toUpperCase() : 'LABEL'}
|
|
820
|
+
</Text>
|
|
821
|
+
<Text style={{ fontSize: 10, fontWeight: '800', color: colors.textSecondary, letterSpacing: 0.5 }}>
|
|
822
|
+
{tableColumns[1] ? formatKeyLabel(tableColumns[1]).toUpperCase() : 'VALUE'}
|
|
823
|
+
</Text>
|
|
824
|
+
</View>
|
|
825
|
+
|
|
826
|
+
{/* 2-Column Rows */}
|
|
827
|
+
{visibleDataItems.length > 0 ? (
|
|
828
|
+
visibleDataItems.map((item, idx) => {
|
|
829
|
+
const val = item.total;
|
|
830
|
+
const displayVal = (val === null || val === undefined || val === '') ? '-' : String(val);
|
|
831
|
+
return (
|
|
832
|
+
<View
|
|
833
|
+
key={idx}
|
|
834
|
+
style={{
|
|
835
|
+
flexDirection: 'row',
|
|
836
|
+
justifyContent: 'space-between',
|
|
837
|
+
alignItems: 'center',
|
|
838
|
+
paddingHorizontal: 16,
|
|
839
|
+
paddingVertical: 11,
|
|
840
|
+
borderBottomWidth: 1,
|
|
841
|
+
borderBottomColor: colors.gridLine,
|
|
842
|
+
}}
|
|
843
|
+
>
|
|
844
|
+
<Text
|
|
845
|
+
style={{
|
|
846
|
+
flex: 1,
|
|
847
|
+
fontSize: 12,
|
|
848
|
+
fontWeight: '700',
|
|
849
|
+
color: colors.textPrimary,
|
|
850
|
+
paddingRight: 14,
|
|
851
|
+
}}
|
|
852
|
+
numberOfLines={1}
|
|
853
|
+
>
|
|
854
|
+
{isDateSeries ? formatMonthLabel(item.label) : item.label}
|
|
855
|
+
</Text>
|
|
856
|
+
<Text
|
|
857
|
+
style={{
|
|
858
|
+
fontSize: 13,
|
|
859
|
+
fontWeight: '800',
|
|
860
|
+
color: colors.textPrimary,
|
|
861
|
+
textAlign: 'right',
|
|
862
|
+
minWidth: 40,
|
|
863
|
+
}}
|
|
864
|
+
>
|
|
865
|
+
{displayVal}
|
|
866
|
+
</Text>
|
|
867
|
+
</View>
|
|
868
|
+
);
|
|
869
|
+
})
|
|
870
|
+
) : (
|
|
871
|
+
<View style={{ padding: 20, alignItems: 'center' }}>
|
|
872
|
+
<Text style={{ fontSize: 12, color: colors.textSecondary }}>No breakdown data available</Text>
|
|
873
|
+
</View>
|
|
874
|
+
)}
|
|
875
|
+
</View>
|
|
876
|
+
)}
|
|
877
|
+
</View>
|
|
878
|
+
</View>
|
|
879
|
+
);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
export default ReportChart;
|