@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.
Files changed (39) hide show
  1. package/App.tsx +69 -0
  2. package/README.md +97 -0
  3. package/app.json +4 -0
  4. package/babel.config.js +18 -0
  5. package/global.css +143 -0
  6. package/index.js +9 -0
  7. package/metro.config.js +209 -0
  8. package/nativewind-env.d.ts +1 -0
  9. package/package.json +106 -0
  10. package/patches/@react-navigation+stack+7.6.16.patch +11 -0
  11. package/patches/@suflon+native-ui+0.0.18.patch +26020 -0
  12. package/patches/react-native+0.83.1.patch +52 -0
  13. package/react-native.config.js +27 -0
  14. package/scripts/fix-suflon-native-ui.js +25 -0
  15. package/scripts/link-react-native-pnpm.js +42 -0
  16. package/src/config/index.ts +10 -0
  17. package/src/context/ConnectionI18nContext.tsx +61 -0
  18. package/src/modules/Reporting/component/README.md +3 -0
  19. package/src/modules/Reporting/component/ReportChart.tsx +882 -0
  20. package/src/modules/Reporting/component/ReportFilterModal.tsx +403 -0
  21. package/src/modules/Reporting/component/ReportingDetail.tsx +481 -0
  22. package/src/modules/Reporting/index.tsx +239 -0
  23. package/src/modules/Reporting/utils.tsx +14 -0
  24. package/src/navigation/index.tsx +31 -0
  25. package/src/screens/ConnectionListScreen.tsx +471 -0
  26. package/src/screens/DevToolsCorner.tsx +810 -0
  27. package/src/screens/NewConnectionModal.tsx +282 -0
  28. package/src/services/ApiService.ts +22 -0
  29. package/src/services/ConnectionService.ts +81 -0
  30. package/src/services/api.ts +83 -0
  31. package/src/stores/connection.store.ts +3 -0
  32. package/src/stores/language.store.ts +54 -0
  33. package/src/theme/colors.ts +56 -0
  34. package/src/types/connection.ts +69 -0
  35. package/src/utils/AsyncStorageUtils.ts +56 -0
  36. package/src/utils/connectionStrings.ts +158 -0
  37. package/src/utils/errorMessage.ts +11 -0
  38. package/tailwind.config.js +196 -0
  39. 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;