@suflon/rnmd-reporting 0.0.3 → 0.0.5
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 +2 -2
- package/index.js +3 -0
- package/metro.config.js +31 -0
- package/package.json +4 -3
- package/patches/@suflon+native-ui+0.0.18.patch +1715 -4257
- package/src/index.ts +1 -0
- package/src/modules/ManagementReport/components/AnalyticsChartView.tsx +530 -0
- package/src/modules/ManagementReport/components/CategoryTabs.tsx +85 -0
- package/src/modules/ManagementReport/components/DetailsListingView.tsx +781 -0
- package/src/modules/ManagementReport/components/DoctorFilterDropdown.tsx +157 -0
- package/src/modules/ManagementReport/components/KpiMetricsGrid.tsx +183 -0
- package/src/modules/ManagementReport/components/ManagementFilterModal.tsx +142 -0
- package/src/modules/ManagementReport/components/ManagementReportHeader.tsx +115 -0
- package/src/modules/ManagementReport/components/SubModuleTabs.tsx +58 -0
- package/src/modules/ManagementReport/components/TimeframeDropdown.tsx +162 -0
- package/src/modules/ManagementReport/components/ViewModeSwitcher.tsx +71 -0
- package/src/modules/ManagementReport/index.tsx +202 -0
- package/src/modules/Reporting/component/ReportChart.tsx +1 -1
- package/src/modules/Reporting/component/ReportFilterModal.tsx +1 -1
- package/src/modules/Reporting/component/ReportingDetail.tsx +86 -98
- package/src/modules/Reporting/index.tsx +72 -79
- package/src/navigation/index.tsx +17 -6
- package/src/screens/DevToolsCorner.tsx +43 -8
- package/tailwind.config.js +11 -0
package/src/index.ts
CHANGED
|
@@ -3,3 +3,4 @@ export { default as Reporting } from './modules/Reporting';
|
|
|
3
3
|
export { default as ReportingDetail } from './modules/Reporting/component/ReportingDetail';
|
|
4
4
|
export { default as ReportChart } from './modules/Reporting/component/ReportChart';
|
|
5
5
|
export { default as ReportFilterModal } from './modules/Reporting/component/ReportFilterModal';
|
|
6
|
+
export { default as ManagementReport } from './modules/ManagementReport';
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import React, { useState, useMemo, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
ScrollView,
|
|
6
|
+
TouchableOpacity,
|
|
7
|
+
ActivityIndicator,
|
|
8
|
+
useColorScheme,
|
|
9
|
+
Dimensions,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
import Svg, { Path, Circle, Defs, LinearGradient, Stop, Line as SvgLine } from 'react-native-svg';
|
|
12
|
+
import {
|
|
13
|
+
IManagementReportDetail,
|
|
14
|
+
Icon,
|
|
15
|
+
useManagementReportStore,
|
|
16
|
+
} from '@suflon/native-ui';
|
|
17
|
+
import { TimeframeDropdown, TimeframeOption, getTimeframeDateFilter } from './TimeframeDropdown';
|
|
18
|
+
|
|
19
|
+
interface AnalyticsChartViewProps {
|
|
20
|
+
details: IManagementReportDetail[];
|
|
21
|
+
executionData: Record<number, any[]>;
|
|
22
|
+
loading?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ChartItemData {
|
|
26
|
+
label: string;
|
|
27
|
+
value: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatLabel(label: string): string {
|
|
31
|
+
if (!label) return '-';
|
|
32
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(label)) {
|
|
33
|
+
const parts = label.split('-');
|
|
34
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
35
|
+
const m = parseInt(parts[1], 10) - 1;
|
|
36
|
+
return `${parts[2]} ${months[m] || parts[1]}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return label
|
|
40
|
+
.replace(/_/g, ' ')
|
|
41
|
+
.replace(/([A-Z])/g, ' $1')
|
|
42
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatYAxisValue(val: number): string {
|
|
47
|
+
if (val >= 1000000) return `${(val / 1000000).toFixed(1)}M`;
|
|
48
|
+
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`;
|
|
49
|
+
if (Number.isInteger(val)) return String(val);
|
|
50
|
+
return val.toFixed(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseChartData(rows: any[]): ChartItemData[] {
|
|
54
|
+
if (!rows || rows.length === 0) return [];
|
|
55
|
+
|
|
56
|
+
return rows.map((r, i) => {
|
|
57
|
+
if (typeof r === 'number') {
|
|
58
|
+
return { label: `Item ${i + 1}`, value: r };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof r === 'object' && r !== null) {
|
|
62
|
+
const keys = Object.keys(r);
|
|
63
|
+
const labelKey =
|
|
64
|
+
keys.find((k) =>
|
|
65
|
+
[
|
|
66
|
+
'date',
|
|
67
|
+
'month',
|
|
68
|
+
'day',
|
|
69
|
+
'label',
|
|
70
|
+
'name',
|
|
71
|
+
'status',
|
|
72
|
+
'type',
|
|
73
|
+
'department',
|
|
74
|
+
'category',
|
|
75
|
+
'group_key',
|
|
76
|
+
'appointment_type',
|
|
77
|
+
'severity',
|
|
78
|
+
].includes(k.toLowerCase())
|
|
79
|
+
) || keys[0];
|
|
80
|
+
|
|
81
|
+
const valueKey =
|
|
82
|
+
keys.find((k) =>
|
|
83
|
+
[
|
|
84
|
+
'count',
|
|
85
|
+
'total',
|
|
86
|
+
'value',
|
|
87
|
+
'amount',
|
|
88
|
+
'total_amount',
|
|
89
|
+
'net_amount',
|
|
90
|
+
'sum',
|
|
91
|
+
'metric_value',
|
|
92
|
+
'patients',
|
|
93
|
+
].includes(k.toLowerCase())
|
|
94
|
+
) || keys[1] || keys[0];
|
|
95
|
+
|
|
96
|
+
const val = Number(r[valueKey]) || 0;
|
|
97
|
+
const rawLbl = String(r[labelKey] || `Item ${i + 1}`);
|
|
98
|
+
|
|
99
|
+
return { label: formatLabel(rawLbl), value: val };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { label: `Item ${i + 1}`, value: 0 };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SingleChartCard: React.FC<{
|
|
107
|
+
detail: IManagementReportDetail;
|
|
108
|
+
rows: any[];
|
|
109
|
+
loading: boolean;
|
|
110
|
+
}> = ({ detail, rows, loading }) => {
|
|
111
|
+
const isDark = useColorScheme() === 'dark';
|
|
112
|
+
const [timeframe, setTimeframe] = useState<TimeframeOption>('Monthly');
|
|
113
|
+
const [chartType, setChartType] = useState<'bar' | 'line' | 'trend'>('bar');
|
|
114
|
+
const [fetching, setFetching] = useState(false);
|
|
115
|
+
|
|
116
|
+
const executeReportItem = useManagementReportStore((s) => s.executeReportItem);
|
|
117
|
+
|
|
118
|
+
const handleSelectTimeframe = useCallback(
|
|
119
|
+
async (selectedTf: TimeframeOption) => {
|
|
120
|
+
setTimeframe(selectedTf);
|
|
121
|
+
if (detail.report_id) {
|
|
122
|
+
setFetching(true);
|
|
123
|
+
const dateFilter = getTimeframeDateFilter(selectedTf);
|
|
124
|
+
const state = useManagementReportStore.getState();
|
|
125
|
+
const origin = (state.selectedReport?.mgmt_report_type || state.activeCategory || 'opd').toLowerCase();
|
|
126
|
+
await executeReportItem(
|
|
127
|
+
detail.report_id,
|
|
128
|
+
detail.report?.is_dynamic || false,
|
|
129
|
+
{ filters: dateFilter },
|
|
130
|
+
origin,
|
|
131
|
+
true
|
|
132
|
+
);
|
|
133
|
+
setFetching(false);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
[detail, executeReportItem]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const chartData = useMemo(() => parseChartData(rows), [rows]);
|
|
140
|
+
const maxValue = useMemo(
|
|
141
|
+
() => Math.max(...chartData.map((d) => d.value), 1),
|
|
142
|
+
[chartData]
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Generate 4 evenly spaced Y-axis ticks
|
|
146
|
+
const yTicks = useMemo(() => {
|
|
147
|
+
return [
|
|
148
|
+
maxValue,
|
|
149
|
+
Math.round(maxValue * 0.66),
|
|
150
|
+
Math.round(maxValue * 0.33),
|
|
151
|
+
0,
|
|
152
|
+
];
|
|
153
|
+
}, [maxValue]);
|
|
154
|
+
|
|
155
|
+
const isBusy = loading || fetching;
|
|
156
|
+
const title = detail.report?.name || detail.report_num || 'Performance Metric';
|
|
157
|
+
const isCompact = chartData.length <= 4;
|
|
158
|
+
const chartHeight = 130;
|
|
159
|
+
const cardWidth = Dimensions.get('window').width - 90;
|
|
160
|
+
const svgWidth = Math.max(cardWidth, chartData.length * 64);
|
|
161
|
+
|
|
162
|
+
// SVG Line / Trend calculations
|
|
163
|
+
const svgPoints = useMemo(() => {
|
|
164
|
+
if (chartData.length === 0) return [];
|
|
165
|
+
const step = chartData.length > 1 ? svgWidth / (chartData.length - 1) : svgWidth / 2;
|
|
166
|
+
const paddingBottom = 15;
|
|
167
|
+
const paddingTop = 15;
|
|
168
|
+
const availableHeight = chartHeight - paddingTop - paddingBottom;
|
|
169
|
+
|
|
170
|
+
return chartData.map((d, i) => {
|
|
171
|
+
const x = chartData.length === 1 ? svgWidth / 2 : i * step;
|
|
172
|
+
const y = chartHeight - paddingBottom - (d.value / maxValue) * availableHeight;
|
|
173
|
+
return { x, y, value: d.value, label: d.label };
|
|
174
|
+
});
|
|
175
|
+
}, [chartData, maxValue, svgWidth, chartHeight]);
|
|
176
|
+
|
|
177
|
+
const linePath = useMemo(() => {
|
|
178
|
+
if (svgPoints.length === 0) return '';
|
|
179
|
+
return svgPoints.reduce((acc, p, i) => `${acc} ${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`, '');
|
|
180
|
+
}, [svgPoints]);
|
|
181
|
+
|
|
182
|
+
const areaPath = useMemo(() => {
|
|
183
|
+
if (svgPoints.length === 0) return '';
|
|
184
|
+
const lastX = svgPoints[svgPoints.length - 1].x;
|
|
185
|
+
const firstX = svgPoints[0].x;
|
|
186
|
+
return `${linePath} L ${lastX},${chartHeight - 10} L ${firstX},${chartHeight - 10} Z`;
|
|
187
|
+
}, [linePath, svgPoints, chartHeight]);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<View className="p-3.5 rounded-3xl bg-white dark:bg-background-darkSecondaryBg border border-slate-100 dark:border-slate-800 mb-3.5">
|
|
191
|
+
{/* Header: Title & Timeframe Selector */}
|
|
192
|
+
<View className="flex-row items-center justify-between mb-3 pb-2 border-b border-slate-100 dark:border-slate-800">
|
|
193
|
+
<Text
|
|
194
|
+
style={{ color: isDark ? '#FFFFFF' : '#0F172A' }}
|
|
195
|
+
className="text-sm font-extrabold tracking-tight flex-1 mr-2"
|
|
196
|
+
numberOfLines={1}
|
|
197
|
+
>
|
|
198
|
+
{title}
|
|
199
|
+
</Text>
|
|
200
|
+
|
|
201
|
+
<View className="flex-row items-center gap-1.5 shrink-0">
|
|
202
|
+
{/* Chart Type Selector */}
|
|
203
|
+
<View className="p-0.5 rounded-xl bg-slate-100 dark:bg-slate-800 flex-row items-center">
|
|
204
|
+
<TouchableOpacity
|
|
205
|
+
onPress={() => setChartType('bar')}
|
|
206
|
+
activeOpacity={0.8}
|
|
207
|
+
className={`p-1 rounded-lg ${chartType === 'bar' ? 'bg-white dark:bg-slate-700 shadow-sm' : 'bg-transparent'}`}
|
|
208
|
+
>
|
|
209
|
+
<Icon
|
|
210
|
+
name="bar-chart-2"
|
|
211
|
+
type="Feather"
|
|
212
|
+
size={11}
|
|
213
|
+
color={chartType === 'bar' ? '#7c3aed' : (isDark ? '#9CA3AF' : '#94a3b8')}
|
|
214
|
+
/>
|
|
215
|
+
</TouchableOpacity>
|
|
216
|
+
<TouchableOpacity
|
|
217
|
+
onPress={() => setChartType('line')}
|
|
218
|
+
activeOpacity={0.8}
|
|
219
|
+
className={`p-1 rounded-lg ${chartType === 'line' ? 'bg-white dark:bg-slate-700 shadow-sm' : 'bg-transparent'}`}
|
|
220
|
+
>
|
|
221
|
+
<Icon
|
|
222
|
+
name="activity"
|
|
223
|
+
type="Feather"
|
|
224
|
+
size={11}
|
|
225
|
+
color={chartType === 'line' ? '#7c3aed' : (isDark ? '#9CA3AF' : '#94a3b8')}
|
|
226
|
+
/>
|
|
227
|
+
</TouchableOpacity>
|
|
228
|
+
<TouchableOpacity
|
|
229
|
+
onPress={() => setChartType('trend')}
|
|
230
|
+
activeOpacity={0.8}
|
|
231
|
+
className={`p-1 rounded-lg ${chartType === 'trend' ? 'bg-white dark:bg-slate-700 shadow-sm' : 'bg-transparent'}`}
|
|
232
|
+
>
|
|
233
|
+
<Icon
|
|
234
|
+
name="trending-up"
|
|
235
|
+
type="Feather"
|
|
236
|
+
size={11}
|
|
237
|
+
color={chartType === 'trend' ? '#7c3aed' : (isDark ? '#9CA3AF' : '#94a3b8')}
|
|
238
|
+
/>
|
|
239
|
+
</TouchableOpacity>
|
|
240
|
+
</View>
|
|
241
|
+
|
|
242
|
+
{/* Timeframe Dropdown */}
|
|
243
|
+
<TimeframeDropdown
|
|
244
|
+
value={timeframe}
|
|
245
|
+
onChange={handleSelectTimeframe}
|
|
246
|
+
/>
|
|
247
|
+
</View>
|
|
248
|
+
</View>
|
|
249
|
+
|
|
250
|
+
{/* Chart Visual Surface */}
|
|
251
|
+
{isBusy ? (
|
|
252
|
+
<View className="h-44 items-center justify-center">
|
|
253
|
+
<ActivityIndicator size="small" color="#7c3aed" />
|
|
254
|
+
<Text style={{ color: isDark ? '#9CA3AF' : '#94A3B8' }} className="text-[10px] font-semibold mt-2">
|
|
255
|
+
Fetching chart metrics...
|
|
256
|
+
</Text>
|
|
257
|
+
</View>
|
|
258
|
+
) : chartData.length === 0 ? (
|
|
259
|
+
<View className="h-36 items-center justify-center rounded-2xl border border-dashed border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-background-lightBlack">
|
|
260
|
+
<Icon name="bar-chart-2" type="Feather" size={20} color="#94a3b8" />
|
|
261
|
+
<Text style={{ color: isDark ? '#FFFFFF' : '#0F172A' }} className="text-xs font-bold mt-1.5">No metrics recorded</Text>
|
|
262
|
+
<Text style={{ color: isDark ? '#9CA3AF' : '#94A3B8' }} className="text-[10px] text-center mt-0.5">
|
|
263
|
+
Try adjusting your timeframe filter.
|
|
264
|
+
</Text>
|
|
265
|
+
</View>
|
|
266
|
+
) : (
|
|
267
|
+
<View className="pt-1">
|
|
268
|
+
{/* Main Chart Body: Left Y-Axis Scale Column + Chart Canvas */}
|
|
269
|
+
<View className="flex-row">
|
|
270
|
+
{/* 1. Left Y-Axis Labels Column */}
|
|
271
|
+
<View className="w-9 justify-between items-end pr-1.5 pb-6" style={{ height: chartHeight + 24 }}>
|
|
272
|
+
{yTicks.map((tick, i) => (
|
|
273
|
+
<Text
|
|
274
|
+
key={i}
|
|
275
|
+
style={{ color: isDark ? '#64748B' : '#94A3B8' }}
|
|
276
|
+
className="text-[9px] font-mono font-medium text-right"
|
|
277
|
+
numberOfLines={1}
|
|
278
|
+
>
|
|
279
|
+
{formatYAxisValue(tick)}
|
|
280
|
+
</Text>
|
|
281
|
+
))}
|
|
282
|
+
</View>
|
|
283
|
+
|
|
284
|
+
{/* 2. Chart Grid & Plot Area */}
|
|
285
|
+
<View className="flex-1 relative">
|
|
286
|
+
{/* Background Horizontal Guide Lines */}
|
|
287
|
+
<View className="absolute inset-0 justify-between pb-6 pointer-events-none" style={{ height: chartHeight + 24 }}>
|
|
288
|
+
{yTicks.map((_, i) => (
|
|
289
|
+
<View
|
|
290
|
+
key={i}
|
|
291
|
+
style={{
|
|
292
|
+
height: 1,
|
|
293
|
+
backgroundColor: i === yTicks.length - 1
|
|
294
|
+
? (isDark ? '#475569' : '#cbd5e1')
|
|
295
|
+
: (isDark ? '#1e293b' : '#f1f5f9'),
|
|
296
|
+
}}
|
|
297
|
+
className="w-full"
|
|
298
|
+
/>
|
|
299
|
+
))}
|
|
300
|
+
</View>
|
|
301
|
+
|
|
302
|
+
{/* Chart Mode Rendering */}
|
|
303
|
+
{chartType === 'bar' ? (
|
|
304
|
+
isCompact ? (
|
|
305
|
+
/* Balanced Full-Width Grid for 2-4 items */
|
|
306
|
+
<View>
|
|
307
|
+
<View className="flex-row justify-around items-end px-1" style={{ height: chartHeight }}>
|
|
308
|
+
{chartData.map((item, idx) => {
|
|
309
|
+
const barHeight = Math.max(14, Math.round((item.value / maxValue) * (chartHeight - 30)));
|
|
310
|
+
const isTopValue = item.value === maxValue;
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<View
|
|
314
|
+
key={idx}
|
|
315
|
+
className="items-center justify-end flex-1 max-w-[68px] px-1"
|
|
316
|
+
>
|
|
317
|
+
<Text
|
|
318
|
+
style={{
|
|
319
|
+
color: isTopValue ? (isDark ? '#A78BFA' : '#7C3AED') : (isDark ? '#E2E8F0' : '#475569'),
|
|
320
|
+
}}
|
|
321
|
+
className="text-[9px] font-extrabold mb-1 font-mono"
|
|
322
|
+
numberOfLines={1}
|
|
323
|
+
>
|
|
324
|
+
{formatYAxisValue(item.value)}
|
|
325
|
+
</Text>
|
|
326
|
+
|
|
327
|
+
<View
|
|
328
|
+
style={{ height: barHeight }}
|
|
329
|
+
className={`w-full rounded-t-xl ${
|
|
330
|
+
isTopValue
|
|
331
|
+
? 'bg-violet-600 dark:bg-violet-500'
|
|
332
|
+
: 'bg-violet-400/80 dark:bg-violet-600/60'
|
|
333
|
+
}`}
|
|
334
|
+
/>
|
|
335
|
+
</View>
|
|
336
|
+
);
|
|
337
|
+
})}
|
|
338
|
+
</View>
|
|
339
|
+
|
|
340
|
+
{/* Continuous X-Axis Baseline */}
|
|
341
|
+
<View className="w-full h-[1.5px] bg-slate-300 dark:bg-slate-700" />
|
|
342
|
+
|
|
343
|
+
{/* Category Labels below Baseline */}
|
|
344
|
+
<View className="flex-row justify-around px-1 pt-1.5">
|
|
345
|
+
{chartData.map((item, idx) => (
|
|
346
|
+
<View key={idx} className="flex-1 max-w-[68px] items-center px-0.5">
|
|
347
|
+
<Text
|
|
348
|
+
style={{ color: isDark ? '#E2E8F0' : '#475569' }}
|
|
349
|
+
className="text-[9px] font-semibold text-center leading-tight"
|
|
350
|
+
numberOfLines={2}
|
|
351
|
+
>
|
|
352
|
+
{item.label}
|
|
353
|
+
</Text>
|
|
354
|
+
</View>
|
|
355
|
+
))}
|
|
356
|
+
</View>
|
|
357
|
+
</View>
|
|
358
|
+
) : (
|
|
359
|
+
/* Horizontal Scroll for > 4 items */
|
|
360
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
361
|
+
<View>
|
|
362
|
+
<View className="flex-row items-end px-1" style={{ height: chartHeight }}>
|
|
363
|
+
{chartData.map((item, idx) => {
|
|
364
|
+
const barHeight = Math.max(14, Math.round((item.value / maxValue) * (chartHeight - 30)));
|
|
365
|
+
const isTopValue = item.value === maxValue;
|
|
366
|
+
|
|
367
|
+
return (
|
|
368
|
+
<View
|
|
369
|
+
key={idx}
|
|
370
|
+
className="items-center justify-end mr-3"
|
|
371
|
+
style={{ width: 52 }}
|
|
372
|
+
>
|
|
373
|
+
<Text
|
|
374
|
+
style={{
|
|
375
|
+
color: isTopValue ? (isDark ? '#A78BFA' : '#7C3AED') : (isDark ? '#E2E8F0' : '#475569'),
|
|
376
|
+
}}
|
|
377
|
+
className="text-[9px] font-extrabold mb-1 font-mono"
|
|
378
|
+
numberOfLines={1}
|
|
379
|
+
>
|
|
380
|
+
{formatYAxisValue(item.value)}
|
|
381
|
+
</Text>
|
|
382
|
+
|
|
383
|
+
<View
|
|
384
|
+
style={{ height: barHeight }}
|
|
385
|
+
className={`w-full rounded-t-xl ${
|
|
386
|
+
isTopValue
|
|
387
|
+
? 'bg-violet-600 dark:bg-violet-500'
|
|
388
|
+
: 'bg-violet-400/80 dark:bg-violet-600/60'
|
|
389
|
+
}`}
|
|
390
|
+
/>
|
|
391
|
+
</View>
|
|
392
|
+
);
|
|
393
|
+
})}
|
|
394
|
+
</View>
|
|
395
|
+
|
|
396
|
+
{/* Continuous X-Axis Baseline */}
|
|
397
|
+
<View className="w-full h-[1.5px] bg-slate-300 dark:bg-slate-700" />
|
|
398
|
+
|
|
399
|
+
{/* Category Labels below Baseline */}
|
|
400
|
+
<View className="flex-row px-1 pt-1.5">
|
|
401
|
+
{chartData.map((item, idx) => (
|
|
402
|
+
<View key={idx} style={{ width: 52 }} className="mr-3 items-center">
|
|
403
|
+
<Text
|
|
404
|
+
style={{ color: isDark ? '#E2E8F0' : '#475569' }}
|
|
405
|
+
className="text-[9px] font-semibold text-center leading-tight"
|
|
406
|
+
numberOfLines={2}
|
|
407
|
+
>
|
|
408
|
+
{item.label}
|
|
409
|
+
</Text>
|
|
410
|
+
</View>
|
|
411
|
+
))}
|
|
412
|
+
</View>
|
|
413
|
+
</View>
|
|
414
|
+
</ScrollView>
|
|
415
|
+
)
|
|
416
|
+
) : (
|
|
417
|
+
/* LINE / TREND SVG GRAPH MODE */
|
|
418
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
419
|
+
<View style={{ width: svgWidth }}>
|
|
420
|
+
<Svg width={svgWidth} height={chartHeight}>
|
|
421
|
+
<Defs>
|
|
422
|
+
<LinearGradient id="gradientArea" x1="0" y1="0" x2="0" y2="1">
|
|
423
|
+
<Stop offset="0%" stopColor="#7c3aed" stopOpacity="0.35" />
|
|
424
|
+
<Stop offset="100%" stopColor="#7c3aed" stopOpacity="0.0" />
|
|
425
|
+
</LinearGradient>
|
|
426
|
+
</Defs>
|
|
427
|
+
|
|
428
|
+
{/* Continuous X-Axis Baseline */}
|
|
429
|
+
<SvgLine
|
|
430
|
+
x1="0"
|
|
431
|
+
y1={chartHeight - 10}
|
|
432
|
+
x2={svgWidth}
|
|
433
|
+
y2={chartHeight - 10}
|
|
434
|
+
stroke={isDark ? '#475569' : '#cbd5e1'}
|
|
435
|
+
strokeWidth="1.5"
|
|
436
|
+
/>
|
|
437
|
+
|
|
438
|
+
{/* Trend Area Gradient Underfill */}
|
|
439
|
+
{chartType === 'trend' && (
|
|
440
|
+
<Path d={areaPath} fill="url(#gradientArea)" />
|
|
441
|
+
)}
|
|
442
|
+
|
|
443
|
+
{/* Line Path */}
|
|
444
|
+
<Path
|
|
445
|
+
d={linePath}
|
|
446
|
+
fill="none"
|
|
447
|
+
stroke="#7c3aed"
|
|
448
|
+
strokeWidth="2.5"
|
|
449
|
+
strokeLinecap="round"
|
|
450
|
+
strokeLinejoin="round"
|
|
451
|
+
/>
|
|
452
|
+
|
|
453
|
+
{/* Data Points */}
|
|
454
|
+
{svgPoints.map((p, i) => (
|
|
455
|
+
<Circle
|
|
456
|
+
key={i}
|
|
457
|
+
cx={p.x}
|
|
458
|
+
cy={p.y}
|
|
459
|
+
r="4"
|
|
460
|
+
fill={isDark ? '#1C1C1E' : '#FFFFFF'}
|
|
461
|
+
stroke="#7c3aed"
|
|
462
|
+
strokeWidth="2"
|
|
463
|
+
/>
|
|
464
|
+
))}
|
|
465
|
+
</Svg>
|
|
466
|
+
|
|
467
|
+
{/* Data Labels under Points */}
|
|
468
|
+
<View className="flex-row justify-between w-full px-1 pt-1.5">
|
|
469
|
+
{svgPoints.map((p, i) => (
|
|
470
|
+
<View key={i} style={{ width: 56, alignItems: 'center' }}>
|
|
471
|
+
<Text style={{ color: isDark ? '#E2E8F0' : '#475569' }} className="text-[8px] font-semibold text-center" numberOfLines={1}>
|
|
472
|
+
{p.label}
|
|
473
|
+
</Text>
|
|
474
|
+
<Text style={{ color: isDark ? '#FFFFFF' : '#0F172A' }} className="text-[9px] font-mono font-bold mt-0.5">
|
|
475
|
+
{formatYAxisValue(p.value)}
|
|
476
|
+
</Text>
|
|
477
|
+
</View>
|
|
478
|
+
))}
|
|
479
|
+
</View>
|
|
480
|
+
</View>
|
|
481
|
+
</ScrollView>
|
|
482
|
+
)}
|
|
483
|
+
</View>
|
|
484
|
+
</View>
|
|
485
|
+
|
|
486
|
+
{/* Subtitle / Legend Footer */}
|
|
487
|
+
<View className="flex-row items-center justify-between pt-2.5 mt-2 border-t border-slate-100 dark:border-slate-800">
|
|
488
|
+
<Text style={{ color: isDark ? '#9CA3AF' : '#94A3B8' }} className="text-[10px] font-medium">
|
|
489
|
+
Peak: <Text style={{ color: isDark ? '#FFFFFF' : '#0F172A' }} className="font-bold">{maxValue.toLocaleString()}</Text>
|
|
490
|
+
</Text>
|
|
491
|
+
<View className="flex-row items-center gap-1">
|
|
492
|
+
<View className="w-2 h-2 rounded-full bg-violet-600" />
|
|
493
|
+
<Text style={{ color: isDark ? '#9CA3AF' : '#94A3B8' }} className="text-[10px] font-semibold">
|
|
494
|
+
{chartType === 'bar' ? `${timeframe} Comparison` : chartType === 'line' ? 'Line Trajectory' : 'Trend Area'}
|
|
495
|
+
</Text>
|
|
496
|
+
</View>
|
|
497
|
+
</View>
|
|
498
|
+
</View>
|
|
499
|
+
)}
|
|
500
|
+
</View>
|
|
501
|
+
);
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
export const AnalyticsChartView: React.FC<AnalyticsChartViewProps> = ({
|
|
505
|
+
details,
|
|
506
|
+
executionData,
|
|
507
|
+
loading = false,
|
|
508
|
+
}) => {
|
|
509
|
+
const chartDetails = details.filter(
|
|
510
|
+
(d) =>
|
|
511
|
+
d.display_size === 'MD' ||
|
|
512
|
+
d.report?.report_type === 'CHART' ||
|
|
513
|
+
d.report?.report_type === 'SUMMARY'
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
if (chartDetails.length === 0) return null;
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
<View className="space-y-3.5">
|
|
520
|
+
{chartDetails.map((detail) => (
|
|
521
|
+
<SingleChartCard
|
|
522
|
+
key={detail.id || detail.report_id}
|
|
523
|
+
detail={detail}
|
|
524
|
+
rows={executionData[detail.report_id] || []}
|
|
525
|
+
loading={loading}
|
|
526
|
+
/>
|
|
527
|
+
))}
|
|
528
|
+
</View>
|
|
529
|
+
);
|
|
530
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ScrollView, TouchableOpacity, View, Text } from 'react-native';
|
|
3
|
+
import { Icon } from '@suflon/native-ui';
|
|
4
|
+
|
|
5
|
+
export interface CategoryItem {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
icon: string;
|
|
9
|
+
type: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const CATEGORIES_DATA: CategoryItem[] = [
|
|
13
|
+
{ id: 'OPD', name: 'OPD', icon: 'calendar', type: 'Feather' },
|
|
14
|
+
{ id: 'PRESCRIPTION', name: 'PRESCRIPTION', icon: 'file-text', type: 'Feather' },
|
|
15
|
+
{ id: 'SALES', name: 'SALES', icon: 'pie-chart', type: 'Feather' },
|
|
16
|
+
{ id: 'PURCHASE', name: 'PURCHASE', icon: 'package', type: 'Feather' },
|
|
17
|
+
{ id: 'TRANSACTION', name: 'TRANSACTION', icon: 'credit-card', type: 'Feather' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
interface CategoryTabsProps {
|
|
21
|
+
activeCategory: string;
|
|
22
|
+
onSelectCategory: (category: string) => void;
|
|
23
|
+
reportCounts?: Record<string, number>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
|
27
|
+
activeCategory,
|
|
28
|
+
onSelectCategory,
|
|
29
|
+
reportCounts = {},
|
|
30
|
+
}) => {
|
|
31
|
+
return (
|
|
32
|
+
<View className="py-0.5">
|
|
33
|
+
<ScrollView
|
|
34
|
+
horizontal
|
|
35
|
+
showsHorizontalScrollIndicator={false}
|
|
36
|
+
contentContainerStyle={{ gap: 8 }}
|
|
37
|
+
>
|
|
38
|
+
{CATEGORIES_DATA.map((cat) => {
|
|
39
|
+
const isActive = activeCategory === cat.id;
|
|
40
|
+
const count = reportCounts[cat.id];
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<TouchableOpacity
|
|
44
|
+
key={cat.id}
|
|
45
|
+
onPress={() => onSelectCategory(cat.id)}
|
|
46
|
+
activeOpacity={0.8}
|
|
47
|
+
className={`px-3.5 py-1.5 rounded-xl flex-row items-center gap-1.5 border ${
|
|
48
|
+
isActive
|
|
49
|
+
? 'bg-violet-600 border-violet-600'
|
|
50
|
+
: 'bg-white dark:bg-slate-800/90 border-slate-200/60 dark:border-slate-700/60'
|
|
51
|
+
}`}
|
|
52
|
+
>
|
|
53
|
+
<Icon
|
|
54
|
+
name={cat.icon}
|
|
55
|
+
type={cat.type as any}
|
|
56
|
+
size={12}
|
|
57
|
+
color={isActive ? '#FFFFFF' : '#94a3b8'}
|
|
58
|
+
/>
|
|
59
|
+
<Text
|
|
60
|
+
style={{ color: isActive ? '#FFFFFF' : '#94a3b8' }}
|
|
61
|
+
className={`text-xs ${isActive ? 'font-bold' : 'font-normal'}`}
|
|
62
|
+
>
|
|
63
|
+
{cat.name}
|
|
64
|
+
</Text>
|
|
65
|
+
{count !== undefined && count > 0 && (
|
|
66
|
+
<View
|
|
67
|
+
className={`px-1.5 py-0.5 rounded-full ${
|
|
68
|
+
isActive ? 'bg-white/20' : 'bg-slate-100 dark:bg-slate-700'
|
|
69
|
+
}`}
|
|
70
|
+
>
|
|
71
|
+
<Text
|
|
72
|
+
style={{ color: isActive ? '#FFFFFF' : '#94a3b8' }}
|
|
73
|
+
className={`text-[9px] ${isActive ? 'font-bold' : 'font-normal'}`}
|
|
74
|
+
>
|
|
75
|
+
{count}
|
|
76
|
+
</Text>
|
|
77
|
+
</View>
|
|
78
|
+
)}
|
|
79
|
+
</TouchableOpacity>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
</ScrollView>
|
|
83
|
+
</View>
|
|
84
|
+
);
|
|
85
|
+
};
|