Merge pull request #6 from eduardolopesx03/feature/front-modais

Feat: Novo layout dos modais e campos travados
This commit is contained in:
Eduardo Lopes 2025-12-23 17:58:10 -03:00 committed by GitHub
commit 6fd87ae2b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 777 additions and 551 deletions

View File

@ -38,6 +38,7 @@
<i class="bi bi-file-earmark-excel me-1"></i> Importar
</button>
<input #excelInput type="file" class="d-none" accept=".xlsx" (change)="onExcelSelected($event)" />
<button type="button" class="btn btn-brand btn-sm" (click)="onCadastrarLinha()" [disabled]="loading">
<i class="bi bi-plus-circle me-1"></i> Nova Linha
</button>
@ -119,7 +120,6 @@
<div class="geral-body">
<div class="groups-container" *ngIf="isGroupMode; else tableView">
<div class="text-center p-5" *ngIf="loading">
<span class="spinner-border text-brand"></span>
</div>
@ -140,6 +140,14 @@
</div>
<div class="group-body" *ngIf="expandedGroup === group.cliente">
<div class="d-flex justify-content-between align-items-center px-4 py-2 border-bottom bg-white">
<small class="text-muted fw-bold">Gerenciar Grupo</small>
<button class="btn btn-sm btn-primary" (click)="onAddLineToGroup(group.cliente)">
<i class="bi bi-plus-lg me-1"></i> Adicionar Linha
</button>
</div>
<div class="table-wrap inner-table-wrap">
<div *ngIf="loadingLines" class="p-4 text-center text-muted">
<span class="spinner-border spinner-border-sm me-2"></span> Carregando linhas...
@ -196,24 +204,12 @@
</colgroup>
<thead>
<tr>
<th class="sortable text-center" (click)="setSort('item')">
<div class="th-content justify-content-center">ITEM <span class="sort-caret" [class.active]="sortKey==='item'">{{ sortKey==='item' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable text-center" (click)="setSort('linha')">
<div class="th-content justify-content-center">LINHA <span class="sort-caret" [class.active]="sortKey==='linha'">{{ sortKey==='linha' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable text-center" (click)="setSort('cliente')">
<div class="th-content justify-content-center">CLIENTE <span class="sort-caret" [class.active]="sortKey==='cliente'">{{ sortKey==='cliente' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable text-center" (click)="setSort('status')">
<div class="th-content justify-content-center">STATUS <span class="sort-caret" [class.active]="sortKey==='status'">{{ sortKey==='status' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable text-center" (click)="setSort('skil')">
<div class="th-content justify-content-center">SKIL <span class="sort-caret" [class.active]="sortKey==='skil'">{{ sortKey==='skil' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable text-center" (click)="setSort('contrato')">
<div class="th-content justify-content-center">VENCIMENTO <span class="sort-caret" [class.active]="sortKey==='contrato'">{{ sortKey==='contrato' && sortDir==='desc' ? '▼' : '▲' }}</span></div>
</th>
<th class="sortable text-center" (click)="setSort('item')"><div class="th-content justify-content-center">ITEM <span class="sort-caret" [class.active]="sortKey==='item'">{{ sortKey==='item' && sortDir==='desc' ? '▼' : '▲' }}</span></div></th>
<th class="sortable text-center" (click)="setSort('linha')"><div class="th-content justify-content-center">LINHA <span class="sort-caret" [class.active]="sortKey==='linha'">{{ sortKey==='linha' && sortDir==='desc' ? '▼' : '▲' }}</span></div></th>
<th class="sortable text-center" (click)="setSort('cliente')"><div class="th-content justify-content-center">CLIENTE <span class="sort-caret" [class.active]="sortKey==='cliente'">{{ sortKey==='cliente' && sortDir==='desc' ? '▼' : '▲' }}</span></div></th>
<th class="sortable text-center" (click)="setSort('status')"><div class="th-content justify-content-center">STATUS <span class="sort-caret" [class.active]="sortKey==='status'">{{ sortKey==='status' && sortDir==='desc' ? '▼' : '▲' }}</span></div></th>
<th class="sortable text-center" (click)="setSort('skil')"><div class="th-content justify-content-center">SKIL <span class="sort-caret" [class.active]="sortKey==='skil'">{{ sortKey==='skil' && sortDir==='desc' ? '▼' : '▲' }}</span></div></th>
<th class="sortable text-center" (click)="setSort('contrato')"><div class="th-content justify-content-center">VENCIMENTO <span class="sort-caret" [class.active]="sortKey==='contrato'">{{ sortKey==='contrato' && sortDir==='desc' ? '▼' : '▲' }}</span></div></th>
<th class="text-center">AÇÕES</th>
</tr>
</thead>
@ -232,9 +228,9 @@
<td class="text-center fw-bold text-muted small">{{ r.contrato }}</td>
<td class="text-center">
<div class="action-group justify-content-center">
<button type="button" class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
<button type="button" class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
<button type="button" class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button type="button" class="btn-icon" (click)="onDetalhes(r)"><i class="bi bi-eye"></i></button>
<button type="button" class="btn-icon success" (click)="onFinanceiro(r)"><i class="bi bi-cash-coin"></i></button>
<button type="button" class="btn-icon primary" (click)="onEditar(r)"><i class="bi bi-pencil-square"></i></button>
<button type="button" class="btn-icon danger" (click)="onRemover(r)"><i class="bi bi-trash"></i></button>
</div>
</td>
@ -263,7 +259,196 @@
</div>
</section>
<div class="modal-backdrop-custom" *ngIf="detailOpen || financeOpen || editOpen" (click)="closeDetail(); closeFinance(); closeEdit()"></div>
<div class="modal-backdrop-custom" *ngIf="detailOpen || financeOpen || editOpen || createOpen" (click)="closeDetail(); closeFinance(); closeEdit(); closeCreate()"></div>
<div class="modal-custom" *ngIf="createOpen">
<div class="modal-card modal-lg" (click)="$event.stopPropagation()" style="width: 1100px; max-width: 95vw;">
<div class="modal-header">
<div class="modal-title">
<span class="icon-bg brand-soft"><i class="bi bi-plus-lg"></i></span>
{{ createMode === 'NEW_CLIENT' ? 'Cadastrar Novo Cliente' : 'Nova Linha para ' + createModel.cliente }}
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-glass btn-sm" (click)="closeCreate()" [disabled]="createSaving">
<i class="bi bi-x-lg me-1"></i> Cancelar
</button>
<button type="button" class="btn btn-brand btn-sm" (click)="saveCreate()" [disabled]="createSaving">
<span *ngIf="!createSaving"><i class="bi bi-check2-circle me-1"></i> Cadastrar</span>
<span *ngIf="createSaving"><span class="spinner-border spinner-border-sm me-2"></span> Salvando...</span>
</button>
</div>
</div>
<div class="modal-body modern-body bg-light-gray">
<div class="details-dashboard">
<div class="dashboard-column">
<details class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2" *ngIf="createMode === 'NEW_CLIENT'">
<div class="d-flex gap-4 p-2 bg-white rounded border align-items-center justify-content-center">
<div class="form-check">
<input class="form-check-input" type="radio" name="docType" value="PF" [(ngModel)]="createModel.docType" (change)="onDocTypeChange()">
<label class="form-check-label fw-bold small">Pessoa Física</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="docType" value="PJ" [(ngModel)]="createModel.docType" (change)="onDocTypeChange()">
<label class="form-check-label fw-bold small">Pessoa Jurídica</label>
</div>
</div>
</div>
<div class="form-field span-2" *ngIf="createMode === 'NEW_CLIENT'">
<label>Nome do Cliente <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.cliente" placeholder="Razão Social ou Nome Completo" />
</div>
<div class="form-field span-2" *ngIf="createMode === 'NEW_CLIENT'">
<label>{{ createModel.docType === 'PF' ? 'CPF' : 'CNPJ' }}</label>
<input class="form-control form-control-sm" [(ngModel)]="createModel.docNumber" (input)="onDocInput($event)" placeholder="Somente números" />
</div>
<div class="form-field">
<label>Item (Automático)</label>
<input class="form-control form-control-sm bg-light fst-italic text-muted"
value="Gerado ao Salvar"
readonly
title="O ID será gerado automaticamente pelo sistema" />
</div>
<div class="form-field">
<label>Conta <span class="text-danger">*</span></label>
<select class="form-select form-select-sm" [(ngModel)]="createModel.conta">
<option value="" disabled selected>Selecione...</option>
<option *ngFor="let acc of contaOptions" [value]="acc">{{ acc }}</option>
</select>
</div>
<div class="form-field"><label>Linha <span class="text-danger">*</span></label><input class="form-control form-control-sm" [(ngModel)]="createModel.linha" placeholder="119..." /></div>
<div class="form-field"><label>Chip (ICCID) <span class="text-danger">*</span></label><input class="form-control form-control-sm" [(ngModel)]="createModel.chip" /></div>
<div class="form-field span-2"><label>Usuário da Linha</label><input class="form-control form-control-sm" [(ngModel)]="createModel.usuario" /></div>
</div>
</div>
</details>
<details class="detail-box mt-3">
<summary class="box-header">
<span><i class="bi bi-sliders me-2"></i> Gestão</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Skil (Automático)</label><input class="form-control form-control-sm bg-light" [(ngModel)]="createModel.skil" readonly /></div>
<div class="form-field"><label>Cedente</label><input class="form-control form-control-sm" [(ngModel)]="createModel.cedente" /></div>
<div class="form-field span-2"><label>Solicitante</label><input class="form-control form-control-sm" [(ngModel)]="createModel.solicitante" /></div>
</div>
</div>
</details>
</div>
<div class="dashboard-column">
<details class="detail-box">
<summary class="box-header">
<span><i class="bi bi-file-earmark-text me-2"></i> Contrato & Status</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Plano Contrato <span class="text-danger">*</span></label>
<select class="form-select form-select-sm" [(ngModel)]="createModel.planoContrato">
<option value="" disabled selected>Selecione...</option>
<option *ngFor="let p of planOptions" [value]="p">{{ p }}</option>
</select>
</div>
<div class="form-field"><label>Venc. Conta</label><input class="form-control form-control-sm" [(ngModel)]="createModel.vencConta" placeholder="ex: Dia 10" /></div>
<div class="form-field"><label>Modalidade</label><input class="form-control form-control-sm" [(ngModel)]="createModel.modalidade" /></div>
<div class="form-field span-2">
<label>Status <span class="text-danger">*</span></label>
<select class="form-select form-select-sm" [(ngModel)]="createModel.status">
<option value="" disabled selected>Selecione...</option>
<option *ngFor="let s of statusOptions" [value]="s">{{ s }}</option>
</select>
</div>
</div>
</div>
</details>
<details class="detail-box mt-3">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Datas Importantes</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2"><label>Data Entrega Operadora</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataEntregaOpera" /></div>
<div class="form-field span-2"><label>Data Entrega Cliente</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataEntregaCliente" /></div>
<div class="form-field span-2"><label>Data de Bloqueio</label><input class="form-control form-control-sm" type="date" [(ngModel)]="createModel.dataBloqueio" /></div>
</div>
</div>
</details>
</div>
<div class="dashboard-column">
<details class="detail-box vivo-border">
<summary class="box-header header-vivo">
<span><i class="bi bi-telephone-fill me-2"></i> Financeiro Vivo</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Franquia (GB)</label><input class="form-control form-control-sm" type="number" step="0.1" [(ngModel)]="createModel.franquiaVivo" /></div>
<div class="form-field"><label>Valor Plano</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.valorPlanoVivo" /></div>
<div class="form-field"><label>Gestão Voz</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.gestaoVozDados" /></div>
<div class="form-field"><label>Skeelo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.skeelo" /></div>
<div class="form-field"><label>News+</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoNewsPlus" /></div>
<div class="form-field"><label>Travel Mundo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoTravelMundo" /></div>
<div class="form-field"><label>Gestão Disp.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.vivoGestaoDispositivo" /></div>
<div class="form-field"><label class="text-vivo fw-bold">Total Vivo</label><input class="form-control form-control-sm fw-bold border-vivo" type="number" step="0.01" [(ngModel)]="createModel.valorContratoVivo" /></div>
</div>
</div>
</details>
<details class="detail-box line-border mt-3">
<summary class="box-header header-line">
<span><i class="bi bi-hdd-network-fill me-2"></i> Financeiro Line</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Franquia Line</label><input class="form-control form-control-sm" type="number" step="0.1" [(ngModel)]="createModel.franquiaLine" /></div>
<div class="form-field"><label>Franquia Gestão</label><input class="form-control form-control-sm" type="number" step="0.1" [(ngModel)]="createModel.franquiaGestao" /></div>
<div class="form-field"><label>Locação Ap.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.locacaoAp" /></div>
<div class="form-field"><label class="text-line fw-bold">Total Line</label><input class="form-control form-control-sm fw-bold border-line" type="number" step="0.01" [(ngModel)]="createModel.valorContratoLine" /></div>
</div>
</div>
</details>
<details class="detail-box mt-3">
<summary class="box-header">
<span><i class="bi bi-graph-up-arrow me-2"></i> Resultados</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label class="text-success">Desconto</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.desconto" /></div>
<div class="form-field"><label class="text-brand">Lucro Estimado</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="createModel.lucro" /></div>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
</div>
<div class="modal-custom" *ngIf="detailOpen">
<div class="modal-card modal-responsive" (click)="$event.stopPropagation()">
@ -271,11 +456,16 @@
<div class="modal-title"><span class="icon-bg primary-soft"><i class="bi bi-sim"></i></span> Detalhes da Linha</div>
<button type="button" class="btn btn-sm btn-icon" (click)="closeDetail()"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body modern-body bg-light-gray" *ngIf="detailData; else detailLoading">
<div class="details-dashboard">
<div class="dashboard-column">
<div class="detail-box">
<div class="box-header"><i class="bi bi-person-badge me-2"></i> Identificação</div>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body compact">
<div class="row-item"><span class="lbl">LINHA</span><span class="val text-blue fs-5">{{ detailData.linha || '-' }}</span></div>
<div class="row-item"><span class="lbl">CLIENTE</span><span class="val text-end text-truncate" style="max-width: 250px;" [title]="detailData.cliente">{{ detailData.cliente || '-' }}</span></div>
@ -286,37 +476,90 @@
<div class="mini-item text-end"><span class="lbl">CHIP</span><span class="val">{{ detailData.chip || '-' }}</span></div>
</div>
</div>
</div>
<div class="detail-box mt-3">
<div class="box-header"><i class="bi bi-file-earmark-text me-2"></i> Contrato & Plano</div>
<div class="box-body compact">
<div class="row-item"><span class="lbl">PLANO CONTRATO</span><span class="val fw-bold">{{ detailData.planoContrato || '-' }}</span></div>
<div class="row-item"><span class="lbl">CONTA</span><span class="val">{{ detailData.conta || '-' }}</span></div>
<div class="row-item"><span class="lbl">VENC. DA CONTA</span><span class="val">{{ detailData.vencConta || '-' }}</span></div>
</div>
</div>
</div>
<div class="dashboard-column">
<div class="detail-box">
<div class="box-header"><i class="bi bi-activity me-2"></i> Status & Logística</div>
<div class="box-body compact">
<div class="row-item align-items-center"><span class="lbl">STATUS</span><span class="status-pill static" [ngClass]="statusClass(detailData.status)">{{ statusLabel(detailData.status) }}</span></div>
<div class="row-item"><span class="lbl">DATA DO BLOQUEIO</span><span class="val">{{ formatDateBr(detailData.dataBloqueio) }}</span></div>
<div class="row-item"><span class="lbl">MODALIDADE</span><span class="val">{{ detailData.modalidade || '-' }}</span></div>
<div class="divider small"></div>
<div class="row-item"><span class="lbl">DATA ENTREGA OPERA.</span><span class="val">{{ formatDateBr(detailData.dataEntregaOpera) }}</span></div>
<div class="row-item"><span class="lbl">DATA ENTREGA CLIENTE</span><span class="val">{{ formatDateBr(detailData.dataEntregaCliente) }}</span></div>
</div>
</div>
<div class="detail-box mt-3">
<div class="box-header"><i class="bi bi-sliders me-2"></i> Dados de Gestão</div>
</details>
<details open class="detail-box mt-3">
<summary class="box-header">
<span><i class="bi bi-sliders me-2"></i> Gestão</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body compact">
<div class="row-item"><span class="lbl">SKIL</span><span class="val">{{ detailData.skil || '-' }}</span></div>
<div class="row-item"><span class="lbl">CEDENTE</span><span class="val">{{ detailData.cedente || '-' }}</span></div>
<div class="row-item"><span class="lbl">SOLICITANTE</span><span class="val">{{ detailData.solicitante || '-' }}</span></div>
</div>
</details>
</div>
<div class="dashboard-column">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-file-earmark-text me-2"></i> Contrato & Status</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body compact">
<div class="row-item"><span class="lbl">PLANO</span><span class="val fw-bold">{{ detailData.planoContrato || '-' }}</span></div>
<div class="row-item"><span class="lbl">CONTA</span><span class="val">{{ detailData.conta || '-' }}</span></div>
<div class="row-item"><span class="lbl">VENCIMENTO</span><span class="val">{{ detailData.vencConta || '-' }}</span></div>
<div class="divider small"></div>
<div class="row-item align-items-center"><span class="lbl">STATUS</span><span class="status-pill static" [ngClass]="statusClass(detailData.status)">{{ statusLabel(detailData.status) }}</span></div>
<div class="row-item"><span class="lbl">MODALIDADE</span><span class="val">{{ detailData.modalidade || '-' }}</span></div>
</div>
</details>
<details open class="detail-box mt-3">
<summary class="box-header">
<span><i class="bi bi-calendar-event me-2"></i> Datas</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body compact">
<div class="row-item"><span class="lbl">ENTREGA OPERADORA</span><span class="val">{{ formatDateBr(detailData.dataEntregaOpera) }}</span></div>
<div class="row-item"><span class="lbl">ENTREGA CLIENTE</span><span class="val">{{ formatDateBr(detailData.dataEntregaCliente) }}</span></div>
<div class="row-item"><span class="lbl">DATA BLOQUEIO</span><span class="val">{{ formatDateBr(detailData.dataBloqueio) }}</span></div>
</div>
</details>
</div>
<div class="dashboard-column">
<details open class="detail-box vivo-border">
<summary class="box-header header-vivo">
<span><i class="bi bi-telephone-fill me-2"></i> Financeiro Vivo</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body compact">
<div class="row-item"><span>Franquia</span> <strong>{{ formatFranquia(detailData.franquiaVivo) }}</strong></div>
<div class="row-item"><span>Valor Plano</span> <strong>{{ formatMoney(detailData.valorPlanoVivo) }}</strong></div>
<div class="row-item"><span>Gestão Voz</span> <strong>{{ formatMoney(detailData.gestaoVozDados) }}</strong></div>
<div class="divider small"></div>
<div class="row-item total text-vivo"><span>Total Vivo</span> <strong>{{ formatMoney(detailData.valorContratoVivo) }}</strong></div>
</div>
</details>
<details open class="detail-box line-border mt-3">
<summary class="box-header header-line">
<span><i class="bi bi-hdd-network-fill me-2"></i> Financeiro Line</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body compact">
<div class="row-item"><span>Franquia Line</span> <strong>{{ formatFranquia(detailData.franquiaLine) }}</strong></div>
<div class="row-item"><span>Locação Ap.</span> <strong>{{ formatMoney(detailData.locacaoAp) }}</strong></div>
<div class="divider small"></div>
<div class="row-item total text-line"><span>Total Line</span> <strong>{{ formatMoney(detailData.valorContratoLine) }}</strong></div>
</div>
</details>
<details open class="detail-box mt-3">
<summary class="box-header">
<span><i class="bi bi-graph-up-arrow me-2"></i> Resultados</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body compact">
<div class="row-item"><span>Desconto</span> <strong class="text-success">{{ formatMoney(detailData.desconto) }}</strong></div>
<div class="row-item"><span>Lucro</span> <strong class="text-brand">{{ formatMoney(detailData.lucro) }}</strong></div>
</div>
</details>
</div>
</div>
</div>
<ng-template #detailLoading><div class="p-5 text-center text-muted">Carregando detalhes...</div></ng-template>
@ -381,60 +624,131 @@
<div class="modal-body modern-body bg-light-gray">
<ng-container *ngIf="editModel; else editLoadingTpl">
<div class="edit-sections">
<details open class="edit-section"><summary><i class="bi bi-person-badge me-2"></i>Identificação</summary>
<div class="edit-grid">
<div class="form-field"><label>Item</label><input class="form-control form-control-sm" type="number" [(ngModel)]="editModel.item" name="edit_item" /></div>
<div class="form-field"><label>Conta</label><input class="form-control form-control-sm" [(ngModel)]="editModel.conta" name="edit_conta" /></div>
<div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="editModel.linha" name="edit_linha" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.chip" name="edit_chip" /></div>
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" name="edit_cliente" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" name="edit_usuario" /></div>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Identificação</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Item</label>
<input class="form-control form-control-sm bg-light"
type="number"
[(ngModel)]="editModel.item"
disabled
title="O ID não pode ser alterado" />
</div>
<div class="form-field">
<label>Conta</label>
<select class="form-select form-select-sm" [(ngModel)]="editModel.conta">
<option *ngFor="let acc of contaOptions" [value]="acc">{{ acc }}</option>
</select>
</div>
<div class="form-field"><label>Linha</label><input class="form-control form-control-sm" [(ngModel)]="editModel.linha" /></div>
<div class="form-field"><label>Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.chip" /></div>
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></div>
<div class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" /></div>
</div>
</div>
</details>
<details open class="edit-section"><summary><i class="bi bi-file-earmark-text me-2"></i>Contrato & Plano</summary>
<div class="edit-grid">
<div class="form-field span-2"><label>Plano Contrato</label><input class="form-control form-control-sm" [(ngModel)]="editModel.planoContrato" name="edit_planoContrato" /></div>
<div class="form-field"><label>Venc. da Conta</label><input class="form-control form-control-sm" [(ngModel)]="editModel.vencConta" name="edit_vencConta" /></div>
<div class="form-field"><label>Modalidade</label><input class="form-control form-control-sm" [(ngModel)]="editModel.modalidade" name="edit_modalidade" /></div>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-file-earmark-text me-2"></i> Contrato & Plano</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field span-2">
<label>Plano Contrato</label>
<select class="form-select form-select-sm" [(ngModel)]="editModel.planoContrato">
<option *ngFor="let p of planOptions" [value]="p">{{ p }}</option>
</select>
</div>
<div class="form-field"><label>Venc. da Conta</label><input class="form-control form-control-sm" [(ngModel)]="editModel.vencConta" /></div>
<div class="form-field"><label>Modalidade</label><input class="form-control form-control-sm" [(ngModel)]="editModel.modalidade" /></div>
</div>
</div>
</details>
<details open class="edit-section"><summary><i class="bi bi-activity me-2"></i>Status & Logística</summary>
<div class="edit-grid">
<div class="form-field"><label>Status</label><input class="form-control form-control-sm" [(ngModel)]="editModel.status" name="edit_status" /></div>
<div class="form-field"><label>Data do Bloqueio</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataBloqueio" name="edit_dataBloqueio" /></div>
<div class="form-field"><label>Data Entrega Operadora</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataEntregaOpera" name="edit_dataEntregaOpera" /></div>
<div class="form-field"><label>Data Entrega Cliente</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataEntregaCliente" name="edit_dataEntregaCliente" /></div>
<div class="form-field"><label>Skil</label><input class="form-control form-control-sm" [(ngModel)]="editModel.skil" name="edit_skil" /></div>
<div class="form-field"><label>Cedente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cedente" name="edit_cedente" /></div>
<div class="form-field"><label>Solicitante</label><input class="form-control form-control-sm" [(ngModel)]="editModel.solicitante" name="edit_solicitante" /></div>
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-activity me-2"></i> Status & Logística</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field">
<label>Status</label>
<select class="form-select form-select-sm" [(ngModel)]="editModel.status">
<option *ngFor="let s of statusOptions" [value]="s">{{ s }}</option>
</select>
</div>
<div class="form-field"><label>Data do Bloqueio</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataBloqueio" /></div>
<div class="form-field"><label>Entrega Operadora</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataEntregaOpera" /></div>
<div class="form-field"><label>Entrega Cliente</label><input class="form-control form-control-sm" type="date" [(ngModel)]="editModel.dataEntregaCliente" /></div>
<div class="form-field">
<label>Skil</label>
<select class="form-select form-select-sm" [(ngModel)]="editModel.skil">
<option *ngFor="let k of skilOptions" [value]="k">{{ k }}</option>
</select>
</div>
<div class="form-field"><label>Cedente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cedente" /></div>
<div class="form-field span-2"><label>Solicitante</label><input class="form-control form-control-sm" [(ngModel)]="editModel.solicitante" /></div>
</div>
</div>
</details>
<details class="edit-section"><summary><i class="bi bi-telephone-fill me-2"></i>Vivo</summary>
<div class="edit-grid">
<div class="form-field"><label>Franquia Vivo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.franquiaVivo" name="edit_franquiaVivo" /></div>
<div class="form-field"><label>Valor Plano Vivo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.valorPlanoVivo" name="edit_valorPlanoVivo" /></div>
<div class="form-field"><label>Gestão Voz/Dados</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.gestaoVozDados" name="edit_gestaoVozDados" /></div>
<div class="form-field"><label>Skeelo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.skeelo" name="edit_skeelo" /></div>
<div class="form-field"><label>Vivo News+</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoNewsPlus" name="edit_vivoNewsPlus" /></div>
<div class="form-field"><label>Vivo Travel Mundo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoTravelMundo" name="edit_vivoTravelMundo" /></div>
<div class="form-field"><label>Vivo Gestão Dispositivo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoGestaoDispositivo" name="edit_vivoGestaoDispositivo" /></div>
<div class="form-field"><label>Valor Contrato Vivo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.valorContratoVivo" name="edit_valorContratoVivo" /></div>
<details class="detail-box vivo-border">
<summary class="box-header header-vivo">
<span><i class="bi bi-telephone-fill me-2"></i> Financeiro Vivo</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Franquia Vivo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.franquiaVivo" /></div>
<div class="form-field"><label>Valor Plano Vivo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.valorPlanoVivo" /></div>
<div class="form-field"><label>Gestão Voz/Dados</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.gestaoVozDados" /></div>
<div class="form-field"><label>Skeelo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.skeelo" /></div>
<div class="form-field"><label>Vivo News+</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoNewsPlus" /></div>
<div class="form-field"><label>Vivo Travel Mundo</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoTravelMundo" /></div>
<div class="form-field"><label>Vivo Gestão Disp.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.vivoGestaoDispositivo" /></div>
<div class="form-field"><label class="text-vivo fw-bold">Valor Contrato Vivo</label><input class="form-control form-control-sm fw-bold border-vivo" type="number" step="0.01" [(ngModel)]="editModel.valorContratoVivo" /></div>
</div>
</div>
</details>
<details class="edit-section"><summary><i class="bi bi-hdd-network-fill me-2"></i>Line Móvel</summary>
<div class="edit-grid">
<div class="form-field"><label>Franquia Line</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.franquiaLine" name="edit_franquiaLine" /></div>
<div class="form-field"><label>Franquia Gestão</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.franquiaGestao" name="edit_franquiaGestao" /></div>
<div class="form-field"><label>Locação Ap.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.locacaoAp" name="edit_locacaoAp" /></div>
<div class="form-field"><label>Valor Contrato Line</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.valorContratoLine" name="edit_valorContratoLine" /></div>
<details class="detail-box line-border">
<summary class="box-header header-line">
<span><i class="bi bi-hdd-network-fill me-2"></i> Financeiro Line</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label>Franquia Line</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.franquiaLine" /></div>
<div class="form-field"><label>Franquia Gestão</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.franquiaGestao" /></div>
<div class="form-field"><label>Locação Ap.</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.locacaoAp" /></div>
<div class="form-field"><label class="text-line fw-bold">Valor Contrato Line</label><input class="form-control form-control-sm fw-bold border-line" type="number" step="0.01" [(ngModel)]="editModel.valorContratoLine" /></div>
</div>
</div>
</details>
<details class="edit-section"><summary><i class="bi bi-graph-up-arrow me-2"></i>Resultado</summary>
<div class="edit-grid">
<div class="form-field"><label>Desconto</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.desconto" name="edit_desconto" /></div>
<div class="form-field"><label>Lucro</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.lucro" name="edit_lucro" /></div>
<details class="detail-box">
<summary class="box-header">
<span><i class="bi bi-graph-up-arrow me-2"></i> Resultado</span>
<i class="bi bi-chevron-down ms-auto transition-icon"></i>
</summary>
<div class="box-body">
<div class="form-grid">
<div class="form-field"><label class="text-success">Desconto</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.desconto" /></div>
<div class="form-field"><label class="text-brand">Lucro</label><input class="form-control form-control-sm" type="number" step="0.01" [(ngModel)]="editModel.lucro" /></div>
</div>
</div>
</details>
</div>
</ng-container>
<ng-template #editLoadingTpl><div class="p-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span> Carregando...</div></ng-template>

View File

@ -29,6 +29,7 @@
display: block;
font-family: 'Inter', sans-serif;
color: var(--text);
box-sizing: border-box;
}
/* ========================================================== */
@ -48,7 +49,7 @@
radial-gradient(820px 380px at 80% 30%, rgba(227, 61, 207, 0.08), transparent 60%),
linear-gradient(180deg, #ffffff 0%, #f5f5f7 70%);
/* Overlay sutil de ruído/textura (opcional, aqui usado como clareador) */
/* Overlay sutil */
&::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: rgba(255, 255, 255, 0.25);
@ -231,8 +232,8 @@
overflow: hidden;
display: flex;
align-items: stretch;
background: #fff; /* Fundo Branco Sólido */
border: 1px solid rgba(17, 18, 20, 0.15); /* Borda Visível */
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;
@ -243,33 +244,21 @@
}
.input-group-text {
background: transparent;
border: none;
color: var(--muted);
padding-left: 14px;
padding-right: 8px;
display: flex; align-items: center;
background: transparent; border: none; color: var(--muted);
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: var(--text);
box-shadow: none;
border: none; background: transparent; padding: 10px 0;
font-size: 0.9rem; color: var(--text); box-shadow: none;
&::placeholder { color: rgba(17, 18, 20, 0.4); font-weight: 500; }
&:focus { outline: none; }
}
.btn-clear {
background: transparent;
border: none;
color: var(--muted);
padding: 0 12px;
display: flex; align-items: center; cursor: pointer;
transition: color 0.2s;
background: transparent; border: none; color: var(--muted); padding: 0 12px;
display: flex; align-items: center; cursor: pointer; transition: color 0.2s;
&:hover { color: #dc3545; }
i { font-size: 1rem; }
}
@ -280,19 +269,10 @@
border-radius: 12px;
background: #fff;
border: 1px solid rgba(17, 18, 20, 0.15);
color: var(--text);
font-weight: 600;
color: var(--text); font-weight: 600;
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
padding: 8px 32px 8px 12px;
cursor: pointer;
transition: all 0.2s;
width: auto;
&:focus {
border-color: var(--brand);
box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15);
outline: none;
}
padding: 8px 32px 8px 12px; cursor: pointer; transition: all 0.2s; width: auto;
&:focus { border-color: var(--brand); box-shadow: 0 4px 12px rgba(227, 61, 207, 0.15); outline: none; }
}
/* ========================================================== */
@ -375,6 +355,7 @@
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
&.is-active { background: rgba(25, 135, 84, 0.15); border-color: rgba(25, 135, 84, 0.25); color: #157347; }
&.is-blocked { background: rgba(220, 53, 69, 0.15); border-color: rgba(220, 53, 69, 0.25); color: #b02a37; }
&.static { display: inline-flex; width: auto; }
}
/* Botões de Ação na Tabela */
@ -420,28 +401,135 @@
.modal-header {
padding: 16px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); background: #fff; display: flex; justify-content: space-between; align-items: center;
.modal-title { font-size: 1.1rem; font-weight: 800; color: var(--text); display: flex; align-items: center; gap: 12px; }
.icon-bg { width: 32px; height: 32px; border-radius: 10px; background: rgba(3, 15, 170, 0.1); color: var(--blue); display: flex; align-items: center; justify-content: center; font-size: 16px; &.success { background: var(--success-bg); color: var(--success-text); } &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } }
.icon-bg { width: 32px; height: 32px; border-radius: 10px; background: rgba(3, 15, 170, 0.1); color: var(--blue); display: flex; align-items: center; justify-content: center; font-size: 16px; &.success { background: var(--success-bg); color: var(--success-text); } &.primary-soft { background: rgba(3, 15, 170, 0.1); color: var(--blue); } &.brand-soft { background: rgba(227, 61, 207, 0.1); color: var(--brand); } }
.btn-icon { color: var(--muted); background: transparent; font-size: 1.2rem; &:hover { color: var(--brand); transform: rotate(90deg); } }
}
.modal-body { padding: 24px; overflow-y: auto; &.bg-light-gray { background-color: #f8f9fa; } }
/* Estilos de conteúdo dos modais (Detalhes, Financeiro, Edit) - Mantidos para garantir consistência */
@media (min-width: 1441px) {
.modal-card.modal-responsive { width: min(1200px, 95%); height: 85vh; max-height: none; }
.detail-box .box-header { padding: 8px 16px; }
.detail-box .box-body.compact { padding: 10px 14px; }
.box-body.compact .row-item { margin-bottom: 4px; font-size: 0.85rem; }
.details-dashboard { gap: 16px; }
/* ========================================================== */
/* 9. LAYOUT DE DASHBOARD (MODAIS DE CRIAÇÃO E DETALHES) */
/* ========================================================== */
.details-dashboard {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.details-dashboard { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; @media(max-width: 700px) { grid-template-columns: 1fr; } }
.detail-box { background: #fff; border-radius: 12px; border: 1px solid rgba(0,0,0,0.06); overflow: hidden; box-shadow: 0 2px 5px rgba(0,0,0,0.02); height: 100%; .box-header { padding: 10px 16px; font-weight: 800; font-size: 0.85rem; background: #fdfdfd; border-bottom: 1px solid rgba(0,0,0,0.04); color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; i { color: var(--blue); } } .box-body { padding: 16px; } .box-body.compact { padding: 12px 14px; } }
.row-item { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; font-size: 0.9rem; color: var(--muted); .lbl { font-size: 0.75rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.3px; } .val { color: var(--text); font-weight: 700; text-align: right; } }
.box-body.compact .row-item { margin-bottom: 6px; }
.row-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.mini-item { display: flex; flex-direction: column; .lbl { font-size: 0.7rem; color: var(--muted); font-weight: 800; text-transform: uppercase; } .val { font-size: 0.9rem; color: var(--text); font-weight: 700; } }
.divider { height: 1px; background: rgba(0,0,0,0.06); margin: 12px 0; }
.divider.small { margin: 8px 0; }
@media (max-width: 992px) {
.details-dashboard {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 700px) {
.details-dashboard {
grid-template-columns: 1fr;
}
}
/* ESTILOS ESPECÍFICOS PARA DETAILS/SUMMARY (ACORDEÃO)
*/
details.detail-box {
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0,0,0,0.05);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
overflow: hidden;
height: fit-content;
&:not([open]) {
padding-bottom: 0;
}
}
/* O elemento Detail Box padrão (para modais que não usam details tag) */
div.detail-box {
background: #fff; border-radius: 16px; border: 1px solid rgba(0,0,0,0.05);
box-shadow: 0 2px 8px rgba(0,0,0,0.02); overflow: hidden; height: 100%;
}
summary.box-header {
padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted);
border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center;
cursor: pointer;
list-style: none;
user-select: none;
&::-webkit-details-marker {
display: none;
}
i:not(.transition-icon) { color: var(--brand); }
}
.transition-icon {
transition: transform 0.3s ease;
font-size: 1rem;
color: var(--muted);
}
details[open] summary .transition-icon {
transform: rotate(180deg);
color: var(--brand);
}
/* Header normal para divs */
div.box-header {
padding: 10px 16px; font-size: 0.8rem; font-weight: 800; text-transform: uppercase; color: var(--muted);
border-bottom: 1px solid rgba(0,0,0,0.04); background: #fdfdfd; display: flex; align-items: center;
i { color: var(--brand); }
}
.box-body { padding: 16px; &.compact { padding: 12px 16px; } }
/* Células de Informação (Modal Detalhes) */
.row-item {
display: flex; justify-content: space-between; margin-bottom: 8px;
font-size: 0.85rem; color: var(--muted); align-items: baseline;
&:last-child { margin-bottom: 0; }
.lbl { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; }
.val { font-weight: 600; color: var(--text); text-align: right; }
}
.divider { height: 1px; background: rgba(0,0,0,0.06); margin: 12px 0; &.small { margin: 8px 0; } }
.row-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.mini-item { display: flex; flex-direction: column; .lbl { font-size: 0.65rem; font-weight: 700; color: var(--muted); } .val { font-size: 0.85rem; font-weight: 700; color: var(--text); } }
/* ========================================================== */
/* 10. ESTILOS DE FORMULÁRIO (PARA O MODAL DE CRIAÇÃO/EDIÇÃO) */
/* ========================================================== */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-field {
display: flex; flex-direction: column; gap: 6px;
label { font-size: 0.75rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(17,18,20,0.65); }
&.span-2 { grid-column: span 2; }
}
.form-control, .form-select {
border-radius: 8px; border: 1px solid rgba(17,18,20,0.15);
&:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(227,61,207,0.15); }
}
/* Bordas coloridas para seções financeiras */
.vivo-border { border-left: 4px solid #8a2be2; }
.header-vivo { color: #8a2be2; background-color: #fbf5fc; i { color: #8a2be2; } }
.border-vivo { border-color: #8a2be2 !important; }
.text-vivo { color: #8a2be2 !important; }
.line-border { border-left: 4px solid var(--blue); }
.header-line { color: var(--blue); background-color: #f5f6ff; i { color: var(--blue); } }
.border-line { border-color: var(--blue) !important; }
.text-line { color: var(--blue) !important; }
/* ========================================================== */
/* 11. FINANCEIRO (MODAL DE VISUALIZAÇÃO) & EDIÇÃO (ACCORDION) */
/* ========================================================== */
.finance-dashboard { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; @media(max-width: 700px) { grid-template-columns: 1fr; } }
.finance-card { background: #fff; border-radius: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); border: 1px solid rgba(0,0,0,0.04); overflow: hidden; &.vivo-card { border-top: 4px solid var(--brand); .card-header-f { color: var(--brand); background: var(--bg-vivo); } } &.line-card { border-top: 4px solid var(--blue); .card-header-f { color: var(--blue); background: var(--bg-line); } } }
.card-header-f { padding: 12px 16px; font-weight: 800; font-size: 0.95rem; display: flex; align-items: center; }
@ -453,6 +541,4 @@
.edit-sections { display: grid; gap: 12px; }
.edit-section { background: #fff; border: 1px solid rgba(0,0,0,0.06); border-radius: 16px; overflow: hidden; box-shadow: 0 2px 10px rgba(0,0,0,0.03); summary { cursor: pointer; user-select: none; padding: 12px 16px; font-weight: 950; color: rgba(17,18,20,0.75); display: flex; align-items: center; background: linear-gradient(180deg, rgba(227,61,207,0.06), rgba(255,255,255,0.6)); border-bottom: 1px solid rgba(0,0,0,0.06); i { color: var(--blue); } } }
.edit-grid { padding: 14px 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; @media (max-width: 700px) { grid-template-columns: 1fr; } }
.form-field { display: flex; flex-direction: column; gap: 6px; label { font-size: 0.75rem; font-weight: 900; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(17,18,20,0.65); } }
.form-field.span-2 { grid-column: span 2; @media (max-width: 700px) { grid-column: span 1; } }
.edit-section .form-control { border-radius: 12px; border: 1px solid rgba(17,18,20,0.12); background: rgba(255,255,255,0.85); &:focus { border-color: rgba(227,61,207,0.5); box-shadow: 0 0 0 2px rgba(227,61,207,0.12); } }

View File

@ -3,80 +3,30 @@ import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpClientModule, HttpParams, HttpErrorResponse } from '@angular/common/http';
// Tipos e Interfaces
type SortDir = 'asc' | 'desc';
type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP';
interface LineRow {
id: string;
item: string;
linha: string;
cliente: string;
usuario: string;
status: string;
skil: string;
contrato: string;
id: string; item: string; linha: string; cliente: string; usuario: string; status: string; skil: string; contrato: string;
}
interface ApiPagedResult<T> {
page: number;
pageSize: number;
total: number;
items: T[];
page: number; pageSize: number; total: number; items: T[];
}
interface ApiLineList {
id: string;
item: number;
linha: string | null;
cliente: string | null;
usuario: string | null;
vencConta: string | null;
status?: string | null;
skil?: string | null;
id: string; item: number; linha: string | null; cliente: string | null; usuario: string | null; vencConta: string | null; status?: string | null; skil?: string | null;
}
// Interface completa para Edição/Detalhes
interface ApiLineDetail {
id: string;
item: number;
conta?: string | null;
linha?: string | null;
chip?: string | null;
cliente?: string | null;
usuario?: string | null;
planoContrato?: string | null;
status?: string | null;
skil?: string | null;
modalidade?: string | null;
dataBloqueio?: string | null;
cedente?: string | null;
solicitante?: string | null;
dataEntregaOpera?: string | null;
dataEntregaCliente?: string | null;
vencConta?: string | null;
franquiaVivo?: number | null;
valorPlanoVivo?: number | null;
gestaoVozDados?: number | null;
skeelo?: number | null;
vivoNewsPlus?: number | null;
vivoTravelMundo?: number | null;
vivoGestaoDispositivo?: number | null;
valorContratoVivo?: number | null;
franquiaLine?: number | null;
franquiaGestao?: number | null;
locacaoAp?: number | null;
valorContratoLine?: number | null;
desconto?: number | null;
lucro?: number | null;
id: string; item: number; conta?: string | null; linha?: string | null; chip?: string | null; cliente?: string | null; usuario?: string | null; planoContrato?: string | null; status?: string | null; skil?: string | null; modalidade?: string | null; dataBloqueio?: string | null; cedente?: string | null; solicitante?: string | null; dataEntregaOpera?: string | null; dataEntregaCliente?: string | null; vencConta?: string | null; franquiaVivo?: number | null; valorPlanoVivo?: number | null; gestaoVozDados?: number | null; skeelo?: number | null; vivoNewsPlus?: number | null; vivoTravelMundo?: number | null; vivoGestaoDispositivo?: number | null; valorContratoVivo?: number | null; franquiaLine?: number | null; franquiaGestao?: number | null; locacaoAp?: number | null; valorContratoLine?: number | null; desconto?: number | null; lucro?: number | null;
}
type UpdateMobileLineRequest = Omit<ApiLineDetail, 'id'>;
type CreateMobileLineRequest = Omit<ApiLineDetail, 'id'>;
interface ClientGroupDto {
cliente: string;
totalLinhas: number;
ativos: number;
bloqueados: number;
cliente: string; totalLinhas: number; ativos: number; bloqueados: number;
}
@Component({
@ -86,82 +36,86 @@ interface ClientGroupDto {
styleUrls: ['./geral.scss']
})
export class Geral implements AfterViewInit {
// Toast & Upload
toastMessage = '';
@ViewChild('successToast', { static: false }) successToast!: ElementRef;
@ViewChild('excelInput') excelInput!: ElementRef<HTMLInputElement>;
constructor(
@Inject(PLATFORM_ID) private platformId: object,
private http: HttpClient,
private cdr: ChangeDetectorRef
) {}
constructor(@Inject(PLATFORM_ID) private platformId: object, private http: HttpClient, private cdr: ChangeDetectorRef) {}
private readonly apiBase = 'https://localhost:7205/api/lines';
loading = false;
// Dados
rows: LineRow[] = [];
clientGroups: ClientGroupDto[] = []; // Cards de Clientes
groupLines: LineRow[] = []; // Linhas internas do Card (Accordion)
// Controle de Estado
clientGroups: ClientGroupDto[] = [];
groupLines: LineRow[] = [];
expandedGroup: string | null = null;
loadingLines = false;
// Filtros
searchTerm = '';
filterSkil: 'ALL' | 'PF' | 'PJ' | 'RESERVA' = 'ALL';
// Dropdown de Clientes
clientsList: string[] = [];
selectedClient: string | null = null;
showClientMenu = false;
clientSearchTerm = '';
// Modo de Visualização: 'GROUPS' (Cards) ou 'TABLE' (Linhas)
viewMode: 'GROUPS' | 'TABLE' = 'GROUPS';
// Paginação e Ordenação
sortKey: keyof LineRow = 'item';
sortDir: SortDir = 'asc';
page = 1;
pageSize = 10;
total = 0;
// Modais
detailOpen = false;
financeOpen = false;
editOpen = false;
editSaving = false;
detailData: any = null;
financeData: any = null;
editModel: any = null;
detailOpen = false; financeOpen = false; editOpen = false; editSaving = false;
createOpen = false; createSaving = false;
createMode: CreateMode = 'NEW_CLIENT';
detailData: any = null; financeData: any = null; editModel: any = null;
private editingId: string | null = null;
private searchTimer: any = null;
// Getter auxiliar para o HTML saber se exibe os grupos
get isGroupMode(): boolean {
return this.viewMode === 'GROUPS';
}
// ========================================================================
// ✅ STATUS CORRIGIDOS (Apenas as opções permitidas)
// ========================================================================
readonly statusOptions = ['ATIVA', 'BLOQUEIO PERDA/ROUBO', 'BLOQUEIO 120 DIAS'];
readonly skilOptions = ['PESSOA FÍSICA', 'PESSOA JURÍDICA', 'RESERVA'];
readonly planOptions = [
'SMART EMPRESAS 0.2GB TE', 'SMART EMPRESAS 0.5GB TE', 'SMART EMPRESAS 2GB D',
'SMART EMPRESAS 4GB', 'SMART EMPRESAS 6GB', 'SMART EMPRESAS 8GB',
'SMART EMPRESAS 10GB', 'SMART EMPRESAS 20GB', 'SMART EMPRESAS 50GB',
'M2M 20MB', 'M2M 50MB'
];
readonly contaOptions = [
'0172593311', '0172593840', '0430237019', '0435288088',
'0437488125', '0449508564', '0455371844', 'CLARO', 'TIM'
];
createModel: any = {
cliente: '', docType: 'PF', docNumber: '', linha: '', chip: '', usuario: '',
status: '', planoContrato: '', conta: '', vencConta: '', skil: 'PESSOA FÍSICA',
modalidade: '', item: 0, cedente: '', solicitante: '',
dataBloqueio: '', dataEntregaOpera: '', dataEntregaCliente: '',
franquiaVivo: null, valorPlanoVivo: null, gestaoVozDados: null, skeelo: null,
vivoNewsPlus: null, vivoTravelMundo: null, vivoGestaoDispositivo: null, valorContratoVivo: null,
franquiaLine: null, franquiaGestao: null, locacaoAp: null, valorContratoLine: null,
desconto: null, lucro: null
};
get isGroupMode(): boolean { return this.viewMode === 'GROUPS'; }
async ngAfterViewInit() {
if (!isPlatformBrowser(this.platformId)) return;
this.initAnimations();
setTimeout(() => {
this.refreshData();
this.loadClients();
// Verifica se houve redirecionamento com mensagem de toast
const state = history.state;
if (state && state.toastMessage) {
const msg = String(state.toastMessage);
const newState = { ...state };
delete newState.toastMessage;
const newState = { ...state }; delete newState.toastMessage;
history.replaceState(newState, '', location.href);
this.showToast(msg);
}
@ -176,86 +130,64 @@ export class Geral implements AfterViewInit {
}, 100);
}
// ==========================================================
// LÓGICA PRINCIPAL DE DADOS
// ==========================================================
refreshData() {
// Se um cliente específico foi selecionado no dropdown, força modo TABELA
if (this.selectedClient) {
this.viewMode = 'TABLE';
this.loadFromApi();
return;
}
// Se não, respeita o modo definido pela busca ou padrão
if (this.viewMode === 'GROUPS') {
if (!this.searchTerm.trim()) {
this.viewMode = 'GROUPS';
this.loadGroups();
} else {
this.loadFromApi();
if (this.viewMode === 'GROUPS') this.loadGroups();
else this.loadFromApi();
}
}
/**
* Lógica de Pesquisa Inteligente:
* - Se contém números -> Busca Linha/Chip -> Modo TABELA
* - Se é texto -> Busca Nome Cliente -> Modo GRUPOS (Cards)
*/
onSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.page = 1; // Reseta paginação ao pesquisar
this.page = 1;
const s = this.searchTerm.trim();
if (!s) {
// Busca vazia -> Volta para o padrão (Grupos)
this.viewMode = 'GROUPS';
this.loadGroups();
return;
}
// Verifica se tem números (busca específica)
if (/\d/.test(s)) {
this.viewMode = 'TABLE';
this.loadFromApi();
} else {
// Texto -> Busca por cliente nos Grupos
this.viewMode = 'GROUPS';
this.loadGroups();
}
}, 300); // Debounce de 300ms
}, 300);
}
// Carrega os Cards (Grupos)
private loadGroups() {
this.loading = true;
let params = new HttpParams()
.set('page', String(this.page))
.set('pageSize', String(this.pageSize));
// Filtros de Aba
if (this.filterSkil === 'PF') params = params.set('skil', 'PESSOA FÍSICA');
else if (this.filterSkil === 'PJ') params = params.set('skil', 'PESSOA JURÍDICA');
else if (this.filterSkil === 'RESERVA') params = params.set('skil', 'RESERVA');
// Filtro de Busca Textual (Nome do Cliente)
if (this.searchTerm) params = params.set('search', this.searchTerm);
this.http.get<ApiPagedResult<ClientGroupDto>>(`${this.apiBase}/groups`, { params }).subscribe({
next: (res) => {
// Fallback: Se buscou texto, mas não retornou nenhum grupo de cliente,
// pode ser que o usuário esteja buscando o nome de um "Usuário da Linha".
// Nesse caso, tentamos buscar na tabela.
if (this.searchTerm && res.total === 0 && !/\d/.test(this.searchTerm)) {
this.viewMode = 'TABLE';
this.loadFromApi();
return;
}
this.clientGroups = res.items || [];
this.total = res.total; // Total de Grupos para paginação
this.total = res.total;
this.loading = false;
},
error: () => {
@ -265,25 +197,22 @@ export class Geral implements AfterViewInit {
});
}
// Expandir o Card do Cliente (Accordion)
toggleGroup(clientName: string) {
if (this.expandedGroup === clientName) {
this.expandedGroup = null; // Fecha se já estiver aberto
this.expandedGroup = null;
return;
}
this.expandedGroup = clientName;
this.groupLines = [];
this.loadingLines = true;
// Busca as linhas desse cliente específico
let params = new HttpParams()
.set('client', clientName)
.set('page', '1')
.set('pageSize', '500') // Carrega até 500 linhas na expansão
.set('pageSize', '500')
.set('sortBy', 'item')
.set('sortDir', 'asc');
// ✅ IMPORTANTE: Passa o filtro Skil ativo (Ex: Reserva)
if (this.filterSkil === 'PF') params = params.set('skil', 'PESSOA FÍSICA');
else if (this.filterSkil === 'PJ') params = params.set('skil', 'PESSOA JURÍDICA');
else if (this.filterSkil === 'RESERVA') params = params.set('skil', 'RESERVA');
@ -291,14 +220,7 @@ export class Geral implements AfterViewInit {
this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, { params }).subscribe({
next: (res) => {
this.groupLines = (res.items ?? []).map(x => ({
id: x.id,
item: String(x.item ?? ''),
linha: x.linha ?? '',
cliente: x.cliente ?? '',
usuario: x.usuario ?? '',
status: x.status ?? '',
skil: x.skil ?? '',
contrato: x.vencConta ?? ''
id: x.id, item: String(x.item ?? ''), linha: x.linha ?? '', cliente: x.cliente ?? '', usuario: x.usuario ?? '', status: x.status ?? '', skil: x.skil ?? '', contrato: x.vencConta ?? ''
}));
this.loadingLines = false;
},
@ -309,15 +231,9 @@ export class Geral implements AfterViewInit {
});
}
// Carrega a Tabela Plana (Modo Tabela)
private loadFromApi() {
this.loading = true;
let params = new HttpParams()
.set('page', String(this.page))
.set('pageSize', String(this.pageSize))
.set('search', (this.searchTerm ?? '').trim())
.set('sortBy', this.mapSortKeyToApi(this.sortKey))
.set('sortDir', this.sortDir);
let params = new HttpParams().set('page', String(this.page)).set('pageSize', String(this.pageSize)).set('search', (this.searchTerm ?? '').trim()).set('sortBy', this.mapSortKeyToApi(this.sortKey)).set('sortDir', this.sortDir);
if (this.filterSkil === 'PF') params = params.set('skil', 'PESSOA FÍSICA');
else if (this.filterSkil === 'PJ') params = params.set('skil', 'PESSOA JURÍDICA');
@ -327,16 +243,9 @@ export class Geral implements AfterViewInit {
this.http.get<ApiPagedResult<ApiLineList>>(this.apiBase, { params }).subscribe({
next: (res) => {
this.total = res.total ?? 0; // Total de Linhas para paginação
this.total = res.total ?? 0;
this.rows = (res.items ?? []).map(x => ({
id: x.id,
item: String(x.item ?? ''),
linha: x.linha ?? '',
cliente: x.cliente ?? '',
usuario: x.usuario ?? '',
status: x.status ?? '',
skil: x.skil ?? '',
contrato: x.vencConta ?? ''
id: x.id, item: String(x.item ?? ''), linha: x.linha ?? '', cliente: x.cliente ?? '', usuario: x.usuario ?? '', status: x.status ?? '', skil: x.skil ?? '', contrato: x.vencConta ?? ''
}));
this.loading = false;
},
@ -347,7 +256,6 @@ export class Geral implements AfterViewInit {
});
}
// Troca de Abas (Todos, PF, PJ, Reserva)
setFilter(type: 'ALL' | 'PF' | 'PJ' | 'RESERVA') {
if (this.filterSkil === type) return;
this.filterSkil = type;
@ -355,246 +263,59 @@ export class Geral implements AfterViewInit {
this.refreshData();
}
// ==========================================================
// UX & FILTROS DE CLIENTE
// ==========================================================
clearSearch() {
this.searchTerm = '';
this.page = 1;
this.refreshData();
}
private loadClients() {
this.http.get<string[]>(`${this.apiBase}/clients`).subscribe({
next: (data) => this.clientsList = data || []
});
}
toggleClientMenu() {
this.showClientMenu = !this.showClientMenu;
this.clientSearchTerm = '';
}
closeClientDropdown() {
this.showClientMenu = false;
}
selectClient(client: string | null) {
this.selectedClient = client;
this.showClientMenu = false;
this.page = 1;
this.refreshData();
}
get filteredClientsList(): string[] {
if (!this.clientSearchTerm) return this.clientsList;
const s = this.clientSearchTerm.toLowerCase();
return this.clientsList.filter(c => c.toLowerCase().includes(s));
}
// ==========================================================
// PAGINAÇÃO E ORDENAÇÃO
// ==========================================================
setSort(key: keyof LineRow) {
if (this.sortKey === key) {
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortDir = 'asc';
}
this.page = 1;
this.loadFromApi(); // Ordenação só faz sentido visualmente na tabela
}
onPageSizeChange() {
this.page = 1;
this.refreshData();
}
goToPage(p: number) {
this.page = Math.max(1, Math.min(this.totalPages, p));
this.refreshData();
}
clearSearch() { this.searchTerm = ''; this.page = 1; this.refreshData(); }
private loadClients() { this.http.get<string[]>(`${this.apiBase}/clients`).subscribe({ next: (data) => this.clientsList = data || [] }); }
toggleClientMenu() { this.showClientMenu = !this.showClientMenu; this.clientSearchTerm = ''; }
closeClientDropdown() { this.showClientMenu = false; }
selectClient(client: string | null) { this.selectedClient = client; this.showClientMenu = false; this.page = 1; this.refreshData(); }
get filteredClientsList(): string[] { if (!this.clientSearchTerm) return this.clientsList; const s = this.clientSearchTerm.toLowerCase(); return this.clientsList.filter(c => c.toLowerCase().includes(s)); }
setSort(key: keyof LineRow) { if (this.sortKey === key) this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc'; else { this.sortKey = key; this.sortDir = 'asc'; } this.page = 1; this.loadFromApi(); }
onPageSizeChange() { this.page = 1; this.refreshData(); }
goToPage(p: number) { this.page = Math.max(1, Math.min(this.totalPages, p)); this.refreshData(); }
trackById(_: number, row: LineRow) { return row.id; }
get pagedRows() { return this.rows; } // Dados já vêm paginados do back
get pagedRows() { return this.rows; }
get totalPages() { return Math.ceil((this.total || 0) / this.pageSize) || 1; }
get filteredCount() { return this.total || 0; }
get pageStart() { return this.filteredCount === 0 ? 0 : (this.page - 1) * this.pageSize + 1; }
get pageEnd() {
return this.filteredCount === 0 ? 0 : Math.min((this.page - 1) * this.pageSize + (this.isGroupMode ? this.clientGroups.length : this.rows.length), this.filteredCount);
}
get pageNumbers() {
const total = this.totalPages;
const current = this.page;
const max = 5;
let start = Math.max(1, current - 2);
let end = Math.min(total, start + (max - 1));
start = Math.max(1, end - (max - 1));
const pages = [];
for (let i = start; i <= end; i++) pages.push(i);
return pages;
}
// ==========================================================
// IMPORTAÇÃO / AÇÕES
// ==========================================================
async onImportExcel() {
if (!this.excelInput?.nativeElement) return;
this.excelInput.nativeElement.value = '';
this.excelInput.nativeElement.click();
}
onExcelSelected(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
this.loading = true;
this.http.post<{ imported: number }>(`${this.apiBase}/import-excel`, form).subscribe({
next: async (r) => {
await this.showToast(`Sucesso! ${r?.imported ?? 0} registros importados.`);
this.page = 1;
this.refreshData();
},
error: async () => {
this.loading = false;
await this.showToast('Falha ao importar planilha.');
}
});
}
async onCadastrarLinha() {
await this.showToast('Em breve.');
}
// ==========================================================
// MODAIS E HELPERS
// ==========================================================
private async showToast(message: string) {
if (!isPlatformBrowser(this.platformId)) return;
this.toastMessage = message;
this.cdr.detectChanges();
if (!this.successToast?.nativeElement) return;
try {
const bs = await import('bootstrap');
const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, { autohide: true, delay: 3000 });
toastInstance.show();
} catch (error) {
console.error(error);
}
}
private getById(id: string, cb: (d: any) => void) {
this.http.get(`${this.apiBase}/${id}`).subscribe({
next: cb,
error: () => this.showToast('Erro ao carregar detalhes.')
});
}
onDetalhes(r: LineRow) {
this.detailOpen = true;
this.detailData = null;
this.getById(r.id, d => this.detailData = d);
}
onFinanceiro(r: LineRow) {
this.financeOpen = true;
this.financeData = null;
this.getById(r.id, d => this.financeData = d);
}
get pageEnd() { return this.filteredCount === 0 ? 0 : Math.min((this.page - 1) * this.pageSize + (this.isGroupMode ? this.clientGroups.length : this.rows.length), this.filteredCount); }
get pageNumbers() { const total = this.totalPages; const current = this.page; const max = 5; let start = Math.max(1, current - 2); let end = Math.min(total, start + (max - 1)); start = Math.max(1, end - (max - 1)); const pages = []; for (let i = start; i <= end; i++) pages.push(i); return pages; }
async onImportExcel() { if (!this.excelInput?.nativeElement) return; this.excelInput.nativeElement.value = ''; this.excelInput.nativeElement.click(); }
onExcelSelected(ev: Event) { const file = (ev.target as HTMLInputElement).files?.[0]; if (!file) return; const form = new FormData(); form.append('file', file); this.loading = true; this.http.post<{ imported: number }>(`${this.apiBase}/import-excel`, form).subscribe({ next: async (r) => { await this.showToast(`Sucesso! ${r?.imported ?? 0} registros importados.`); this.page = 1; this.refreshData(); }, error: async () => { this.loading = false; await this.showToast('Falha ao importar planilha.'); } }); }
private getById(id: string, cb: (d: any) => void) { this.http.get(`${this.apiBase}/${id}`).subscribe({ next: cb, error: () => this.showToast('Erro ao carregar detalhes.') }); }
onDetalhes(r: LineRow) { this.detailOpen = true; this.detailData = null; this.getById(r.id, d => this.detailData = d); }
onFinanceiro(r: LineRow) { this.financeOpen = true; this.financeData = null; this.getById(r.id, d => this.financeData = d); }
closeDetail() { this.detailOpen = false; this.detailData = null; }
closeFinance() { this.financeOpen = false; this.financeData = null; }
// Edição
async onEditar(r: LineRow) {
this.editOpen = true;
this.editSaving = false;
this.editModel = null;
this.editingId = r.id;
this.http.get<ApiLineDetail>(`${this.apiBase}/${r.id}`).subscribe({
next: (d) => { this.editModel = this.toEditModel(d); },
error: async () => {
this.editOpen = false;
await this.showToast('Erro ao carregar dados para edição.');
}
error: async () => { this.editOpen = false; await this.showToast('Erro ao carregar dados para edição.'); }
});
}
closeEdit() {
this.editOpen = false;
this.editSaving = false;
this.editModel = null;
this.editingId = null;
}
closeEdit() { this.editOpen = false; this.editSaving = false; this.editModel = null; this.editingId = null; }
async saveEdit() {
if (!this.editingId || !this.editModel) return;
this.editSaving = true;
const payload: UpdateMobileLineRequest = {
item: this.toInt(this.editModel.item),
conta: (this.editModel.conta ?? '').toString(),
linha: (this.editModel.linha ?? '').toString(),
chip: (this.editModel.chip ?? '').toString(),
cliente: (this.editModel.cliente ?? '').toString(),
usuario: (this.editModel.usuario ?? '').toString(),
planoContrato: (this.editModel.planoContrato ?? '').toString(),
status: (this.editModel.status ?? '').toString(),
skil: (this.editModel.skil ?? '').toString(),
modalidade: (this.editModel.modalidade ?? '').toString(),
dataBloqueio: this.dateInputToIso(this.editModel.dataBloqueio),
cedente: (this.editModel.cedente ?? '').toString(),
solicitante: (this.editModel.solicitante ?? '').toString(),
dataEntregaOpera: this.dateInputToIso(this.editModel.dataEntregaOpera),
dataEntregaCliente: this.dateInputToIso(this.editModel.dataEntregaCliente),
vencConta: (this.editModel.vencConta ?? '').toString(),
franquiaVivo: this.toNullableNumber(this.editModel.franquiaVivo),
valorPlanoVivo: this.toNullableNumber(this.editModel.valorPlanoVivo),
gestaoVozDados: this.toNullableNumber(this.editModel.gestaoVozDados),
skeelo: this.toNullableNumber(this.editModel.skeelo),
vivoNewsPlus: this.toNullableNumber(this.editModel.vivoNewsPlus),
vivoTravelMundo: this.toNullableNumber(this.editModel.vivoTravelMundo),
vivoGestaoDispositivo: this.toNullableNumber(this.editModel.vivoGestaoDispositivo),
valorContratoVivo: this.toNullableNumber(this.editModel.valorContratoVivo),
franquiaLine: this.toNullableNumber(this.editModel.franquiaLine),
franquiaGestao: this.toNullableNumber(this.editModel.franquiaGestao),
locacaoAp: this.toNullableNumber(this.editModel.locacaoAp),
valorContratoLine: this.toNullableNumber(this.editModel.valorContratoLine),
desconto: this.toNullableNumber(this.editModel.desconto),
lucro: this.toNullableNumber(this.editModel.lucro)
};
const payload: UpdateMobileLineRequest = { ...this.editModel, item: this.toInt(this.editModel.item), dataBloqueio: this.dateInputToIso(this.editModel.dataBloqueio), dataEntregaOpera: this.dateInputToIso(this.editModel.dataEntregaOpera), dataEntregaCliente: this.dateInputToIso(this.editModel.dataEntregaCliente), vencConta: (this.editModel.vencConta ?? '').toString(), franquiaVivo: this.toNullableNumber(this.editModel.franquiaVivo), valorPlanoVivo: this.toNullableNumber(this.editModel.valorPlanoVivo), gestaoVozDados: this.toNullableNumber(this.editModel.gestaoVozDados), skeelo: this.toNullableNumber(this.editModel.skeelo), vivoNewsPlus: this.toNullableNumber(this.editModel.vivoNewsPlus), vivoTravelMundo: this.toNullableNumber(this.editModel.vivoTravelMundo), vivoGestaoDispositivo: this.toNullableNumber(this.editModel.vivoGestaoDispositivo), valorContratoVivo: this.toNullableNumber(this.editModel.valorContratoVivo), franquiaLine: this.toNullableNumber(this.editModel.franquiaLine), franquiaGestao: this.toNullableNumber(this.editModel.franquiaGestao), locacaoAp: this.toNullableNumber(this.editModel.locacaoAp), valorContratoLine: this.toNullableNumber(this.editModel.valorContratoLine), desconto: this.toNullableNumber(this.editModel.desconto), lucro: this.toNullableNumber(this.editModel.lucro) };
this.http.put(`${this.apiBase}/${this.editingId}`, payload).subscribe({
next: async () => {
this.editSaving = false;
this.closeEdit();
await this.showToast('Registro atualizado!');
// Se estiver em grupo e ele estiver aberto, recarrega o grupo
if(this.isGroupMode && this.expandedGroup) {
this.toggleGroup(this.expandedGroup);
this.toggleGroup(this.expandedGroup!);
} else {
this.refreshData();
}
},
error: async (err: HttpErrorResponse) => {
this.editSaving = false;
await this.showToast('Erro ao salvar.');
}
error: async () => { this.editSaving = false; await this.showToast('Erro ao salvar.'); }
});
}
@ -605,27 +326,132 @@ export class Geral implements AfterViewInit {
next: async () => {
await this.showToast('Removido com sucesso.');
if(fromGroup && this.expandedGroup) {
this.toggleGroup(this.expandedGroup);
this.toggleGroup(this.expandedGroup!);
} else {
this.refreshData();
}
},
error: async () => {
this.loading = false;
await this.showToast('Erro ao remover.');
error: async () => { this.loading = false; await this.showToast('Erro ao remover.'); }
});
}
onCadastrarLinha() {
this.createMode = 'NEW_CLIENT';
this.resetCreateModel();
this.createOpen = true;
}
onAddLineToGroup(clientName: string) {
this.createMode = 'NEW_LINE_IN_GROUP';
this.resetCreateModel();
this.createModel.cliente = clientName;
if (this.filterSkil === 'PJ') this.createModel.skil = 'PESSOA JURÍDICA';
else if (this.filterSkil === 'RESERVA') this.createModel.skil = 'RESERVA';
this.createOpen = true;
}
closeCreate() { this.createOpen = false; }
private resetCreateModel() {
this.createModel = {
cliente: '', docType: 'PF', docNumber: '', linha: '', chip: '', usuario: '',
status: '', planoContrato: '', conta: '', vencConta: '', skil: 'PESSOA FÍSICA',
modalidade: '', item: 0, cedente: '', solicitante: '',
dataBloqueio: '', dataEntregaOpera: '', dataEntregaCliente: '',
franquiaVivo: null, valorPlanoVivo: null, gestaoVozDados: null, skeelo: null,
vivoNewsPlus: null, vivoTravelMundo: null, vivoGestaoDispositivo: null,
valorContratoVivo: null, franquiaLine: null, franquiaGestao: null,
locacaoAp: null, valorContratoLine: null, desconto: null, lucro: null
};
this.createSaving = false;
}
onDocTypeChange() {
this.createModel.docNumber = '';
this.createModel.skil = this.createModel.docType === 'PF' ? 'PESSOA FÍSICA' : 'PESSOA JURÍDICA';
}
onDocInput(event: any) {
let value = event.target.value.replace(/\D/g, '');
if (this.createModel.docType === 'PF') {
if (value.length > 11) value = value.slice(0, 11);
value = value.replace(/(\d{3})(\d)/, '$1.$2');
value = value.replace(/(\d{3})(\d)/, '$1.$2');
value = value.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
} else {
if (value.length > 14) value = value.slice(0, 14);
value = value.replace(/^(\d{2})(\d)/, '$1.$2');
value = value.replace(/^(\d{2})\.(\d{3})(\d)/, '$1.$2.$3');
value = value.replace(/\.(\d{3})(\d)/, '.$1/$2');
value = value.replace(/(\d{4})(\d)/, '$1-$2');
}
this.createModel.docNumber = value;
}
async saveCreate() {
if (!this.createModel.linha) { this.showToast('O número da Linha é obrigatório.'); return; }
if (!this.createModel.chip) { this.showToast('O Chip (ICCID) é obrigatório.'); return; }
if (!this.createModel.status) { this.showToast('Selecione um Status.'); return; }
if (!this.createModel.planoContrato) { this.showToast('Selecione um Plano.'); return; }
if (!this.createModel.conta) { this.showToast('Selecione uma Conta.'); return; }
if (!this.createModel.skil) { this.showToast('Selecione o Tipo (Skil).'); return; }
if (this.createMode === 'NEW_CLIENT') {
if (!this.createModel.cliente) { this.showToast(this.createModel.docType === 'PF' ? 'Informe o Nome Completo.' : 'Informe a Razão Social.'); return; }
if (!this.createModel.docNumber) { this.showToast(`O ${this.createModel.docType === 'PF' ? 'CPF' : 'CNPJ'} é obrigatório.`); return; }
}
this.createSaving = true;
const payload: CreateMobileLineRequest = {
...this.createModel,
item: Number(this.createModel.item),
dataBloqueio: this.dateInputToIso(this.createModel.dataBloqueio),
dataEntregaOpera: this.dateInputToIso(this.createModel.dataEntregaOpera),
dataEntregaCliente: this.dateInputToIso(this.createModel.dataEntregaCliente),
franquiaVivo: this.toNullableNumber(this.createModel.franquiaVivo),
valorPlanoVivo: this.toNullableNumber(this.createModel.valorPlanoVivo),
gestaoVozDados: this.toNullableNumber(this.createModel.gestaoVozDados),
skeelo: this.toNullableNumber(this.createModel.skeelo),
vivoNewsPlus: this.toNullableNumber(this.createModel.vivoNewsPlus),
vivoTravelMundo: this.toNullableNumber(this.createModel.vivoTravelMundo),
vivoGestaoDispositivo: this.toNullableNumber(this.createModel.vivoGestaoDispositivo),
valorContratoVivo: this.toNullableNumber(this.createModel.valorContratoVivo),
franquiaLine: this.toNullableNumber(this.createModel.franquiaLine),
franquiaGestao: this.toNullableNumber(this.createModel.franquiaGestao),
locacaoAp: this.toNullableNumber(this.createModel.locacaoAp),
valorContratoLine: this.toNullableNumber(this.createModel.valorContratoLine),
desconto: this.toNullableNumber(this.createModel.desconto),
lucro: this.toNullableNumber(this.createModel.lucro)
};
this.http.post<ApiLineDetail>(this.apiBase, payload).subscribe({
next: async (newRecord) => {
this.createSaving = false;
this.closeCreate();
await this.showToast('Sucesso! Registro criado.');
if (this.createMode === 'NEW_LINE_IN_GROUP' && this.expandedGroup === this.createModel.cliente) {
this.toggleGroup(this.expandedGroup!);
} else {
this.refreshData();
}
},
error: async (err: HttpErrorResponse) => {
this.createSaving = false;
const msg = err.error?.message || 'Erro ao criar registro.';
await this.showToast(msg);
}
});
}
// Formatters
// Helpers
private async showToast(message: string) { if (!isPlatformBrowser(this.platformId)) return; this.toastMessage = message; this.cdr.detectChanges(); if (!this.successToast?.nativeElement) return; try { const bs = await import('bootstrap'); const toastInstance = bs.Toast.getOrCreateInstance(this.successToast.nativeElement, { autohide: true, delay: 3000 }); toastInstance.show(); } catch (error) { console.error(error); } }
formatMoney(v: any): string { if (v == null || Number.isNaN(v)) return '-'; return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v); }
formatNumber(v: any): string { if (v == null || Number.isNaN(v)) return '-'; return new Intl.NumberFormat('pt-BR').format(v); }
formatFranquia(v: any): string { if (v == null || Number.isNaN(v)) return '-'; return `${new Intl.NumberFormat('pt-BR').format(v)} GB`; }
formatDateBr(iso: any): string { if (!iso) return '-'; const d = new Date(iso); return Number.isNaN(d.getTime()) ? '-' : new Intl.DateTimeFormat('pt-BR').format(d); }
statusClass(s: any): string { const n = (s??'').toLowerCase(); if(n.includes('bloq')||n.includes('perda')) return 'is-blocked'; if(n.includes('ativo')) return 'is-active'; return ''; }
statusLabel(s: any): string { return s || '-'; }
// Mappers
private mapSortKeyToApi(sortKey: keyof LineRow): string { const map: Record<string, string> = { item: 'item', linha: 'linha', cliente: 'cliente', status: 'status', skil: 'skil', contrato: 'vencConta' }; return map[String(sortKey)] ?? 'item'; }
private isoToDateInput(iso: string | null | undefined): string { if (!iso) return ''; const dt = new Date(iso); return Number.isNaN(dt.getTime()) ? '' : dt.toISOString().slice(0, 10); }
private dateInputToIso(dateStr: string | null | undefined): string | null { const s = (dateStr ?? '').trim(); return s ? new Date(`${s}T00:00:00.000Z`).toISOString() : null; }