@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,403 @@
1
+ import React, { useEffect, useMemo } from 'react';
2
+ import { View, Text, ScrollView, TouchableOpacity, useColorScheme } from 'react-native';
3
+ import { useForm } from 'react-hook-form';
4
+ import {
5
+ BottomModal,
6
+ ModalTitle,
7
+ Dropdown,
8
+ DatePicker as DatePickerInput,
9
+ Button as CustomButton,
10
+ usePatientStore,
11
+ useStaffStore,
12
+ IReportMetadata
13
+ } from '@suflon/native-ui';
14
+ import { themeColors } from '@/theme/colors';
15
+
16
+ const CustomView = View;
17
+ const CustomText = ({ variant, className, style, ...props }: any) => <Text style={style} {...props} />;
18
+ const useTranslation = () => ({ t: (k: string, f?: string) => f || k });
19
+
20
+ const formatKeyLabel = (key: string) => {
21
+ return key
22
+ .replace(/_/g, ' ')
23
+ .replace(/\b\w/g, (char) => char.toUpperCase());
24
+ };
25
+
26
+ interface ReportFilterModalProps {
27
+ isVisible: boolean;
28
+ onClose: () => void;
29
+ metadata: IReportMetadata | null;
30
+ onApply: (payload: any) => void;
31
+ }
32
+
33
+ const ReportFilterModal: React.FC<ReportFilterModalProps> = ({
34
+ isVisible,
35
+ onClose,
36
+ metadata,
37
+ onApply,
38
+ }) => {
39
+ const isDarkMode = useColorScheme() === 'dark';
40
+ const { t } = useTranslation();
41
+ const { staffs, staffsByUser, getStaffs, getStaffsByUser } = useStaffStore();
42
+ const { patients, getPatients } = usePatientStore();
43
+
44
+ // Ensure staff/doctors & patients are loaded
45
+ useEffect(() => {
46
+ if (isVisible) {
47
+ if ((!staffs || staffs.length === 0) && getStaffs) {
48
+ getStaffs('DOCTOR').catch(() => {});
49
+ }
50
+ if ((!staffsByUser || staffsByUser.length === 0) && getStaffsByUser) {
51
+ getStaffsByUser().catch(() => {});
52
+ }
53
+ if ((!patients || patients.length === 0) && getPatients) {
54
+ getPatients().catch(() => {});
55
+ }
56
+ }
57
+ }, [isVisible, staffs, staffsByUser, patients, getStaffs, getStaffsByUser, getPatients]);
58
+
59
+ const isDynamicReport = useMemo(() => {
60
+ if (!metadata?.metadata) return false;
61
+ const meta = metadata.metadata.meta;
62
+ return Boolean(meta?.is_dynamic === true);
63
+ }, [metadata]);
64
+
65
+ const { control, handleSubmit, reset, setValue } = useForm({
66
+ defaultValues: {
67
+ display_by: '',
68
+ operation_key: '',
69
+ staff_id: '',
70
+ patient_id: '',
71
+ appointment_status: '',
72
+ date_from: '',
73
+ date_to: '',
74
+ }
75
+ });
76
+
77
+ useEffect(() => {
78
+ if (metadata) {
79
+ const displayByList = metadata.metadata?.display_by || [];
80
+ const defaultDisplay = displayByList.find((d: any) => d.isDefault) || displayByList[0];
81
+ const defaultDisplayKey = metadata.metadata?.meta?.default_display_key || defaultDisplay?.key || '';
82
+
83
+ const opByList = metadata.metadata?.operation_by || [];
84
+ const defaultOp = opByList.find((o: any) => o.isDefault) || opByList[0];
85
+ const defaultOpVal = defaultOp ? `${defaultOp.key}:${defaultOp.operation}` : '';
86
+
87
+ const defaultFilters = metadata.metadata?.meta?.default_filters || {};
88
+ const filterByList = metadata.metadata?.filter_by || [];
89
+
90
+ // Check if date_range has defaultValue in filter_by or default_filters
91
+ const dateFilter = filterByList.find((f: any) => f.key === 'date_range');
92
+ let fromDate = '';
93
+ let toDate = '';
94
+ if (dateFilter?.defaultValue) {
95
+ fromDate = dateFilter.defaultValue.from_date || dateFilter.defaultValue.from || '';
96
+ toDate = dateFilter.defaultValue.to_date || dateFilter.defaultValue.to || '';
97
+ } else if (defaultFilters.date_range) {
98
+ if (typeof defaultFilters.date_range === 'object') {
99
+ fromDate = defaultFilters.date_range.from_date || defaultFilters.date_range.from || '';
100
+ toDate = defaultFilters.date_range.to_date || defaultFilters.date_range.to || '';
101
+ } else if (typeof defaultFilters.date_range === 'string' && defaultFilters.date_range.includes(',')) {
102
+ const [f, t] = defaultFilters.date_range.split(',');
103
+ fromDate = f;
104
+ toDate = t;
105
+ }
106
+ }
107
+
108
+ const initialValues: any = {
109
+ display_by: defaultDisplayKey,
110
+ operation_key: defaultOpVal,
111
+ staff_id: defaultFilters.staff_id ? String(defaultFilters.staff_id) : '',
112
+ patient_id: defaultFilters.patient_id ? String(defaultFilters.patient_id) : '',
113
+ appointment_status: defaultFilters.appointment_status || '',
114
+ date_from: fromDate,
115
+ date_to: toDate,
116
+ };
117
+
118
+ reset(initialValues);
119
+ }
120
+ }, [metadata, reset]);
121
+
122
+ const doctors = useMemo(() => {
123
+ const list = (staffs && staffs.length > 0 ? staffs : staffsByUser || []);
124
+ const docOpts = list.map((s: any) => {
125
+ const firstName = s.first_name || s.name || s.user_name || `Staff #${s.id}`;
126
+ const lastName = s.last_name ? ` ${s.last_name}` : '';
127
+ const fullName = `${firstName}${lastName}`.trim();
128
+ const prefix = fullName.toLowerCase().startsWith('dr') ? '' : 'Dr. ';
129
+ return {
130
+ label: `${prefix}${fullName}`,
131
+ value: String(s.id || s.staff_id),
132
+ };
133
+ });
134
+ return [
135
+ { label: 'All Doctors', value: '' },
136
+ ...docOpts
137
+ ];
138
+ }, [staffs, staffsByUser]);
139
+
140
+ const patientOptions = useMemo(() => {
141
+ const pOpts = (patients || []).map((p: any) => ({
142
+ label: `${p.first_name || ''} ${p.last_name || ''} (${p.patient_num || p.id})`.trim(),
143
+ value: String(p.id),
144
+ }));
145
+ return [
146
+ { label: 'All Patients', value: '' },
147
+ ...pOpts
148
+ ];
149
+ }, [patients]);
150
+
151
+ const statusOptions = [
152
+ { label: 'All Status', value: '' },
153
+ { label: 'Completed', value: 'COMPLETED' },
154
+ { label: 'Cancelled', value: 'CANCELLED' },
155
+ { label: 'Schedule', value: 'SCHEDULED' },
156
+ { label: 'Pending', value: 'PENDING' },
157
+ ];
158
+
159
+ const displayOptions = useMemo(() => {
160
+ const list = metadata?.metadata?.display_by || [];
161
+ return list.map((d: any) => ({
162
+ label: d.label || d.name || formatKeyLabel(d.key),
163
+ value: d.key,
164
+ }));
165
+ }, [metadata]);
166
+
167
+ const operationOptions = useMemo(() => {
168
+ const list = metadata?.metadata?.operation_by || [];
169
+ return list.map((o: any) => ({
170
+ label: o.label || `${o.operation} of ${o.key}`,
171
+ value: `${o.key}:${o.operation}`,
172
+ }));
173
+ }, [metadata]);
174
+
175
+ const onSubmit = (data: any) => {
176
+ const todayStr = new Date().toISOString().split('T')[0];
177
+ const threeMonthsAgoStr = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
178
+
179
+ const dateVal = (data.date_from && data.date_to) ? {
180
+ from_date: data.date_from,
181
+ to_date: data.date_to
182
+ } : {
183
+ from_date: threeMonthsAgoStr,
184
+ to_date: todayStr
185
+ };
186
+
187
+ const payload: any = {};
188
+
189
+ if (isDynamicReport) {
190
+ // Dynamic report -> filters is a LIST / ARRAY
191
+ const filtersList: any[] = [
192
+ {
193
+ key: 'date_range',
194
+ value: dateVal
195
+ }
196
+ ];
197
+
198
+ if (data.staff_id !== undefined && data.staff_id !== null && data.staff_id !== '') {
199
+ filtersList.push({ key: 'staff_id', value: Number(data.staff_id) });
200
+ }
201
+ if (data.patient_id !== undefined && data.patient_id !== null && data.patient_id !== '') {
202
+ filtersList.push({ key: 'patient_id', value: Number(data.patient_id) });
203
+ }
204
+ if (data.appointment_status) {
205
+ filtersList.push({ key: 'appointment_status', value: data.appointment_status });
206
+ }
207
+
208
+ payload.filters = filtersList;
209
+
210
+ if (data.display_by) {
211
+ payload.display_by = data.display_by;
212
+ }
213
+ if (data.operation_key) {
214
+ const [opKey, opType] = data.operation_key.split(':');
215
+ const selectedOp = metadata?.metadata?.operation_by?.find(
216
+ o => o.key === opKey && o.operation === opType
217
+ );
218
+ if (opKey && opType) {
219
+ payload.operation_by = {
220
+ key: opKey,
221
+ operation: opType,
222
+ label: selectedOp?.label || `${opType} of ${opKey}`
223
+ };
224
+ }
225
+ }
226
+ } else {
227
+ // Static report -> filters is a DICTIONARY / OBJECT
228
+ const filtersObj: Record<string, any> = {
229
+ date_range: dateVal
230
+ };
231
+
232
+ if (data.staff_id !== undefined && data.staff_id !== null && data.staff_id !== '') {
233
+ filtersObj.staff_id = Number(data.staff_id);
234
+ }
235
+ if (data.patient_id !== undefined && data.patient_id !== null && data.patient_id !== '') {
236
+ filtersObj.patient_id = Number(data.patient_id);
237
+ }
238
+ if (data.appointment_status) {
239
+ filtersObj.appointment_status = data.appointment_status;
240
+ }
241
+
242
+ payload.filters = filtersObj;
243
+ }
244
+
245
+ onApply(payload);
246
+ onClose();
247
+ };
248
+
249
+ const setQuickRange = (days: number | null) => {
250
+ if (days === null) {
251
+ setValue('date_from', '');
252
+ setValue('date_to', '');
253
+ return;
254
+ }
255
+ const to = new Date().toISOString().split('T')[0];
256
+ const from = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
257
+ setValue('date_from', from);
258
+ setValue('date_to', to);
259
+ };
260
+
261
+ return (
262
+ <BottomModal isVisible={isVisible} onClose={onClose} heightMode="fixed" fixedHeightPercent={85}>
263
+ <ModalTitle heading="Report Filters Selection" showCloseIcon onClose={onClose} variant="text18B" />
264
+
265
+ <ScrollView className="px-6 py-4" showsVerticalScrollIndicator={false}>
266
+ <CustomView className="gap-2">
267
+ {/* Display By & Operation By - Render ONLY for Dynamic reports */}
268
+ {isDynamicReport && displayOptions.length > 0 && (
269
+ <Dropdown
270
+ name="display_by"
271
+ label="Display By"
272
+ control={control}
273
+ items={displayOptions}
274
+ rules={{}}
275
+ defaultValue=""
276
+ />
277
+ )}
278
+
279
+ {isDynamicReport && operationOptions.length > 0 && (
280
+ <Dropdown
281
+ name="operation_key"
282
+ label="Operation By"
283
+ control={control}
284
+ items={operationOptions}
285
+ rules={{}}
286
+ defaultValue=""
287
+ />
288
+ )}
289
+
290
+ {/* Date Range Section */}
291
+ <CustomView className="mb-4">
292
+ <Text style={{ color: isDarkMode ? '#FFFFFF' : '#475569' }} className="ml-1 uppercase tracking-widest text-[10px] font-extrabold mb-2">
293
+ Date Range
294
+ </Text>
295
+ <CustomView className="flex-row gap-3">
296
+ <View className="flex-1">
297
+ <DatePickerInput
298
+ name="date_from"
299
+ control={control}
300
+ placeholder="From"
301
+ />
302
+ </View>
303
+ <View className="flex-1">
304
+ <DatePickerInput
305
+ name="date_to"
306
+ control={control}
307
+ placeholder="To"
308
+ />
309
+ </View>
310
+ </CustomView>
311
+
312
+ {/* Presets */}
313
+ <CustomView className="flex-row flex-wrap gap-2 mt-2">
314
+ {[
315
+ { label: 'Today', val: 0 },
316
+ { label: 'Last 7 Days', val: 7 },
317
+ { label: 'Last 30 Days', val: 30 },
318
+ { label: 'All Time', val: null },
319
+ ].map((p) => (
320
+ <TouchableOpacity
321
+ key={p.label}
322
+ onPress={() => setQuickRange(p.val)}
323
+ style={{
324
+ backgroundColor: isDarkMode ? '#2C2C2E' : '#F1F5F9',
325
+ borderColor: isDarkMode ? '#3A3A3C' : '#E2E8F0',
326
+ }}
327
+ className="px-3.5 py-2 rounded-full border"
328
+ >
329
+ <Text style={{ color: isDarkMode ? '#FFFFFF' : '#334155' }} className="text-xs font-bold">{p.label}</Text>
330
+ </TouchableOpacity>
331
+ ))}
332
+ </CustomView>
333
+ </CustomView>
334
+
335
+ {/* Dynamic Filters Mapping */}
336
+ {metadata?.metadata?.filter_by?.map((item) => {
337
+ if (item.key === 'staff_id' || item.key.includes('doctor') || item.key.includes('staff')) {
338
+ return (
339
+ <Dropdown
340
+ key={item.key}
341
+ name="staff_id"
342
+ label={item.label || 'Filter by Doctor'}
343
+ control={control}
344
+ items={doctors}
345
+ rules={{}}
346
+ defaultValue=""
347
+ />
348
+ );
349
+ }
350
+ if (item.key === 'patient_id' || item.key.includes('patient')) {
351
+ return (
352
+ <Dropdown
353
+ key={item.key}
354
+ name="patient_id"
355
+ label={item.label || 'Filter by Patient'}
356
+ control={control}
357
+ items={patientOptions}
358
+ rules={{}}
359
+ defaultValue=""
360
+ />
361
+ );
362
+ }
363
+ if (item.key === 'appointment_status' || item.key.includes('status')) {
364
+ return (
365
+ <Dropdown
366
+ key={item.key}
367
+ name="appointment_status"
368
+ label={item.label || 'Filter by Status'}
369
+ control={control}
370
+ items={statusOptions}
371
+ rules={{}}
372
+ defaultValue=""
373
+ />
374
+ );
375
+ }
376
+ return null;
377
+ })}
378
+ </CustomView>
379
+
380
+ <View className="h-10" />
381
+ </ScrollView>
382
+
383
+ <CustomView className="p-6 border-t border-gray-50 dark:border-gray-800 bg-white dark:bg-surface-dark">
384
+ <CustomButton
385
+ title="Apply Filters"
386
+ onPress={handleSubmit(onSubmit)}
387
+ className="rounded-2xl py-4"
388
+ />
389
+ <TouchableOpacity
390
+ onPress={() => {
391
+ onApply(null);
392
+ onClose();
393
+ }}
394
+ className="mt-4 text-center items-center py-2"
395
+ >
396
+ <Text style={{ color: isDarkMode ? '#A78BFA' : themeColors.brand[600] }} className="text-sm font-extrabold">Reset defaults</Text>
397
+ </TouchableOpacity>
398
+ </CustomView>
399
+ </BottomModal>
400
+ );
401
+ };
402
+
403
+ export default ReportFilterModal;