@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,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;
|