@blackcode_sa/metaestetics-api 1.12.49 → 1.12.51
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 +12 -6
- package/dist/admin/index.d.ts +12 -6
- package/dist/backoffice/index.d.mts +275 -18
- package/dist/backoffice/index.d.ts +275 -18
- package/dist/backoffice/index.js +802 -14
- package/dist/backoffice/index.mjs +830 -38
- package/dist/index.d.mts +255 -10
- package/dist/index.d.ts +255 -10
- package/dist/index.js +754 -25
- package/dist/index.mjs +765 -33
- package/package.json +1 -1
- package/src/backoffice/services/brand.service.ts +86 -0
- package/src/backoffice/services/category.service.ts +84 -0
- package/src/backoffice/services/constants.service.ts +77 -0
- package/src/backoffice/services/migrate-products.ts +116 -0
- package/src/backoffice/services/product.service.ts +316 -18
- package/src/backoffice/services/requirement.service.ts +76 -0
- package/src/backoffice/services/subcategory.service.ts +87 -0
- package/src/backoffice/services/technology.service.ts +289 -0
- package/src/backoffice/types/product.types.ts +116 -6
- package/src/services/practitioner/practitioner.service.ts +1 -1
- package/src/services/procedure/procedure.service.ts +5 -5
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
startAfter,
|
|
14
14
|
getCountFromServer,
|
|
15
15
|
QueryConstraint,
|
|
16
|
+
arrayUnion,
|
|
17
|
+
arrayRemove,
|
|
16
18
|
} from 'firebase/firestore';
|
|
17
19
|
import { Product, PRODUCTS_COLLECTION, IProductService } from '../types/product.types';
|
|
18
20
|
import { BaseService } from '../../services/base.service';
|
|
@@ -20,7 +22,15 @@ import { TECHNOLOGIES_COLLECTION } from '../types/technology.types';
|
|
|
20
22
|
|
|
21
23
|
export class ProductService extends BaseService implements IProductService {
|
|
22
24
|
/**
|
|
23
|
-
* Gets reference to products collection
|
|
25
|
+
* Gets reference to top-level products collection (source of truth)
|
|
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)
|
|
24
34
|
* @param technologyId - ID of the technology
|
|
25
35
|
* @returns Firestore collection reference
|
|
26
36
|
*/
|
|
@@ -37,11 +47,11 @@ export class ProductService extends BaseService implements IProductService {
|
|
|
37
47
|
product: Omit<Product, 'id' | 'createdAt' | 'updatedAt' | 'brandId' | 'technologyId'>,
|
|
38
48
|
): Promise<Product> {
|
|
39
49
|
const now = new Date();
|
|
40
|
-
//
|
|
50
|
+
// Create product with legacy structure for subcollection compatibility
|
|
41
51
|
const newProduct: Omit<Product, 'id'> = {
|
|
42
52
|
...product,
|
|
43
53
|
brandId,
|
|
44
|
-
technologyId,
|
|
54
|
+
technologyId, // Required for old subcollection structure
|
|
45
55
|
createdAt: now,
|
|
46
56
|
updatedAt: now,
|
|
47
57
|
isActive: true,
|
|
@@ -125,38 +135,35 @@ export class ProductService extends BaseService implements IProductService {
|
|
|
125
135
|
|
|
126
136
|
/**
|
|
127
137
|
* Gets counts of active products grouped by category, subcategory, and technology.
|
|
128
|
-
*
|
|
138
|
+
* Queries technology subcollections which have the legacy fields synced by Cloud Functions.
|
|
129
139
|
*/
|
|
130
140
|
async getProductCounts(): Promise<{
|
|
131
141
|
byCategory: Record<string, number>;
|
|
132
142
|
bySubcategory: Record<string, number>;
|
|
133
143
|
byTechnology: Record<string, number>;
|
|
134
144
|
}> {
|
|
135
|
-
const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), where('isActive', '==', true));
|
|
136
|
-
const snapshot = await getDocs(q);
|
|
137
|
-
|
|
138
145
|
const counts = {
|
|
139
146
|
byCategory: {} as Record<string, number>,
|
|
140
147
|
bySubcategory: {} as Record<string, number>,
|
|
141
148
|
byTechnology: {} as Record<string, number>,
|
|
142
149
|
};
|
|
143
150
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
151
|
+
// Query technology subcollections (which have synced legacy fields)
|
|
152
|
+
const q = query(collectionGroup(this.db, PRODUCTS_COLLECTION), where('isActive', '==', true));
|
|
153
|
+
const snapshot = await getDocs(q);
|
|
147
154
|
|
|
148
155
|
snapshot.docs.forEach(doc => {
|
|
149
156
|
const product = doc.data() as Product;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (categoryId) {
|
|
153
|
-
counts.byCategory[categoryId] = (counts.byCategory[categoryId] || 0) + 1;
|
|
157
|
+
|
|
158
|
+
// Use legacy fields from subcollections
|
|
159
|
+
if (product.categoryId) {
|
|
160
|
+
counts.byCategory[product.categoryId] = (counts.byCategory[product.categoryId] || 0) + 1;
|
|
154
161
|
}
|
|
155
|
-
if (subcategoryId) {
|
|
156
|
-
counts.bySubcategory[subcategoryId] = (counts.bySubcategory[subcategoryId] || 0) + 1;
|
|
162
|
+
if (product.subcategoryId) {
|
|
163
|
+
counts.bySubcategory[product.subcategoryId] = (counts.bySubcategory[product.subcategoryId] || 0) + 1;
|
|
157
164
|
}
|
|
158
|
-
if (technologyId) {
|
|
159
|
-
counts.byTechnology[technologyId] = (counts.byTechnology[technologyId] || 0) + 1;
|
|
165
|
+
if (product.technologyId) {
|
|
166
|
+
counts.byTechnology[product.technologyId] = (counts.byTechnology[product.technologyId] || 0) + 1;
|
|
160
167
|
}
|
|
161
168
|
});
|
|
162
169
|
|
|
@@ -252,4 +259,295 @@ export class ProductService extends BaseService implements IProductService {
|
|
|
252
259
|
...docSnap.data(),
|
|
253
260
|
} as Product;
|
|
254
261
|
}
|
|
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
|
+
}
|
|
255
553
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
query,
|
|
8
8
|
updateDoc,
|
|
9
9
|
where,
|
|
10
|
+
orderBy,
|
|
10
11
|
} from "firebase/firestore";
|
|
11
12
|
import {
|
|
12
13
|
Requirement,
|
|
@@ -156,4 +157,79 @@ export class RequirementService extends BaseService {
|
|
|
156
157
|
...docSnap.data(),
|
|
157
158
|
} as Requirement;
|
|
158
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Exports requirements to CSV string, suitable for Excel/Sheets.
|
|
163
|
+
* Includes headers and optional UTF-8 BOM.
|
|
164
|
+
* By default exports only active requirements (set includeInactive to true to export all).
|
|
165
|
+
*/
|
|
166
|
+
async exportToCsv(options?: {
|
|
167
|
+
includeInactive?: boolean;
|
|
168
|
+
includeBom?: boolean;
|
|
169
|
+
}): Promise<string> {
|
|
170
|
+
const includeInactive = options?.includeInactive ?? false;
|
|
171
|
+
const includeBom = options?.includeBom ?? true;
|
|
172
|
+
|
|
173
|
+
const headers = [
|
|
174
|
+
"id",
|
|
175
|
+
"type",
|
|
176
|
+
"name",
|
|
177
|
+
"description",
|
|
178
|
+
"timeframe_duration",
|
|
179
|
+
"timeframe_unit",
|
|
180
|
+
"timeframe_notifyAt",
|
|
181
|
+
"importance",
|
|
182
|
+
"isActive",
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const rows: string[] = [];
|
|
186
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
187
|
+
|
|
188
|
+
// Get all requirements (they're not paginated in the original service)
|
|
189
|
+
const q = includeInactive
|
|
190
|
+
? query(this.requirementsRef, orderBy("name"))
|
|
191
|
+
: query(this.requirementsRef, where("isActive", "==", true), orderBy("name"));
|
|
192
|
+
|
|
193
|
+
const snapshot = await getDocs(q);
|
|
194
|
+
|
|
195
|
+
for (const d of snapshot.docs) {
|
|
196
|
+
const requirement = ({ id: d.id, ...d.data() } as unknown) as Requirement;
|
|
197
|
+
rows.push(this.requirementToCsvRow(requirement));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const csvBody = rows.join("\r\n");
|
|
201
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private requirementToCsvRow(requirement: Requirement): string {
|
|
205
|
+
const values = [
|
|
206
|
+
requirement.id ?? "",
|
|
207
|
+
requirement.type ?? "",
|
|
208
|
+
requirement.name ?? "",
|
|
209
|
+
requirement.description ?? "",
|
|
210
|
+
requirement.timeframe?.duration?.toString() ?? "",
|
|
211
|
+
requirement.timeframe?.unit ?? "",
|
|
212
|
+
requirement.timeframe?.notifyAt?.join(";") ?? "",
|
|
213
|
+
requirement.importance ?? "",
|
|
214
|
+
String(requirement.isActive ?? ""),
|
|
215
|
+
];
|
|
216
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private formatDateIso(value: any): string {
|
|
220
|
+
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
221
|
+
if (value instanceof Date) return value.toISOString();
|
|
222
|
+
if (value && typeof value.toDate === "function") {
|
|
223
|
+
const d = value.toDate();
|
|
224
|
+
return d instanceof Date ? d.toISOString() : String(value);
|
|
225
|
+
}
|
|
226
|
+
return String(value ?? "");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private formatCsvValue(value: any): string {
|
|
230
|
+
const str = value === null || value === undefined ? "" : String(value);
|
|
231
|
+
// Escape double quotes by doubling them and wrap in quotes
|
|
232
|
+
const escaped = str.replace(/"/g, '""');
|
|
233
|
+
return `"${escaped}"`;
|
|
234
|
+
}
|
|
159
235
|
}
|
|
@@ -305,4 +305,91 @@ export class SubcategoryService extends BaseService {
|
|
|
305
305
|
...docSnap.data(),
|
|
306
306
|
} as Subcategory;
|
|
307
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Exports subcategories to CSV string, suitable for Excel/Sheets.
|
|
311
|
+
* Includes headers and optional UTF-8 BOM.
|
|
312
|
+
* By default exports only active subcategories (set includeInactive to true to export all).
|
|
313
|
+
*/
|
|
314
|
+
async exportToCsv(options?: {
|
|
315
|
+
includeInactive?: boolean;
|
|
316
|
+
includeBom?: boolean;
|
|
317
|
+
}): Promise<string> {
|
|
318
|
+
const includeInactive = options?.includeInactive ?? false;
|
|
319
|
+
const includeBom = options?.includeBom ?? true;
|
|
320
|
+
|
|
321
|
+
const headers = [
|
|
322
|
+
"id",
|
|
323
|
+
"name",
|
|
324
|
+
"categoryId",
|
|
325
|
+
"description",
|
|
326
|
+
"isActive",
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
const rows: string[] = [];
|
|
330
|
+
rows.push(headers.map((h) => this.formatCsvValue(h)).join(","));
|
|
331
|
+
|
|
332
|
+
const PAGE_SIZE = 1000;
|
|
333
|
+
let cursor: any | undefined;
|
|
334
|
+
|
|
335
|
+
// Build base constraints
|
|
336
|
+
const constraints: any[] = [];
|
|
337
|
+
if (!includeInactive) {
|
|
338
|
+
constraints.push(where("isActive", "==", true));
|
|
339
|
+
}
|
|
340
|
+
constraints.push(orderBy("name"));
|
|
341
|
+
|
|
342
|
+
// Page through all results using collectionGroup
|
|
343
|
+
// eslint-disable-next-line no-constant-condition
|
|
344
|
+
while (true) {
|
|
345
|
+
const queryConstraints: any[] = [...constraints, limit(PAGE_SIZE)];
|
|
346
|
+
if (cursor) queryConstraints.push(startAfter(cursor));
|
|
347
|
+
|
|
348
|
+
const q = query(
|
|
349
|
+
collectionGroup(this.db, SUBCATEGORIES_COLLECTION),
|
|
350
|
+
...queryConstraints
|
|
351
|
+
);
|
|
352
|
+
const snapshot = await getDocs(q);
|
|
353
|
+
if (snapshot.empty) break;
|
|
354
|
+
|
|
355
|
+
for (const d of snapshot.docs) {
|
|
356
|
+
const subcategory = ({ id: d.id, ...d.data() } as unknown) as Subcategory;
|
|
357
|
+
rows.push(this.subcategoryToCsvRow(subcategory));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
cursor = snapshot.docs[snapshot.docs.length - 1];
|
|
361
|
+
if (snapshot.size < PAGE_SIZE) break;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const csvBody = rows.join("\r\n");
|
|
365
|
+
return includeBom ? "\uFEFF" + csvBody : csvBody;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private subcategoryToCsvRow(subcategory: Subcategory): string {
|
|
369
|
+
const values = [
|
|
370
|
+
subcategory.id ?? "",
|
|
371
|
+
subcategory.name ?? "",
|
|
372
|
+
subcategory.categoryId ?? "",
|
|
373
|
+
subcategory.description ?? "",
|
|
374
|
+
String(subcategory.isActive ?? ""),
|
|
375
|
+
];
|
|
376
|
+
return values.map((v) => this.formatCsvValue(v)).join(",");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private formatDateIso(value: any): string {
|
|
380
|
+
// Firestore timestamps may come back as Date or Timestamp; handle both
|
|
381
|
+
if (value instanceof Date) return value.toISOString();
|
|
382
|
+
if (value && typeof value.toDate === "function") {
|
|
383
|
+
const d = value.toDate();
|
|
384
|
+
return d instanceof Date ? d.toISOString() : String(value);
|
|
385
|
+
}
|
|
386
|
+
return String(value ?? "");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private formatCsvValue(value: any): string {
|
|
390
|
+
const str = value === null || value === undefined ? "" : String(value);
|
|
391
|
+
// Escape double quotes by doubling them and wrap in quotes
|
|
392
|
+
const escaped = str.replace(/"/g, '""');
|
|
393
|
+
return `"${escaped}"`;
|
|
394
|
+
}
|
|
308
395
|
}
|