@blackcode_sa/metaestetics-api 1.12.31 → 1.12.33
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/dist/admin/index.d.mts +68 -21
- package/dist/admin/index.d.ts +68 -21
- package/dist/admin/index.js +35 -5
- package/dist/admin/index.mjs +35 -5
- package/dist/index.d.mts +203 -44
- package/dist/index.d.ts +203 -44
- package/dist/index.js +2602 -1771
- package/dist/index.mjs +1823 -992
- package/package.json +1 -1
- package/src/admin/booking/booking.admin.ts +40 -4
- package/src/services/appointment/appointment.service.ts +278 -0
- package/src/services/appointment/utils/extended-procedure.utils.ts +232 -0
- package/src/services/appointment/utils/zone-management.utils.ts +335 -0
- package/src/services/patient/patient.service.ts +46 -0
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +57 -2
- package/src/services/procedure/procedure.service.ts +54 -0
- package/src/types/appointment/index.ts +75 -26
- package/src/types/patient/aesthetic-analysis.types.ts +37 -23
- package/src/validations/appointment.schema.ts +100 -4
- package/src/validations/patient/aesthetic-analysis.schema.ts +40 -32
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { Firestore, getDoc, updateDoc, serverTimestamp } from 'firebase/firestore';
|
|
2
|
+
import {
|
|
3
|
+
ZoneItemData,
|
|
4
|
+
AppointmentMetadata,
|
|
5
|
+
FinalBilling,
|
|
6
|
+
Appointment,
|
|
7
|
+
APPOINTMENTS_COLLECTION,
|
|
8
|
+
} from '../../../types/appointment';
|
|
9
|
+
import { getAppointmentByIdUtil } from './appointment.utils';
|
|
10
|
+
import { doc } from 'firebase/firestore';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validates that a zone key follows the category.zone format
|
|
14
|
+
* @param zoneKey Zone key to validate
|
|
15
|
+
* @throws Error if format is invalid
|
|
16
|
+
*/
|
|
17
|
+
export function validateZoneKeyFormat(zoneKey: string): void {
|
|
18
|
+
const parts = zoneKey.split('.');
|
|
19
|
+
if (parts.length !== 2) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Invalid zone key format: "${zoneKey}". Must be "category.zone" (e.g., "face.forehead")`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Calculates subtotal for a zone item
|
|
28
|
+
* @param item Zone item data
|
|
29
|
+
* @returns Calculated subtotal
|
|
30
|
+
*/
|
|
31
|
+
export function calculateItemSubtotal(item: Partial<ZoneItemData>): number {
|
|
32
|
+
if (item.type === 'note') {
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// If price override amount is set, use it
|
|
37
|
+
if (item.priceOverrideAmount !== undefined && item.priceOverrideAmount !== null) {
|
|
38
|
+
return item.priceOverrideAmount;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Calculate normally: price * quantity
|
|
42
|
+
const price = item.price || 0;
|
|
43
|
+
const quantity = item.quantity || 0;
|
|
44
|
+
return price * quantity;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Recalculates final billing based on all zone items
|
|
49
|
+
* @param zonesData Zone items data
|
|
50
|
+
* @param taxRate Tax rate (e.g., 0.20 for 20%)
|
|
51
|
+
* @returns Calculated final billing
|
|
52
|
+
*/
|
|
53
|
+
export function calculateFinalBilling(
|
|
54
|
+
zonesData: Record<string, ZoneItemData[]>,
|
|
55
|
+
taxRate: number = 0.20
|
|
56
|
+
): FinalBilling {
|
|
57
|
+
let subtotalAll = 0;
|
|
58
|
+
|
|
59
|
+
// Sum up all zone items
|
|
60
|
+
Object.values(zonesData).forEach(items => {
|
|
61
|
+
items.forEach(item => {
|
|
62
|
+
if (item.type === 'item' && item.subtotal) {
|
|
63
|
+
subtotalAll += item.subtotal;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const taxPrice = subtotalAll * taxRate;
|
|
69
|
+
const finalPrice = subtotalAll + taxPrice;
|
|
70
|
+
|
|
71
|
+
// Get currency from first item (assuming all same currency)
|
|
72
|
+
let currency: any = 'CHF'; // Default
|
|
73
|
+
|
|
74
|
+
for (const items of Object.values(zonesData)) {
|
|
75
|
+
const firstItem = items.find(i => i.type === 'item');
|
|
76
|
+
if (firstItem) {
|
|
77
|
+
currency = firstItem.currency || currency;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
subtotalAll,
|
|
84
|
+
taxRate,
|
|
85
|
+
taxPrice,
|
|
86
|
+
finalPrice,
|
|
87
|
+
currency,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Gets appointment and validates it exists
|
|
93
|
+
* @param db Firestore instance
|
|
94
|
+
* @param appointmentId Appointment ID
|
|
95
|
+
* @returns Appointment document
|
|
96
|
+
*/
|
|
97
|
+
export async function getAppointmentOrThrow(
|
|
98
|
+
db: Firestore,
|
|
99
|
+
appointmentId: string
|
|
100
|
+
): Promise<Appointment> {
|
|
101
|
+
const appointment = await getAppointmentByIdUtil(db, appointmentId);
|
|
102
|
+
if (!appointment) {
|
|
103
|
+
throw new Error(`Appointment with ID ${appointmentId} not found`);
|
|
104
|
+
}
|
|
105
|
+
return appointment;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Initializes appointment metadata if it doesn't exist
|
|
110
|
+
* @param appointment Appointment document
|
|
111
|
+
* @returns Initialized metadata
|
|
112
|
+
*/
|
|
113
|
+
export function initializeMetadata(
|
|
114
|
+
appointment: Appointment
|
|
115
|
+
): AppointmentMetadata {
|
|
116
|
+
return appointment.metadata || {
|
|
117
|
+
selectedZones: null,
|
|
118
|
+
zonePhotos: null,
|
|
119
|
+
zonesData: null,
|
|
120
|
+
appointmentProducts: [],
|
|
121
|
+
extendedProcedures: [],
|
|
122
|
+
finalbilling: null,
|
|
123
|
+
finalizationNotes: null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Adds an item to a specific zone
|
|
129
|
+
* @param db Firestore instance
|
|
130
|
+
* @param appointmentId Appointment ID
|
|
131
|
+
* @param zoneId Zone ID (must be category.zone format)
|
|
132
|
+
* @param item Zone item data to add (without parentZone)
|
|
133
|
+
* @returns Updated appointment
|
|
134
|
+
*/
|
|
135
|
+
export async function addItemToZoneUtil(
|
|
136
|
+
db: Firestore,
|
|
137
|
+
appointmentId: string,
|
|
138
|
+
zoneId: string,
|
|
139
|
+
item: Omit<ZoneItemData, 'subtotal' | 'parentZone'>
|
|
140
|
+
): Promise<Appointment> {
|
|
141
|
+
// Validate zone key format
|
|
142
|
+
validateZoneKeyFormat(zoneId);
|
|
143
|
+
|
|
144
|
+
// Get appointment
|
|
145
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
146
|
+
const metadata = initializeMetadata(appointment);
|
|
147
|
+
|
|
148
|
+
// Initialize zonesData if needed
|
|
149
|
+
const zonesData = metadata.zonesData || {};
|
|
150
|
+
|
|
151
|
+
// Initialize zone array if needed
|
|
152
|
+
if (!zonesData[zoneId]) {
|
|
153
|
+
zonesData[zoneId] = [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Calculate subtotal for the item
|
|
157
|
+
const itemWithSubtotal: ZoneItemData = {
|
|
158
|
+
...item,
|
|
159
|
+
parentZone: zoneId, // Set parentZone to the zone key
|
|
160
|
+
subtotal: calculateItemSubtotal(item),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Add item to zone
|
|
164
|
+
zonesData[zoneId].push(itemWithSubtotal);
|
|
165
|
+
|
|
166
|
+
// Recalculate final billing
|
|
167
|
+
const finalbilling = calculateFinalBilling(zonesData); //TODO: add correct amount of tax
|
|
168
|
+
|
|
169
|
+
// Update appointment
|
|
170
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
171
|
+
await updateDoc(appointmentRef, {
|
|
172
|
+
'metadata.zonesData': zonesData,
|
|
173
|
+
'metadata.finalbilling': finalbilling,
|
|
174
|
+
updatedAt: serverTimestamp(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Return updated appointment
|
|
178
|
+
return getAppointmentOrThrow(db, appointmentId);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Removes an item from a specific zone
|
|
183
|
+
* @param db Firestore instance
|
|
184
|
+
* @param appointmentId Appointment ID
|
|
185
|
+
* @param zoneId Zone ID
|
|
186
|
+
* @param itemIndex Index of item to remove
|
|
187
|
+
* @returns Updated appointment
|
|
188
|
+
*/
|
|
189
|
+
export async function removeItemFromZoneUtil(
|
|
190
|
+
db: Firestore,
|
|
191
|
+
appointmentId: string,
|
|
192
|
+
zoneId: string,
|
|
193
|
+
itemIndex: number
|
|
194
|
+
): Promise<Appointment> {
|
|
195
|
+
validateZoneKeyFormat(zoneId);
|
|
196
|
+
|
|
197
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
198
|
+
const metadata = initializeMetadata(appointment);
|
|
199
|
+
|
|
200
|
+
if (!metadata.zonesData || !metadata.zonesData[zoneId]) {
|
|
201
|
+
throw new Error(`No items found for zone ${zoneId}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const items = metadata.zonesData[zoneId];
|
|
205
|
+
if (itemIndex < 0 || itemIndex >= items.length) {
|
|
206
|
+
throw new Error(`Invalid item index ${itemIndex} for zone ${zoneId}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Remove item
|
|
210
|
+
items.splice(itemIndex, 1);
|
|
211
|
+
|
|
212
|
+
// If zone is now empty, remove it
|
|
213
|
+
if (items.length === 0) {
|
|
214
|
+
delete metadata.zonesData[zoneId];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Recalculate final billing
|
|
218
|
+
const finalbilling = calculateFinalBilling(metadata.zonesData); //TODO: add correct amount of tax
|
|
219
|
+
|
|
220
|
+
// Update appointment
|
|
221
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
222
|
+
await updateDoc(appointmentRef, {
|
|
223
|
+
'metadata.zonesData': metadata.zonesData,
|
|
224
|
+
'metadata.finalbilling': finalbilling,
|
|
225
|
+
updatedAt: serverTimestamp(),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return getAppointmentOrThrow(db, appointmentId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Updates a specific item in a zone
|
|
233
|
+
* @param db Firestore instance
|
|
234
|
+
* @param appointmentId Appointment ID
|
|
235
|
+
* @param zoneId Zone ID
|
|
236
|
+
* @param itemIndex Index of item to update
|
|
237
|
+
* @param updates Partial updates to apply
|
|
238
|
+
* @returns Updated appointment
|
|
239
|
+
*/
|
|
240
|
+
export async function updateZoneItemUtil(
|
|
241
|
+
db: Firestore,
|
|
242
|
+
appointmentId: string,
|
|
243
|
+
zoneId: string,
|
|
244
|
+
itemIndex: number,
|
|
245
|
+
updates: Partial<ZoneItemData>
|
|
246
|
+
): Promise<Appointment> {
|
|
247
|
+
validateZoneKeyFormat(zoneId);
|
|
248
|
+
|
|
249
|
+
const appointment = await getAppointmentOrThrow(db, appointmentId);
|
|
250
|
+
const metadata = initializeMetadata(appointment);
|
|
251
|
+
|
|
252
|
+
if (!metadata.zonesData || !metadata.zonesData[zoneId]) {
|
|
253
|
+
throw new Error(`No items found for zone ${zoneId}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const items = metadata.zonesData[zoneId];
|
|
257
|
+
if (itemIndex < 0 || itemIndex >= items.length) {
|
|
258
|
+
throw new Error(`Invalid item index ${itemIndex} for zone ${zoneId}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Update item
|
|
262
|
+
items[itemIndex] = {
|
|
263
|
+
...items[itemIndex],
|
|
264
|
+
...updates,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Recalculate subtotal for this item
|
|
268
|
+
items[itemIndex].subtotal = calculateItemSubtotal(items[itemIndex]);
|
|
269
|
+
|
|
270
|
+
// Recalculate final billing
|
|
271
|
+
const finalbilling = calculateFinalBilling(metadata.zonesData); //TODO: add correct amount of tax
|
|
272
|
+
|
|
273
|
+
// Update appointment
|
|
274
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
275
|
+
await updateDoc(appointmentRef, {
|
|
276
|
+
'metadata.zonesData': metadata.zonesData,
|
|
277
|
+
'metadata.finalbilling': finalbilling,
|
|
278
|
+
updatedAt: serverTimestamp(),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return getAppointmentOrThrow(db, appointmentId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Overrides price for a specific zone item
|
|
286
|
+
* @param db Firestore instance
|
|
287
|
+
* @param appointmentId Appointment ID
|
|
288
|
+
* @param zoneId Zone ID
|
|
289
|
+
* @param itemIndex Index of item
|
|
290
|
+
* @param newPrice New price amount
|
|
291
|
+
* @returns Updated appointment
|
|
292
|
+
*/
|
|
293
|
+
export async function overridePriceForZoneItemUtil(
|
|
294
|
+
db: Firestore,
|
|
295
|
+
appointmentId: string,
|
|
296
|
+
zoneId: string,
|
|
297
|
+
itemIndex: number,
|
|
298
|
+
newPrice: number
|
|
299
|
+
): Promise<Appointment> {
|
|
300
|
+
return updateZoneItemUtil(db, appointmentId, zoneId, itemIndex, {
|
|
301
|
+
priceOverrideAmount: newPrice,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Updates subzones for a specific zone item
|
|
307
|
+
* @param db Firestore instance
|
|
308
|
+
* @param appointmentId Appointment ID
|
|
309
|
+
* @param zoneId Zone ID
|
|
310
|
+
* @param itemIndex Index of item
|
|
311
|
+
* @param subzones Array of subzone keys (empty array = entire zone)
|
|
312
|
+
* @returns Updated appointment
|
|
313
|
+
*/
|
|
314
|
+
export async function updateSubzonesUtil(
|
|
315
|
+
db: Firestore,
|
|
316
|
+
appointmentId: string,
|
|
317
|
+
zoneId: string,
|
|
318
|
+
itemIndex: number,
|
|
319
|
+
subzones: string[]
|
|
320
|
+
): Promise<Appointment> {
|
|
321
|
+
// Validate subzone format if provided
|
|
322
|
+
subzones.forEach(subzone => {
|
|
323
|
+
const parts = subzone.split('.');
|
|
324
|
+
if (parts.length !== 3) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`Invalid subzone format: "${subzone}". Must be "category.zone.subzone" (e.g., "face.forehead.left")`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return updateZoneItemUtil(db, appointmentId, zoneId, itemIndex, {
|
|
332
|
+
subzones,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
@@ -93,6 +93,17 @@ import {
|
|
|
93
93
|
getActiveInviteTokensByPatientUtil,
|
|
94
94
|
} from './utils';
|
|
95
95
|
|
|
96
|
+
import {
|
|
97
|
+
getAestheticAnalysisUtil,
|
|
98
|
+
createOrUpdateAestheticAnalysisUtil,
|
|
99
|
+
} from './utils/aesthetic-analysis.utils';
|
|
100
|
+
|
|
101
|
+
import {
|
|
102
|
+
AestheticAnalysis,
|
|
103
|
+
CreateAestheticAnalysisData,
|
|
104
|
+
UpdateAestheticAnalysisData,
|
|
105
|
+
} from '../../types/patient';
|
|
106
|
+
|
|
96
107
|
import { CreatePatientTokenData, PatientToken } from '../../types/patient/token.types';
|
|
97
108
|
|
|
98
109
|
export class PatientService extends BaseService {
|
|
@@ -834,4 +845,39 @@ export class PatientService extends BaseService {
|
|
|
834
845
|
// the admin has permission to view this patient's tokens.
|
|
835
846
|
return getActiveInviteTokensByPatientUtil(this.db, patientId);
|
|
836
847
|
}
|
|
848
|
+
|
|
849
|
+
async getAestheticAnalysis(patientId: string): Promise<AestheticAnalysis | null> {
|
|
850
|
+
const currentUser = await this.getCurrentUser();
|
|
851
|
+
return getAestheticAnalysisUtil(this.db, patientId, currentUser.uid, currentUser.roles);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async createAestheticAnalysis(
|
|
855
|
+
patientId: string,
|
|
856
|
+
data: CreateAestheticAnalysisData
|
|
857
|
+
): Promise<void> {
|
|
858
|
+
const currentUser = await this.getCurrentUser();
|
|
859
|
+
return createOrUpdateAestheticAnalysisUtil(
|
|
860
|
+
this.db,
|
|
861
|
+
patientId,
|
|
862
|
+
data,
|
|
863
|
+
currentUser.uid,
|
|
864
|
+
currentUser.roles,
|
|
865
|
+
false
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async updateAestheticAnalysis(
|
|
870
|
+
patientId: string,
|
|
871
|
+
data: UpdateAestheticAnalysisData
|
|
872
|
+
): Promise<void> {
|
|
873
|
+
const currentUser = await this.getCurrentUser();
|
|
874
|
+
return createOrUpdateAestheticAnalysisUtil(
|
|
875
|
+
this.db,
|
|
876
|
+
patientId,
|
|
877
|
+
data,
|
|
878
|
+
currentUser.uid,
|
|
879
|
+
currentUser.roles,
|
|
880
|
+
true
|
|
881
|
+
);
|
|
882
|
+
}
|
|
837
883
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
UpdateAestheticAnalysisData,
|
|
6
6
|
AESTHETIC_ANALYSIS_COLLECTION,
|
|
7
7
|
PATIENTS_COLLECTION,
|
|
8
|
+
AestheticAnalysisStatus,
|
|
8
9
|
} from '../../../types/patient';
|
|
9
10
|
import { UserRole } from '../../../types';
|
|
10
11
|
import {
|
|
@@ -63,6 +64,37 @@ const checkAestheticAnalysisAccessUtil = async (
|
|
|
63
64
|
);
|
|
64
65
|
};
|
|
65
66
|
|
|
67
|
+
export const calculateCompletionPercentage = (data: Partial<AestheticAnalysis>): number => {
|
|
68
|
+
let completed = 0;
|
|
69
|
+
const total = 4;
|
|
70
|
+
|
|
71
|
+
if (data.selectedConcerns && data.selectedConcerns.length > 0) completed++;
|
|
72
|
+
|
|
73
|
+
if (data.patientGoals && (
|
|
74
|
+
data.patientGoals.timeline ||
|
|
75
|
+
data.patientGoals.budget ||
|
|
76
|
+
data.patientGoals.selectedTemplate
|
|
77
|
+
)) completed++;
|
|
78
|
+
|
|
79
|
+
if (data.assessmentScales && Object.keys(data.assessmentScales).length > 0) completed++;
|
|
80
|
+
|
|
81
|
+
if (data.clinicalFindings && Object.keys(data.clinicalFindings).length > 0) completed++;
|
|
82
|
+
|
|
83
|
+
return Math.round((completed / total) * 100);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const determineStatus = (completionPercentage: number, selectedConcerns: string[]): AestheticAnalysisStatus => {
|
|
87
|
+
if (completionPercentage < 50) {
|
|
88
|
+
return 'incomplete';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (completionPercentage >= 50 && selectedConcerns.length > 0) {
|
|
92
|
+
return 'ready_for_planning';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return 'incomplete';
|
|
96
|
+
};
|
|
97
|
+
|
|
66
98
|
export const getAestheticAnalysisUtil = async (
|
|
67
99
|
db: Firestore,
|
|
68
100
|
patientId: string,
|
|
@@ -78,7 +110,11 @@ export const getAestheticAnalysisUtil = async (
|
|
|
78
110
|
return null;
|
|
79
111
|
}
|
|
80
112
|
|
|
81
|
-
|
|
113
|
+
const data = snapshot.data();
|
|
114
|
+
return aestheticAnalysisSchema.parse({
|
|
115
|
+
...data,
|
|
116
|
+
id: patientId,
|
|
117
|
+
});
|
|
82
118
|
};
|
|
83
119
|
|
|
84
120
|
export const createOrUpdateAestheticAnalysisUtil = async (
|
|
@@ -100,10 +136,28 @@ export const createOrUpdateAestheticAnalysisUtil = async (
|
|
|
100
136
|
|
|
101
137
|
const requesterRole = requesterRoles.includes(UserRole.PRACTITIONER) ? 'PRACTITIONER' : 'PATIENT';
|
|
102
138
|
|
|
139
|
+
const existingData = snapshot.exists() ? snapshot.data() : null;
|
|
140
|
+
const mergedData: any = {
|
|
141
|
+
...(existingData || {}),
|
|
142
|
+
...validatedData,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const completionPercentage = calculateCompletionPercentage(mergedData);
|
|
146
|
+
const status = determineStatus(
|
|
147
|
+
completionPercentage,
|
|
148
|
+
mergedData.selectedConcerns || []
|
|
149
|
+
);
|
|
150
|
+
|
|
103
151
|
if (!snapshot.exists()) {
|
|
104
152
|
await setDoc(docRef, {
|
|
153
|
+
id: patientId,
|
|
105
154
|
patientId,
|
|
155
|
+
selectedConcerns: [],
|
|
156
|
+
clinicalFindings: {},
|
|
157
|
+
assessmentScales: {},
|
|
106
158
|
...validatedData,
|
|
159
|
+
completionPercentage,
|
|
160
|
+
status,
|
|
107
161
|
lastUpdatedBy: requesterId,
|
|
108
162
|
lastUpdatedByRole: requesterRole,
|
|
109
163
|
createdAt: serverTimestamp(),
|
|
@@ -112,10 +166,11 @@ export const createOrUpdateAestheticAnalysisUtil = async (
|
|
|
112
166
|
} else {
|
|
113
167
|
await updateDoc(docRef, {
|
|
114
168
|
...validatedData,
|
|
169
|
+
completionPercentage,
|
|
170
|
+
status,
|
|
115
171
|
lastUpdatedBy: requesterId,
|
|
116
172
|
lastUpdatedByRole: requesterRole,
|
|
117
173
|
updatedAt: serverTimestamp(),
|
|
118
174
|
});
|
|
119
175
|
}
|
|
120
176
|
};
|
|
121
|
-
|
|
@@ -1508,4 +1508,58 @@ export class ProcedureService extends BaseService {
|
|
|
1508
1508
|
});
|
|
1509
1509
|
return proceduresForMap;
|
|
1510
1510
|
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Gets procedures filtered by clinic and practitioner with optional family filter
|
|
1514
|
+
* @param clinicBranchId Clinic branch ID to filter by
|
|
1515
|
+
* @param practitionerId Practitioner ID to filter by
|
|
1516
|
+
* @param filterByFamily If true, shows only procedures of the same family as the default procedure
|
|
1517
|
+
* @param defaultProcedureId Optional default procedure ID to determine the family
|
|
1518
|
+
* @returns Array of procedures
|
|
1519
|
+
*/
|
|
1520
|
+
async getProceduresForConsultation(
|
|
1521
|
+
clinicBranchId: string,
|
|
1522
|
+
practitionerId: string,
|
|
1523
|
+
filterByFamily: boolean = true,
|
|
1524
|
+
defaultProcedureId?: string
|
|
1525
|
+
): Promise<Procedure[]> {
|
|
1526
|
+
let familyToFilter: ProcedureFamily | null = null;
|
|
1527
|
+
|
|
1528
|
+
// If family filtering is enabled and we have a default procedure, get its family
|
|
1529
|
+
if (filterByFamily && defaultProcedureId) {
|
|
1530
|
+
const defaultProcedureRef = doc(this.db, PROCEDURES_COLLECTION, defaultProcedureId);
|
|
1531
|
+
const defaultProcedureSnap = await getDoc(defaultProcedureRef);
|
|
1532
|
+
|
|
1533
|
+
if (defaultProcedureSnap.exists()) {
|
|
1534
|
+
const defaultProcedure = defaultProcedureSnap.data() as Procedure;
|
|
1535
|
+
familyToFilter = defaultProcedure.family;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Build query constraints
|
|
1540
|
+
const constraints: QueryConstraint[] = [
|
|
1541
|
+
where('clinicBranchId', '==', clinicBranchId),
|
|
1542
|
+
where('practitionerId', '==', practitionerId),
|
|
1543
|
+
where('isActive', '==', true),
|
|
1544
|
+
];
|
|
1545
|
+
|
|
1546
|
+
// Add family filter if applicable
|
|
1547
|
+
if (filterByFamily && familyToFilter) {
|
|
1548
|
+
constraints.push(where('family', '==', familyToFilter));
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Execute query
|
|
1552
|
+
const proceduresQuery = query(
|
|
1553
|
+
collection(this.db, PROCEDURES_COLLECTION),
|
|
1554
|
+
...constraints,
|
|
1555
|
+
orderBy('name', 'asc')
|
|
1556
|
+
);
|
|
1557
|
+
|
|
1558
|
+
const querySnapshot = await getDocs(proceduresQuery);
|
|
1559
|
+
|
|
1560
|
+
return querySnapshot.docs.map(doc => ({
|
|
1561
|
+
id: doc.id,
|
|
1562
|
+
...doc.data(),
|
|
1563
|
+
} as Procedure));
|
|
1564
|
+
}
|
|
1511
1565
|
}
|
|
@@ -76,8 +76,12 @@ export interface ProcedureExtendedInfo {
|
|
|
76
76
|
procedureTechnologyName: string;
|
|
77
77
|
procedureProductBrandId: string;
|
|
78
78
|
procedureProductBrandName: string;
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
procedureProducts: Array<{
|
|
80
|
+
productId: string;
|
|
81
|
+
productName: string;
|
|
82
|
+
brandId: string;
|
|
83
|
+
brandName: string;
|
|
84
|
+
}>;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/**
|
|
@@ -132,26 +136,39 @@ export interface ZonePhotoUploadData {
|
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
/**
|
|
135
|
-
* Interface for
|
|
139
|
+
* Interface for zone item data (products or notes per zone)
|
|
136
140
|
*/
|
|
137
|
-
export interface
|
|
138
|
-
|
|
141
|
+
export interface ZoneItemData {
|
|
142
|
+
productId?: string;
|
|
143
|
+
productName?: string;
|
|
144
|
+
productBrandId?: string;
|
|
145
|
+
productBrandName?: string;
|
|
146
|
+
belongingProcedureId: string;
|
|
147
|
+
type: 'item' | 'note';
|
|
148
|
+
price?: number;
|
|
149
|
+
currency?: Currency;
|
|
150
|
+
unitOfMeasurement?: PricingMeasure;
|
|
151
|
+
priceOverrideAmount?: number; // If set, takes precedence over price
|
|
152
|
+
quantity?: number;
|
|
153
|
+
parentZone: string; // Zone key in format "category.zone" (e.g., "face.forehead")
|
|
154
|
+
subzones: string[];
|
|
155
|
+
notes?: string;
|
|
156
|
+
subtotal?: number;
|
|
157
|
+
ionNumber?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @deprecated Use ZoneItemData instead
|
|
162
|
+
*/
|
|
163
|
+
export interface BillingPerZone {
|
|
139
164
|
Product: string;
|
|
140
|
-
/** Product ID */
|
|
141
165
|
ProductId: string | null;
|
|
142
|
-
/** Quantity used (can be decimal) */
|
|
143
166
|
Quantity: number;
|
|
144
|
-
/** Unit of measurement */
|
|
145
167
|
UnitOfMeasurement: PricingMeasure;
|
|
146
|
-
/** Unit price for the product */
|
|
147
168
|
UnitPrice: number;
|
|
148
|
-
/** Currency for the unit price */
|
|
149
169
|
UnitCurency: Currency;
|
|
150
|
-
/** Calculated subtotal */
|
|
151
170
|
Subtotal: number;
|
|
152
|
-
/** Optional billing note */
|
|
153
171
|
Note: string | null;
|
|
154
|
-
/** Ion/Batch number for traceability */
|
|
155
172
|
IonNumber: string | null;
|
|
156
173
|
}
|
|
157
174
|
|
|
@@ -167,29 +184,61 @@ export interface FinalBilling {
|
|
|
167
184
|
taxPrice: number;
|
|
168
185
|
/** Final price including tax */
|
|
169
186
|
finalPrice: number;
|
|
170
|
-
/** Total final quantity across all zones */
|
|
171
|
-
finalQuantity: number; // Not sure we should keep this as well, we keep track of this per item
|
|
172
187
|
/** Currency for the final billing */
|
|
173
188
|
currency: Currency;
|
|
174
|
-
|
|
175
|
-
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Interface for product metadata in appointment
|
|
193
|
+
*/
|
|
194
|
+
export interface AppointmentProductMetadata {
|
|
195
|
+
productId: string;
|
|
196
|
+
productName: string;
|
|
197
|
+
brandId: string;
|
|
198
|
+
brandName: string;
|
|
199
|
+
procedureId: string;
|
|
200
|
+
price: number;
|
|
201
|
+
currency: Currency;
|
|
202
|
+
unitOfMeasurement: PricingMeasure;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Interface for extended procedures in appointment
|
|
207
|
+
*/
|
|
208
|
+
export interface ExtendedProcedureInfo {
|
|
209
|
+
procedureId: string;
|
|
210
|
+
procedureName: string;
|
|
211
|
+
procedureFamily?: ProcedureFamily;
|
|
212
|
+
procedureCategoryId: string;
|
|
213
|
+
procedureCategoryName: string;
|
|
214
|
+
procedureSubCategoryId: string;
|
|
215
|
+
procedureSubCategoryName: string;
|
|
216
|
+
procedureTechnologyId: string;
|
|
217
|
+
procedureTechnologyName: string;
|
|
218
|
+
procedureProducts: Array<{
|
|
219
|
+
productId: string;
|
|
220
|
+
productName: string;
|
|
221
|
+
brandId: string;
|
|
222
|
+
brandName: string;
|
|
223
|
+
}>;
|
|
176
224
|
}
|
|
177
225
|
|
|
178
226
|
/**
|
|
179
227
|
* Interface for appointment metadata containing zone-specific information
|
|
180
228
|
*/
|
|
181
229
|
export interface AppointmentMetadata {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
zoneBilling: Record<string, BillingPerZone> | null; // This is going to change, name it zonesData, and expand this to accept more than one item per key (which is basically zone like category.zone)
|
|
188
|
-
/** Final billing calculations for the appointment */
|
|
230
|
+
selectedZones: string[] | null;
|
|
231
|
+
zonePhotos: Record<string, BeforeAfterPerZone> | null;
|
|
232
|
+
zonesData?: Record<string, ZoneItemData[]> | null;
|
|
233
|
+
appointmentProducts?: AppointmentProductMetadata[];
|
|
234
|
+
extendedProcedures?: ExtendedProcedureInfo[];
|
|
189
235
|
finalbilling: FinalBilling | null;
|
|
190
|
-
/** Final note for the appointment */
|
|
191
236
|
finalizationNotes: string | null;
|
|
192
|
-
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @deprecated Use zonesData instead
|
|
240
|
+
*/
|
|
241
|
+
zoneBilling?: Record<string, BillingPerZone> | null;
|
|
193
242
|
}
|
|
194
243
|
|
|
195
244
|
/**
|