@blackcode_sa/metaestetics-api 1.12.32 → 1.12.34
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 +169 -22
- package/dist/index.d.ts +169 -22
- package/dist/index.js +2787 -1793
- package/dist/index.mjs +2662 -1668
- 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 +285 -0
- package/src/services/appointment/utils/form-initialization.utils.ts +225 -0
- package/src/services/appointment/utils/zone-management.utils.ts +335 -0
- package/src/services/patient/patient.service.ts +46 -0
- package/src/services/procedure/procedure.service.ts +54 -0
- package/src/types/appointment/index.ts +75 -26
- package/src/validations/appointment.schema.ts +100 -4
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Firestore, collection, doc, addDoc, deleteDoc, getDocs, query, where, serverTimestamp, getDoc } from 'firebase/firestore';
|
|
2
|
+
import {
|
|
3
|
+
DocumentTemplate,
|
|
4
|
+
FilledDocumentStatus,
|
|
5
|
+
DOCTOR_FORMS_SUBCOLLECTION,
|
|
6
|
+
DOCUMENTATION_TEMPLATES_COLLECTION,
|
|
7
|
+
} from '../../../types/documentation-templates';
|
|
8
|
+
import {
|
|
9
|
+
APPOINTMENTS_COLLECTION,
|
|
10
|
+
LinkedFormInfo,
|
|
11
|
+
} from '../../../types/appointment';
|
|
12
|
+
import { TechnologyDocumentationTemplate } from '../../../backoffice/types/technology.types';
|
|
13
|
+
|
|
14
|
+
export interface InitializeExtendedProcedureFormsResult {
|
|
15
|
+
initializedFormsInfo: LinkedFormInfo[];
|
|
16
|
+
pendingUserFormsIds: string[];
|
|
17
|
+
allLinkedFormIds: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Initializes forms for an extended procedure using client-side Firestore
|
|
22
|
+
* Similar to DocumentManagerAdminService but for client-side usage
|
|
23
|
+
* @param db Firestore instance
|
|
24
|
+
* @param appointmentId Appointment ID
|
|
25
|
+
* @param procedureId Procedure ID for forms
|
|
26
|
+
* @param technologyTemplates Technology documentation templates
|
|
27
|
+
* @param patientId Patient ID
|
|
28
|
+
* @param practitionerId Practitioner ID
|
|
29
|
+
* @param clinicId Clinic ID
|
|
30
|
+
* @returns Form initialization result
|
|
31
|
+
*/
|
|
32
|
+
export async function initializeFormsForExtendedProcedure(
|
|
33
|
+
db: Firestore,
|
|
34
|
+
appointmentId: string,
|
|
35
|
+
procedureId: string,
|
|
36
|
+
technologyTemplates: TechnologyDocumentationTemplate[],
|
|
37
|
+
patientId: string,
|
|
38
|
+
practitionerId: string,
|
|
39
|
+
clinicId: string
|
|
40
|
+
): Promise<InitializeExtendedProcedureFormsResult> {
|
|
41
|
+
const initializedFormsInfo: LinkedFormInfo[] = [];
|
|
42
|
+
const pendingUserFormsIds: string[] = [];
|
|
43
|
+
const allLinkedFormIds: string[] = [];
|
|
44
|
+
|
|
45
|
+
if (!technologyTemplates || technologyTemplates.length === 0) {
|
|
46
|
+
console.log(
|
|
47
|
+
`[FormInit] No document templates to initialize for extended procedure ${procedureId} in appointment ${appointmentId}.`
|
|
48
|
+
);
|
|
49
|
+
return {
|
|
50
|
+
initializedFormsInfo,
|
|
51
|
+
pendingUserFormsIds,
|
|
52
|
+
allLinkedFormIds,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Fetch all template documents
|
|
57
|
+
const templateIds = technologyTemplates.map((t) => t.templateId);
|
|
58
|
+
|
|
59
|
+
// Note: Client-side Firestore doesn't support 'in' queries with more than 10 items
|
|
60
|
+
// We'll fetch templates one by one for now (can be optimized later)
|
|
61
|
+
const templatesMap = new Map<string, DocumentTemplate>();
|
|
62
|
+
|
|
63
|
+
for (const templateId of templateIds) {
|
|
64
|
+
try {
|
|
65
|
+
const templateDoc = doc(db, DOCUMENTATION_TEMPLATES_COLLECTION, templateId);
|
|
66
|
+
const templateSnap = await getDoc(templateDoc);
|
|
67
|
+
|
|
68
|
+
if (templateSnap.exists()) {
|
|
69
|
+
templatesMap.set(templateId, templateSnap.data() as DocumentTemplate);
|
|
70
|
+
} else {
|
|
71
|
+
console.warn(
|
|
72
|
+
`[FormInit] Template ${templateId} not found in Firestore.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(`[FormInit] Error fetching template ${templateId}:`, error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Initialize forms for each template (only doctor forms for extended procedures)
|
|
81
|
+
for (const templateRef of technologyTemplates) {
|
|
82
|
+
const template = templatesMap.get(templateRef.templateId);
|
|
83
|
+
if (!template) {
|
|
84
|
+
console.warn(
|
|
85
|
+
`[FormInit] Template ${templateRef.templateId} not found in Firestore.`
|
|
86
|
+
);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Skip user forms - only create doctor forms for extended procedures
|
|
91
|
+
if (templateRef.isUserForm) {
|
|
92
|
+
console.log(
|
|
93
|
+
`[FormInit] Skipping user form ${templateRef.templateId} for extended procedure ${procedureId}.`
|
|
94
|
+
);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isRequired = templateRef.isRequired;
|
|
99
|
+
const formSubcollectionPath = DOCTOR_FORMS_SUBCOLLECTION;
|
|
100
|
+
|
|
101
|
+
// Create form document in subcollection
|
|
102
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
103
|
+
const formsCollectionRef = collection(appointmentRef, formSubcollectionPath);
|
|
104
|
+
|
|
105
|
+
const filledDocumentData = {
|
|
106
|
+
templateId: templateRef.templateId,
|
|
107
|
+
templateVersion: template.version,
|
|
108
|
+
isUserForm: false, // Always false for extended procedures
|
|
109
|
+
isRequired: isRequired,
|
|
110
|
+
appointmentId: appointmentId,
|
|
111
|
+
procedureId: procedureId,
|
|
112
|
+
patientId: patientId,
|
|
113
|
+
practitionerId: practitionerId,
|
|
114
|
+
clinicId: clinicId,
|
|
115
|
+
createdAt: serverTimestamp(),
|
|
116
|
+
updatedAt: serverTimestamp(),
|
|
117
|
+
values: {},
|
|
118
|
+
status: FilledDocumentStatus.PENDING,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const docRef = await addDoc(formsCollectionRef, filledDocumentData);
|
|
123
|
+
const filledDocumentId = docRef.id;
|
|
124
|
+
|
|
125
|
+
// No user forms for extended procedures, so no pending user forms
|
|
126
|
+
|
|
127
|
+
allLinkedFormIds.push(filledDocumentId);
|
|
128
|
+
|
|
129
|
+
const linkedForm: LinkedFormInfo = {
|
|
130
|
+
formId: filledDocumentId,
|
|
131
|
+
templateId: template.id,
|
|
132
|
+
templateVersion: template.version,
|
|
133
|
+
title: template.title,
|
|
134
|
+
isUserForm: false, // Always false for extended procedures
|
|
135
|
+
isRequired: isRequired,
|
|
136
|
+
sortingOrder: templateRef.sortingOrder,
|
|
137
|
+
status: FilledDocumentStatus.PENDING,
|
|
138
|
+
path: docRef.path,
|
|
139
|
+
};
|
|
140
|
+
initializedFormsInfo.push(linkedForm);
|
|
141
|
+
|
|
142
|
+
console.log(
|
|
143
|
+
`[FormInit] Created FilledDocument ${filledDocumentId} (template: ${template.id}) for extended procedure ${procedureId} in appointment ${appointmentId}.`
|
|
144
|
+
);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error(
|
|
147
|
+
`[FormInit] Error creating form for template ${templateRef.templateId}:`,
|
|
148
|
+
error
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { initializedFormsInfo, pendingUserFormsIds, allLinkedFormIds };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Removes all forms associated with a specific procedure from an appointment
|
|
158
|
+
* @param db Firestore instance
|
|
159
|
+
* @param appointmentId Appointment ID
|
|
160
|
+
* @param procedureId Procedure ID to remove forms for
|
|
161
|
+
* @returns Array of removed form IDs
|
|
162
|
+
*/
|
|
163
|
+
export async function removeFormsForExtendedProcedure(
|
|
164
|
+
db: Firestore,
|
|
165
|
+
appointmentId: string,
|
|
166
|
+
procedureId: string
|
|
167
|
+
): Promise<string[]> {
|
|
168
|
+
const removedFormIds: string[] = [];
|
|
169
|
+
|
|
170
|
+
// Only remove from doctor forms subcollection (no user forms for extended procedures)
|
|
171
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
172
|
+
const doctorFormsRef = collection(appointmentRef, DOCTOR_FORMS_SUBCOLLECTION);
|
|
173
|
+
|
|
174
|
+
// Query doctor forms for this procedure
|
|
175
|
+
const doctorFormsQuery = query(
|
|
176
|
+
doctorFormsRef,
|
|
177
|
+
where('procedureId', '==', procedureId)
|
|
178
|
+
);
|
|
179
|
+
const doctorFormsSnap = await getDocs(doctorFormsQuery);
|
|
180
|
+
|
|
181
|
+
for (const formDoc of doctorFormsSnap.docs) {
|
|
182
|
+
try {
|
|
183
|
+
await deleteDoc(formDoc.ref);
|
|
184
|
+
removedFormIds.push(formDoc.id);
|
|
185
|
+
console.log(
|
|
186
|
+
`[FormInit] Removed doctor form ${formDoc.id} for extended procedure ${procedureId} from appointment ${appointmentId}.`
|
|
187
|
+
);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error(
|
|
190
|
+
`[FormInit] Error removing doctor form ${formDoc.id}:`,
|
|
191
|
+
error
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return removedFormIds;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Gets all forms associated with a specific procedure in an appointment
|
|
201
|
+
* @param db Firestore instance
|
|
202
|
+
* @param appointmentId Appointment ID
|
|
203
|
+
* @param procedureId Procedure ID
|
|
204
|
+
* @returns Array of form IDs
|
|
205
|
+
*/
|
|
206
|
+
export async function getFormsForExtendedProcedure(
|
|
207
|
+
db: Firestore,
|
|
208
|
+
appointmentId: string,
|
|
209
|
+
procedureId: string
|
|
210
|
+
): Promise<string[]> {
|
|
211
|
+
const formIds: string[] = [];
|
|
212
|
+
|
|
213
|
+
const appointmentRef = doc(db, APPOINTMENTS_COLLECTION, appointmentId);
|
|
214
|
+
const doctorFormsRef = collection(appointmentRef, DOCTOR_FORMS_SUBCOLLECTION);
|
|
215
|
+
|
|
216
|
+
// Query doctor forms for this procedure
|
|
217
|
+
const doctorFormsQuery = query(
|
|
218
|
+
doctorFormsRef,
|
|
219
|
+
where('procedureId', '==', procedureId)
|
|
220
|
+
);
|
|
221
|
+
const doctorFormsSnap = await getDocs(doctorFormsQuery);
|
|
222
|
+
doctorFormsSnap.docs.forEach(doc => formIds.push(doc.id));
|
|
223
|
+
|
|
224
|
+
return formIds;
|
|
225
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|