diff --git a/src/app/components/custom-select/custom-select.html b/src/app/components/custom-select/custom-select.html index 6b77859..fe421ac 100644 --- a/src/app/components/custom-select/custom-select.html +++ b/src/app/components/custom-select/custom-select.html @@ -6,6 +6,7 @@ [attr.aria-expanded]="isOpen" [attr.aria-disabled]="disabled" > + {{ displayLabel }} diff --git a/src/app/components/custom-select/custom-select.scss b/src/app/components/custom-select/custom-select.scss index 117324d..3a26693 100644 --- a/src/app/components/custom-select/custom-select.scss +++ b/src/app/components/custom-select/custom-select.scss @@ -67,6 +67,12 @@ padding-right: 24px; } +.app-select-leading-icon { + flex: 0 0 auto; + color: #64748b; + font-size: 13px; +} + .app-select-label { flex: 1 1 auto; min-width: 0; diff --git a/src/app/components/custom-select/custom-select.ts b/src/app/components/custom-select/custom-select.ts index 820eef0..6477190 100644 --- a/src/app/components/custom-select/custom-select.ts +++ b/src/app/components/custom-select/custom-select.ts @@ -29,6 +29,7 @@ export class CustomSelectComponent implements ControlValueAccessor, OnDestroy { @Input() disabled = false; @Input() searchable = false; @Input() searchPlaceholder = 'Pesquisar...'; + @Input() leadingIcon = ''; isOpen = false; value: any = null; diff --git a/src/app/components/smart-search-input/smart-search-input.html b/src/app/components/smart-search-input/smart-search-input.html new file mode 100644 index 0000000..95b0404 --- /dev/null +++ b/src/app/components/smart-search-input/smart-search-input.html @@ -0,0 +1,28 @@ +
+ + + + + + + +
diff --git a/src/app/components/smart-search-input/smart-search-input.scss b/src/app/components/smart-search-input/smart-search-input.scss new file mode 100644 index 0000000..5821e21 --- /dev/null +++ b/src/app/components/smart-search-input/smart-search-input.scss @@ -0,0 +1,72 @@ +:host { + min-width: 0; +} + +.smart-search-group { + width: 100%; + border-radius: 12px; + overflow: hidden; + display: flex; + align-items: stretch; + background: #fff; + border: 1px solid rgba(17, 18, 20, 0.15); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04); + transition: all 0.2s ease; + + &:focus-within { + border-color: var(--brand, #E33DCF); + box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); + transform: translateY(-1px); + } +} + +.input-group-text { + background: transparent; + border: none; + color: rgba(17, 18, 20, 0.65); + padding-left: 14px; + padding-right: 8px; + display: flex; + align-items: center; + + i { + font-size: 1rem; + } +} + +.form-control { + border: none; + background: transparent; + padding: 10px 0; + font-size: 0.9rem; + color: #111214; + box-shadow: none; + + &::placeholder { + color: rgba(17, 18, 20, 0.4); + font-weight: 500; + } + + &:focus { + outline: none; + } +} + +.btn-clear { + background: transparent; + border: none; + color: rgba(17, 18, 20, 0.65); + padding: 0 12px; + display: flex; + align-items: center; + cursor: pointer; + transition: color 0.2s; + + &:hover:not(:disabled) { + color: #dc3545; + } + + i { + font-size: 1rem; + } +} diff --git a/src/app/components/smart-search-input/smart-search-input.ts b/src/app/components/smart-search-input/smart-search-input.ts new file mode 100644 index 0000000..6a5c711 --- /dev/null +++ b/src/app/components/smart-search-input/smart-search-input.ts @@ -0,0 +1,71 @@ +import { CommonModule } from '@angular/common'; +import { Component, HostBinding, Input, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +@Component({ + selector: 'app-smart-search-input', + standalone: true, + imports: [CommonModule], + templateUrl: './smart-search-input.html', + styleUrls: ['./smart-search-input.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SmartSearchInputComponent), + multi: true, + }, + ], +}) +export class SmartSearchInputComponent implements ControlValueAccessor { + @Input() placeholder = 'Pesquisar...'; + @Input() loading = false; + @Input() disabled = false; + @Input() maxWidth = '270px'; + + @HostBinding('style.display') readonly hostDisplay = 'block'; + + @HostBinding('style.width') + get hostWidth(): string { + return this.maxWidth === '100%' ? '100%' : `min(100%, ${this.maxWidth})`; + } + + value = ''; + + private onChange: (value: string) => void = () => {}; + private onTouched: () => void = () => {}; + + writeValue(value: string | null | undefined): void { + this.value = value ?? ''; + } + + registerOnChange(fn: (value: string) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + onInput(event: Event): void { + const nextValue = (event.target as HTMLInputElement | null)?.value ?? ''; + this.value = nextValue; + this.onChange(nextValue); + } + + clear(event?: Event): void { + event?.stopPropagation(); + if (!this.value) return; + + this.value = ''; + this.onChange(''); + this.onTouched(); + } + + onBlur(): void { + this.onTouched(); + } +} diff --git a/src/app/pages/geral/geral.html b/src/app/pages/geral/geral.html index 3ee8341..f7e4509 100644 --- a/src/app/pages/geral/geral.html +++ b/src/app/pages/geral/geral.html @@ -46,31 +46,31 @@ class="btn btn-glass btn-sm header-action-btn header-action-btn-export" (click)="onExport()" [disabled]="loading || exporting"> - Exportar - Exportando... + Exportar + Exportando...
- +
-
+
@@ -460,6 +453,17 @@
+
+ +
+
diff --git a/src/app/pages/geral/geral.scss b/src/app/pages/geral/geral.scss index 1428bd4..61a441e 100644 --- a/src/app/pages/geral/geral.scss +++ b/src/app/pages/geral/geral.scss @@ -173,16 +173,30 @@ } .header-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; white-space: nowrap; } +.header-action-btn-content { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.header-action-btn-export { + align-self: center; + min-width: 152px; +} + .header-action-btn-wide { min-width: 220px; } -.header-action-btn-export { - align-self: center; - min-width: 140px; +.header-action-btn-template { + min-width: 220px; } @media (max-width: 1366px) { @@ -206,8 +220,8 @@ } .header-action-btn, - .header-action-btn-wide, - .header-action-btn-export { + .header-action-btn-export, + .header-action-btn-wide { width: 100%; min-width: 0; } @@ -247,7 +261,12 @@ } .filter-tabs { display: flex; gap: 4px; padding: 4px; background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(17, 18, 20, 0.08); border-radius: 12px; backdrop-filter: blur(8px); flex-wrap: wrap; justify-content: center; } .filter-tab { border: none; background: transparent; padding: 8px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 700; color: var(--muted); transition: all 0.2s ease; display: flex; align-items: center; gap: 6px; flex: 0 0 auto; white-space: nowrap; line-height: 1.1; &:hover { color: var(--text); background: rgba(255, 255, 255, 0.5); } &.active { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(227, 61, 207, 0.15); } &:disabled { opacity: 0.5; cursor: not-allowed; } } -.filter-blocked-select-box { width: 196px; flex: 0 0 auto; position: relative; z-index: 80; } +.filter-blocked-select-box { + width: 196px; + flex: 0 0 auto; + position: relative; + z-index: 80; +} @media (max-width: 1366px) { .filter-tabs { @@ -529,6 +548,15 @@ .group-toggle-icon { font-size: 1.2rem; color: var(--muted); transition: transform 0.3s ease; } .client-group-card.expanded .group-toggle-icon { transform: rotate(180deg); color: var(--brand); } .group-body { border-top: 1px solid rgba(17,18,20,0.06); background: #fbfbfc; animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); } +.group-search-row { + padding: 14px 24px 0; + background: rgba(255, 255, 255, 0.82); +} + +.group-search-input { + width: min(100%, 420px); +} + .btn-add-line-group { display: inline-flex; align-items: center; diff --git a/src/app/pages/geral/geral.ts b/src/app/pages/geral/geral.ts index 85a787b..ec9d35e 100644 --- a/src/app/pages/geral/geral.ts +++ b/src/app/pages/geral/geral.ts @@ -19,6 +19,7 @@ import { } from '@angular/common/http'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { CustomSelectComponent } from '../../components/custom-select/custom-select'; +import { SmartSearchInputComponent } from '../../components/smart-search-input/smart-search-input'; import { GeralModalsComponent } from '../../components/page-modals/geral-modals/geral-modals'; import { PlanAutoFillService } from '../../services/plan-autofill.service'; import { AuthService } from '../../services/auth.service'; @@ -178,6 +179,8 @@ interface ApiLineDetail { desconto?: number | null; lucro?: number | null; + createdAt?: string | null; + updatedAt?: string | null; } type UpdateMobileLineRequest = Omit; @@ -359,7 +362,7 @@ interface MveApplySelectionSummary { @Component({ standalone: true, - imports: [CommonModule, FormsModule, CustomSelectComponent, GeralModalsComponent], + imports: [CommonModule, FormsModule, CustomSelectComponent, SmartSearchInputComponent, GeralModalsComponent], templateUrl: './geral.html', styleUrls: ['./geral.scss'] }) @@ -415,6 +418,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { loadingLines = false; searchTerm = ''; + groupSearchTerm = ''; filterSkil: SkilFilterMode = 'ALL'; filterStatus: 'ALL' | 'ACTIVE' | 'BLOCKED' = 'ALL'; blockedStatusMode: BlockedStatusMode = 'ALL'; @@ -423,8 +427,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { filterOperadora: OperadoraFilterMode = 'ALL'; filterContaEmpresa = ''; readonly blockedStatusFilterOptions: Array<{ label: string; value: BlockedStatusFilterValue }> = [ - { label: 'Todos os status', value: '' }, - { label: 'Bloqueadas', value: 'ALL' }, + { label: 'Todos os bloqueios', value: 'ALL' }, { label: 'Bloqueio Perda/Roubo', value: 'PERDA_ROUBO' }, { label: 'Bloqueio por 120 dias', value: 'BLOQUEIO_120' }, { label: 'Bloqueio de Pré Ativação', value: 'PRE_ATIVACAO' }, @@ -526,6 +529,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private editingId: string | null = null; private searchTimer: any = null; + private groupSearchTimer: any = null; private navigationSub?: Subscription; private dropdownSyncSub?: Subscription; private keepPageOnNextGroupsLoad = false; @@ -1040,6 +1044,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { ngOnDestroy(): void { if (this.searchTimer) clearTimeout(this.searchTimer); + if (this.groupSearchTimer) clearTimeout(this.groupSearchTimer); this.navigationSub?.unsubscribe(); this.dropdownSyncSub?.unsubscribe(); } @@ -1468,6 +1473,22 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { return false; } + private resetGroupSearchState(): void { + this.groupSearchTerm = ''; + if (this.groupSearchTimer) { + clearTimeout(this.groupSearchTimer); + this.groupSearchTimer = null; + } + } + + private getActiveExpandedGroupSearchTerm(): string | undefined { + const groupTerm = (this.groupSearchTerm ?? '').trim(); + if (groupTerm) return groupTerm; + + const globalTerm = (this.searchTerm ?? '').trim(); + return globalTerm || undefined; + } + private normalizeDigits(value: unknown): string { return String(value ?? '').replace(/\D/g, ''); } @@ -1632,6 +1653,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.searchTimer = setTimeout(async () => { const requestVersion = ++this.searchRequestVersion; + this.resetGroupSearchState(); this.expandedGroup = null; this.groupLines = []; this.page = 1; @@ -1679,6 +1701,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { }, 300); } + onGroupSearchChange(): void { + if (this.groupSearchTimer) clearTimeout(this.groupSearchTimer); + + this.groupSearchTimer = setTimeout(() => { + if (!this.expandedGroup) return; + this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm()); + }, 300); + } + private loadOnlyThisClientGroup(clientName: string): Promise { const requestVersion = ++this.groupsRequestVersion; this.loading = true; @@ -2485,18 +2516,17 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { toggleGroup(clientName: string) { if (this.expandedGroup === clientName) { + this.resetGroupSearchState(); this.expandedGroup = null; this.groupLines = []; this.clearReservaSelection(); return; } + + this.resetGroupSearchState(); this.clearReservaSelection(); this.expandedGroup = clientName; - - const term = (this.searchTerm ?? '').trim(); - const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; - - this.fetchGroupLines(clientName, useTerm); + this.fetchGroupLines(clientName, this.getActiveExpandedGroupSearchTerm()); } private async fetchAllGroupLines( @@ -2582,8 +2612,9 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.groupLines = []; this.clearReservaSelection(); this.loadingLines = true; + const normalizedSearch = (search ?? '').trim() || undefined; - void this.fetchAllGroupLines(clientName, search, requestVersion); + void this.fetchAllGroupLines(clientName, normalizedSearch, requestVersion); } toggleClientMenu() { @@ -2740,6 +2771,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { clearSearch() { this.searchTerm = ''; this.searchResolvedClient = null; + this.resetGroupSearchState(); this.expandedGroup = null; this.groupLines = []; this.page = 1; @@ -2761,12 +2793,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { const suffix = this.getExportFilterSuffix(); const timestamp = this.tableExportService.buildTimestamp(); const fileName = `geral_${suffix}_${timestamp}`; - const templateBuffer = await this.getGeralTemplateBuffer(); await this.tableExportService.exportAsXlsx({ fileName, sheetName: 'Geral', - templateBuffer, rows, columns: [ { header: 'ID', value: (row) => row.id }, @@ -2882,29 +2912,27 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { private async getDetailedRowsForExport(baseRows: LineRow[]): Promise { if (!baseRows.length) return []; - const result: ApiLineDetail[] = []; - const chunkSize = 8; - - for (let i = 0; i < baseRows.length; i += chunkSize) { - const chunk = baseRows.slice(i, i + chunkSize); - const fetched = await Promise.all( - chunk.map(async (row) => { - try { - return await firstValueFrom( - this.http.get(`${this.apiBase}/${row.id}`, { - params: this.withNoCache(new HttpParams()), - }) - ); - } catch { - return this.toDetailFallback(row); - } - }) + try { + const ids = Array.from( + new Set( + baseRows + .map((row) => (row.id ?? '').toString().trim()) + .filter((id) => !!id) + ) ); - result.push(...fetched); + const fetched = await firstValueFrom( + this.http.post(`${this.apiBase}/export-details`, { ids }) + ); + + if (Array.isArray(fetched) && fetched.length > 0) { + return fetched; + } + } catch { + // Fallback local preserva a exportacao se o endpoint em lote falhar. } - return result; + return baseRows.map((row) => this.toDetailFallback(row)); } private toDetailFallback(row: LineRow): ApiLineDetail { @@ -2954,24 +2982,11 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { valorContratoLine: null, desconto: null, lucro: null, + createdAt: null, + updatedAt: null, }; } - private async getGeralTemplateBuffer(): Promise { - try { - const params = new HttpParams().set('_', `${Date.now()}`); - const blob = await firstValueFrom( - this.http.get(`${this.templatesApiBase}/planilha-geral`, { - params, - responseType: 'blob', - }) - ); - return await blob.arrayBuffer(); - } catch { - return null; - } - } - private getExportFilterSuffix(): string { const parts: string[] = []; @@ -3488,9 +3503,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { await this.showToast(msg); if (this.isGroupMode && this.expandedGroup) { - const term = (this.searchTerm ?? '').trim(); - const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; - this.fetchGroupLines(this.expandedGroup, useTerm); + this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm()); this.loadGroups(); this.loadKpis(); } else { @@ -3516,10 +3529,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { } if (this.isGroupMode && this.expandedGroup) { - const term = (this.searchTerm ?? '').trim(); - const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; - - this.fetchGroupLines(this.expandedGroup, useTerm); + this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm()); this.loadGroups(); this.loadKpis(); } else { @@ -3683,10 +3693,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { this.loading = false; if (fromGroup && this.expandedGroup) { - const term = (this.searchTerm ?? '').trim(); - const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; - - this.fetchGroupLines(this.expandedGroup, useTerm); + this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm()); this.loadGroups(); this.loadKpis(); } else { @@ -4480,10 +4487,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { await this.showToast(this.getCreateSuccessMessage(createdCount)); if (this.createMode === 'NEW_LINE_IN_GROUP' && this.expandedGroup === targetClient) { - const term = (this.searchTerm ?? '').trim(); - const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; - - this.fetchGroupLines(this.expandedGroup!, useTerm); + this.fetchGroupLines(this.expandedGroup!, this.getActiveExpandedGroupSearchTerm()); this.loadGroups(); this.loadKpis(); } else { @@ -4762,9 +4766,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { ); if (this.expandedGroup) { - const term = (this.searchTerm ?? '').trim(); - const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; - this.fetchGroupLines(this.expandedGroup, useTerm); + this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm()); } this.loadGroups(); @@ -4923,9 +4925,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { ); if (this.expandedGroup) { - const term = (this.searchTerm ?? '').trim(); - const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; - this.fetchGroupLines(this.expandedGroup, useTerm); + this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm()); } this.loadGroups(); this.loadKpis(); @@ -4979,9 +4979,7 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy { ); if (this.expandedGroup) { - const term = (this.searchTerm ?? '').trim(); - const useTerm = term && this.isSpecificSearchTerm(term) ? term : undefined; - this.fetchGroupLines(this.expandedGroup, useTerm); + this.fetchGroupLines(this.expandedGroup, this.getActiveExpandedGroupSearchTerm()); } this.loadGroups(); this.loadKpis(); diff --git a/src/app/services/import-page-template.service.ts b/src/app/services/import-page-template.service.ts index a9fa969..0f4f1b9 100644 --- a/src/app/services/import-page-template.service.ts +++ b/src/app/services/import-page-template.service.ts @@ -280,6 +280,7 @@ export class ImportPageTemplateService { rows: [], headerRows, excludeItemAndIdColumns: false, + skipTemplateStyle: true, }); } diff --git a/src/app/services/table-export.service.ts b/src/app/services/table-export.service.ts index 21c0165..295fc79 100644 --- a/src/app/services/table-export.service.ts +++ b/src/app/services/table-export.service.ts @@ -22,6 +22,7 @@ export interface TableExportRequest { headerRows?: Array>; excludeItemAndIdColumns?: boolean; templateBuffer?: ArrayBuffer | null; + skipTemplateStyle?: boolean; } type ExcelJsModule = typeof import('exceljs'); @@ -61,7 +62,9 @@ export class TableExportService { async exportAsXlsx(request: TableExportRequest): Promise { const excelJsModule = await this.getExcelJs(); - const templateBuffer = request.templateBuffer ?? (await this.getDefaultTemplateBuffer()); + const templateBuffer = request.skipTemplateStyle + ? null + : (request.templateBuffer ?? (await this.getDefaultTemplateBuffer())); const templateStyle = await this.resolveTemplateStyle(excelJsModule, templateBuffer); const workbook = new excelJsModule.Workbook(); const sheet = workbook.addWorksheet(this.sanitizeSheetName(request.sheetName || 'Dados'));