feat: UI improvements — foto de perfil, tab-bar Samsung, toast dismiss y mejoras de equipo

- Foto de perfil (con fallback a icono) en contratos, viewsuppliers, team y assign-technician
- Eliminado item "Tarjetas" del side menu y sus rutas del router
- Fix tab-bar cortado en Samsung: --padding-start/end: 2px en ion-tab-button (global.scss)
- Safe area vars explícitas en variables.scss para edge-to-edge en Android
- Botón X para cerrar toasts manualmente (alert.service.ts)
- ion-input de equipo: ion-label separado para evitar color verde de validación en focus
- ion-textarea report-discussion: --highlight-height: 0 y fondo adaptable al tema
- Enter en textarea de discusión envía el mensaje
- HTML en alertas de equipo vía querySelector('.alert-message').innerHTML

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 17:17:31 -06:00
parent c677fdcb59
commit 9098b126ad
12 changed files with 395 additions and 16 deletions

View File

@@ -18,8 +18,6 @@ const routes: Routes = [
{ path: 'home', loadChildren: () => import('./home/home.module').then(m => m.HomePageModule), canActivate: [AuthGuard] }, { path: 'home', loadChildren: () => import('./home/home.module').then(m => m.HomePageModule), canActivate: [AuthGuard] },
{ path: 'verify', loadChildren: () => import('./verify/verify.module').then(m => m.verifyPageModule), canActivate: [AuthoriginGuard]}, { path: 'verify', loadChildren: () => import('./verify/verify.module').then(m => m.verifyPageModule), canActivate: [AuthoriginGuard]},
{ path: 'category/:category', loadChildren: () => import('./pages/category/category.module').then(m => m.CategoryPageModule), canActivate: [AuthGuard] }, { path: 'category/:category', loadChildren: () => import('./pages/category/category.module').then(m => m.CategoryPageModule), canActivate: [AuthGuard] },
{ path: 'addcard', loadChildren: () => import('./pages/card/addcard/addcard.module').then(m => m.AddcardPageModule), canActivate: [AuthGuard] },
{ path: 'cards', loadChildren: () => import('./pages/card/cards/cards.module').then(m => m.CardsPageModule), canActivate: [AuthGuard] },
{ path: 'postulations', loadChildren: () => import('./pages/postulations/postulations.module').then(m => m.PostulationsPageModule), canActivate: [AuthGuard] }, { path: 'postulations', loadChildren: () => import('./pages/postulations/postulations.module').then(m => m.PostulationsPageModule), canActivate: [AuthGuard] },
{ path: 'contracts', loadChildren: () => import('./pages/contracts/contracts.module').then(m => m.ContractsPageModule), canActivate: [AuthGuard] }, { path: 'contracts', loadChildren: () => import('./pages/contracts/contracts.module').then(m => m.ContractsPageModule), canActivate: [AuthGuard] },
{ path: 'viewsuppliers/:postulation_id', loadChildren: () => import('./pages/contracts/viewsuppliers/viewsuppliers.module').then(m => m.ViewsuppliersPageModule), canActivate: [AuthGuard] }, { path: 'viewsuppliers/:postulation_id', loadChildren: () => import('./pages/contracts/viewsuppliers/viewsuppliers.module').then(m => m.ViewsuppliersPageModule), canActivate: [AuthGuard] },
@@ -33,6 +31,7 @@ const routes: Routes = [
{ path: 'hero', loadChildren: () => import('./pages/hero/hero.module').then(m => m.HeroPageModule), canActivate: [AuthGuard] }, { path: 'hero', loadChildren: () => import('./pages/hero/hero.module').then(m => m.HeroPageModule), canActivate: [AuthGuard] },
{ path: 'comments', loadChildren: () => import('./pages/contracts/viewsuppliers/comments/comments.module').then(m => m.CommentsPageModule), canActivate: [AuthGuard] }, { path: 'comments', loadChildren: () => import('./pages/contracts/viewsuppliers/comments/comments.module').then(m => m.CommentsPageModule), canActivate: [AuthGuard] },
{ path: 'faq', loadChildren: () => import('./pages/faq/faq.module').then(m => m.FaqPageModule), canActivate: [AuthGuard] }, { path: 'faq', loadChildren: () => import('./pages/faq/faq.module').then(m => m.FaqPageModule), canActivate: [AuthGuard] },
{ path: 'team', loadChildren: () => import('./pages/team/team.module').then(m => m.TeamPageModule), canActivate: [AuthGuard] },
]; ];
@NgModule({ @NgModule({

View File

@@ -14,12 +14,6 @@
<ion-label>{{'menu.home' | translate}}</ion-label> <ion-label>{{'menu.home' | translate}}</ion-label>
</ion-item> </ion-item>
</ion-menu-toggle> </ion-menu-toggle>
<ion-menu-toggle auto-hide="false" [class.ion-hide]="userRole === 0">
<ion-item [routerDirection]="'root'" [routerLink]="['/cards']">
<ion-icon slot="start" name="card"></ion-icon>
<ion-label>{{'menu.cards' | translate}}</ion-label>
</ion-item>
</ion-menu-toggle>
<ion-menu-toggle auto-hide="false" [class.ion-hide]="userRole < 2"> <ion-menu-toggle auto-hide="false" [class.ion-hide]="userRole < 2">
<ion-item [routerDirection]="'root'" [routerLink]="['/postulations']"> <ion-item [routerDirection]="'root'" [routerLink]="['/postulations']">
<ion-icon slot="start" name="hammer"></ion-icon> <ion-icon slot="start" name="hammer"></ion-icon>
@@ -50,6 +44,12 @@
<ion-label>{{'menu.hero' | translate}}</ion-label> <ion-label>{{'menu.hero' | translate}}</ion-label>
</ion-item> </ion-item>
</ion-menu-toggle> </ion-menu-toggle>
<ion-menu-toggle auto-hide="false" [class.ion-hide]="userRole !== 2">
<ion-item [routerDirection]="'root'" [routerLink]="['/team']">
<ion-icon slot="start" name="people"></ion-icon>
<ion-label>{{'menu.team' | translate}}</ion-label>
</ion-item>
</ion-menu-toggle>
<ion-item (click)="logout()"> <ion-item (click)="logout()">
<ion-icon slot="start" name="log-out"></ion-icon> <ion-icon slot="start" name="log-out"></ion-icon>
<ion-label> <ion-label>

View File

@@ -14,7 +14,7 @@
<ng-container *ngIf="loading"> <ng-container *ngIf="loading">
<ion-card *ngFor="let _ of [1,2]"> <ion-card *ngFor="let _ of [1,2]">
<ion-item style="--border-color: #fff"> <ion-item class="no-separator">
<ion-label> <ion-label>
<ion-skeleton-text [animated]="true" style="width: 55%; height: 18px; margin-bottom: 8px"></ion-skeleton-text> <ion-skeleton-text [animated]="true" style="width: 55%; height: 18px; margin-bottom: 8px"></ion-skeleton-text>
<ion-skeleton-text [animated]="true" style="width: 85%"></ion-skeleton-text> <ion-skeleton-text [animated]="true" style="width: 85%"></ion-skeleton-text>
@@ -31,7 +31,11 @@
<ng-container *ngIf="!loading"> <ng-container *ngIf="!loading">
<ng-container *ngFor="let ccontract of ccontracts; let i = index"> <ng-container *ngFor="let ccontract of ccontracts; let i = index">
<ion-card *ngIf="ccontract.status==1"> <ion-card *ngIf="ccontract.status==1">
<ion-item style="--border-color: #fff"> <ion-item class="no-separator">
<ion-avatar slot="start">
<img *ngIf="ccontract.profile_photo" [src]="ccontract.profile_photo" (error)="ccontract.profile_photo=null">
<ion-icon *ngIf="!ccontract.profile_photo" name="person-circle" style="width:100%;height:100%;color:var(--ion-color-medium)"></ion-icon>
</ion-avatar>
<ion-label> <ion-label>
<h2 *ngIf="lang===true" class="ion-text-capitalize">{{ ccontract.category }}</h2> <h2 *ngIf="lang===true" class="ion-text-capitalize">{{ ccontract.category }}</h2>
<h2 *ngIf="lang===false" class="ion-text-capitalize">{{ ccontract.en_category }}</h2> <h2 *ngIf="lang===false" class="ion-text-capitalize">{{ ccontract.en_category }}</h2>
@@ -44,7 +48,9 @@
<p class="ion-text-wrap">{{'contracts.info_pin' | translate}}</p> <p class="ion-text-wrap">{{'contracts.info_pin' | translate}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-button *ngIf="ccontract.past_due < 0" style="height: 4.0em; padding-right: 1.0em; padding-bottom:1.0em; float: right" color="danger" (click)="cancelContract(ccontract.id, ccontract.date)">{{'contracts.cancel_1.1' | translate}}<br>{{'contracts.cancel_1.2' | translate}}</ion-button> <div *ngIf="ccontract.past_due < 0" style="display: flex; justify-content: flex-end; padding: 0.5em 1em 1.25em;">
<ion-button style="height: 4.0em" color="danger" (click)="cancelContract(ccontract.id, ccontract.date)">{{'contracts.cancel_1.1' | translate}}<br>{{'contracts.cancel_1.2' | translate}}</ion-button>
</div>
</ion-card> </ion-card>
</ng-container> </ng-container>
</ng-container> </ng-container>

View File

@@ -19,6 +19,10 @@
<img style="display:block;width:100%;height:100%" src="{{ pcontractsupplier.cover_photo }}" *ngIf="pcontractsupplier.supplier != null"/> <img style="display:block;width:100%;height:100%" src="{{ pcontractsupplier.cover_photo }}" *ngIf="pcontractsupplier.supplier != null"/>
</ion-card-header> </ion-card-header>
<ion-item *ngIf="pcontractsupplier.supplier != null"> <ion-item *ngIf="pcontractsupplier.supplier != null">
<ion-avatar slot="start">
<img *ngIf="pcontractsupplier.profile_photo" [src]="pcontractsupplier.profile_photo" (error)="pcontractsupplier.profile_photo=null">
<ion-icon *ngIf="!pcontractsupplier.profile_photo" name="person-circle" style="width:100%;height:100%;color:var(--ion-color-medium)"></ion-icon>
</ion-avatar>
<ion-label> <ion-label>
<h2 class="ion-text-wrap ion-text-capitalize" *ngIf="pcontractsupplier.supplier != null">{{ pcontractsupplier.supplier }}</h2> <h2 class="ion-text-wrap ion-text-capitalize" *ngIf="pcontractsupplier.supplier != null">{{ pcontractsupplier.supplier }}</h2>
<p class="ion-text-wrap" *ngIf="pcontractsupplier.supplier != null"><ion-icon name="checkmark-circle" color="secondary" *ngIf="pcontractsupplier.membership != false"></ion-icon> {{ pcontractssuppliers_certified[i] }}</p> <p class="ion-text-wrap" *ngIf="pcontractsupplier.supplier != null"><ion-icon name="checkmark-circle" color="secondary" *ngIf="pcontractsupplier.membership != false"></ion-icon> {{ pcontractssuppliers_certified[i] }}</p>

View File

@@ -82,6 +82,8 @@
placeholder="Escribe un comentario..." placeholder="Escribe un comentario..."
rows="1" rows="1"
autoGrow="true" autoGrow="true"
style="--highlight-height: 0"
(keydown.enter)="$event.preventDefault(); newMessage.trim() && !sending && sendMessage()"
></ion-textarea> ></ion-textarea>
<ion-button <ion-button
fill="clear" fill="clear"

View File

@@ -94,8 +94,7 @@
align-items: flex-end; align-items: flex-end;
padding: 6px 6px; padding: 6px 6px;
gap: 2px; gap: 2px;
background: var(--ion-background-color, #fff); background: var(--ion-tab-bar-background, #fff);
border-top: 1px solid var(--ion-color-light-shade);
ion-textarea { ion-textarea {
flex: 1; flex: 1;
@@ -103,7 +102,7 @@
--padding-end: 14px; --padding-end: 14px;
--padding-top: 8px; --padding-top: 8px;
--padding-bottom: 8px; --padding-bottom: 8px;
background: var(--ion-color-light); --background: var(--ion-color-step-100, #f4f5f8);
border-radius: 20px; border-radius: 20px;
margin: 0; margin: 0;
max-height: 120px; max-height: 120px;

View File

@@ -0,0 +1,43 @@
<ion-header>
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>Asignar Técnico</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ng-container *ngIf="loading">
<ion-item *ngFor="let _ of [1,2,3]">
<ion-label>
<ion-skeleton-text [animated]="true" style="width: 60%; height: 16px; margin-bottom: 6px"></ion-skeleton-text>
<ion-skeleton-text [animated]="true" style="width: 45%"></ion-skeleton-text>
</ion-label>
<ion-skeleton-text [animated]="true" slot="end" style="width: 70px; height: 32px"></ion-skeleton-text>
</ion-item>
</ng-container>
<ng-container *ngIf="!loading">
<p *ngIf="technicians.length === 0" class="ion-text-center" style="color: var(--ion-color-medium); margin-top: 32px">
No tienes técnicos en tu equipo. Agrégalos desde la sección <strong>Técnicos</strong> en el menú.
</p>
<ion-list>
<ion-item *ngFor="let tech of technicians">
<ion-avatar slot="start">
<img *ngIf="tech.profile_photo" [src]="tech.profile_photo" (error)="tech.profile_photo=null">
<ion-icon *ngIf="!tech.profile_photo" name="person-circle" style="width:100%;height:100%;color:var(--ion-color-medium)"></ion-icon>
</ion-avatar>
<ion-label>
<h3>{{ tech.name }}</h3>
<p>{{ tech.email }}</p>
</ion-label>
<ion-button slot="end" (click)="assign(tech)" [disabled]="assigning">
Asignar
</ion-button>
</ion-item>
</ion-list>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,59 @@
<ion-header>
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-menu-button></ion-menu-button>
</ion-buttons>
<ion-title>Técnicos</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-refresher slot="fixed" (ionRefresh)="refresh($event)">
<ion-refresher-content></ion-refresher-content>
</ion-refresher>
<ion-item lines="full" style="margin-bottom: 16px; --padding-start: 0">
<ion-label position="stacked">Agregar técnico</ion-label>
<ion-input
[(ngModel)]="searchEmail"
type="email"
placeholder="Correo electrónico del técnico"
></ion-input>
<ion-button slot="end" (click)="addTechnician()" [disabled]="searching || !searchEmail.trim()">
<ion-icon slot="start" name="person-add-outline"></ion-icon>
Agregar
</ion-button>
</ion-item>
<h3 style="margin-top: 16px">Mi equipo</h3>
<ng-container *ngIf="loading">
<ion-item *ngFor="let _ of [1,2,3]">
<ion-label>
<ion-skeleton-text [animated]="true" style="width: 60%; height: 16px; margin-bottom: 6px"></ion-skeleton-text>
<ion-skeleton-text [animated]="true" style="width: 45%"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-container>
<ng-container *ngIf="!loading">
<p *ngIf="technicians.length === 0" class="ion-text-center" style="color: var(--ion-color-medium); margin-top: 32px">
Aún no tienes técnicos en tu equipo.
</p>
<ion-list>
<ion-item *ngFor="let tech of technicians">
<ion-avatar slot="start">
<img *ngIf="tech.profile_photo" [src]="tech.profile_photo" (error)="tech.profile_photo=null">
<ion-icon *ngIf="!tech.profile_photo" name="person-circle" style="width:100%;height:100%;color:var(--ion-color-medium)"></ion-icon>
</ion-avatar>
<ion-label>
<h3>{{ tech.name }}</h3>
<p>{{ tech.email }}</p>
</ion-label>
<ion-button slot="end" fill="clear" color="danger" (click)="confirmRemove(tech)">
<ion-icon slot="icon-only" name="trash"></ion-icon>
</ion-button>
</ion-item>
</ion-list>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,124 @@
import { Component, ChangeDetectorRef } from '@angular/core';
import { AlertController } from '@ionic/angular';
import { IchambaService } from 'src/app/services/ichamba.service';
import { AlertService } from 'src/app/services/alert.service';
@Component({
selector: 'app-team',
templateUrl: './team.page.html',
styleUrls: ['./team.page.scss'],
standalone: false
})
export class TeamPage {
technicians: any[] = [];
loading = true;
searchEmail = '';
searching = false;
constructor(
private ichambaService: IchambaService,
private alertService: AlertService,
private alertCtrl: AlertController,
private cdr: ChangeDetectorRef,
) {}
ionViewWillEnter() {
this.loadTechnicians();
}
loadTechnicians() {
this.loading = true;
this.ichambaService.getTeamTechnicians().subscribe({
next: data => {
this.technicians = data;
this.loading = false;
this.cdr.detectChanges();
},
error: () => {
this.loading = false;
this.cdr.detectChanges();
}
});
}
refresh(event: any) {
this.ichambaService.getTeamTechnicians().subscribe({
next: data => {
this.technicians = data;
this.cdr.detectChanges();
event.target.complete();
},
error: () => {
event.target.complete();
}
});
}
async addTechnician() {
const email = this.searchEmail.trim();
if (!email || this.searching) return;
this.searching = true;
this.cdr.detectChanges();
this.ichambaService.findUserByEmail(email).subscribe({
next: async (user: any) => {
this.searching = false;
this.cdr.detectChanges();
const alert = await this.alertCtrl.create({
header: 'Confirmar',
message: ' ',
buttons: [
{ text: 'Cancelar', role: 'cancel' },
{ text: 'Agregar', handler: () => this.confirmAdd(user.id) }
]
});
await alert.present();
alert.querySelector('.alert-message')!.innerHTML = `¿Estás seguro de agregar a <strong>${user.name}</strong> a tu compañía?`;
},
error: () => {
this.searching = false;
this.alertService.presentToast('No existe ningún usuario asignado a esa cuenta');
this.cdr.detectChanges();
}
});
}
confirmAdd(userId: any) {
this.ichambaService.addTechnicianByUserId(userId).subscribe({
next: () => {
this.alertService.presentToast('Técnico agregado exitosamente');
this.searchEmail = '';
this.loadTechnicians();
},
error: () => {
this.alertService.presentToast('No se pudo agregar el técnico.');
}
});
}
async confirmRemove(tech: any) {
const alert = await this.alertCtrl.create({
header: 'Eliminar técnico',
message: ' ',
buttons: [
{ text: 'Cancelar', role: 'cancel' },
{ text: 'Eliminar', role: 'destructive', handler: () => this.removeTechnician(tech.id) }
]
});
await alert.present();
alert.querySelector('.alert-message')!.innerHTML = `¿Estás seguro de eliminar a <strong>${tech.name}</strong> de tu equipo?`;
}
removeTechnician(technicianId: any) {
this.ichambaService.removeTechnician(technicianId).subscribe({
next: () => {
this.alertService.presentToast('Técnico eliminado');
this.loadTechnicians();
},
error: () => {
this.alertService.presentToast('No se pudo eliminar el técnico.');
}
});
}
}

View File

@@ -12,7 +12,8 @@ export class AlertService {
message: message, message: message,
duration: 5000, duration: 5000,
position: 'bottom', position: 'bottom',
color: 'dark' color: 'dark',
buttons: [{ icon: 'close', role: 'cancel' }]
}); });
toast.present(); toast.present();
} }

View File

@@ -27,6 +27,106 @@
/* Swiper styles - Swiper 11 uses bundled styles */ /* Swiper styles - Swiper 11 uses bundled styles */
/* Safe area bottom padding for edge-to-edge layout */
ion-content.ion-padding {
--padding-bottom: env(safe-area-inset-bottom);
}
ion-footer {
padding-bottom: env(safe-area-inset-bottom);
}
/* Dark mode overrides para componentes que no heredan --ion-background-color */
@media (prefers-color-scheme: dark) {
/* Menú lateral */
ion-menu ion-content {
--background: #1B1B1B;
}
ion-menu ion-list {
background: #1B1B1B;
}
ion-menu ion-item {
--border-color: #2d2d2d;
--color: #ffffff;
}
/* Searchbar: .autocomplete sube especificidad a 0,1,1 para ganar a .sc-ion-searchbar-md-h (0,1,0) */
.autocomplete ion-searchbar {
--background: #2a2a2a;
--color: #ffffff;
--icon-color: #888888;
--cancel-button-color: #888888;
--clear-button-color: #888888;
}
/* Listboxes de autocomplete: color y borde en dark */
ion-list ion-item {
--background: transparent;
--color: #ffffff;
--border-color: #808080;
}
/* Dropdown: borde y sombra en dark */
.dropdown-list {
border-color: #808080;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
}
.dropdown-list ion-item:hover {
--background: #252525;
}
/* FAQ acordeones: adaptar color="light" hardcodeado al dark theme */
ion-accordion ion-item {
--ion-color-light: var(--ion-card-background);
--ion-color-light-rgb: 40, 40, 40;
--ion-color-light-contrast: #ffffff;
--ion-color-light-contrast-rgb: 255, 255, 255;
--ion-color-light-shade: #161616;
--ion-color-light-tint: #252525;
}
ion-accordion [slot="content"] {
background: var(--ion-background-color);
color: var(--ion-text-color);
}
/* Welcome card del dashboard: sin fondo */
.welcome-card {
--background: transparent;
box-shadow: none !important;
}
/* Texto dentro de ion-card en dark mode */
ion-card {
color: #ffffff;
}
ion-card ion-card-title,
ion-card ion-card-subtitle,
ion-card ion-card-header,
ion-card ion-card-content {
color: #ffffff;
}
.sc-ion-label-md-s > p {
color: var(--ion-color-step-800, #cccccc);
}
}
/* Dropdown de autocomplete: fondo sólido adaptable al tema */
.dropdown-list {
background: var(--ion-background-color, #ffffff);
}
/* Separador entre ion-item y botón en tarjetas de contrato */
.no-separator {
--border-color: transparent;
border: 0;
}
ion-tab-button {
--padding-start: 2px;
--padding-end: 2px;
}
/* Desktop/Web responsive styles */ /* Desktop/Web responsive styles */
ion-app { ion-app {
max-width: 100% !important; max-width: 100% !important;

View File

@@ -11,6 +11,11 @@
} }
:root { :root {
--ion-safe-area-top: env(safe-area-inset-top, 0px);
--ion-safe-area-bottom: env(safe-area-inset-bottom, 0px);
--ion-safe-area-left: env(safe-area-inset-left, 0px);
--ion-safe-area-right: env(safe-area-inset-right, 0px);
/** primary **/ /** primary **/
--ion-color-primary: #0080ff; --ion-color-primary: #0080ff;
--ion-color-primary-rgb: 0, 128, 255; --ion-color-primary-rgb: 0, 128, 255;
@@ -86,3 +91,40 @@
/** social networks **/ /** social networks **/
--ion-color-facebook: #3b5998; --ion-color-facebook: #3b5998;
} }
@media (prefers-color-scheme: dark) {
:root {
--ion-background-color: #1B1B1B;
--ion-background-color-rgb: 27, 27, 27;
--ion-text-color: #ffffff;
--ion-text-color-rgb: 255, 255, 255;
--ion-item-background: transparent;
--ion-toolbar-background: #111111;
--ion-tab-bar-background: #111111;
--ion-card-background: #252525;
--ion-border-color: #808080;
--ion-color-step-50: #1a1a1a;
--ion-color-step-100: #1e1e1e;
--ion-color-step-150: #252525;
--ion-color-step-200: #2b2b2b;
--ion-color-step-250: #323232;
--ion-color-step-300: #393939;
--ion-color-step-350: #3f3f3f;
--ion-color-step-400: #464646;
--ion-color-step-500: #545454;
--ion-color-step-600: #616161;
--ion-color-step-700: #6f6f6f;
--ion-color-step-800: #d1d1d1;
--ion-color-step-800: #9e9e9e;
--ion-color-step-850: #b0b0b0;
--ion-color-step-900: #c3c3c3;
--ion-color-step-950: #d6d6d6;
}
body {
background-color: #1B1B1B;
}
}