Docker aplicado

This commit is contained in:
Eduardo 2026-02-09 18:32:07 -03:00
parent 49cdaefddf
commit 99807d78f7
27 changed files with 808 additions and 2077 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.git
.github
.vscode
.angular
node_modules
dist
out-tsc
coverage
*.log
README.md

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=4000
ENV API_BASE_URL=http://backend:8080
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
EXPOSE 4000
CMD ["node", "dist/line-gestao-frontend/server/server.mjs"]

View File

@ -44,13 +44,13 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumWarning": "2MB",
"maximumError": "3MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "20kB",
"maximumError": "40kB"
}
],
"outputHashing": "all"

1794
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,14 +23,14 @@
},
"private": true,
"dependencies": {
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/platform-server": "^20.3.0",
"@angular/router": "^20.3.0",
"@angular/ssr": "^20.3.10",
"@angular/common": "20.3.16",
"@angular/compiler": "20.3.16",
"@angular/core": "20.3.16",
"@angular/forms": "20.3.16",
"@angular/platform-browser": "20.3.16",
"@angular/platform-server": "20.3.16",
"@angular/router": "20.3.16",
"@angular/ssr": "20.3.16",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
@ -41,9 +41,9 @@
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.3.10",
"@angular/cli": "^20.3.10",
"@angular/compiler-cli": "^20.3.0",
"@angular/build": "20.3.16",
"@angular/cli": "20.3.16",
"@angular/compiler-cli": "20.3.16",
"@types/bootstrap": "^5.2.10",
"@types/express": "^5.0.1",
"@types/jasmine": "~5.1.0",
@ -55,5 +55,8 @@
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2"
},
"overrides": {
"qs": "^6.14.1"
}
}

View File

@ -3,6 +3,6 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '**',
renderMode: RenderMode.Prerender
renderMode: RenderMode.Server
}
];

View File

@ -14,7 +14,6 @@ import { VigenciaComponent } from './pages/vigencia/vigencia';
import { TrocaNumero } from './pages/troca-numero/troca-numero';
import { Dashboard } from './pages/dashboard/dashboard';
import { Notificacoes } from './pages/notificacoes/notificacoes';
import { NovoUsuario } from './pages/novo-usuario/novo-usuario';
import { ChipsControleRecebidos } from './pages/chips-controle-recebidos/chips-controle-recebidos';
import { Resumo } from './pages/resumo/resumo';
import { Parcelamentos } from './pages/parcelamentos/parcelamentos';
@ -33,7 +32,6 @@ export const routes: Routes = [
{ path: 'vigencia', component: VigenciaComponent, canActivate: [authGuard], title: 'Vigência' },
{ path: 'trocanumero', component: TrocaNumero, canActivate: [authGuard], title: 'Troca de Número' },
{ path: 'notificacoes', component: Notificacoes, canActivate: [authGuard], title: 'Notificações' },
{ path: 'novo-usuario', component: NovoUsuario, canActivate: [authGuard], title: 'Novo Usuário' },
{ path: 'chips-controle-recebidos', component: ChipsControleRecebidos, canActivate: [authGuard], title: 'Chips Controle Recebidos' },
{ path: 'resumo', component: Resumo, canActivate: [authGuard], title: 'Resumo' },
{ path: 'parcelamentos', component: Parcelamentos, canActivate: [authGuard], title: 'Parcelamentos' },

View File

@ -1,10 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents();
});
@ -14,10 +22,10 @@ describe('App', () => {
expect(app).toBeTruthy();
});
it('should render title', () => {
it('should render app layout', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, line-gestao-frontend');
expect(compiled.querySelector('main.app-main')).toBeTruthy();
});
});

View File

@ -1,4 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { Header } from './header';
@ -8,7 +11,12 @@ describe('Header', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Header]
imports: [Header],
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
.compileComponents();

View File

@ -68,7 +68,6 @@ export class Header {
'/trocanumero',
'/dashboard',
'/notificacoes',
'/novo-usuario',
'/chips-controle-recebidos',
'/resumo',
'/parcelamentos',

View File

@ -1,4 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { DadosUsuarios } from './dados-usuarios';
describe('DadosUsuarios', () => {
@ -8,6 +11,11 @@ describe('DadosUsuarios', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DadosUsuarios], // standalone component
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents();
fixture = TestBed.createComponent(DadosUsuarios);

View File

@ -325,7 +325,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy {
private resumoService: ResumoService,
@Inject(PLATFORM_ID) private platformId: object
) {
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}

View File

@ -1,4 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { Geral } from './geral';
@ -8,7 +11,12 @@ describe('Geral', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Geral]
imports: [Geral],
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
.compileComponents();

View File

@ -21,6 +21,7 @@ import { CustomSelectComponent } from '../../components/custom-select/custom-sel
import { PlanAutoFillService } from '../../services/plan-autofill.service';
import { AuthService } from '../../services/auth.service';
import { firstValueFrom, Subscription, filter } from 'rxjs';
import { environment } from '../../../environments/environment';
type SortDir = 'asc' | 'desc';
type CreateMode = 'NEW_CLIENT' | 'NEW_LINE_IN_GROUP';
@ -146,7 +147,11 @@ export class Geral implements AfterViewInit, OnDestroy {
private router: Router
) {}
private readonly apiBase = 'https://localhost:7205/api/lines';
private readonly apiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/lines`;
})();
loading = false;
isAdmin = false;

View File

@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { LinesService } from '../../services/lines.service';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { environment } from '../../../environments/environment';
type MuregKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataDaMureg' | 'cliente';
@ -92,7 +93,11 @@ export class Mureg implements AfterViewInit {
private linesService: LinesService
) {}
private readonly apiBase = 'https://localhost:7205/api/mureg';
private readonly apiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/mureg`;
})();
// ====== DATA ======
clientGroups: ClientGroup[] = [];

View File

@ -1,176 +0,0 @@
<section class="create-user-page">
<div class="page-shell">
<div class="grid-shell">
<div class="form-card">
<div class="form-header">
<h1>Novo Usuário LineGestão</h1>
<p>Preencha os dados para criar um novo usuário.</p>
</div>
<div class="form-alert error" *ngIf="createErrors.length">
<strong>Confira os campos:</strong>
<ul>
<li *ngFor="let err of createErrors">{{ err.message }}</li>
</ul>
</div>
<div class="form-alert success" *ngIf="createSuccess">{{ createSuccess }}</div>
<form class="user-form" [formGroup]="createForm" (ngSubmit)="submitCreate()">
<div class="form-field" [class.has-error]="hasCreateFieldError('nome') || (createForm.get('nome')?.touched && createForm.get('nome')?.invalid)">
<label for="nome">Nome</label>
<input id="nome" type="text" placeholder="Nome completo" formControlName="nome" />
<small class="field-error" *ngIf="createForm.get('nome')?.touched && createForm.get('nome')?.invalid">Nome obrigatório.</small>
</div>
<div class="form-field" [class.has-error]="hasCreateFieldError('email') || (createForm.get('email')?.touched && createForm.get('email')?.invalid)">
<label for="email">Email</label>
<input id="email" type="email" placeholder="nome@empresa.com" formControlName="email" />
<small class="field-error" *ngIf="createForm.get('email')?.touched && createForm.get('email')?.invalid">Email inválido.</small>
</div>
<div class="form-field" [class.has-error]="hasCreateFieldError('senha') || (createForm.get('senha')?.touched && createForm.get('senha')?.invalid)">
<label for="senha">Senha</label>
<input id="senha" type="password" placeholder="Defina uma senha segura" formControlName="senha" />
<small class="field-error" *ngIf="createForm.get('senha')?.touched && createForm.get('senha')?.invalid">Senha inválida.</small>
</div>
<div class="form-field" [class.has-error]="hasCreateFieldError('confirmarSenha') || (createForm.get('confirmarSenha')?.touched && createForm.get('confirmarSenha')?.invalid) || (createPasswordMismatch && createForm.get('confirmarSenha')?.touched)">
<label for="confirmarSenha">Confirmar Senha</label>
<input id="confirmarSenha" type="password" placeholder="Repita a senha" formControlName="confirmarSenha" />
<small class="field-error" *ngIf="createPasswordMismatch && createForm.get('confirmarSenha')?.touched">As senhas não conferem.</small>
</div>
<div class="form-field" [class.has-error]="hasCreateFieldError('permissao') || (createForm.get('permissao')?.touched && createForm.get('permissao')?.invalid)">
<label for="permissoes">Permissões</label>
<app-select formControlName="permissao" [options]="permissionOptions" labelKey="label" valueKey="value" placeholder="Selecione o nivel"></app-select>
<small class="field-error" *ngIf="createForm.get('permissao')?.touched && createForm.get('permissao')?.invalid">Selecione uma permissão.</small>
</div>
<div class="form-actions">
<button type="button" class="btn-secondary" (click)="createForm.reset({ permissao: '' })" [disabled]="createSubmitting">Cancelar</button>
<button type="submit" class="btn-primary" [disabled]="createSubmitting || createForm.invalid">
<span *ngIf="!createSubmitting">Salvar</span>
<span *ngIf="createSubmitting">Salvando...</span>
</button>
</div>
</form>
</div>
<div class="list-card">
<div class="list-header">
<div>
<h2>Usuários</h2>
<p>Gerencie permissões e status.</p>
</div>
<div class="list-actions">
<input type="text" placeholder="Pesquisar..." [(ngModel)]="search" (keyup.enter)="onSearch()" />
<button type="button" class="btn-secondary" (click)="onSearch()">Buscar</button>
<button type="button" class="btn-ghost" (click)="clearSearch()">Limpar</button>
</div>
</div>
<div class="list-body">
<div class="loading" *ngIf="loading">Carregando...</div>
<table *ngIf="!loading && users.length" class="users-table">
<thead>
<tr>
<th>Nome</th>
<th>Email</th>
<th>Permissão</th>
<th>Status</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let u of users">
<td>{{ u.nome }}</td>
<td>{{ u.email }}</td>
<td class="cap">{{ u.permissao }}</td>
<td>
<span class="status-pill" [class.off]="u.ativo === false">
{{ u.ativo === false ? 'Inativo' : 'Ativo' }}
</span>
</td>
<td>
<button type="button" class="btn-link" (click)="openEdit(u)">Editar</button>
</td>
</tr>
</tbody>
</table>
<div class="empty" *ngIf="!loading && !users.length">
Nenhum usuario encontrado.
</div>
</div>
<div class="list-footer">
<div class="page-info">
Página {{ page }} de {{ totalPages }}
</div>
<div class="pagination">
<button type="button" class="btn-ghost" (click)="goToPage(page - 1)" [disabled]="page <= 1">Anterior</button>
<button type="button" class="btn-ghost" (click)="goToPage(p)" *ngFor="let p of pageNumbers" [class.active]="p === page">{{ p }}</button>
<button type="button" class="btn-ghost" (click)="goToPage(page + 1)" [disabled]="page >= totalPages">Próxima</button>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="modal-overlay" *ngIf="editOpen" (click)="closeEdit()"></div>
<div class="modal-card" *ngIf="editOpen" (click)="$event.stopPropagation()">
<div class="modal-header">
<h3>Editar Usuário</h3>
<button type="button" class="btn-icon close-x" (click)="closeEdit()" aria-label="Fechar">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<div class="form-alert error" *ngIf="editErrors.length">
<strong>Confira os campos:</strong>
<ul>
<li *ngFor="let err of editErrors">{{ err.message }}</li>
</ul>
</div>
<div class="form-alert success" *ngIf="editSuccess">{{ editSuccess }}</div>
<form class="user-form" id="editUserForm" [formGroup]="editForm" (ngSubmit)="submitEdit()">
<div class="form-field">
<label for="editNome">Nome</label>
<input id="editNome" type="text" placeholder="Nome completo" formControlName="nome" />
</div>
<div class="form-field">
<label for="editEmail">Email</label>
<input id="editEmail" type="email" placeholder="nome@empresa.com" formControlName="email" />
</div>
<div class="form-field">
<label for="editSenha">Nova senha (opcional)</label>
<input id="editSenha" type="password" placeholder="Nova senha" formControlName="senha" />
</div>
<div class="form-field">
<label for="editConfirmarSenha">Confirmar senha</label>
<input id="editConfirmarSenha" type="password" placeholder="Confirme a nova senha" formControlName="confirmarSenha" />
</div>
<div class="form-field">
<label for="editPermissao">Permissões</label>
<app-select formControlName="permissao" [options]="permissionOptions" labelKey="label" valueKey="value" placeholder="Selecione o nivel"></app-select>
</div>
<div class="form-field inline">
<label for="editAtivo">Status</label>
<div class="toggle">
<input id="editAtivo" type="checkbox" formControlName="ativo" />
<span>Ativo</span>
</div>
</div>
</form>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" (click)="closeEdit()" [disabled]="editSubmitting">Cancelar</button>
<button type="submit" form="editUserForm" class="btn-primary" [disabled]="editSubmitting">
<span *ngIf="!editSubmitting">Salvar</span>
<span *ngIf="editSubmitting">Salvando...</span>
</button>
</div>
</div>

View File

@ -1,372 +0,0 @@
/* Página Criar Novo Usuário */
.create-user-page {
min-height: calc(100vh - 69.2px);
padding: 32px 16px 80px;
background: radial-gradient(circle at 10% 15%, #e8f0ff 0%, #f6f7fb 45%, #ffffff 100%);
}
.page-shell {
max-width: 980px;
margin: 0 auto;
display: grid;
place-items: center;
}
.grid-shell {
width: 100%;
display: grid;
gap: 24px;
grid-template-columns: minmax(0, 1fr);
}
.form-card {
width: min(720px, 100%);
background: #ffffff;
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
padding: 28px 28px 24px;
}
.form-header {
margin-bottom: 18px;
h1 {
font-size: 22px;
font-weight: 700;
color: #0f172a;
margin: 0 0 6px;
}
p {
margin: 0;
color: #64748b;
font-size: 13px;
}
}
.user-form {
display: grid;
gap: 14px;
}
.form-field {
display: grid;
gap: 6px;
label {
font-size: 13px;
font-weight: 600;
color: #0f172a;
}
input,
select {
height: 42px;
border-radius: 10px;
border: 1.5px solid #d7dbe6;
padding: 0 12px;
font-size: 14px;
color: #0f172a;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
select:focus {
outline: none;
border-color: #3b5bff;
box-shadow: 0 0 0 3px rgba(59, 91, 255, 0.15);
}
}
.form-field.inline {
grid-template-columns: 1fr;
}
.form-alert {
border-radius: 12px;
padding: 12px 14px;
font-size: 13px;
margin-bottom: 12px;
}
.form-alert.error {
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #b91c1c;
}
.form-alert.success {
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.2);
color: #047857;
}
.field-error {
color: #b91c1c;
font-size: 12px;
}
.list-card {
width: min(900px, 100%);
background: #ffffff;
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
padding: 22px 22px 18px;
}
.list-header {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
h2 {
margin: 0;
font-size: 18px;
color: #0f172a;
}
p {
margin: 4px 0 0;
font-size: 12px;
color: #64748b;
}
}
.list-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
input {
height: 38px;
border-radius: 10px;
border: 1.5px solid #d7dbe6;
padding: 0 12px;
font-size: 13px;
min-width: 220px;
}
}
.list-body {
margin-top: 16px;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 10px 8px;
border-bottom: 1px solid #edf0f6;
text-align: left;
}
th {
font-weight: 600;
color: #475569;
}
}
.status-pill {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.12);
color: #047857;
font-weight: 600;
font-size: 12px;
}
.status-pill.off {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.list-footer {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-top: 12px;
font-size: 12px;
color: #64748b;
}
.pagination {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.btn-ghost {
height: 34px;
border-radius: 10px;
border: 1px solid #d7dbe6;
background: #fff;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
}
.btn-ghost.active {
background: #2f6bff;
border-color: #2f6bff;
color: #ffffff;
}
.btn-link {
border: none;
background: transparent;
color: #2f6bff;
cursor: pointer;
font-weight: 600;
}
.loading,
.empty {
padding: 18px 0;
text-align: center;
color: #64748b;
}
.cap {
text-transform: capitalize;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 6px;
}
.btn-primary,
.btn-secondary {
height: 40px;
min-width: 110px;
border-radius: 10px;
border: none;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-primary {
background: #2f6bff;
color: #ffffff;
box-shadow: 0 10px 20px rgba(47, 107, 255, 0.2);
}
.btn-secondary {
background: #e2e8f0;
color: #0f172a;
}
.btn-secondary,
.btn-ghost {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-primary:hover,
.btn-secondary:hover,
.btn-ghost:hover {
transform: translateY(-1px);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
z-index: 999;
}
.modal-card {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(520px, 92vw);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.2);
z-index: 1000;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px 10px;
border-bottom: 1px solid #edf0f6;
}
.modal-body {
padding: 16px 18px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 0 18px 16px;
}
.btn-icon {
background: transparent;
border: none;
cursor: pointer;
}
@media (max-width: 768px) {
.grid-shell {
gap: 18px;
}
.form-card {
padding: 22px 20px 20px;
}
.list-card {
padding: 20px 18px 16px;
}
.form-actions {
flex-direction: column;
align-items: stretch;
}
.btn-primary,
.btn-secondary {
width: 100%;
}
}
@media (min-width: 980px) {
.grid-shell {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
align-items: start;
}
.form-card,
.list-card {
width: 100%;
}
}

View File

@ -1,309 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from '@angular/forms';
import { UsersService, CreateUserPayload, UpdateUserPayload, UserDto, ApiFieldError } from '../../services/users.service';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
@Component({
selector: 'app-novo-usuario',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FormsModule, CustomSelectComponent],
templateUrl: './novo-usuario.html',
styleUrls: ['./novo-usuario.scss'],
})
export class NovoUsuario implements OnInit {
createForm: FormGroup;
editForm: FormGroup;
permissionOptions = [
{ value: 'admin', label: 'Administrador' },
{ value: 'gestor', label: 'Gestor' },
];
createSubmitting = false;
editSubmitting = false;
createErrors: ApiFieldError[] = [];
editErrors: ApiFieldError[] = [];
createSuccess = '';
editSuccess = '';
users: UserDto[] = [];
loading = false;
search = '';
page = 1;
pageSize = 10;
total = 0;
editOpen = false;
private editBase: UserDto | null = null;
constructor(private usersService: UsersService, private fb: FormBuilder) {
this.createForm = this.fb.group(
{
nome: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
senha: ['', [Validators.required, Validators.minLength(6)]],
confirmarSenha: ['', [Validators.required, Validators.minLength(6)]],
permissao: ['', [Validators.required]],
},
{ validators: this.passwordsMatchValidator }
);
this.editForm = this.fb.group(
{
nome: [''],
email: [''],
senha: [''],
confirmarSenha: [''],
permissao: [''],
ativo: [true],
}
);
}
ngOnInit(): void {
this.fetchUsers(1);
}
get totalPages(): number {
return Math.max(1, Math.ceil((this.total || 0) / (this.pageSize || 10)));
}
get pageNumbers(): number[] {
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: number[] = [];
for (let i = start; i <= end; i++) pages.push(i);
return pages;
}
fetchUsers(goToPage?: number) {
if (goToPage) this.page = goToPage;
this.loading = true;
this.usersService.list({
search: this.search?.trim() || undefined,
page: this.page,
pageSize: this.pageSize,
}).subscribe({
next: (res) => {
this.users = res.items || [];
this.total = res.total || 0;
this.loading = false;
},
error: () => {
this.users = [];
this.total = 0;
this.loading = false;
}
});
}
onSearch() {
this.page = 1;
this.fetchUsers();
}
clearSearch() {
this.search = '';
this.page = 1;
this.fetchUsers();
}
onPageSizeChange() {
this.page = 1;
this.fetchUsers();
}
goToPage(p: number) {
this.page = p;
this.fetchUsers();
}
submitCreate() {
if (this.createSubmitting) return;
if (this.createForm.invalid) {
this.createForm.markAllAsTouched();
return;
}
this.createSubmitting = true;
this.setCreateFormDisabled(true);
this.createErrors = [];
this.createSuccess = '';
const payload = this.createForm.value as CreateUserPayload;
this.usersService.create(payload).subscribe({
next: (created) => {
this.createSubmitting = false;
this.setCreateFormDisabled(false);
this.createSuccess = `Usuario ${created.nome} criado com sucesso.`;
this.createForm.reset({ permissao: '' });
this.fetchUsers(1);
},
error: (err: HttpErrorResponse) => {
this.createSubmitting = false;
this.setCreateFormDisabled(false);
const apiErrors = err?.error?.errors;
if (Array.isArray(apiErrors)) {
this.createErrors = apiErrors.map((e: any) => ({
field: e?.field,
message: e?.message || 'Erro ao criar usuario.',
}));
} else {
this.createErrors = [{ message: err?.error?.message || 'Erro ao criar usuario.' }];
}
},
});
}
openEdit(user: UserDto) {
this.editOpen = true;
this.editErrors = [];
this.editSuccess = '';
this.editSubmitting = false;
this.setEditFormDisabled(false);
this.editBase = null;
this.editForm.reset({ nome: '', email: '', senha: '', confirmarSenha: '', permissao: '', ativo: true });
this.usersService.getById(user.id).subscribe({
next: (full) => {
this.editBase = full;
this.editForm.reset({
nome: full.nome ?? '',
email: full.email ?? '',
senha: '',
confirmarSenha: '',
permissao: full.permissao ?? '',
ativo: full.ativo ?? true,
});
},
error: () => {
this.editErrors = [{ message: 'Erro ao carregar usuario.' }];
},
});
}
closeEdit() {
this.editOpen = false;
this.editErrors = [];
this.editSuccess = '';
this.editSubmitting = false;
this.editBase = null;
this.setEditFormDisabled(false);
}
submitEdit() {
if (this.editSubmitting || !this.editBase) return;
this.editErrors = [];
this.editSuccess = '';
const payload: UpdateUserPayload = {};
const nome = (this.editForm.get('nome')?.value || '').toString().trim();
const email = (this.editForm.get('email')?.value || '').toString().trim();
const permissao = (this.editForm.get('permissao')?.value || '').toString().trim();
const ativo = !!this.editForm.get('ativo')?.value;
if (nome && nome !== (this.editBase.nome || '').trim()) payload.nome = nome;
if (email && email !== (this.editBase.email || '').trim()) payload.email = email;
if (permissao && permissao !== (this.editBase.permissao || '').trim()) payload.permissao = permissao as any;
if ((this.editBase.ativo ?? true) !== ativo) payload.ativo = ativo;
const senha = (this.editForm.get('senha')?.value || '').toString();
const confirmar = (this.editForm.get('confirmarSenha')?.value || '').toString();
if (senha || confirmar) {
if (!senha || !confirmar) {
this.editErrors = [{ message: 'Para alterar a senha, preencha senha e confirmaçao.' }];
return;
}
if (senha.length < 6) {
this.editErrors = [{ message: 'Senha deve ter no minimo 6 caracteres.' }];
return;
}
if (senha !== confirmar) {
this.editErrors = [{ message: 'As senhas nao conferem.' }];
return;
}
payload.senha = senha;
payload.confirmarSenha = confirmar;
}
if (Object.keys(payload).length === 0) {
this.editErrors = [{ message: 'Nenhuma alteraçao detectada.' }];
return;
}
this.editSubmitting = true;
this.setEditFormDisabled(true);
this.usersService.update(this.editBase.id, payload).subscribe({
next: (updated) => {
this.editSubmitting = false;
this.setEditFormDisabled(false);
this.editSuccess = `Usuario ${updated.nome} atualizado.`;
this.fetchUsers();
},
error: (err: HttpErrorResponse) => {
this.editSubmitting = false;
this.setEditFormDisabled(false);
const apiErrors = err?.error?.errors;
if (Array.isArray(apiErrors)) {
this.editErrors = apiErrors.map((e: any) => ({
field: e?.field,
message: e?.message || 'Erro ao atualizar usuario.',
}));
} else {
this.editErrors = [{ message: err?.error?.message || 'Erro ao atualizar usuario.' }];
}
}
});
}
hasCreateFieldError(field: string): boolean {
return this.getFieldErrors(this.createErrors, field).length > 0;
}
hasEditFieldError(field: string): boolean {
return this.getFieldErrors(this.editErrors, field).length > 0;
}
getFieldErrors(source: ApiFieldError[], field: string): string[] {
const key = this.normalizeField(field);
return source
.filter((e) => this.normalizeField(e.field) === key)
.map((e) => e.message || 'Erro');
}
get createPasswordMismatch(): boolean {
return !!this.createForm.errors?.['passwordsMismatch'];
}
private normalizeField(field?: string | null): string {
return (field || '').trim().toLowerCase();
}
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
const senha = group.get('senha')?.value;
const confirmar = group.get('confirmarSenha')?.value;
if (!senha || !confirmar) return null;
return senha === confirmar ? null : { passwordsMismatch: true };
}
private setCreateFormDisabled(disabled: boolean) {
if (disabled) this.createForm.disable({ emitEvent: false });
else this.createForm.enable({ emitEvent: false });
}
private setEditFormDisabled(disabled: boolean) {
if (disabled) this.editForm.disable({ emitEvent: false });
else this.editForm.enable({ emitEvent: false });
}
}

View File

@ -1,4 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { Register } from './register';
@ -8,7 +11,12 @@ describe('Register', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Register]
imports: [Register],
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
.compileComponents();

View File

@ -11,6 +11,7 @@ import { isPlatformBrowser, CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { CustomSelectComponent } from '../../components/custom-select/custom-select';
import { environment } from '../../../environments/environment';
type TrocaKey = 'item' | 'linhaAntiga' | 'linhaNova' | 'iccid' | 'dataTroca' | 'motivo' | 'observacao';
@ -71,10 +72,18 @@ export class TrocaNumero implements AfterViewInit {
private cdr: ChangeDetectorRef
) {}
private readonly apiBase = 'https://localhost:7205/api/trocanumero';
private readonly apiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/trocanumero`;
})();
/** ✅ base do GERAL (para buscar clientes/linhas no modal) */
private readonly linesApiBase = 'https://localhost:7205/api/lines';
private readonly linesApiBase = (() => {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
return `${apiBase}/lines`;
})();
// ====== DATA ======
groups: GroupItem[] = [];

View File

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { environment } from '../../environments/environment';
export type SortDir = 'asc' | 'desc';
export type TipoCliente = 'PF' | 'PJ';
@ -71,9 +72,13 @@ export interface ApiPagedResult<T> {
@Injectable({ providedIn: 'root' })
export class BillingService {
private readonly baseUrl = 'https://localhost:7205/api/billing';
private readonly baseUrl: string;
constructor(private http: HttpClient) {}
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseUrl = `${apiBase}/billing`;
}
getPaged(q: BillingQuery): Observable<ApiPagedResult<BillingItem>> {
let params = new HttpParams()

View File

@ -71,7 +71,7 @@ export class ChipsControleService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}

View File

@ -75,7 +75,7 @@ export class DadosUsuariosService {
private readonly baseApi: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.baseApi = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}

View File

@ -1,6 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface PagedResult<T> {
page: number;
@ -58,10 +59,13 @@ export interface LineOption {
@Injectable({ providedIn: 'root' })
export class LinesService {
// ✅ Mesma base do Swagger (evita redirect no preflight/CORS)
private baseUrl = 'https://localhost:7205/api/lines';
private readonly baseUrl: string;
constructor(private http: HttpClient) {}
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
const apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
this.baseUrl = `${apiBase}/lines`;
}
getLines(page: number, pageSize: number, search: string): Observable<PagedResult<MobileLineList>> {
let params = new HttpParams()

View File

@ -119,7 +119,7 @@ export class ResumoService {
private readonly apiBase: string;
constructor(private http: HttpClient) {
const raw = (environment.apiUrl || 'https://localhost:7205').replace(/\/+$/, '');
const raw = (environment.apiUrl || '').replace(/\/+$/, '');
this.apiBase = raw.toLowerCase().endsWith('/api') ? raw : `${raw}/api`;
}

View File

@ -1,4 +1,4 @@
export const environment = {
production: false,
apiUrl: 'https://localhost:7205'
apiUrl: ''
};

View File

@ -6,23 +6,60 @@ import {
} from '@angular/ssr/node';
import express from 'express';
import { join } from 'node:path';
import { Readable } from 'node:stream';
const browserDistFolder = join(import.meta.dirname, '../browser');
const apiBaseUrl = (process.env['API_BASE_URL'] || 'http://backend:8080').replace(/\/+$/, '');
const app = express();
const angularApp = new AngularNodeAppEngine();
/**
* Example Express Rest API endpoints can be defined here.
* Uncomment and define endpoints as necessary.
*
* Example:
* ```ts
* app.get('/api/{*splat}', (req, res) => {
* // Handle API request
* });
* ```
* Proxy API calls from the browser-facing frontend server to the backend API.
*/
app.use(['/api', '/auth'], async (req, res, next) => {
try {
const targetUrl = new URL(req.originalUrl, `${apiBaseUrl}/`);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
const lower = key.toLowerCase();
if (lower === 'host' || lower === 'content-length') continue;
headers.set(key, Array.isArray(value) ? value.join(',') : value);
}
const requestInit: RequestInit & { duplex?: 'half' } = {
method: req.method,
headers,
redirect: 'manual',
};
if (req.method !== 'GET' && req.method !== 'HEAD') {
requestInit.body = req as unknown as BodyInit;
requestInit.duplex = 'half';
}
const upstream = await fetch(targetUrl, requestInit);
res.status(upstream.status);
upstream.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (lower === 'connection' || lower === 'transfer-encoding') return;
res.setHeader(key, value);
});
if (!upstream.body) {
res.end();
return;
}
Readable.fromWeb(upstream.body as any).pipe(res);
} catch (error) {
next(error);
}
});
/**
* Serve static files from /browser