ng-firebase-table-kxp 1.0.0

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.
@@ -0,0 +1,1235 @@
1
+ import { Injectable, Optional } from '@angular/core';
2
+ import {
3
+ AngularFirestore,
4
+ CollectionReference,
5
+ QueryDocumentSnapshot,
6
+ } from '@angular/fire/compat/firestore';
7
+ import firebase from 'firebase/compat/app';
8
+ import { Arrange, Condition, Pagination } from '../types/Table';
9
+ import { firstValueFrom } from 'rxjs';
10
+ import { MatDialog } from '@angular/material/dialog';
11
+ import { ToastrService } from 'ngx-toastr';
12
+ import { AbstractControl, ValidatorFn } from '@angular/forms';
13
+ import * as moment from 'moment';
14
+
15
+ interface PaginationResult {
16
+ items: any[];
17
+ filterLength: number | null;
18
+ firstDoc: firebase.firestore.QueryDocumentSnapshot<unknown> | null;
19
+ lastDoc: firebase.firestore.QueryDocumentSnapshot<unknown> | null;
20
+ hasNextPage: boolean;
21
+ hasPreviousPage?: boolean;
22
+ currentClientPageIndex?: number;
23
+ totalPages?: number;
24
+ }
25
+
26
+ @Injectable({
27
+ providedIn: 'root',
28
+ })
29
+ export class TableService {
30
+ constructor(
31
+ @Optional() private ngFire: AngularFirestore,
32
+ @Optional() private dialog: MatDialog,
33
+ @Optional() private toastr: ToastrService
34
+ ) {}
35
+
36
+ async getItems(collection: CollectionReference<unknown>): Promise<any[]> {
37
+ try {
38
+ const querySnapshot = await collection.get();
39
+ return querySnapshot.docs.map(
40
+ (doc: firebase.firestore.QueryDocumentSnapshot<unknown>) => {
41
+ return { ...(doc.data() as any), id: doc.id };
42
+ }
43
+ );
44
+ } catch (error) {
45
+ console.warn('Collection não encontrada:', error);
46
+ return [];
47
+ }
48
+ }
49
+
50
+ private async executeQuery(params: Pagination): Promise<PaginationResult> {
51
+ if (params.filterFn) {
52
+ // Lógica com filtro no cliente (filterFn)
53
+ const BATCH_FETCH_SIZE = params.batchSize;
54
+ const GOAL_SIZE = params.batchSize + 1;
55
+
56
+ if (params.navigation === 'forward' || params.navigation === 'reload') {
57
+ if (params.navigation === 'reload' && params.doc) {
58
+ params.doc.lastDoc = null;
59
+ }
60
+
61
+ let lastDocCursor: firebase.firestore.QueryDocumentSnapshot<unknown> | null =
62
+ params.doc ? params.doc.lastDoc : null;
63
+ let pageResults: any[] = [];
64
+ let allFetchedDocs: firebase.firestore.QueryDocumentSnapshot<unknown>[] =
65
+ [];
66
+ let hasMoreDocsInDb = true;
67
+
68
+ while (pageResults.length < GOAL_SIZE && hasMoreDocsInDb) {
69
+ let query: firebase.firestore.Query<unknown> = this.ngFire.collection(
70
+ params.collection
71
+ ).ref;
72
+ query = this.applyFilters(query, params.arrange, params.conditions);
73
+
74
+ if (lastDocCursor) {
75
+ query = query.startAfter(lastDocCursor);
76
+ }
77
+ query = query.limit(BATCH_FETCH_SIZE);
78
+
79
+ const snapshot = await query.get();
80
+
81
+ if (snapshot.empty) {
82
+ hasMoreDocsInDb = false;
83
+ break;
84
+ }
85
+
86
+ lastDocCursor = snapshot.docs[snapshot.docs.length - 1];
87
+ allFetchedDocs.push(...snapshot.docs);
88
+
89
+ const batchUsers = snapshot.docs
90
+ .map((doc: firebase.firestore.QueryDocumentSnapshot<unknown>) => ({
91
+ id: doc.id,
92
+ ...(doc.data() as any),
93
+ }))
94
+ .filter(params.filterFn);
95
+
96
+ pageResults.push(...batchUsers);
97
+
98
+ if (snapshot.size < BATCH_FETCH_SIZE) {
99
+ hasMoreDocsInDb = false;
100
+ }
101
+ }
102
+
103
+ const hasNextPage = pageResults.length > params.batchSize;
104
+ const finalItems = pageResults.slice(0, params.batchSize);
105
+
106
+ const firstDocOfPage =
107
+ allFetchedDocs.find(
108
+ (doc: firebase.firestore.QueryDocumentSnapshot<unknown>) =>
109
+ doc.id === finalItems[0]?.id
110
+ ) || null;
111
+ const lastDocOfPage =
112
+ allFetchedDocs.find(
113
+ (doc: firebase.firestore.QueryDocumentSnapshot<unknown>) =>
114
+ doc.id === finalItems[finalItems.length - 1]?.id
115
+ ) || null;
116
+
117
+ return {
118
+ items: finalItems,
119
+ filterLength: null,
120
+ firstDoc: firstDocOfPage,
121
+ lastDoc: lastDocOfPage,
122
+ hasNextPage: hasNextPage,
123
+ hasPreviousPage:
124
+ !!(params.doc && params.doc.lastDoc) &&
125
+ params.navigation !== 'reload',
126
+ currentClientPageIndex: undefined,
127
+ };
128
+ }
129
+ // Lógica para trás (backward)
130
+ else if (params.navigation === 'backward') {
131
+ if (!params.doc || !params.doc.firstDoc) {
132
+ return {
133
+ items: [],
134
+ filterLength: null,
135
+ firstDoc: null,
136
+ lastDoc: null,
137
+ hasNextPage: true,
138
+ hasPreviousPage: false,
139
+ currentClientPageIndex: undefined,
140
+ };
141
+ }
142
+
143
+ let pageResults: any[] = [];
144
+ let allFetchedDocs: firebase.firestore.QueryDocumentSnapshot<unknown>[] =
145
+ [];
146
+ let hasMoreDocsInDb = true;
147
+
148
+ let boundaryDoc = params.doc.firstDoc;
149
+
150
+ while (pageResults.length < GOAL_SIZE && hasMoreDocsInDb) {
151
+ let query: firebase.firestore.Query<unknown> = this.ngFire.collection(
152
+ params.collection
153
+ ).ref;
154
+
155
+ query = this.applyFilters(query, params.arrange, params.conditions);
156
+
157
+ query = query.endBefore(boundaryDoc);
158
+ query = query.limitToLast(BATCH_FETCH_SIZE);
159
+
160
+ const snapshot = await query.get();
161
+
162
+ if (snapshot.empty) {
163
+ hasMoreDocsInDb = false;
164
+ break;
165
+ }
166
+
167
+ boundaryDoc = snapshot.docs[0] as any;
168
+
169
+ allFetchedDocs = [...snapshot.docs, ...allFetchedDocs];
170
+
171
+ const batchUsers = snapshot.docs
172
+ .map((doc: firebase.firestore.QueryDocumentSnapshot<unknown>) => ({
173
+ id: doc.id,
174
+ ...(doc.data() as any),
175
+ }))
176
+ .filter(params.filterFn);
177
+
178
+ pageResults = [...batchUsers, ...pageResults];
179
+
180
+ if (snapshot.size < BATCH_FETCH_SIZE) {
181
+ hasMoreDocsInDb = false;
182
+ }
183
+ }
184
+
185
+ const finalItems = pageResults.slice(0, params.batchSize);
186
+
187
+ const firstDocOfPage =
188
+ allFetchedDocs.find(
189
+ (doc: firebase.firestore.QueryDocumentSnapshot<unknown>) =>
190
+ doc.id === finalItems[0]?.id
191
+ ) || null;
192
+ const lastDocOfPage =
193
+ allFetchedDocs.find(
194
+ (doc: firebase.firestore.QueryDocumentSnapshot<unknown>) =>
195
+ doc.id === finalItems[finalItems.length - 1]?.id
196
+ ) || null;
197
+
198
+ return {
199
+ items: finalItems,
200
+ filterLength: null,
201
+ firstDoc: firstDocOfPage,
202
+ lastDoc: lastDocOfPage,
203
+ hasNextPage: true,
204
+ currentClientPageIndex: undefined,
205
+ };
206
+ }
207
+ } else {
208
+ let items: any[] = [];
209
+ let docs: firebase.firestore.QueryDocumentSnapshot<unknown>[] = [];
210
+ let hasNextPage = false;
211
+ let filterLength: null | number = null;
212
+
213
+ let query: firebase.firestore.Query<unknown> = this.ngFire.collection(
214
+ params.collection
215
+ ).ref;
216
+
217
+ if (params.conditions) {
218
+ params.conditions.forEach((c: Condition) => {
219
+ if (c.operator === '!=') {
220
+ query = query.orderBy(c.firestoreProperty);
221
+ }
222
+ });
223
+ }
224
+
225
+ query = this.applyFilters(query, params.arrange, params.conditions);
226
+
227
+ if (params.navigation === 'reload') {
228
+ query = query.limit(params.batchSize + 1);
229
+ if (params.doc && params.doc.firstDoc) {
230
+ query = query.startAt(params.doc.firstDoc);
231
+ }
232
+ } else if (params.navigation === 'forward') {
233
+ query = query.limit(params.batchSize + 1);
234
+
235
+ if (params.doc && params.doc.lastDoc) {
236
+ query = query.startAfter(params.doc.lastDoc);
237
+ }
238
+ } else {
239
+ // backward
240
+ query = query.limitToLast(params.batchSize + 1);
241
+ if (params.doc && params.doc.firstDoc) {
242
+ query = query.endBefore(params.doc.firstDoc);
243
+ }
244
+ }
245
+
246
+ const itemCol = await query.get();
247
+ itemCol.docs.forEach(
248
+ (doc: firebase.firestore.QueryDocumentSnapshot<unknown>) =>
249
+ docs.push(doc)
250
+ );
251
+ const itemPromises = docs.map(
252
+ async (item: firebase.firestore.QueryDocumentSnapshot<unknown>) => {
253
+ const itemData = item.data() as any;
254
+ items.push({ id: item.id, ...itemData });
255
+ }
256
+ );
257
+
258
+ let lastDoc: firebase.firestore.QueryDocumentSnapshot<unknown> | null =
259
+ docs[docs.length - 1] || null;
260
+ let firstDoc: firebase.firestore.QueryDocumentSnapshot<unknown> | null =
261
+ docs[0];
262
+
263
+ if (
264
+ (items.length > params.batchSize && params.navigation === 'forward') ||
265
+ (params.navigation === 'reload' && items.length > params.batchSize)
266
+ ) {
267
+ lastDoc = docs[docs.length - 2] || null;
268
+ items.pop();
269
+ hasNextPage = true;
270
+ }
271
+ if (items.length > params.batchSize && params.navigation === 'backward') {
272
+ firstDoc = docs[1];
273
+ items.shift();
274
+ hasNextPage = true;
275
+ }
276
+
277
+ await Promise.all(itemPromises);
278
+ return {
279
+ items,
280
+ filterLength,
281
+ lastDoc,
282
+ firstDoc,
283
+ hasNextPage,
284
+ currentClientPageIndex: undefined,
285
+ };
286
+ }
287
+
288
+ // Fallback para garantir que sempre retornamos algo
289
+ return {
290
+ items: [],
291
+ filterLength: null,
292
+ firstDoc: null,
293
+ lastDoc: null,
294
+ hasNextPage: false,
295
+ currentClientPageIndex: undefined,
296
+ };
297
+ }
298
+
299
+ applyFilters(
300
+ query: firebase.firestore.Query<unknown>,
301
+ arrange: Arrange,
302
+ conditions: Condition[] | undefined
303
+ ): firebase.firestore.Query<unknown> {
304
+ if (conditions) {
305
+ conditions.map((cond: Condition) => {
306
+ query = query.where(
307
+ cond.firestoreProperty,
308
+ cond.operator,
309
+ cond.dashProperty
310
+ );
311
+ });
312
+ }
313
+
314
+ let hasFilterSpecificOrderBy = false;
315
+ let appliedOrderByField: string | null = null;
316
+
317
+ const equalsFilters = arrange.filters.filter(
318
+ (f: any) => f.arrange === 'equals' && f.filter
319
+ );
320
+ const otherFilters = arrange.filters.filter(
321
+ (f: any) => f.arrange !== 'equals'
322
+ );
323
+
324
+ const equalsGroupedByProperty = equalsFilters.reduce(
325
+ (acc: any, current: any) => {
326
+ const prop = current.filter.property;
327
+ if (!acc[prop]) {
328
+ acc[prop] = [];
329
+ }
330
+ acc[prop].push(current.filter.filtering);
331
+ return acc;
332
+ },
333
+ {}
334
+ );
335
+
336
+ for (const prop in equalsGroupedByProperty) {
337
+ const values = equalsGroupedByProperty[prop];
338
+ if (values.length > 0) {
339
+ query = query.where(prop, 'in', values);
340
+ }
341
+ }
342
+
343
+ otherFilters.forEach((filterItem: any) => {
344
+ // Aplicar filtragem por busca
345
+ if (
346
+ filterItem.filter?.filtering &&
347
+ filterItem.filter?.property !== '' &&
348
+ filterItem.arrange === 'filter'
349
+ ) {
350
+ query = query
351
+ .where(
352
+ filterItem.filter.property,
353
+ '>=',
354
+ filterItem.filter.filtering.trim().toUpperCase()
355
+ )
356
+ .where(
357
+ filterItem.filter.property,
358
+ '<=',
359
+ filterItem.filter.filtering.trim().toUpperCase() + '\uf8ff'
360
+ );
361
+ if (!hasFilterSpecificOrderBy) {
362
+ query = query.orderBy(filterItem.filter.property);
363
+ hasFilterSpecificOrderBy = true;
364
+ appliedOrderByField = filterItem.filter.property;
365
+ }
366
+ }
367
+
368
+ // Aplicar filtro do tipo "filterByDate"
369
+ if (filterItem.dateFilter && filterItem.arrange === 'filterByDate') {
370
+ query = query
371
+ .where(arrange.sortBy.field, '>=', filterItem.dateFilter.initial)
372
+ .where(arrange.sortBy.field, '<=', filterItem.dateFilter.final);
373
+ if (!hasFilterSpecificOrderBy) {
374
+ query = query.orderBy(arrange.sortBy.field);
375
+ hasFilterSpecificOrderBy = true;
376
+ appliedOrderByField = arrange.sortBy.field;
377
+ }
378
+ }
379
+ });
380
+
381
+ // Aplicar sortBy
382
+ if (arrange.sortBy && arrange.sortBy.field && arrange.sortBy.order) {
383
+ if (appliedOrderByField !== arrange.sortBy.field) {
384
+ query = query.orderBy(arrange.sortBy.field, arrange.sortBy.order);
385
+ }
386
+ }
387
+ return query;
388
+ }
389
+
390
+ /**
391
+ * Detecta se a query vai precisar de index composto e deve usar fallback client-side
392
+ */
393
+ private shouldUseClientSideFallback(params: Pagination): boolean {
394
+ const hasConditions = params.conditions && params.conditions.length > 0;
395
+ const hasArrangeFilters =
396
+ params.arrange?.filters && params.arrange.filters.length > 0;
397
+ const hasSortBy = params.arrange?.sortBy?.field;
398
+
399
+ if (params.filterFn) {
400
+ return false;
401
+ }
402
+
403
+ if (hasConditions && hasArrangeFilters && hasSortBy) {
404
+ return true;
405
+ }
406
+
407
+ if (hasConditions && hasArrangeFilters) {
408
+ return true;
409
+ }
410
+
411
+ if (hasArrangeFilters && params.arrange.filters.length > 1 && hasSortBy) {
412
+ return true;
413
+ }
414
+
415
+ return false;
416
+ }
417
+
418
+ async getPaginated(params: Pagination): Promise<PaginationResult> {
419
+ // Detectar preventivamente se deve usar fallback
420
+ if (this.shouldUseClientSideFallback(params)) {
421
+ await this.trackMissingIndexPreventive(
422
+ params.collection,
423
+ params.arrange,
424
+ params.conditions
425
+ );
426
+
427
+ const result = await this.executeClientSideQuery(params);
428
+ console.log('📊 [TABLE] Resultados paginados via fallback client-side:', {
429
+ totalItems: result.filterLength,
430
+ returnedItems: result.items.length,
431
+ hasNextPage: result.hasNextPage,
432
+ currentPage: (result.currentClientPageIndex || 0) + 1,
433
+ });
434
+ return result;
435
+ }
436
+
437
+ try {
438
+ const result = await this.executeQuery(params);
439
+ console.log('📊 [TABLE] Resultados paginados via Firestore:', {
440
+ totalItems: result.filterLength || 'N/A',
441
+ returnedItems: result.items.length,
442
+ hasNextPage: result.hasNextPage,
443
+ });
444
+ return result;
445
+ } catch (error: any) {
446
+ if (error && error.code === 'failed-precondition') {
447
+ await this.trackMissingIndex(
448
+ error,
449
+ params.collection,
450
+ params.arrange,
451
+ params.conditions
452
+ );
453
+
454
+ const result = await this.executeClientSideQuery(params);
455
+ console.log(
456
+ '📊 [TABLE] Resultados paginados via fallback (erro de index):',
457
+ {
458
+ totalItems: result.filterLength,
459
+ returnedItems: result.items.length,
460
+ hasNextPage: result.hasNextPage,
461
+ currentPage: (result.currentClientPageIndex || 0) + 1,
462
+ }
463
+ );
464
+ return result;
465
+ } else if (error && error.code === 'invalid-argument') {
466
+ await this.trackMissingIndex(
467
+ error,
468
+ params.collection,
469
+ params.arrange,
470
+ params.conditions
471
+ );
472
+
473
+ const result = await this.executeClientSideQuery(params);
474
+ console.log(
475
+ '📊 [TABLE] Resultados paginados via fallback (argumento inválido):',
476
+ {
477
+ totalItems: result.filterLength,
478
+ returnedItems: result.items.length,
479
+ hasNextPage: result.hasNextPage,
480
+ currentPage: (result.currentClientPageIndex || 0) + 1,
481
+ }
482
+ );
483
+ return result;
484
+ } else {
485
+ throw error;
486
+ }
487
+ }
488
+ }
489
+
490
+ async executeClientSideQuery(params: Pagination): Promise<PaginationResult> {
491
+ // Otimizar usando pelo menos uma cláusula .where() quando possível
492
+ let query: firebase.firestore.Query<unknown> = this.ngFire.collection(
493
+ params.collection
494
+ ).ref;
495
+
496
+ let appliedCondition: Condition | null = null;
497
+ let hasAppliedWhereClause = false;
498
+
499
+ // Primeiro, tenta aplicar condições simples
500
+ if (params.conditions && params.conditions.length > 0) {
501
+ const simpleCondition = params.conditions.find((cond: Condition) =>
502
+ ['==', '>', '<', '>=', '<='].includes(cond.operator)
503
+ );
504
+
505
+ if (simpleCondition) {
506
+ query = query.where(
507
+ simpleCondition.firestoreProperty,
508
+ simpleCondition.operator,
509
+ simpleCondition.dashProperty
510
+ );
511
+ appliedCondition = simpleCondition;
512
+ hasAppliedWhereClause = true;
513
+ }
514
+ }
515
+
516
+ // Se não há condições disponíveis, tenta aplicar filtros do arrange
517
+ let appliedFirestoreFilter: any = null;
518
+ if (!hasAppliedWhereClause && params.arrange?.filters) {
519
+ const equalsFilter = params.arrange.filters.find(
520
+ (f: any) => f.arrange === 'equals' && f.filter?.filtering
521
+ );
522
+
523
+ if (equalsFilter && equalsFilter.filter) {
524
+ query = query.where(
525
+ equalsFilter.filter.property,
526
+ '==',
527
+ equalsFilter.filter.filtering
528
+ );
529
+ hasAppliedWhereClause = true;
530
+ appliedFirestoreFilter = equalsFilter;
531
+ } else {
532
+ const otherFilter = params.arrange.filters.find(
533
+ (f: any) =>
534
+ (f.arrange === 'filter' &&
535
+ f.filter?.filtering &&
536
+ f.filter?.property) ||
537
+ (f.arrange === 'filterByDate' &&
538
+ f.dateFilter?.initial &&
539
+ f.dateFilter?.final)
540
+ );
541
+
542
+ if (otherFilter) {
543
+ if (otherFilter.arrange === 'filter' && otherFilter.filter) {
544
+ const filterValue = otherFilter.filter.filtering
545
+ .trim()
546
+ .toUpperCase();
547
+ query = query
548
+ .where(otherFilter.filter.property, '>=', filterValue)
549
+ .where(otherFilter.filter.property, '<=', filterValue + '\uf8ff');
550
+ hasAppliedWhereClause = true;
551
+ appliedFirestoreFilter = otherFilter;
552
+ } else if (
553
+ otherFilter.arrange === 'filterByDate' &&
554
+ otherFilter.dateFilter &&
555
+ params.arrange.sortBy?.field
556
+ ) {
557
+ query = query
558
+ .where(
559
+ params.arrange.sortBy.field,
560
+ '>=',
561
+ otherFilter.dateFilter.initial
562
+ )
563
+ .where(
564
+ params.arrange.sortBy.field,
565
+ '<=',
566
+ otherFilter.dateFilter.final
567
+ );
568
+ hasAppliedWhereClause = true;
569
+ appliedFirestoreFilter = otherFilter;
570
+ }
571
+ }
572
+ }
573
+ }
574
+
575
+ const allDocsSnapshot = await query.get();
576
+ let items = allDocsSnapshot.docs.map(
577
+ (doc: firebase.firestore.QueryDocumentSnapshot<unknown>) => ({
578
+ id: doc.id,
579
+ ...(doc.data() as any),
580
+ })
581
+ );
582
+
583
+ // Aplicar condições restantes
584
+ if (params.conditions) {
585
+ const remainingConditions = params.conditions.filter(
586
+ (cond: Condition) => cond !== appliedCondition
587
+ );
588
+ if (remainingConditions.length > 0) {
589
+ const operators = this.operators;
590
+ items = items.filter((item: any) => {
591
+ return remainingConditions.every((cond: Condition) => {
592
+ const operatorFn =
593
+ operators[cond.operator as keyof typeof operators];
594
+ return operatorFn
595
+ ? operatorFn(item[cond.firestoreProperty], cond.dashProperty)
596
+ : false;
597
+ });
598
+ });
599
+ }
600
+ }
601
+
602
+ const { filters, sortBy } = params.arrange;
603
+
604
+ // Track which filter was already applied in Firestore to avoid double filtering
605
+ if (hasAppliedWhereClause && !appliedCondition && params.arrange?.filters) {
606
+ const equalsFilter = params.arrange.filters.find(
607
+ (f: any) => f.arrange === 'equals' && f.filter?.filtering
608
+ );
609
+ if (equalsFilter) {
610
+ appliedFirestoreFilter = equalsFilter;
611
+ } else {
612
+ appliedFirestoreFilter = params.arrange.filters.find(
613
+ (f: any) =>
614
+ (f.arrange === 'filter' &&
615
+ f.filter?.filtering &&
616
+ f.filter?.property) ||
617
+ (f.arrange === 'filterByDate' &&
618
+ f.dateFilter?.initial &&
619
+ f.dateFilter?.final)
620
+ );
621
+ }
622
+ }
623
+
624
+ const equalsFilters = filters.filter((f: any) => f.arrange === 'equals');
625
+ const otherFilters = filters.filter((f: any) => f.arrange !== 'equals');
626
+
627
+ const remainingEqualsFilters = equalsFilters.filter(
628
+ (f: any) => f !== appliedFirestoreFilter
629
+ );
630
+ if (remainingEqualsFilters.length > 0) {
631
+ items = items.filter((item: any) => {
632
+ return remainingEqualsFilters.every(
633
+ (f: any) => item[f.filter.property] === f.filter.filtering
634
+ );
635
+ });
636
+ }
637
+
638
+ otherFilters.forEach((filterItem: any) => {
639
+ if (appliedFirestoreFilter === filterItem) {
640
+ return;
641
+ }
642
+
643
+ if (
644
+ filterItem.arrange === 'filter' &&
645
+ filterItem.filter?.filtering &&
646
+ filterItem.filter?.property
647
+ ) {
648
+ const filterValue = String(filterItem.filter.filtering)
649
+ .trim()
650
+ .toLowerCase();
651
+ items = items.filter((item: any) => {
652
+ const itemValue = String(
653
+ item[filterItem.filter.property]
654
+ ).toLowerCase();
655
+ return itemValue.includes(filterValue);
656
+ });
657
+ }
658
+
659
+ if (
660
+ filterItem.arrange === 'filterByDate' &&
661
+ filterItem.dateFilter?.initial &&
662
+ filterItem.dateFilter?.final &&
663
+ sortBy.field
664
+ ) {
665
+ items = items.filter((item: any) => {
666
+ try {
667
+ const fieldValue = item[sortBy.field];
668
+
669
+ if (!fieldValue) {
670
+ return false;
671
+ }
672
+
673
+ let itemDate;
674
+ if (typeof fieldValue.toDate === 'function') {
675
+ itemDate = fieldValue.toDate();
676
+ } else if (fieldValue instanceof Date) {
677
+ itemDate = fieldValue;
678
+ } else if (typeof fieldValue === 'string') {
679
+ itemDate = new Date(fieldValue);
680
+ if (isNaN(itemDate.getTime())) {
681
+ return false;
682
+ }
683
+ } else if (typeof fieldValue === 'number') {
684
+ itemDate = new Date(fieldValue);
685
+ } else {
686
+ return false;
687
+ }
688
+
689
+ return (
690
+ itemDate >= filterItem.dateFilter.initial &&
691
+ itemDate <= filterItem.dateFilter.final
692
+ );
693
+ } catch (error) {
694
+ console.warn(
695
+ 'Erro ao processar filtro de data para o item:',
696
+ item.id,
697
+ error
698
+ );
699
+ return false;
700
+ }
701
+ });
702
+ }
703
+ });
704
+
705
+ // Aplicar filterFn se existir
706
+ if (params.filterFn) {
707
+ items = items.filter(params.filterFn);
708
+ }
709
+
710
+ if (sortBy && sortBy.field && sortBy.order) {
711
+ items.sort((a: any, b: any) => {
712
+ const valA = a[sortBy.field];
713
+ const valB = b[sortBy.field];
714
+
715
+ if (valA < valB) {
716
+ return sortBy.order === 'asc' ? -1 : 1;
717
+ }
718
+ if (valA > valB) {
719
+ return sortBy.order === 'asc' ? 1 : -1;
720
+ }
721
+ return 0;
722
+ });
723
+ }
724
+
725
+ // Paginação
726
+ let currentClientPageIndex = 0;
727
+
728
+ if (params.navigation === 'reload') {
729
+ currentClientPageIndex = params.clientPageIndex || 0;
730
+ } else if (params.navigation === 'forward') {
731
+ currentClientPageIndex = (params.clientPageIndex || 0) + 1;
732
+ } else if (params.navigation === 'backward') {
733
+ currentClientPageIndex = Math.max(0, (params.clientPageIndex || 0) - 1);
734
+ }
735
+
736
+ const pageSize = params.batchSize;
737
+ const startIndex = currentClientPageIndex * pageSize;
738
+ const endIndex = startIndex + pageSize;
739
+ const paginatedItems = items.slice(startIndex, endIndex);
740
+
741
+ const totalPages = Math.ceil(items.length / pageSize);
742
+ const hasNextPage = currentClientPageIndex < totalPages - 1;
743
+ const hasPreviousPage = currentClientPageIndex > 0;
744
+
745
+ return {
746
+ items: paginatedItems,
747
+ filterLength: items.length,
748
+ lastDoc: null,
749
+ firstDoc: null,
750
+ hasNextPage: hasNextPage,
751
+ hasPreviousPage: hasPreviousPage,
752
+ currentClientPageIndex: currentClientPageIndex,
753
+ totalPages: totalPages,
754
+ };
755
+ }
756
+
757
+ async getItemsData(
758
+ collection: string,
759
+ arrange: Arrange,
760
+ conditions: Condition[] | undefined = undefined
761
+ ): Promise<any[]> {
762
+ try {
763
+ let query: firebase.firestore.Query<unknown> =
764
+ this.ngFire.collection(collection).ref;
765
+
766
+ query = this.applyFilters(query, arrange, conditions);
767
+ const snapshot = await query.get();
768
+ return await Promise.all(
769
+ snapshot.docs.map(
770
+ async (doc: firebase.firestore.QueryDocumentSnapshot<unknown>) => {
771
+ const data = doc.data() as any;
772
+ const id = doc.id;
773
+ return {
774
+ id,
775
+ ...data,
776
+ };
777
+ }
778
+ )
779
+ );
780
+ } catch (e) {
781
+ throw e;
782
+ }
783
+ }
784
+
785
+ public operators = {
786
+ '==': (a: any, b: any): boolean => a === b,
787
+ '!=': (a: any, b: any): boolean => a !== b,
788
+ '>': (a: any, b: any): boolean => a > b,
789
+ '<': (a: any, b: any): boolean => a < b,
790
+ '>=': (a: any, b: any): boolean => a >= b,
791
+ '<=': (a: any, b: any): boolean => a <= b,
792
+ includes: (a: any, b: any): any => a.includes(b),
793
+ };
794
+
795
+ async deleteIndex(
796
+ id: string,
797
+ col: string,
798
+ dialogComponent: any
799
+ ): Promise<boolean> {
800
+ const dialogRef = this.dialog.open(dialogComponent, {
801
+ data: {
802
+ title: 'Você realmente deseja deletar esse item?',
803
+ description: 'Essa ação é irreversível.',
804
+ },
805
+ });
806
+
807
+ const result = await firstValueFrom(dialogRef.afterClosed());
808
+ if (!result === true) {
809
+ return false;
810
+ }
811
+
812
+ try {
813
+ const batch = this.ngFire.firestore.batch();
814
+
815
+ const docRef = this.ngFire.collection(col).doc(id);
816
+ const docSnapshot = (await firstValueFrom(
817
+ docRef.get()
818
+ )) as firebase.firestore.DocumentSnapshot<unknown>;
819
+ const doc = docSnapshot.data() as any;
820
+ batch.delete(docRef.ref);
821
+ if (doc && typeof doc.index === 'number') {
822
+ await this.reindex(doc.index, col, batch);
823
+ }
824
+ await batch.commit();
825
+
826
+ this.toastr.success('Item excluído com sucesso!');
827
+ return true;
828
+ } catch (e) {
829
+ const error = e as any;
830
+ console.error('Erro ao deletar item:', error);
831
+ this.toastr.error('Erro ao deletar item.');
832
+ return false;
833
+ }
834
+ }
835
+
836
+ async reindex(
837
+ index: number,
838
+ col: string,
839
+ batch: firebase.firestore.WriteBatch
840
+ ): Promise<void> {
841
+ try {
842
+ const snapshot = (await firstValueFrom(
843
+ this.ngFire.collection(col).get()
844
+ )) as firebase.firestore.QuerySnapshot<unknown>;
845
+ const docs = snapshot.docs;
846
+ for (let doc of docs) {
847
+ const data = doc.data() as any;
848
+ if (data && typeof data.index === 'number' && data.index > index) {
849
+ data.index--;
850
+ const docRef = this.ngFire.collection(col).doc(doc.id).ref;
851
+ batch.update(docRef, data);
852
+ }
853
+ }
854
+ } catch (error) {
855
+ console.error('Erro ao reindexar:', error);
856
+ }
857
+ return;
858
+ }
859
+
860
+ dateFormatValidator(): ValidatorFn {
861
+ return (control: AbstractControl): { [key: string]: any } | null => {
862
+ if (!control.value) {
863
+ return null;
864
+ }
865
+
866
+ const dateStr = control.value.trim();
867
+ const datePattern = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
868
+
869
+ if (!datePattern.test(dateStr)) {
870
+ return { invalidFormat: true };
871
+ }
872
+
873
+ const parts = dateStr.split('/');
874
+ const day = parts[0].padStart(2, '0');
875
+ const month = parts[1].padStart(2, '0');
876
+ const year = parts[2];
877
+ const normalizedDate = `${day}/${month}/${year}`;
878
+
879
+ const date = moment(normalizedDate, 'DD/MM/YYYY', true);
880
+
881
+ if (!date.isValid()) {
882
+ return { invalidDate: true };
883
+ }
884
+
885
+ return null;
886
+ };
887
+ }
888
+
889
+ async updateIndex(index: number, id: string, col: string): Promise<void> {
890
+ await this.ngFire.collection(col).doc(id).update({ index });
891
+ }
892
+
893
+ /**
894
+ * Extrai o link de criação de índice da mensagem de erro do Firestore
895
+ */
896
+ private extractIndexLink(error: any): string | null {
897
+ if (!error || !error.message) return null;
898
+
899
+ const linkMatch = error.message.match(
900
+ /(https:\/\/console\.firebase\.google\.com\/[^\s]+)/
901
+ );
902
+ return linkMatch ? linkMatch[1] : null;
903
+ }
904
+
905
+ /**
906
+ * Rastreia índices ausentes ao usar fallback preventivo
907
+ */
908
+ private async trackMissingIndexPreventive(
909
+ collection: string,
910
+ arrange: Arrange,
911
+ conditions: Condition[] | undefined = undefined
912
+ ): Promise<void> {
913
+ try {
914
+ const querySignature = this.generateQuerySignature(
915
+ collection,
916
+ arrange,
917
+ conditions
918
+ );
919
+ const docId = `${collection}_${querySignature}`;
920
+
921
+ const indexLink = this.generateIndexLink(collection, arrange, conditions);
922
+
923
+ const indexInstructions = this.generateIndexInstructions(
924
+ collection,
925
+ arrange,
926
+ conditions
927
+ );
928
+
929
+ const trackingData: any = {
930
+ collection,
931
+ indexLink,
932
+ indexInstructions,
933
+ arrange: {
934
+ sortBy: arrange.sortBy,
935
+ filters:
936
+ arrange.filters?.map((f: any) => ({
937
+ arrange: f.arrange,
938
+ property: f.filter?.property || null,
939
+ dateField:
940
+ f.arrange === 'filterByDate' ? arrange.sortBy?.field : null,
941
+ })) || [],
942
+ },
943
+ conditions:
944
+ conditions?.map((c: Condition) => ({
945
+ property: c.firestoreProperty,
946
+ operator: c.operator,
947
+ })) || [],
948
+ errorMessage: `Fallback preventivo usado para a collection ${collection}. A query exigiria índice composto.`,
949
+ updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
950
+ };
951
+
952
+ console.log('📄 [INDEX LINK] Dados que serão salvos no documento:', {
953
+ docId,
954
+ collection: trackingData.collection,
955
+ indexLink: trackingData.indexLink,
956
+ arrange: trackingData.arrange,
957
+ conditions: trackingData.conditions,
958
+ errorMessage: trackingData.errorMessage,
959
+ });
960
+
961
+ const docRef = this.ngFire.collection('missingIndexes').doc(docId);
962
+ const doc = await docRef.get().toPromise();
963
+
964
+ if (doc && doc.exists) {
965
+ await docRef.update({
966
+ count: firebase.firestore.FieldValue.increment(1),
967
+ updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
968
+ lastError: trackingData.errorMessage,
969
+ });
970
+ } else {
971
+ await docRef.set({
972
+ ...trackingData,
973
+ count: 1,
974
+ createdAt: firebase.firestore.FieldValue.serverTimestamp(),
975
+ });
976
+ }
977
+ } catch (trackingError) {
978
+ console.warn('Falha ao rastrear fallback preventivo:', trackingError);
979
+ }
980
+ }
981
+
982
+ /**
983
+ * Gera uma assinatura única para uma query
984
+ */
985
+ private generateQuerySignature(
986
+ collection: string,
987
+ arrange: Arrange,
988
+ conditions: Condition[] | undefined = undefined
989
+ ): string {
990
+ const signature = {
991
+ collection,
992
+ sortBy: arrange.sortBy,
993
+ filters:
994
+ arrange.filters?.map((f: any) => ({
995
+ arrange: f.arrange,
996
+ property: f.filter?.property || null,
997
+ })) || [],
998
+ conditions:
999
+ conditions?.map((c: Condition) => ({
1000
+ property: c.firestoreProperty,
1001
+ operator: c.operator,
1002
+ })) || [],
1003
+ };
1004
+
1005
+ return btoa(JSON.stringify(signature))
1006
+ .replace(/[^a-zA-Z0-9]/g, '')
1007
+ .substring(0, 20);
1008
+ }
1009
+
1010
+ /**
1011
+ * Gera instruções claras para criar o índice manualmente
1012
+ */
1013
+ private generateIndexInstructions(
1014
+ collection: string,
1015
+ arrange: Arrange,
1016
+ conditions: Condition[] | undefined = undefined
1017
+ ): any {
1018
+ const instructions: any = {
1019
+ summary: '',
1020
+ collection: collection,
1021
+ fields: [] as any[],
1022
+ queryExample: '',
1023
+ stepByStep: [] as string[],
1024
+ notes: [] as string[],
1025
+ };
1026
+
1027
+ const fields: any[] = [];
1028
+
1029
+ if (conditions && conditions.length > 0) {
1030
+ conditions.forEach((condition: Condition) => {
1031
+ if (condition.firestoreProperty) {
1032
+ fields.push({
1033
+ field: condition.firestoreProperty,
1034
+ order: 'Ascending',
1035
+ type: 'WHERE clause',
1036
+ operator: condition.operator,
1037
+ description: `Filtrar por ${condition.firestoreProperty} usando operador ${condition.operator}`,
1038
+ });
1039
+ }
1040
+ });
1041
+ }
1042
+
1043
+ if (arrange.filters && arrange.filters.length > 0) {
1044
+ arrange.filters.forEach((filter: any) => {
1045
+ if (filter.filter?.property) {
1046
+ fields.push({
1047
+ field: filter.filter.property,
1048
+ order: 'Ascending',
1049
+ type: 'WHERE clause (filter)',
1050
+ operator: filter.arrange === 'filter' ? 'CONTAINS' : 'RANGE',
1051
+ description: `Filtrar por ${filter.filter.property} usando filtro ${filter.arrange}`,
1052
+ });
1053
+ }
1054
+ });
1055
+ }
1056
+
1057
+ if (arrange.sortBy?.field) {
1058
+ fields.push({
1059
+ field: arrange.sortBy.field,
1060
+ order: arrange.sortBy.order === 'desc' ? 'Descending' : 'Ascending',
1061
+ type: 'ORDER BY clause',
1062
+ operator: 'N/A',
1063
+ description: `Ordenar resultados por ${arrange.sortBy.field} em ordem ${arrange.sortBy.order}`,
1064
+ });
1065
+ }
1066
+
1067
+ instructions.fields = fields;
1068
+
1069
+ const fieldNames = fields.map((f: any) => f.field).join(' + ');
1070
+ instructions.summary = `Criar índice composto para ${collection}: ${fieldNames}`;
1071
+
1072
+ let queryExample = `db.collection('${collection}')`;
1073
+
1074
+ fields.forEach((field: any, index: number) => {
1075
+ if (field.type.includes('WHERE')) {
1076
+ if (field.operator === '==') {
1077
+ queryExample += `\n .where('${field.field}', '==', 'value')`;
1078
+ } else if (field.operator === 'CONTAINS') {
1079
+ queryExample += `\n .where('${field.field}', '>=', 'searchText')`;
1080
+ } else {
1081
+ queryExample += `\n .where('${field.field}', '${field.operator}', 'value')`;
1082
+ }
1083
+ }
1084
+ });
1085
+
1086
+ const orderByField = fields.find((f: any) => f.type.includes('ORDER BY'));
1087
+ if (orderByField) {
1088
+ queryExample += `\n .orderBy('${
1089
+ orderByField.field
1090
+ }', '${orderByField.order.toLowerCase()}')`;
1091
+ }
1092
+
1093
+ instructions.queryExample = queryExample;
1094
+
1095
+ instructions.stepByStep = [
1096
+ '1. Ir para Firebase Console → Firestore → Indexes',
1097
+ '2. Clicar em "Create Index"',
1098
+ `3. Definir Collection ID: ${collection}`,
1099
+ '4. Configurar campos nesta ORDEM EXATA:',
1100
+ ...fields.map(
1101
+ (field: any, index: number) =>
1102
+ ` ${index + 1}. Campo: ${field.field}, Order: ${
1103
+ field.order
1104
+ }, Array: No`
1105
+ ),
1106
+ '5. Definir Query scopes: Collection',
1107
+ '6. Clicar em "Create" e aguardar conclusão',
1108
+ ];
1109
+
1110
+ instructions.notes = [
1111
+ '⚠️ A ordem dos campos é CRÍTICA - deve corresponder exatamente à ordem da query',
1112
+ '⚠️ As cláusulas WHERE devem vir ANTES do campo ORDER BY',
1113
+ '⚠️ Este índice só funcionará para queries com esta combinação EXATA de campos',
1114
+ '⚠️ A criação do índice pode levar vários minutos',
1115
+ ];
1116
+
1117
+ return instructions;
1118
+ }
1119
+
1120
+ /**
1121
+ * Gera um link de índice baseado na estrutura da query
1122
+ */
1123
+ private generateIndexLink(
1124
+ collection: string,
1125
+ arrange: Arrange,
1126
+ conditions: Condition[] | undefined = undefined
1127
+ ): string | null {
1128
+ try {
1129
+ const indexFields: string[] = [];
1130
+
1131
+ if (conditions && conditions.length > 0) {
1132
+ conditions.forEach((condition: Condition) => {
1133
+ if (condition.firestoreProperty) {
1134
+ indexFields.push(condition.firestoreProperty);
1135
+ }
1136
+ });
1137
+ }
1138
+
1139
+ if (arrange.filters && arrange.filters.length > 0) {
1140
+ arrange.filters.forEach((filter: any) => {
1141
+ if (filter.filter?.property) {
1142
+ indexFields.push(filter.filter.property);
1143
+ }
1144
+ });
1145
+ }
1146
+
1147
+ if (arrange.sortBy?.field) {
1148
+ indexFields.push(arrange.sortBy.field);
1149
+ }
1150
+
1151
+ if (indexFields.length > 1) {
1152
+ const baseUrl =
1153
+ 'https://console.firebase.google.com/project/toppayy-dev/firestore/indexes';
1154
+ const queryParams = new URLSearchParams({
1155
+ create_composite: `collection=${collection}&fields=${indexFields.join(
1156
+ ','
1157
+ )}`,
1158
+ });
1159
+
1160
+ const finalLink = `${baseUrl}?${queryParams.toString()}`;
1161
+ return finalLink;
1162
+ }
1163
+
1164
+ return null;
1165
+ } catch (error) {
1166
+ console.warn('Falha ao gerar link de índice:', error);
1167
+ return null;
1168
+ }
1169
+ }
1170
+
1171
+ private async trackMissingIndex(
1172
+ error: any,
1173
+ collection: string,
1174
+ arrange: Arrange,
1175
+ conditions: Condition[] | undefined = undefined
1176
+ ): Promise<void> {
1177
+ try {
1178
+ const indexLink = this.extractIndexLink(error);
1179
+ if (!indexLink) return;
1180
+
1181
+ const linkHash = btoa(indexLink)
1182
+ .replace(/[^a-zA-Z0-9]/g, '')
1183
+ .substring(0, 20);
1184
+ const docId = `${collection}_${linkHash}`;
1185
+
1186
+ const indexInstructions = this.generateIndexInstructions(
1187
+ collection,
1188
+ arrange,
1189
+ conditions
1190
+ );
1191
+
1192
+ const trackingData: any = {
1193
+ collection,
1194
+ indexLink,
1195
+ indexInstructions,
1196
+ arrange: {
1197
+ sortBy: arrange.sortBy,
1198
+ filters:
1199
+ arrange.filters?.map((f: any) => ({
1200
+ arrange: f.arrange,
1201
+ property: f.filter?.property || null,
1202
+ dateField:
1203
+ f.arrange === 'filterByDate' ? arrange.sortBy?.field : null,
1204
+ })) || [],
1205
+ },
1206
+ conditions:
1207
+ conditions?.map((c: Condition) => ({
1208
+ property: c.firestoreProperty,
1209
+ operator: c.operator,
1210
+ })) || [],
1211
+ errorMessage: error.message,
1212
+ updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
1213
+ };
1214
+
1215
+ const docRef = this.ngFire.collection('missingIndexes').doc(docId);
1216
+ const doc = await docRef.get().toPromise();
1217
+
1218
+ if (doc && doc.exists) {
1219
+ await docRef.update({
1220
+ count: firebase.firestore.FieldValue.increment(1),
1221
+ updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
1222
+ lastError: error.message,
1223
+ });
1224
+ } else {
1225
+ await docRef.set({
1226
+ ...trackingData,
1227
+ count: 1,
1228
+ createdAt: firebase.firestore.FieldValue.serverTimestamp(),
1229
+ });
1230
+ }
1231
+ } catch (trackingError) {
1232
+ console.warn('Falha ao rastrear índice ausente:', trackingError);
1233
+ }
1234
+ }
1235
+ }