feat: Implementação do anexo e cliente poder editar

This commit is contained in:
Leon 2026-03-03 17:43:20 -03:00
parent 7962420565
commit c38a786f89
3 changed files with 356 additions and 35 deletions

View File

@ -398,9 +398,9 @@
<td> <td>
<div class="action-group justify-content-center"> <div class="action-group justify-content-center">
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<ng-container *ngIf="!isClientRestricted"> <ng-container *ngIf="!isClientRestricted">
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button> <button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onRemover(r, true)" title="Remover"><i class="bi bi-trash"></i></button>
</ng-container> </ng-container>
</div> </div>
@ -517,9 +517,9 @@
<td class="text-center"> <td class="text-center">
<div class="action-group justify-content-center"> <div class="action-group justify-content-center">
<button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button> <button class="btn-icon" (click)="onDetalhes(r)" title="Detalhes"><i class="bi bi-eye"></i></button>
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<ng-container *ngIf="!isClientRestricted"> <ng-container *ngIf="!isClientRestricted">
<button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button> <button class="btn-icon success" (click)="onFinanceiro(r)" title="Financeiro"><i class="bi bi-cash-coin"></i></button>
<button class="btn-icon primary" (click)="onEditar(r)" title="Editar"><i class="bi bi-pencil-square"></i></button>
<button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button> <button *ngIf="isSysAdmin" class="btn-icon danger" (click)="onRemover(r)" title="Remover"><i class="bi bi-trash"></i></button>
</ng-container> </ng-container>
</div> </div>
@ -1712,9 +1712,51 @@
<span class="lbl">Usuário</span> <span class="lbl">Usuário</span>
<span class="val">{{ detailData.usuario || '-' }}</span> <span class="val">{{ detailData.usuario || '-' }}</span>
</div> </div>
<div class="info-item span-2" *ngIf="isClientRestricted"> <div class="info-item span-2">
<span class="lbl">Centro de Custos</span> <span class="lbl">Centro de Custos</span>
<span class="val text-dark">&nbsp;</span> <span class="val text-dark">{{ detailData.centroDeCustos || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Setor</span>
<span class="val">{{ detailData.setorNome || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Aparelho</span>
<span class="val">{{ detailData.aparelhoNome || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">Cor do Aparelho</span>
<span class="val">{{ detailData.aparelhoCor || '-' }}</span>
</div>
<div class="info-item">
<span class="lbl">IMEI</span>
<span class="val">{{ detailData.aparelhoImei || '-' }}</span>
</div>
<div class="info-item span-2">
<span class="lbl">Nota Fiscal (Anexo)</span>
<span class="val">
<ng-container *ngIf="detailData.aparelhoNotaFiscalTemArquivo; else noNotaFiscalAnexo">
<button class="btn btn-sm btn-outline-primary" (click)="downloadAparelhoAnexo('nota-fiscal', detailData.id)">
Baixar arquivo
</button>
</ng-container>
<ng-template #noNotaFiscalAnexo>
-
</ng-template>
</span>
</div>
<div class="info-item span-2">
<span class="lbl">Recibo (Anexo)</span>
<span class="val">
<ng-container *ngIf="detailData.aparelhoReciboTemArquivo; else noReciboAnexo">
<button class="btn btn-sm btn-outline-primary" (click)="downloadAparelhoAnexo('recibo', detailData.id)">
Baixar arquivo
</button>
</ng-container>
<ng-template #noReciboAnexo>
-
</ng-template>
</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="lbl">Item</span> <span class="lbl">Item</span>
@ -1919,7 +1961,74 @@
<div class="modal-body modern-body bg-light-gray"> <div class="modal-body modern-body bg-light-gray">
<ng-container *ngIf="editModel; else editLoadingTpl"> <ng-container *ngIf="editModel; else editLoadingTpl">
<div class="edit-sections"> <div class="edit-sections" *ngIf="isClientRestricted">
<details open class="detail-box">
<summary class="box-header">
<span><i class="bi bi-person-badge me-2"></i> Dados Permitidos para Cliente</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 />
</div>
<div class="form-field">
<label>Linha</label>
<input class="form-control form-control-sm bg-light" [(ngModel)]="editModel.linha" disabled />
</div>
<div class="form-field">
<label>Usuário</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" />
</div>
<div class="form-field">
<label>Centro de Custos</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.centroDeCustos" />
</div>
<div class="form-field span-2">
<label>Aparelho (Nome)</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.aparelhoNome" />
</div>
<div class="form-field">
<label>Cor do Aparelho</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.aparelhoCor" />
</div>
<div class="form-field">
<label>IMEI</label>
<input class="form-control form-control-sm" [(ngModel)]="editModel.aparelhoImei" />
</div>
<div class="form-field span-2">
<label>Nota Fiscal (Arquivo)</label>
<input class="form-control form-control-sm" type="file" accept=".pdf,.png,.jpg,.jpeg,.webp" (change)="onAparelhoNotaFiscalSelected($event)" />
<small class="text-muted d-block mt-1" *ngIf="aparelhoNotaFiscalFile">Selecionado: {{ aparelhoNotaFiscalFile.name }}</small>
<button
class="btn btn-sm btn-outline-primary mt-2"
type="button"
*ngIf="!aparelhoNotaFiscalFile && editModel.aparelhoNotaFiscalTemArquivo"
(click)="downloadAparelhoAnexo('nota-fiscal', editModel?.id)"
>
Baixar arquivo atual
</button>
</div>
<div class="form-field span-2">
<label>Recibo (Arquivo)</label>
<input class="form-control form-control-sm" type="file" accept=".pdf,.png,.jpg,.jpeg,.webp" (change)="onAparelhoReciboSelected($event)" />
<small class="text-muted d-block mt-1" *ngIf="aparelhoReciboFile">Selecionado: {{ aparelhoReciboFile.name }}</small>
<button
class="btn btn-sm btn-outline-primary mt-2"
type="button"
*ngIf="!aparelhoReciboFile && editModel.aparelhoReciboTemArquivo"
(click)="downloadAparelhoAnexo('recibo', editModel?.id)"
>
Baixar arquivo atual
</button>
</div>
</div>
</div>
</details>
</div>
<div class="edit-sections" *ngIf="!isClientRestricted">
<details open class="detail-box"> <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> <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="box-body">
@ -1932,6 +2041,37 @@
<div class="form-field"><label>Tipo de Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.tipoDeChip" /></div> <div class="form-field"><label>Tipo de Chip</label><input class="form-control form-control-sm" [(ngModel)]="editModel.tipoDeChip" /></div>
<div class="form-field"><label>Cliente</label><input class="form-control form-control-sm" [(ngModel)]="editModel.cliente" /></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 class="form-field"><label>Usuário</label><input class="form-control form-control-sm" [(ngModel)]="editModel.usuario" /></div>
<div class="form-field"><label>Centro de Custos</label><input class="form-control form-control-sm" [(ngModel)]="editModel.centroDeCustos" /></div>
<div class="form-field"><label>Setor</label><input class="form-control form-control-sm" [(ngModel)]="editModel.setorNome" /></div>
<div class="form-field"><label>Aparelho (Nome)</label><input class="form-control form-control-sm" [(ngModel)]="editModel.aparelhoNome" /></div>
<div class="form-field"><label>Aparelho (Cor)</label><input class="form-control form-control-sm" [(ngModel)]="editModel.aparelhoCor" /></div>
<div class="form-field"><label>Aparelho (IMEI)</label><input class="form-control form-control-sm" [(ngModel)]="editModel.aparelhoImei" /></div>
<div class="form-field span-2">
<label>Nota Fiscal (Arquivo)</label>
<input class="form-control form-control-sm" type="file" accept=".pdf,.png,.jpg,.jpeg,.webp" (change)="onAparelhoNotaFiscalSelected($event)" />
<small class="text-muted d-block mt-1" *ngIf="aparelhoNotaFiscalFile">Selecionado: {{ aparelhoNotaFiscalFile.name }}</small>
<button
class="btn btn-sm btn-outline-primary mt-2"
type="button"
*ngIf="!aparelhoNotaFiscalFile && editModel.aparelhoNotaFiscalTemArquivo"
(click)="downloadAparelhoAnexo('nota-fiscal', editModel?.id)"
>
Baixar arquivo atual
</button>
</div>
<div class="form-field span-2">
<label>Recibo (Arquivo)</label>
<input class="form-control form-control-sm" type="file" accept=".pdf,.png,.jpg,.jpeg,.webp" (change)="onAparelhoReciboSelected($event)" />
<small class="text-muted d-block mt-1" *ngIf="aparelhoReciboFile">Selecionado: {{ aparelhoReciboFile.name }}</small>
<button
class="btn btn-sm btn-outline-primary mt-2"
type="button"
*ngIf="!aparelhoReciboFile && editModel.aparelhoReciboTemArquivo"
(click)="downloadAparelhoAnexo('recibo', editModel?.id)"
>
Baixar arquivo atual
</button>
</div>
</div> </div>
</div> </div>
</details> </details>

View File

@ -49,6 +49,10 @@ interface LineRow {
chip?: string; chip?: string;
cliente: string; cliente: string;
usuario: string; usuario: string;
centroDeCustos?: string;
setorNome?: string;
aparelhoNome?: string;
aparelhoCor?: string;
status: string; status: string;
skil: string; skil: string;
contrato: string; contrato: string;
@ -68,6 +72,10 @@ interface ApiLineList {
chip?: string | null; chip?: string | null;
cliente: string | null; cliente: string | null;
usuario: string | null; usuario: string | null;
centroDeCustos?: string | null;
setorNome?: string | null;
aparelhoNome?: string | null;
aparelhoCor?: string | null;
vencConta: string | null; vencConta: string | null;
status?: string | null; status?: string | null;
skil?: string | null; skil?: string | null;
@ -89,6 +97,15 @@ interface ApiLineDetail {
tipoDeChip?: string | null; tipoDeChip?: string | null;
cliente?: string | null; cliente?: string | null;
usuario?: string | null; usuario?: string | null;
centroDeCustos?: string | null;
setorId?: string | null;
setorNome?: string | null;
aparelhoId?: string | null;
aparelhoNome?: string | null;
aparelhoCor?: string | null;
aparelhoImei?: string | null;
aparelhoNotaFiscalTemArquivo?: boolean;
aparelhoReciboTemArquivo?: boolean;
planoContrato?: string | null; planoContrato?: string | null;
status?: string | null; status?: string | null;
skil?: string | null; skil?: string | null;
@ -365,6 +382,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
detailData: any = null; detailData: any = null;
financeData: any = null; financeData: any = null;
editModel: any = null; editModel: any = null;
aparelhoNotaFiscalFile: File | null = null;
aparelhoReciboFile: File | null = null;
private editingId: string | null = null; private editingId: string | null = null;
private searchTimer: any = null; private searchTimer: any = null;
@ -835,6 +854,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.detailData = null; this.detailData = null;
this.financeData = null; this.financeData = null;
this.aparelhoNotaFiscalFile = null;
this.aparelhoReciboFile = null;
this.editSaving = false; this.editSaving = false;
this.createSaving = false; this.createSaving = false;
@ -1609,6 +1630,10 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
chip: x.chip ?? '', chip: x.chip ?? '',
cliente: x.cliente ?? '', cliente: x.cliente ?? '',
usuario: x.usuario ?? '', usuario: x.usuario ?? '',
centroDeCustos: x.centroDeCustos ?? '',
setorNome: x.setorNome ?? '',
aparelhoNome: x.aparelhoNome ?? '',
aparelhoCor: x.aparelhoCor ?? '',
status: x.status ?? '', status: x.status ?? '',
skil: x.skil ?? '', skil: x.skil ?? '',
contrato: x.vencConta ?? '' contrato: x.vencConta ?? ''
@ -1841,6 +1866,8 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
this.editOpen = true; this.editOpen = true;
this.editSaving = false; this.editSaving = false;
this.editModel = null; this.editModel = null;
this.aparelhoNotaFiscalFile = null;
this.aparelhoReciboFile = null;
this.editingId = r.id; this.editingId = r.id;
this.cdr.detectChanges(); this.cdr.detectChanges();
@ -1918,11 +1945,26 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
if (!this.editingId || !this.editModel) return; if (!this.editingId || !this.editModel) return;
this.editSaving = true; this.editSaving = true;
const editingId = this.editingId;
const shouldUploadAttachments = !!(this.aparelhoNotaFiscalFile || this.aparelhoReciboFile);
let payload: UpdateMobileLineRequest;
if (this.isClientRestricted) {
payload = {
item: this.toInt(this.editModel.item),
usuario: (this.editModel.usuario ?? '').toString(),
centroDeCustos: (this.editModel.centroDeCustos ?? '').toString(),
aparelhoId: (this.editModel.aparelhoId ?? null) as string | null,
aparelhoNome: (this.editModel.aparelhoNome ?? '').toString(),
aparelhoCor: (this.editModel.aparelhoCor ?? '').toString(),
aparelhoImei: (this.editModel.aparelhoImei ?? '').toString()
};
} else {
this.calculateFinancials(this.editModel); this.calculateFinancials(this.editModel);
const { contaEmpresa: _contaEmpresa, ...editModelPayload } = this.editModel; const { contaEmpresa: _contaEmpresa, ...editModelPayload } = this.editModel;
const payload: UpdateMobileLineRequest = { payload = {
...editModelPayload, ...editModelPayload,
item: this.toInt(this.editModel.item), item: this.toInt(this.editModel.item),
dataBloqueio: this.dateInputToIso(this.editModel.dataBloqueio), dataBloqueio: this.dateInputToIso(this.editModel.dataBloqueio),
@ -1948,15 +1990,26 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
lucro: this.toNullableNumber(this.editModel.lucro), lucro: this.toNullableNumber(this.editModel.lucro),
tipoDeChip: (this.editModel.tipoDeChip ?? '').toString() tipoDeChip: (this.editModel.tipoDeChip ?? '').toString()
}; };
}
this.http.put(`${this.apiBase}/${this.editingId}`, payload).subscribe({ this.http.put(`${this.apiBase}/${editingId}`, payload).subscribe({
next: async () => { next: async () => {
try {
if (shouldUploadAttachments) {
await this.uploadAparelhoAnexos(editingId);
}
} catch {
this.editSaving = false;
await this.showToast('Registro salvo, mas falhou o upload dos anexos.');
return;
}
this.editSaving = false; this.editSaving = false;
// fecha e limpa overlay SEMPRE // fecha e limpa overlay SEMPRE
this.closeAllModals(); this.closeAllModals();
await this.showToast('Registro atualizado!'); await this.showToast(shouldUploadAttachments ? 'Registro e anexos atualizados!' : 'Registro atualizado!');
if (this.isGroupMode && this.expandedGroup) { if (this.isGroupMode && this.expandedGroup) {
const term = (this.searchTerm ?? '').trim(); const term = (this.searchTerm ?? '').trim();
@ -1976,6 +2029,115 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
onAparelhoNotaFiscalSelected(event: Event) {
const input = event.target as HTMLInputElement | null;
this.aparelhoNotaFiscalFile = input?.files && input.files.length > 0 ? input.files[0] : null;
}
onAparelhoReciboSelected(event: Event) {
const input = event.target as HTMLInputElement | null;
this.aparelhoReciboFile = input?.files && input.files.length > 0 ? input.files[0] : null;
}
async downloadAparelhoAnexo(tipo: 'nota-fiscal' | 'recibo', lineId?: string | null) {
const targetLineId = (lineId ?? this.detailData?.id ?? this.editingId ?? '').toString().trim();
if (!targetLineId) {
await this.showToast('Linha inválida para download do anexo.');
return;
}
try {
const response = await firstValueFrom(
this.http.get(`${this.apiBase}/${targetLineId}/aparelho/anexos/${tipo}`, {
observe: 'response',
responseType: 'blob'
})
);
const blob = response.body;
if (!blob) {
await this.showToast('Arquivo de anexo não encontrado.');
return;
}
const disposition = response.headers.get('content-disposition');
const contentType = (response.headers.get('content-type') || blob.type || '').toLowerCase();
const fallbackName = tipo === 'nota-fiscal' ? 'nota-fiscal' : 'recibo';
const fileName = this.extractAttachmentFileName(disposition, fallbackName, contentType);
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch {
await this.showToast('Não foi possível baixar o anexo.');
}
}
private async uploadAparelhoAnexos(lineId: string): Promise<void> {
const hasAnyFile = !!this.aparelhoNotaFiscalFile || !!this.aparelhoReciboFile;
if (!hasAnyFile) {
return;
}
const formData = new FormData();
if (this.aparelhoNotaFiscalFile) {
formData.append('notaFiscal', this.aparelhoNotaFiscalFile, this.aparelhoNotaFiscalFile.name);
}
if (this.aparelhoReciboFile) {
formData.append('recibo', this.aparelhoReciboFile, this.aparelhoReciboFile.name);
}
await firstValueFrom(this.http.post(`${this.apiBase}/${lineId}/aparelho/anexos`, formData));
this.aparelhoNotaFiscalFile = null;
this.aparelhoReciboFile = null;
}
private extractAttachmentFileName(
contentDisposition: string | null,
fallbackBaseName: string,
contentType: string
): string {
const raw = (contentDisposition ?? '').trim();
const inferredExtension = this.extensionFromContentType(contentType) ?? '.pdf';
if (!raw) {
return `${fallbackBaseName}${inferredExtension}`;
}
const utf8Match = raw.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return this.ensureFileNameExtension(decodeURIComponent(utf8Match[1]), inferredExtension);
}
const simpleMatch = raw.match(/filename="?([^";]+)"?/i);
if (simpleMatch?.[1]) {
return this.ensureFileNameExtension(simpleMatch[1], inferredExtension);
}
return `${fallbackBaseName}${inferredExtension}`;
}
private extensionFromContentType(contentType: string): string | null {
if (!contentType) return null;
if (contentType.includes('application/pdf')) return '.pdf';
if (contentType.includes('image/png')) return '.png';
if (contentType.includes('image/jpeg') || contentType.includes('image/jpg')) return '.jpg';
if (contentType.includes('image/webp')) return '.webp';
return null;
}
private ensureFileNameExtension(fileName: string, fallbackExtension: string): string {
const normalized = (fileName ?? '').trim();
if (!normalized) return `anexo${fallbackExtension}`;
const hasExtension = /\.[A-Za-z0-9]{2,6}$/.test(normalized);
return hasExtension ? normalized : `${normalized}${fallbackExtension}`;
}
async onRemover(r: LineRow, fromGroup = false) { async onRemover(r: LineRow, fromGroup = false) {
if (!this.isSysAdmin) { if (!this.isSysAdmin) {
await this.showToast('Apenas sysadmin pode remover linhas.'); await this.showToast('Apenas sysadmin pode remover linhas.');
@ -3430,6 +3592,15 @@ export class Geral implements OnInit, AfterViewInit, OnDestroy {
return { return {
...d, ...d,
item: d.item ?? 0, item: d.item ?? 0,
centroDeCustos: d.centroDeCustos ?? '',
setorId: d.setorId ?? null,
setorNome: d.setorNome ?? '',
aparelhoId: d.aparelhoId ?? null,
aparelhoNome: d.aparelhoNome ?? '',
aparelhoCor: d.aparelhoCor ?? '',
aparelhoImei: d.aparelhoImei ?? '',
aparelhoNotaFiscalTemArquivo: !!d.aparelhoNotaFiscalTemArquivo,
aparelhoReciboTemArquivo: !!d.aparelhoReciboTemArquivo,
dataBloqueio: this.isoToDateInput(d.dataBloqueio), dataBloqueio: this.isoToDateInput(d.dataBloqueio),
dataEntregaOpera: this.isoToDateInput(d.dataEntregaOpera), dataEntregaOpera: this.isoToDateInput(d.dataEntregaOpera),
dataEntregaCliente: this.isoToDateInput(d.dataEntregaCliente), dataEntregaCliente: this.isoToDateInput(d.dataEntregaCliente),

View File

@ -18,6 +18,10 @@ export interface MobileLineList {
chip: string | null; chip: string | null;
cliente: string | null; cliente: string | null;
usuario: string | null; usuario: string | null;
centroDeCustos?: string | null;
setorNome?: string | null;
aparelhoNome?: string | null;
aparelhoCor?: string | null;
planoContrato: string | null; planoContrato: string | null;
status: string | null; status: string | null;
skil: string | null; skil: string | null;
@ -26,6 +30,12 @@ export interface MobileLineList {
} }
export interface MobileLineDetail extends MobileLineList { export interface MobileLineDetail extends MobileLineList {
setorId?: string | null;
aparelhoId?: string | null;
aparelhoImei?: string | null;
aparelhoNotaFiscalTemArquivo?: boolean;
aparelhoReciboTemArquivo?: boolean;
franquiaVivo?: number | null; franquiaVivo?: number | null;
valorPlanoVivo?: number | null; valorPlanoVivo?: number | null;
gestaoVozDados?: number | null; gestaoVozDados?: number | null;