@blackcode_sa/metaestetics-api 1.12.43 → 1.12.46
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 +6 -12
- package/dist/admin/index.d.ts +6 -12
- package/dist/backoffice/index.d.mts +18 -275
- package/dist/backoffice/index.d.ts +18 -275
- package/dist/backoffice/index.js +14 -802
- package/dist/backoffice/index.mjs +38 -830
- package/dist/index.d.mts +10 -255
- package/dist/index.d.ts +10 -255
- package/dist/index.js +36 -754
- package/dist/index.mjs +44 -765
- package/package.json +1 -1
- package/src/backoffice/services/brand.service.ts +0 -86
- package/src/backoffice/services/category.service.ts +0 -84
- package/src/backoffice/services/constants.service.ts +0 -77
- package/src/backoffice/services/product.service.ts +18 -316
- package/src/backoffice/services/requirement.service.ts +0 -76
- package/src/backoffice/services/subcategory.service.ts +0 -87
- package/src/backoffice/services/technology.service.ts +0 -289
- package/src/backoffice/types/product.types.ts +6 -116
- package/src/services/appointment/utils/zone-management.utils.ts +24 -10
- package/src/backoffice/services/migrate-products.ts +0 -116
package/package.json
CHANGED
|
@@ -167,90 +167,4 @@ export class BrandService extends BaseService {
|
|
|
167
167
|
...docSnap.data(),
|
|
168
168
|
} as Brand;
|
|
169
169
|
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Exports brands to CSV string, suitable for Excel/Sheets.
|
|
173
|
-
* Includes headers and optional UTF-8 BOM.
|
|
174
|
-
* By default exports only active brands (set includeInactive to true to export all).
|
|
175
|
-
*/
|
|
176
|
-
async exportToCsv(options?: {
|
|
177
|
-
includeInactive?: boolean;
|
|
178
|
-
includeBom?: boolean;
|
|
179
|
-
}): Promise<string> {
|
|
180
|
-
const includeInactive = options?.includeInactive ?? false;
|
|
181
|
-
const includeBom = options?.includeBom ?? true;
|
|
182
|
-
|
|
183
|
-
const headers = [
|
|
184
|
-
"id",
|
|
185
|
-
"name",
|
|
186
|
-
"manufacturer",
|
|
187
|
-
"website",
|
|
188
|
-
"description",
|
|
189
|
-
"isActive",
|
|
190
|
-
];
|
|
191
|
-
|
|
192
|
-
const rows: string[] = [];
|
|
193
|
-
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
194
|
-
|
|
195
|
-
const PAGE_SIZE = 1000;
|
|
196
|
-
let cursor: any | undefined;
|
|
197
|
-
|
|
198
|
-
// Build base constraints
|
|
199
|
-
const baseConstraints: QueryConstraint[] = [];
|
|
200
|
-
if (!includeInactive) {
|
|
201
|
-
baseConstraints.push(where("isActive", "==", true));
|
|
202
|
-
}
|
|
203
|
-
baseConstraints.push(orderBy("name_lowercase"));
|
|
204
|
-
|
|
205
|
-
// Page through all results
|
|
206
|
-
// eslint-disable-next-line no-constant-condition
|
|
207
|
-
while (true) {
|
|
208
|
-
const constraints: QueryConstraint[] = [...baseConstraints, limit(PAGE_SIZE)];
|
|
209
|
-
if (cursor) constraints.push(startAfter(cursor));
|
|
210
|
-
|
|
211
|
-
const q = query(this.getBrandsRef(), ...constraints);
|
|
212
|
-
const snapshot = await getDocs(q);
|
|
213
|
-
if (snapshot.empty) break;
|
|
214
|
-
|
|
215
|
-
for (const d of snapshot.docs) {
|
|
216
|
-
const brand = ({ id: d.id, ...d.data() } as unknown) as Brand;
|
|
217
|
-
rows.push(this.brandToCsvRow(brand));
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
221
|
-
if (snapshot.size < PAGE_SIZE) break;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const csvBody = rows.join("\r\n");
|
|
225
|
-
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
private brandToCsvRow(brand: Brand): string {
|
|
229
|
-
const values = [
|
|
230
|
-
brand.id ?? "",
|
|
231
|
-
brand.name ?? "",
|
|
232
|
-
brand.manufacturer ?? "",
|
|
233
|
-
brand.website ?? "",
|
|
234
|
-
brand.description ?? "",
|
|
235
|
-
String(brand.isActive ?? ""),
|
|
236
|
-
];
|
|
237
|
-
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private formatDateIso(value: any): string {
|
|
241
|
-
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
242
|
-
if (value instanceof Date) return value.toISOString();
|
|
243
|
-
if (value && typeof value.toDate === "function") {
|
|
244
|
-
const d = value.toDate();
|
|
245
|
-
return d instanceof Date ? d.toISOString() : String(value);
|
|
246
|
-
}
|
|
247
|
-
return String(value ?? "");
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
private formatCsvValue(value: any): string {
|
|
251
|
-
const str = value === null || value === undefined ? "" : String(value);
|
|
252
|
-
// Escape double quotes by doubling them and wrap in quotes
|
|
253
|
-
const escaped = str.replace(/"/g, '""');
|
|
254
|
-
return `"${escaped}"`;
|
|
255
|
-
}
|
|
256
170
|
}
|
|
@@ -231,88 +231,4 @@ export class CategoryService extends BaseService implements ICategoryService {
|
|
|
231
231
|
...docSnap.data(),
|
|
232
232
|
} as Category;
|
|
233
233
|
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Exports categories to CSV string, suitable for Excel/Sheets.
|
|
237
|
-
* Includes headers and optional UTF-8 BOM.
|
|
238
|
-
* By default exports only active categories (set includeInactive to true to export all).
|
|
239
|
-
*/
|
|
240
|
-
async exportToCsv(options?: {
|
|
241
|
-
includeInactive?: boolean;
|
|
242
|
-
includeBom?: boolean;
|
|
243
|
-
}): Promise<string> {
|
|
244
|
-
const includeInactive = options?.includeInactive ?? false;
|
|
245
|
-
const includeBom = options?.includeBom ?? true;
|
|
246
|
-
|
|
247
|
-
const headers = [
|
|
248
|
-
"id",
|
|
249
|
-
"name",
|
|
250
|
-
"description",
|
|
251
|
-
"family",
|
|
252
|
-
"isActive",
|
|
253
|
-
];
|
|
254
|
-
|
|
255
|
-
const rows: string[] = [];
|
|
256
|
-
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
257
|
-
|
|
258
|
-
const PAGE_SIZE = 1000;
|
|
259
|
-
let cursor: any | undefined;
|
|
260
|
-
|
|
261
|
-
// Build base constraints
|
|
262
|
-
const constraints: any[] = [];
|
|
263
|
-
if (!includeInactive) {
|
|
264
|
-
constraints.push(where("isActive", "==", true));
|
|
265
|
-
}
|
|
266
|
-
constraints.push(orderBy("name"));
|
|
267
|
-
|
|
268
|
-
// Page through all results
|
|
269
|
-
// eslint-disable-next-line no-constant-condition
|
|
270
|
-
while (true) {
|
|
271
|
-
const queryConstraints: any[] = [...constraints, limit(PAGE_SIZE)];
|
|
272
|
-
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
273
|
-
|
|
274
|
-
const q = query(this.categoriesRef, ...queryConstraints);
|
|
275
|
-
const snapshot = await getDocs(q);
|
|
276
|
-
if (snapshot.empty) break;
|
|
277
|
-
|
|
278
|
-
for (const d of snapshot.docs) {
|
|
279
|
-
const category = ({ id: d.id, ...d.data() } as unknown) as Category;
|
|
280
|
-
rows.push(this.categoryToCsvRow(category));
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
284
|
-
if (snapshot.size < PAGE_SIZE) break;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const csvBody = rows.join("\r\n");
|
|
288
|
-
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
private categoryToCsvRow(category: Category): string {
|
|
292
|
-
const values = [
|
|
293
|
-
category.id ?? "",
|
|
294
|
-
category.name ?? "",
|
|
295
|
-
category.description ?? "",
|
|
296
|
-
category.family ?? "",
|
|
297
|
-
String(category.isActive ?? ""),
|
|
298
|
-
];
|
|
299
|
-
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
private formatDateIso(value: any): string {
|
|
303
|
-
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
304
|
-
if (value instanceof Date) return value.toISOString();
|
|
305
|
-
if (value && typeof value.toDate === "function") {
|
|
306
|
-
const d = value.toDate();
|
|
307
|
-
return d instanceof Date ? d.toISOString() : String(value);
|
|
308
|
-
}
|
|
309
|
-
return String(value ?? "");
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
private formatCsvValue(value: any): string {
|
|
313
|
-
const str = value === null || value === undefined ? "" : String(value);
|
|
314
|
-
// Escape double quotes by doubling them and wrap in quotes
|
|
315
|
-
const escaped = str.replace(/"/g, '""');
|
|
316
|
-
return `"${escaped}"`;
|
|
317
|
-
}
|
|
318
234
|
}
|
|
@@ -305,81 +305,4 @@ export class ConstantsService extends BaseService {
|
|
|
305
305
|
contraindications: arrayRemove(toRemove),
|
|
306
306
|
});
|
|
307
307
|
}
|
|
308
|
-
|
|
309
|
-
// =================================================================
|
|
310
|
-
// CSV Export Methods
|
|
311
|
-
// =================================================================
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Exports treatment benefits to CSV string, suitable for Excel/Sheets.
|
|
315
|
-
* Includes headers and optional UTF-8 BOM.
|
|
316
|
-
*/
|
|
317
|
-
async exportBenefitsToCsv(options?: {
|
|
318
|
-
includeBom?: boolean;
|
|
319
|
-
}): Promise<string> {
|
|
320
|
-
const includeBom = options?.includeBom ?? true;
|
|
321
|
-
|
|
322
|
-
const headers = ["id", "name", "description"];
|
|
323
|
-
|
|
324
|
-
const rows: string[] = [];
|
|
325
|
-
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
326
|
-
|
|
327
|
-
const benefits = await this.getAllBenefitsForFilter();
|
|
328
|
-
|
|
329
|
-
for (const benefit of benefits) {
|
|
330
|
-
rows.push(this.benefitToCsvRow(benefit));
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const csvBody = rows.join("\r\n");
|
|
334
|
-
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Exports contraindications to CSV string, suitable for Excel/Sheets.
|
|
339
|
-
* Includes headers and optional UTF-8 BOM.
|
|
340
|
-
*/
|
|
341
|
-
async exportContraindicationsToCsv(options?: {
|
|
342
|
-
includeBom?: boolean;
|
|
343
|
-
}): Promise<string> {
|
|
344
|
-
const includeBom = options?.includeBom ?? true;
|
|
345
|
-
|
|
346
|
-
const headers = ["id", "name", "description"];
|
|
347
|
-
|
|
348
|
-
const rows: string[] = [];
|
|
349
|
-
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
350
|
-
|
|
351
|
-
const contraindications = await this.getAllContraindicationsForFilter();
|
|
352
|
-
|
|
353
|
-
for (const contraindication of contraindications) {
|
|
354
|
-
rows.push(this.contraindicationToCsvRow(contraindication));
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const csvBody = rows.join("\r\n");
|
|
358
|
-
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
private benefitToCsvRow(benefit: TreatmentBenefitDynamic): string {
|
|
362
|
-
const values = [
|
|
363
|
-
benefit.id ?? "",
|
|
364
|
-
benefit.name ?? "",
|
|
365
|
-
benefit.description ?? "",
|
|
366
|
-
];
|
|
367
|
-
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
private contraindicationToCsvRow(contraindication: ContraindicationDynamic): string {
|
|
371
|
-
const values = [
|
|
372
|
-
contraindication.id ?? "",
|
|
373
|
-
contraindication.name ?? "",
|
|
374
|
-
contraindication.description ?? "",
|
|
375
|
-
];
|
|
376
|
-
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
private formatCsvValue(value: any): string {
|
|
380
|
-
const str = value === null || value === undefined ? "" : String(value);
|
|
381
|
-
// Escape double quotes by doubling them and wrap in quotes
|
|
382
|
-
const escaped = str.replace(/"/g, '""');
|
|
383
|
-
return `"${escaped}"`;
|
|
384
|
-
}
|
|
385
308
|
}
|
|
@@ -13,8 +13,6 @@ import {
|
|
|
13
13
|
startAfter,
|
|
14
14
|
getCountFromServer,
|
|
15
15
|
QueryConstraint,
|
|
16
|
-
arrayUnion,
|
|
17
|
-
arrayRemove,
|
|
18
16
|
} from 'firebase/firestore';
|
|
19
17
|
import { Product, PRODUCTS_COLLECTION, IProductService } from '../types/product.types';
|
|
20
18
|
import { BaseService } from '../../services/base.service';
|
|
@@ -22,15 +20,7 @@ import { TECHNOLOGIES_COLLECTION } from '../types/technology.types';
|
|
|
22
20
|
|
|
23
21
|
export class ProductService extends BaseService implements IProductService {
|
|
24
22
|
/**
|
|
25
|
-
* Gets reference to
|
|
26
|
-
* @returns Firestore collection reference
|
|
27
|
-
*/
|
|
28
|
-
private getTopLevelProductsRef() {
|
|
29
|
-
return collection(this.db, PRODUCTS_COLLECTION);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Gets reference to products collection under a technology (backward compatibility)
|
|
23
|
+
* Gets reference to products collection under a technology
|
|
34
24
|
* @param technologyId - ID of the technology
|
|
35
25
|
* @returns Firestore collection reference
|
|
36
26
|
*/
|
|
@@ -47,11 +37,11 @@ export class ProductService extends BaseService implements IProductService {
|
|
|
47
37
|
product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'technologyId'>,
|
|
48
38
|
): Promise<Product> {
|
|
49
39
|
const now = new Date();
|
|
50
|
-
//
|
|
40
|
+
// categoryId and subcategoryId are now expected to be part of the product object
|
|
51
41
|
const newProduct: Omit<Product, 'id'> = {
|
|
52
42
|
...product,
|
|
53
43
|
brandId,
|
|
54
|
-
technologyId,
|
|
44
|
+
technologyId,
|
|
55
45
|
createdAt: now,
|
|
56
46
|
updatedAt: now,
|
|
57
47
|
isActive: true,
|
|
@@ -135,35 +125,38 @@ export class ProductService extends BaseService implements IProductService {
|
|
|
135
125
|
|
|
136
126
|
/**
|
|
137
127
|
* Gets counts of active products grouped by category, subcategory, and technology.
|
|
138
|
-
*
|
|
128
|
+
* This uses a single collectionGroup query for efficiency.
|
|
139
129
|
*/
|
|
140
130
|
async getProductCounts(): Promise<{
|
|
141
131
|
byCategory: Record<string, number>;
|
|
142
132
|
bySubcategory: Record<string, number>;
|
|
143
133
|
byTechnology: Record<string, number>;
|
|
144
134
|
}> {
|
|
135
|
+
const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), where('isActive', '==', true));
|
|
136
|
+
const snapshot = await getDocs(q);
|
|
137
|
+
|
|
145
138
|
const counts = {
|
|
146
139
|
byCategory: {} as Record<string, number>,
|
|
147
140
|
bySubcategory: {} as Record<string, number>,
|
|
148
141
|
byTechnology: {} as Record<string, number>,
|
|
149
142
|
};
|
|
150
143
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
144
|
+
if (snapshot.empty) {
|
|
145
|
+
return counts;
|
|
146
|
+
}
|
|
154
147
|
|
|
155
148
|
snapshot.docs.forEach(doc => {
|
|
156
149
|
const product = doc.data() as Product;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
counts.byCategory[
|
|
150
|
+
const { categoryId, subcategoryId, technologyId } = product;
|
|
151
|
+
|
|
152
|
+
if (categoryId) {
|
|
153
|
+
counts.byCategory[categoryId] = (counts.byCategory[categoryId] || 0) + 1;
|
|
161
154
|
}
|
|
162
|
-
if (
|
|
163
|
-
counts.bySubcategory[
|
|
155
|
+
if (subcategoryId) {
|
|
156
|
+
counts.bySubcategory[subcategoryId] = (counts.bySubcategory[subcategoryId] || 0) + 1;
|
|
164
157
|
}
|
|
165
|
-
if (
|
|
166
|
-
counts.byTechnology[
|
|
158
|
+
if (technologyId) {
|
|
159
|
+
counts.byTechnology[technologyId] = (counts.byTechnology[technologyId] || 0) + 1;
|
|
167
160
|
}
|
|
168
161
|
});
|
|
169
162
|
|
|
@@ -259,295 +252,4 @@ export class ProductService extends BaseService implements IProductService {
|
|
|
259
252
|
...docSnap.data(),
|
|
260
253
|
} as Product;
|
|
261
254
|
}
|
|
262
|
-
|
|
263
|
-
// ==========================================
|
|
264
|
-
// NEW METHODS: Top-level collection (preferred)
|
|
265
|
-
// ==========================================
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Creates a new product in the top-level collection
|
|
269
|
-
*/
|
|
270
|
-
async createTopLevel(
|
|
271
|
-
brandId: string,
|
|
272
|
-
product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'assignedTechnologyIds'>,
|
|
273
|
-
technologyIds: string[] = [],
|
|
274
|
-
): Promise<Product> {
|
|
275
|
-
const now = new Date();
|
|
276
|
-
const newProduct: Omit<Product, 'id'> = {
|
|
277
|
-
...product,
|
|
278
|
-
brandId,
|
|
279
|
-
assignedTechnologyIds: technologyIds,
|
|
280
|
-
createdAt: now,
|
|
281
|
-
updatedAt: now,
|
|
282
|
-
isActive: true,
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
const productRef = await addDoc(this.getTopLevelProductsRef(), newProduct);
|
|
286
|
-
return { id: productRef.id, ...newProduct };
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Gets all products from the top-level collection
|
|
291
|
-
*/
|
|
292
|
-
async getAllTopLevel(options: {
|
|
293
|
-
rowsPerPage: number;
|
|
294
|
-
lastVisible?: any;
|
|
295
|
-
brandId?: string;
|
|
296
|
-
}): Promise<{ products: Product[]; lastVisible: any }> {
|
|
297
|
-
const { rowsPerPage, lastVisible, brandId } = options;
|
|
298
|
-
|
|
299
|
-
const constraints: QueryConstraint[] = [where('isActive', '==', true), orderBy('name')];
|
|
300
|
-
|
|
301
|
-
if (brandId) {
|
|
302
|
-
constraints.push(where('brandId', '==', brandId));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (lastVisible) {
|
|
306
|
-
constraints.push(startAfter(lastVisible));
|
|
307
|
-
}
|
|
308
|
-
constraints.push(limit(rowsPerPage));
|
|
309
|
-
|
|
310
|
-
const q = query(this.getTopLevelProductsRef(), ...constraints);
|
|
311
|
-
const snapshot = await getDocs(q);
|
|
312
|
-
|
|
313
|
-
const products = snapshot.docs.map(
|
|
314
|
-
doc =>
|
|
315
|
-
({
|
|
316
|
-
id: doc.id,
|
|
317
|
-
...doc.data(),
|
|
318
|
-
} as Product),
|
|
319
|
-
);
|
|
320
|
-
const newLastVisible = snapshot.docs[snapshot.docs.length - 1];
|
|
321
|
-
|
|
322
|
-
return { products, lastVisible: newLastVisible };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Gets a product by ID from the top-level collection
|
|
327
|
-
*/
|
|
328
|
-
async getByIdTopLevel(productId: string): Promise<Product | null> {
|
|
329
|
-
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
330
|
-
const docSnap = await getDoc(docRef);
|
|
331
|
-
if (!docSnap.exists()) return null;
|
|
332
|
-
return {
|
|
333
|
-
id: docSnap.id,
|
|
334
|
-
...docSnap.data(),
|
|
335
|
-
} as Product;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Updates a product in the top-level collection
|
|
340
|
-
*/
|
|
341
|
-
async updateTopLevel(
|
|
342
|
-
productId: string,
|
|
343
|
-
product: Partial<Omit<Product, 'id' | 'createdAt' | 'brandId'>>,
|
|
344
|
-
): Promise<Product | null> {
|
|
345
|
-
const updateData = {
|
|
346
|
-
...product,
|
|
347
|
-
updatedAt: new Date(),
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
351
|
-
await updateDoc(docRef, updateData);
|
|
352
|
-
|
|
353
|
-
return this.getByIdTopLevel(productId);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Deletes a product from the top-level collection (soft delete)
|
|
358
|
-
*/
|
|
359
|
-
async deleteTopLevel(productId: string): Promise<void> {
|
|
360
|
-
await this.updateTopLevel(productId, {
|
|
361
|
-
isActive: false,
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Assigns a product to a technology
|
|
367
|
-
*/
|
|
368
|
-
async assignToTechnology(productId: string, technologyId: string): Promise<void> {
|
|
369
|
-
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
370
|
-
await updateDoc(docRef, {
|
|
371
|
-
assignedTechnologyIds: arrayUnion(technologyId),
|
|
372
|
-
updatedAt: new Date(),
|
|
373
|
-
});
|
|
374
|
-
// Cloud Function will handle syncing to subcollection
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Unassigns a product from a technology
|
|
379
|
-
*/
|
|
380
|
-
async unassignFromTechnology(productId: string, technologyId: string): Promise<void> {
|
|
381
|
-
const docRef = doc(this.getTopLevelProductsRef(), productId);
|
|
382
|
-
await updateDoc(docRef, {
|
|
383
|
-
assignedTechnologyIds: arrayRemove(technologyId),
|
|
384
|
-
updatedAt: new Date(),
|
|
385
|
-
});
|
|
386
|
-
// Cloud Function will handle removing from subcollection
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Gets products assigned to a specific technology
|
|
391
|
-
*/
|
|
392
|
-
async getAssignedProducts(technologyId: string): Promise<Product[]> {
|
|
393
|
-
const q = query(
|
|
394
|
-
this.getTopLevelProductsRef(),
|
|
395
|
-
where('assignedTechnologyIds', 'array-contains', technologyId),
|
|
396
|
-
where('isActive', '==', true),
|
|
397
|
-
orderBy('name'),
|
|
398
|
-
);
|
|
399
|
-
const snapshot = await getDocs(q);
|
|
400
|
-
return snapshot.docs.map(
|
|
401
|
-
doc =>
|
|
402
|
-
({
|
|
403
|
-
id: doc.id,
|
|
404
|
-
...doc.data(),
|
|
405
|
-
} as Product),
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Gets products NOT assigned to a specific technology
|
|
411
|
-
*/
|
|
412
|
-
async getUnassignedProducts(technologyId: string): Promise<Product[]> {
|
|
413
|
-
const q = query(
|
|
414
|
-
this.getTopLevelProductsRef(),
|
|
415
|
-
where('isActive', '==', true),
|
|
416
|
-
orderBy('name'),
|
|
417
|
-
);
|
|
418
|
-
const snapshot = await getDocs(q);
|
|
419
|
-
|
|
420
|
-
const allProducts = snapshot.docs.map(
|
|
421
|
-
doc =>
|
|
422
|
-
({
|
|
423
|
-
id: doc.id,
|
|
424
|
-
...doc.data(),
|
|
425
|
-
} as Product),
|
|
426
|
-
);
|
|
427
|
-
|
|
428
|
-
// Filter out products already assigned to this technology
|
|
429
|
-
return allProducts.filter(product =>
|
|
430
|
-
!product.assignedTechnologyIds?.includes(technologyId)
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Gets all products for a brand (from top-level collection)
|
|
436
|
-
*/
|
|
437
|
-
async getByBrand(brandId: string): Promise<Product[]> {
|
|
438
|
-
const q = query(
|
|
439
|
-
this.getTopLevelProductsRef(),
|
|
440
|
-
where('brandId', '==', brandId),
|
|
441
|
-
where('isActive', '==', true),
|
|
442
|
-
orderBy('name'),
|
|
443
|
-
);
|
|
444
|
-
const snapshot = await getDocs(q);
|
|
445
|
-
return snapshot.docs.map(
|
|
446
|
-
doc =>
|
|
447
|
-
({
|
|
448
|
-
id: doc.id,
|
|
449
|
-
...doc.data(),
|
|
450
|
-
} as Product),
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Exports products to CSV string, suitable for Excel/Sheets.
|
|
456
|
-
* Includes headers and optional UTF-8 BOM.
|
|
457
|
-
* By default exports only active products (set includeInactive to true to export all).
|
|
458
|
-
*/
|
|
459
|
-
async exportToCsv(options?: {
|
|
460
|
-
includeInactive?: boolean;
|
|
461
|
-
includeBom?: boolean;
|
|
462
|
-
}): Promise<string> {
|
|
463
|
-
const includeInactive = options?.includeInactive ?? false;
|
|
464
|
-
const includeBom = options?.includeBom ?? true;
|
|
465
|
-
|
|
466
|
-
const headers = [
|
|
467
|
-
"id",
|
|
468
|
-
"name",
|
|
469
|
-
"brandId",
|
|
470
|
-
"brandName",
|
|
471
|
-
"assignedTechnologyIds",
|
|
472
|
-
"description",
|
|
473
|
-
"technicalDetails",
|
|
474
|
-
"dosage",
|
|
475
|
-
"composition",
|
|
476
|
-
"indications",
|
|
477
|
-
"contraindications",
|
|
478
|
-
"warnings",
|
|
479
|
-
"isActive",
|
|
480
|
-
];
|
|
481
|
-
|
|
482
|
-
const rows: string[] = [];
|
|
483
|
-
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
484
|
-
|
|
485
|
-
const PAGE_SIZE = 1000;
|
|
486
|
-
let cursor: any | undefined;
|
|
487
|
-
|
|
488
|
-
// Build base constraints
|
|
489
|
-
const constraints: QueryConstraint[] = [];
|
|
490
|
-
if (!includeInactive) {
|
|
491
|
-
constraints.push(where("isActive", "==", true));
|
|
492
|
-
}
|
|
493
|
-
constraints.push(orderBy("name"));
|
|
494
|
-
|
|
495
|
-
// Page through all results from top-level collection
|
|
496
|
-
// eslint-disable-next-line no-constant-condition
|
|
497
|
-
while (true) {
|
|
498
|
-
const queryConstraints: QueryConstraint[] = [...constraints, limit(PAGE_SIZE)];
|
|
499
|
-
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
500
|
-
|
|
501
|
-
const q = query(this.getTopLevelProductsRef(), ...queryConstraints);
|
|
502
|
-
const snapshot = await getDocs(q);
|
|
503
|
-
if (snapshot.empty) break;
|
|
504
|
-
|
|
505
|
-
for (const d of snapshot.docs) {
|
|
506
|
-
const product = ({ id: d.id, ...d.data() } as unknown) as Product;
|
|
507
|
-
rows.push(this.productToCsvRow(product));
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
511
|
-
if (snapshot.size < PAGE_SIZE) break;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const csvBody = rows.join("\r\n");
|
|
515
|
-
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
private productToCsvRow(product: Product): string {
|
|
519
|
-
const values = [
|
|
520
|
-
product.id ?? "",
|
|
521
|
-
product.name ?? "",
|
|
522
|
-
product.brandId ?? "",
|
|
523
|
-
product.brandName ?? "",
|
|
524
|
-
product.assignedTechnologyIds?.join(";") ?? "",
|
|
525
|
-
product.description ?? "",
|
|
526
|
-
product.technicalDetails ?? "",
|
|
527
|
-
product.dosage ?? "",
|
|
528
|
-
product.composition ?? "",
|
|
529
|
-
product.indications?.join(";") ?? "",
|
|
530
|
-
product.contraindications?.map(c => c.name).join(";") ?? "",
|
|
531
|
-
product.warnings?.join(";") ?? "",
|
|
532
|
-
String(product.isActive ?? ""),
|
|
533
|
-
];
|
|
534
|
-
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
private formatDateIso(value: any): string {
|
|
538
|
-
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
539
|
-
if (value instanceof Date) return value.toISOString();
|
|
540
|
-
if (value && typeof value.toDate === "function") {
|
|
541
|
-
const d = value.toDate();
|
|
542
|
-
return d instanceof Date ? d.toISOString() : String(value);
|
|
543
|
-
}
|
|
544
|
-
return String(value ?? "");
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
private formatCsvValue(value: any): string {
|
|
548
|
-
const str = value === null || value === undefined ? "" : String(value);
|
|
549
|
-
// Escape double quotes by doubling them and wrap in quotes
|
|
550
|
-
const escaped = str.replace(/"/g, '""');
|
|
551
|
-
return `"${escaped}"`;
|
|
552
|
-
}
|
|
553
255
|
}
|