1 Commits
main ... betos

Author SHA1 Message Date
48979dca6f feat: Branch betos — propiedades, rediseño postulaciones, chat contratos y flujo cancelar/repostular
- Sistema de propiedades: modal add-property con autocomplete y selector de ícono
- category.page rediseñado: selector de propiedad, fotos (Capacitor Camera), sin fecha/precio
- Modal postulate-offer: proveedor propone 2 fechas requeridas + monto con validación de cuota mínima
- viewsuppliers: chips de ordenamiento frontend (fecha, precio, certificados)
- hire.page: selección de fecha (date_1/date_2) antes de contratar; selected_date en contracts/create y contracts/coupon
- Modal contract-discussion: chat de burbujas para contratos activos
- contracted.page: botón de chat por contrato
- pending.page: botones cancelar y repostular por postulación
- Modal repostulate: pre-llena categoría/propiedad, archiva original como perdido y crea nueva con related_postulation_id
- current.page: temporizador de expiración basado en time_created (1000 min), oculta expiradas
- ichamba.service: nuevos métodos properties, cancelPostulation, contractDiscussion, updated signatures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 11:26:48 -06:00
38 changed files with 1534 additions and 617 deletions

View File

@@ -2,17 +2,13 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Routes, RouterModule } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { CategoryPage } from './category.page';
import { TranslateModule } from '@ngx-translate/core'
import { TranslateModule } from '@ngx-translate/core';
import { AddPropertyPageModule } from '../property/add-property/add-property.module';
const routes: Routes = [
{
path: '',
component: CategoryPage
}
{ path: '', component: CategoryPage }
];
@NgModule({
@@ -21,7 +17,8 @@ const routes: Routes = [
FormsModule,
IonicModule,
RouterModule.forChild(routes),
TranslateModule
TranslateModule,
AddPropertyPageModule,
],
declarations: [CategoryPage]
})

View File

@@ -1,64 +1,66 @@
<style>
ion-list {
position: absolute;
width: 100%;
max-height: 50%;
z-index: 999;
}
ion-item.place:active:after {
cursor: pointer;
background-color: #ff7d00 !important;
}
</style>
<ion-header>
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
<ion-title>{{'categories.header' | translate}}</ion-title>
<ion-title>Nueva postulación</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-content class="ion-padding">
<ion-item>
<ion-label position="fixed">{{'categories.category' | translate}}</ion-label>
<ion-input value="{{ category }}" disabled></ion-input>
<ion-label position="fixed">Categoría</ion-label>
<ion-input [value]="category" disabled></ion-input>
</ion-item>
<!-- Selector de propiedad -->
<ion-item>
<ion-label position="fixed">{{'categories.address' | translate}}</ion-label>
<ion-input [(ngModel)]="addressAutocomplete" (ionInput)="autocomplete($event)" (ionFocus)="showlist()"></ion-input>
<ion-label position="fixed">Propiedad</ion-label>
<ion-select [(ngModel)]="selectedPropertyId" placeholder="Selecciona una propiedad" interface="action-sheet">
<ion-select-option *ngFor="let p of properties" [value]="p.id">
<ion-icon [name]="p.icon"></ion-icon> {{ p.name }}: {{ p.address }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-list *ngIf="showif && placesSearch && placesSearch.length > 0">
<ion-item button=true (click)="geoloc(places.place_id, places.description)" *ngFor="let places of placesSearch" class="place">
{{ places.description }}
<ion-item button="true" (click)="openAddProperty()" lines="none">
<ion-icon name="add-circle-outline" slot="start" color="primary"></ion-icon>
<ion-label color="primary">Agregar propiedad</ion-label>
</ion-item>
</ion-list>
<ion-item>
<ion-label position="fixed">{{'categories.int_number' | translate}}</ion-label>
<ion-input [(ngModel)]="myIntnumber" placeholder="Opcional"></ion-input>
<ion-label position="fixed">Referencias</ion-label>
<ion-textarea [(ngModel)]="references" placeholder="Ej. Frente al parque, casa azul..."></ion-textarea>
</ion-item>
<ion-item>
<ion-label position="fixed">{{'categories.references' | translate}}</ion-label>
<ion-textarea [(ngModel)]="references" placeholder="{{'categories.references_placeholder' | translate}}"></ion-textarea>
<ion-label position="fixed">Comentarios</ion-label>
<ion-textarea [(ngModel)]="details" placeholder="Describe lo que necesitas..."></ion-textarea>
</ion-item>
<ion-item hidden>
<ion-label position="fixed">Latitud</ion-label>
<ion-input value="{{ myPosition.latitude }}" disabled></ion-input>
</ion-item>
<ion-item hidden>
<ion-label position="fixed">Longitud</ion-label>
<ion-input value="{{ myPosition.longitude }}" disabled></ion-input>
</ion-item>
<ion-item>
<ion-label position="fixed">{{'categories.date' | translate}}</ion-label>
<ion-datetime presentation="date" [(ngModel)]="setDate" (ionChange)="changedate($event)" [min]="minyear" [max]="maxyear"></ion-datetime>
</ion-item>
<ion-item>
<ion-label position="fixed">{{'categories.time' | translate}}</ion-label>
<ion-datetime presentation="time" [(ngModel)]="setHour" (ionChange)="changehour($event)"></ion-datetime>
</ion-item>
<ion-item>
<ion-label position="fixed">{{'categories.commentaries' | translate}}</ion-label>
<ion-textarea [(ngModel)]="details"></ion-textarea>
</ion-item>
<ion-button type="submit" expand="full" color="primary" (click)="addpostulation()">{{'categories.add_postulation' | translate}}</ion-button>
<!-- Fotos -->
<div style="padding: 12px 0">
<ion-label style="font-size: 0.9rem; color: var(--ion-color-medium); padding-left: 4px">
Fotos (opcional, máx. 3)
</ion-label>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px">
<div *ngFor="let photo of photos; let i = index" style="position: relative">
<img [src]="photo" style="width: 80px; height: 80px; object-fit: cover; border-radius: 8px">
<ion-button fill="clear" color="danger" size="small"
style="position: absolute; top: -8px; right: -8px; --padding-start: 0; --padding-end: 0"
(click)="removePhoto(i)">
<ion-icon slot="icon-only" name="close-circle"></ion-icon>
</ion-button>
</div>
<ion-button *ngIf="photos.length < 3" fill="outline" color="medium"
style="width: 80px; height: 80px; --border-radius: 8px" (click)="addPhoto()">
<ion-icon slot="icon-only" name="camera-outline"></ion-icon>
</ion-button>
</div>
</div>
<ion-button type="submit" expand="full" color="primary" style="margin-top: 2em" (click)="addpostulation()">
Crear postulación
</ion-button>
</ion-content>

View File

@@ -1,13 +1,10 @@
import { Component, OnInit, NgZone } from '@angular/core';
import { NavController, LoadingController } from '@ionic/angular';
import { Component, OnInit, ChangeDetectorRef } from '@angular/core';
import { NavController, LoadingController, ModalController } from '@ionic/angular';
import { ActivatedRoute } from '@angular/router';
import { AuthService } from 'src/app/services/auth.service';
import { AlertService } from 'src/app/services/alert.service';
import { IchambaService } from 'src/app/services/ichamba.service';
import { Geolocation } from '@capacitor/geolocation';
import { TranslateService } from '@ngx-translate/core';
declare var google: any;
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { AddPropertyPage } from '../property/add-property/add-property.page';
@Component({
selector: 'app-category',
@@ -19,212 +16,102 @@ export class CategoryPage implements OnInit {
private loading: any;
category: any = null;
//money: number = null;
//amount: number = null;
//amount_check = true;
minyear: string = (new Date().getFullYear() + '-' + ((new Date().getMonth() + 1) < 10 ? '0' : '') + (new Date().getMonth() + 1) + '-' + (new Date().getDate() < 10 ? '0' : '') + new Date().getDate()).toString();
maxyear: string | null = null;
mintime: number = 0;
maxtime: number = 0;
setHour: any;
setDate: any;
references: string = '';
details: string = '';
differenceDate: number = 0;
differenceHour: number = 0;
myPosition: any = {};
myAddress: string | null = null;
myIntnumber: string | null = null;
addressAutocomplete: string = '';
placesSearch: any = '';
showif = true;
photos: string[] = [];
properties: any[] = [];
selectedPropertyId: number | null = null;
loadingProperties = true;
constructor(
private authService: AuthService,
private navCtrl: NavController,
private alertService: AlertService,
private ichambaService: IchambaService,
private activatedRoute: ActivatedRoute,
private translateService: TranslateService,
private ngZone: NgZone,
private loadingCtrl: LoadingController
) {
console.log(google);
}
private loadingCtrl: LoadingController,
private modalCtrl: ModalController,
private cdr: ChangeDetectorRef,
) {}
ngOnInit() {
this.category = this.activatedRoute.snapshot.paramMap.get('category');
this.ichambaService.getParameters()
.subscribe( (data: any) => {
this.mintime = data['min_time'];
this.maxtime = data['max_time'];
});
this.getCurrentLocation();
console.log(this.minyear);
// Máximo 2 días de anticipación
const maxDate = new Date();
maxDate.setDate(maxDate.getDate() + 2);
this.maxyear = maxDate.toISOString().slice(0, 10);
console.log(this.maxyear);
this.loadProperties();
}
async getCurrentLocation() {
try {
// Request permissions first
const permissionStatus = await Geolocation.checkPermissions();
if (permissionStatus.location !== 'granted') {
await Geolocation.requestPermissions();
loadProperties() {
this.loadingProperties = true;
this.ichambaService.getProperties().subscribe({
next: (data: any[]) => {
this.properties = data;
this.loadingProperties = false;
this.cdr.detectChanges();
if (data.length === 0) {
this.openAddProperty();
}
const position = await Geolocation.getCurrentPosition({
enableHighAccuracy: true,
timeout: 30000
});
console.log(position);
this.myPosition = {
latitude: position.coords.latitude,
longitude: position.coords.longitude
};
this.doGeocode();
} catch (error) {
console.log(this.translateService.instant('alerts.geo_error') + error);
},
error: () => {
this.loadingProperties = false;
this.cdr.detectChanges();
}
}
doGeocode() {
// Use Google Maps JavaScript API for geocoding
const geocoder = new google.maps.Geocoder();
const latlng = { lat: this.myPosition.latitude, lng: this.myPosition.longitude };
geocoder.geocode({ location: latlng }, (results: any, status: any) => {
this.ngZone.run(() => {
if (status === 'OK' && results[0]) {
const address = results[0].formatted_address;
console.log("data_: ", address);
this.myAddress = address;
this.addressAutocomplete = address; // Sincronizar con el input
}
});
});
}
/* check (ev: any) {
this.amount = parseFloat(ev.target.value.replace(",", ""));
console.log(this.amount);
if (this.amount < 150) {
this.amount_check = false;
} else {
this.amount_check = true;
async openAddProperty() {
const modal = await this.modalCtrl.create({ component: AddPropertyPage });
await modal.present();
const { data } = await modal.onDidDismiss();
if (data?.property) {
this.properties.push(data.property);
this.selectedPropertyId = data.property.id;
this.cdr.detectChanges();
}
}*/
changehour (ev: any) {
console.log(this.setHour);
}
changedate (ev: any) {
console.log(ev.target.value);
let current_date = (new Date().toISOString().slice(0, 10)).toString();
let selected_date = (new Date(this.setDate).toISOString().slice(0, 10)).toString();
this.differenceDate = (Number(new Date(selected_date)) - Number(new Date(current_date)));
console.log(this.differenceDate);
}
autocomplete(ev: any) {
if (!this.addressAutocomplete.trim().length) {
this.placesSearch = null;
}
console.log(this.addressAutocomplete)
new google.maps.places.AutocompleteService().getPredictions({ input: this.addressAutocomplete }, (predictions: any) => {
this.ngZone.run(() => {
this.placesSearch = predictions;
console.log(predictions);
});
});
}
geoloc(place_id: string, place_description: string) {
this.myAddress = place_description;
this.addressAutocomplete = place_description; // Sincronizar con el input
console.log(place_id);
this.hidelist();
new google.maps.Geocoder().geocode({ placeId: place_id }, (coordinates: any) => {
this.ngZone.run(() => {
console.log(coordinates[0].geometry.location.lat() + ", " + coordinates[0].geometry.location.lng());
this.myPosition = {
latitude: coordinates[0].geometry.location.lat(),
longitude: coordinates[0].geometry.location.lng()
}
});
});
}
addpostulation() {
// Validar que fecha y hora estén seleccionadas
if (!this.setDate || !this.setHour) {
this.alertService.presentToast(this.translateService.instant('alerts.select_date_time') || 'Selecciona fecha y hora');
async addPhoto() {
if (this.photos.length >= 3) {
this.alertService.presentToast('Máximo 3 fotos.');
return;
}
// Calcular diferencia de fecha directamente
const today = new Date();
today.setHours(0, 0, 0, 0);
const selectedDate = new Date(this.setDate);
selectedDate.setHours(0, 0, 0, 0);
this.differenceDate = selectedDate.getTime() - today.getTime();
// Calcular diferencia de hora
this.differenceHour = (new Date(this.setHour).getTime() - new Date().getTime());
console.log("differenceDate:", this.differenceDate);
console.log("differenceHour:", this.differenceHour);
console.log("mintime:", this.mintime * 3600000);
// Máximo 2 días de anticipación (172800000 ms = 2 días)
const maxDays = 2 * 24 * 60 * 60 * 1000;
console.log("maxDays:", maxDays);
// Validación de tiempo mínimo (mismo día y menos de mintime horas)
if (this.differenceDate < 86400000 && this.differenceHour < (this.mintime * 3600000)) {
this.alertService.presentToast(this.translateService.instant('alerts.categories_mintime'));
} else if (this.differenceDate <= maxDays) {
if (this.myAddress && this.myPosition.latitude && this.myPosition.longitude) {
this.loadingCtrl.create().then((overlay) => {
this.loading = overlay;
this.loading.present();
try {
const image = await Camera.getPhoto({
quality: 70,
resultType: CameraResultType.Base64,
source: CameraSource.Prompt,
});
if (!Number.isInteger(parseInt(this.myIntnumber ?? ''))) {
this.myIntnumber = null;
this.photos.push('data:image/jpeg;base64,' + image.base64String);
this.cdr.detectChanges();
} catch (e) {}
}
this.ichambaService.addPostulation(this.category, this.myAddress!, this.myIntnumber ?? '', this.references, this.myPosition.latitude, this.myPosition.longitude, this.setDate, this.setHour, this.details).subscribe(
(data: any) => {
if (this.loading) this.loading.dismiss();
if (data['message'] == 'No providers') {
this.alertService.presentToast(this.translateService.instant('alerts.no_provider'));
removePhoto(index: number) {
this.photos.splice(index, 1);
this.cdr.detectChanges();
}
async addpostulation() {
if (!this.selectedPropertyId) {
this.alertService.presentToast('Selecciona una propiedad.');
return;
}
this.loading = await this.loadingCtrl.create();
await this.loading.present();
this.ichambaService.addPostulation(
this.category, this.selectedPropertyId, this.references, this.details, this.photos
).subscribe({
next: (data: any) => {
this.loading.dismiss();
if (data['message'] === 'No providers') {
this.alertService.presentToast('No hay proveedores disponibles para esta categoría.');
} else {
this.navCtrl.navigateRoot('/dashboard');
this.alertService.presentToast(this.translateService.instant('alerts.categories_success'));
this.alertService.presentToast('¡Postulación creada con éxito!');
}
},
error: (error: any) => {
this.loading.dismiss();
this.alertService.presentToast('Error: ' + error['status']);
}
}, (error: any) => {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('alerts.error') + error['status']);
});
} else {
this.alertService.presentToast(this.translateService.instant('alerts.valid_address'));
}
} else {
this.alertService.presentToast(this.translateService.instant('alerts.categories_maxtime'));
}
}
showlist() {
this.showif = true;
}
hidelist() {
this.showif = false;
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { ContractDiscussionPage } from './contract-discussion.page';
@NgModule({
imports: [CommonModule, FormsModule, IonicModule],
declarations: [ContractDiscussionPage],
exports: [ContractDiscussionPage]
})
export class ContractDiscussionPageModule {}

View File

@@ -0,0 +1,80 @@
<ion-header>
<ion-toolbar color="primary">
<ion-buttons slot="start">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="arrow-back"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>Chat del Contrato</ion-title>
</ion-toolbar>
</ion-header>
<ion-content #content>
<div class="messages-container">
<ng-container *ngIf="loading">
<div class="message-wrapper left">
<ion-skeleton-text [animated]="true" style="width: 55%; height: 54px; border-radius: 18px;"></ion-skeleton-text>
</div>
<div class="message-wrapper right">
<ion-skeleton-text [animated]="true" style="width: 42%; height: 40px; border-radius: 18px;"></ion-skeleton-text>
</div>
<div class="message-wrapper left">
<ion-skeleton-text [animated]="true" style="width: 60%; height: 48px; border-radius: 18px;"></ion-skeleton-text>
</div>
</ng-container>
<div class="empty-state" *ngIf="!loading && messages.length === 0">
<ion-icon name="chatbubbles-outline" style="font-size: 48px; margin-bottom: 12px;"></ion-icon>
<p>No hay mensajes aún.</p>
</div>
<ng-container *ngIf="!loading">
<ng-container *ngFor="let msg of messages">
<div class="message-wrapper center" *ngIf="msg.sender_type === 'moderator'">
<div class="message-bubble moderator">
<span class="sender-name">Moderador</span>
<p>{{ msg.comment }}</p>
<span class="timestamp">{{ msg.created_at | date:'d MMM, HH:mm' }}</span>
</div>
</div>
<div class="message-wrapper right"
*ngIf="msg.sender_type !== 'moderator' && msg.sender_id === currentUserId">
<div class="message-bubble mine">
<p>{{ msg.comment }}</p>
<span class="timestamp">{{ msg.created_at | date:'d MMM, HH:mm' }}</span>
</div>
</div>
<div class="message-wrapper left"
*ngIf="msg.sender_type !== 'moderator' && msg.sender_id !== currentUserId">
<div class="message-bubble other">
<span class="sender-name">{{ msg.sender_name }}</span>
<p>{{ msg.comment }}</p>
<span class="timestamp">{{ msg.created_at | date:'d MMM, HH:mm' }}</span>
</div>
</div>
</ng-container>
</ng-container>
</div>
</ion-content>
<ion-footer>
<div class="input-bar">
<ion-textarea
[(ngModel)]="newMessage"
placeholder="Escribe un mensaje..."
rows="1"
autoGrow="true"
style="--highlight-height: 0"
(keydown.enter)="$event.preventDefault(); newMessage.trim() && !sending && sendMessage()"
></ion-textarea>
<ion-button fill="clear" color="primary" [disabled]="!newMessage.trim() || sending" (click)="sendMessage()">
<ion-icon slot="icon-only" name="send"></ion-icon>
</ion-button>
</div>
</ion-footer>

View File

@@ -0,0 +1,117 @@
.messages-container {
display: flex;
flex-direction: column;
padding: 16px;
min-height: 100%;
}
.message-wrapper {
display: flex;
margin-bottom: 10px;
&.right { justify-content: flex-end; }
&.left { justify-content: flex-start; }
&.center { justify-content: center; }
}
.message-bubble {
max-width: 72%;
padding: 10px 14px;
border-radius: 18px;
word-break: break-word;
p {
margin: 0 0 4px 0;
font-size: 0.95rem;
line-height: 1.4;
}
.sender-name {
display: block;
font-size: 0.72rem;
font-weight: 600;
margin-bottom: 4px;
opacity: 0.75;
}
.timestamp {
display: block;
font-size: 0.68rem;
margin-top: 4px;
opacity: 0.65;
text-align: right;
}
&.mine {
background: var(--ion-color-primary);
color: #fff;
border-bottom-right-radius: 4px;
}
&.other {
background: var(--ion-color-light);
color: var(--ion-color-dark);
border-bottom-left-radius: 4px;
}
&.moderator {
background: var(--ion-color-medium);
color: #fff;
max-width: 80%;
text-align: center;
border-radius: 10px;
font-style: italic;
.sender-name {
text-align: center;
font-weight: 700;
opacity: 1;
}
.timestamp {
text-align: center;
}
}
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 16px;
color: var(--ion-color-medium);
p {
margin: 0;
font-size: 0.95rem;
}
}
.input-bar {
display: flex;
align-items: flex-end;
padding: 6px;
gap: 2px;
background: var(--ion-tab-bar-background, #fff);
border-top: 1px solid var(--ion-color-light-shade);
ion-textarea {
flex: 1;
--padding-start: 14px;
--padding-end: 14px;
--padding-top: 8px;
--padding-bottom: 8px;
--background: var(--ion-color-step-100, #f4f5f8);
border-radius: 20px;
margin: 0;
max-height: 120px;
}
ion-button {
--padding-start: 6px;
--padding-end: 6px;
min-height: 40px;
}
}

View File

@@ -0,0 +1,76 @@
import { Component, Input, OnInit, ViewChild, ChangeDetectorRef } from '@angular/core';
import { IonContent, ModalController } from '@ionic/angular';
import { IchambaService } from 'src/app/services/ichamba.service';
import { AuthService } from 'src/app/services/auth.service';
import { AlertService } from 'src/app/services/alert.service';
@Component({
selector: 'app-contract-discussion',
templateUrl: './contract-discussion.page.html',
styleUrls: ['./contract-discussion.page.scss'],
standalone: false
})
export class ContractDiscussionPage implements OnInit {
@Input() contractId: any;
@ViewChild('content') content!: IonContent;
messages: any[] = [];
newMessage: string = '';
currentUserId: any;
loading = true;
sending = false;
constructor(
private modalCtrl: ModalController,
private ichambaService: IchambaService,
private authService: AuthService,
private alertService: AlertService,
private cdr: ChangeDetectorRef,
) {}
ngOnInit() {
this.currentUserId = this.authService.userId;
this.loadMessages();
}
loadMessages() {
this.loading = true;
this.ichambaService.getContractDiscussion(this.contractId).subscribe({
next: data => {
this.messages = data;
this.loading = false;
this.cdr.detectChanges();
setTimeout(() => this.content.scrollToBottom(300), 100);
},
error: () => {
this.loading = false;
this.cdr.detectChanges();
}
});
}
sendMessage() {
const text = this.newMessage.trim();
if (!text || this.sending) return;
this.newMessage = '';
this.sending = true;
this.ichambaService.sendContractMessage(this.contractId, text).subscribe({
next: (msg: any) => {
this.messages.push(msg);
this.sending = false;
this.cdr.detectChanges();
setTimeout(() => this.content.scrollToBottom(300), 100);
},
error: () => {
this.sending = false;
this.alertService.presentToast('No se pudo enviar el mensaje.');
}
});
}
dismiss() {
this.modalCtrl.dismiss();
}
}

View File

@@ -2,17 +2,13 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Routes, RouterModule } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { ContractedPage } from './contracted.page';
import { TranslateModule } from '@ngx-translate/core'
import { TranslateModule } from '@ngx-translate/core';
import { ContractDiscussionPageModule } from '../contract-discussion/contract-discussion.module';
const routes: Routes = [
{
path: '',
component: ContractedPage
}
{ path: '', component: ContractedPage }
];
@NgModule({
@@ -21,7 +17,8 @@ const routes: Routes = [
FormsModule,
IonicModule,
RouterModule.forChild(routes),
TranslateModule
TranslateModule,
ContractDiscussionPageModule,
],
declarations: [ContractedPage]
})

View File

@@ -48,8 +48,12 @@
<p class="ion-text-wrap">{{'contracts.info_pin' | translate}}</p>
</ion-label>
</ion-item>
<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 style="display: flex; justify-content: space-between; padding: 0.5em 1em 1.25em; align-items: center">
<ion-button fill="outline" color="primary" (click)="openDiscussion(ccontract.id)">
<ion-icon slot="start" name="chatbubbles-outline"></ion-icon>
Chat
</ion-button>
<ion-button *ngIf="ccontract.past_due < 0" 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>
</ng-container>

View File

@@ -1,5 +1,6 @@
import { Component, ChangeDetectorRef } from '@angular/core';
import { ModalController, MenuController, NavController } from '@ionic/angular';
import { ContractDiscussionPage } from '../contract-discussion/contract-discussion.page';
import { EventService } from '../../../services/event.service';
import { EnvService } from 'src/app/services/env.service';
import { AuthService } from 'src/app/services/auth.service';
@@ -99,6 +100,14 @@ export class ContractedPage {
//}
}
async openDiscussion(contractId: any) {
const modal = await this.modalController.create({
component: ContractDiscussionPage,
componentProps: { contractId },
});
await modal.present();
}
async presentAlert(id: String) {
const alert = await this.alertController.create({
header: 'Atención',

View File

@@ -6,9 +6,11 @@
<ion-title>{{'contracts.hire' | translate}}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p class="ion-text-wrap">{{'contracts.hire_info_1' | translate}} <b>{{ supplier }}</b> {{'contracts.hire_info_2' | translate}}<br>
{{'contracts.hire_pay' | translate}}</p><br>
<ion-row>
<ion-col size="9">
<p class="ion-text-start" style="margin:0">{{'contracts.fee_amount' | translate}}:</p>
@@ -34,82 +36,36 @@
</ion-col>
</ion-row>
<br>
<p><b>Selecciona la fecha en que recibirás el servicio:</b></p>
<ion-list>
<ion-radio-group [(ngModel)]="selected_date">
<ion-item>
<ion-radio value="date_1" slot="start"></ion-radio>
<ion-label class="ion-text-wrap">{{ date_1_formatted }}</ion-label>
</ion-item>
<ion-item>
<ion-radio value="date_2" slot="start"></ion-radio>
<ion-label class="ion-text-wrap">{{ date_2_formatted }}</ion-label>
</ion-item>
</ion-radio-group>
</ion-list>
<br>
<ion-list>
<ion-item>
<ion-label position="fixed">{{'contracts.coupon' | translate}}:</ion-label>
<ion-input [(ngModel)]="coupon" (ngModelChange)="coupon = $event.toLocaleUpperCase()"></ion-input>
<ion-button style="height: 3em" item-end (click)="checkCoupon()" expand="full" color="primary">{{'contracts.validate' | translate}}</ion-button>
<ion-button style="height: 3em" slot="end" (click)="checkCoupon()" color="primary">{{'contracts.validate' | translate}}</ion-button>
</ion-item>
</ion-list>
<ion-item *ngIf="!paymentBypass">
<ion-label>{{'cards.card' | translate}}</ion-label>
<ion-select style="max-width:90%" interface="action-sheet" [(ngModel)]="cards_menu" (ionChange)="selected_card($event)">
<ion-select-option>Agregar tarjeta</ion-select-option>
<ion-select-option *ngFor="let card of cards" [value]="card.id">{{card.brand}} - {{card.card_number}}</ion-select-option>
</ion-select>
</ion-item>
<form #form="ngForm" id="card_form" (ngSubmit)="createContract(form)" method="post">
<ion-item *ngIf="!paymentBypass" [class.ng-invalid]="!code_check">
<ion-label position="floating">CVV</ion-label>
<ion-input type="password" ngModel name="code" (ionChange)="code_checker($event)" [class.ng-invalid]="!code_check" size="3" maxlength="4"></ion-input>
</ion-item>
<ion-note *ngIf="paymentBypass" color="warning" style="display: block; text-align: center; margin: 1em 0;">
<b>MODO DE PRUEBA:</b> El pago será simulado
</ion-note>
<br><br>
<ion-button type="submit" expand="full" color="secondary">{{'contracts.hire_confirm' | translate}}</ion-button>
</form>
<br>
<div *ngIf="!paymentBypass">
<ion-row class="ion-align-items-center">
<ion-col size="4">
<img src="/assets/openpay/openpay_color.png" style="padding:0.25em 0.5em"/>
</ion-col>
<ion-col size=8>
<p>{{'cards.openpay_1' | translate}}</p>
</ion-col>
</ion-row>
<ion-row>
<ion-col size="6">
<p class="ion-text-center">{{'cards.openpay_2' | translate}}<p>
</ion-col>
<ion-col size="6" style="border-left: 1px solid #d0d0d0">
<p class="ion-text-center">{{'cards.openpay_3' | translate}}<p>
</ion-col>
</ion-row>
<ion-row>
<ion-col size="3">
<img src="/assets/openpay/visa.png" style="padding:0.1em 1.0em"/>
</ion-col>
<ion-col size="3">
<img src="/assets/openpay/masterCard.png" style="padding:0.25em 1.5em;"/>
</ion-col>
<ion-col size="2" style="border-left: 1px solid #d0d0d0">
<img src="/assets/openpay/citibanamex.png"/>
</ion-col>
<ion-col size=2>
<img src="/assets/openpay/bancoAzteca.png"/>
</ion-col>
<ion-col size=2>
<img src="/assets/openpay/scotiabank.png"/>
</ion-col>
</ion-row>
<ion-row>
<ion-col size="3">
<img src="/assets/openpay/americanExpress.png" style="padding:0.5em 1.5em"/>
</ion-col>
<ion-col size="3">
<img src="/assets/openpay/carnet.png"/>
</ion-col>
<ion-col size="2" style="border-left: 1px solid #d0d0d0">
<img src="/assets/openpay/inbursa.png"/>
</ion-col>
<ion-col size=2>
<img src="/assets/openpay/santander.png"/>
</ion-col>
<ion-col size=2>
<img src="/assets/openpay/bbva.png"/>
</ion-col>
</ion-row>
</div>
<ion-button expand="full" color="secondary" (click)="createContract()">
{{'contracts.hire_confirm' | translate}}
</ion-button>
</ion-content>

View File

@@ -1,16 +1,13 @@
import { Component, OnInit } from '@angular/core';
import { ModalController, MenuController, NavController, LoadingController } from '@ionic/angular';
import { NavController, LoadingController } from '@ionic/angular';
import { EventService } from '../../../services/event.service';
import { AddcardPage } from 'src/app/pages/card/addcard/addcard.page';
import { ActivatedRoute } from '@angular/router';
import { NgForm } from '@angular/forms';
import { EnvService } from 'src/app/services/env.service';
import { AuthService } from 'src/app/services/auth.service';
import { IchambaService } from 'src/app/services/ichamba.service';
import { TranslateService } from '@ngx-translate/core';
import { AlertService } from 'src/app/services/alert.service';
declare var OpenPay: any;
import { Stripe, PaymentSheetEventsEnum } from '@capacitor-community/stripe';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-hire',
@@ -21,11 +18,6 @@ declare var OpenPay: any;
export class HirePage implements OnInit {
private loading: HTMLIonLoadingElement | null = null;
code_check = true;
cards: any[] = [];
code: any = null;
device_id: any = null;
card_id: any = null;
supplier: any = null;
supplier_id: any = null;
postulation_id: any = null;
@@ -33,13 +25,14 @@ export class HirePage implements OnInit {
discount = 0;
final_amount: any = null;
coupon: any = null;
cards_menu: any = null;
paymentBypass = false;
date_1: string | null = null;
date_2: string | null = null;
selected_date: 'date_1' | 'date_2' | null = null;
date_1_formatted: string = '';
date_2_formatted: string = '';
constructor(
private modalController: ModalController,
private menu: MenuController,
private authService: AuthService,
private alertService: AlertService,
private ichambaService: IchambaService,
private translateService: TranslateService,
@@ -49,122 +42,77 @@ export class HirePage implements OnInit {
private events: EventService,
private env: EnvService,
) {
this.events.subscribe('refreshcards', (data) => {
this.card_id = null;
this.getcards();
});
this.supplier = this.activatedRoute.snapshot.paramMap.get('supplier');
this.supplier_id = this.activatedRoute.snapshot.paramMap.get('supplier_id');
this.postulation_id = this.activatedRoute.snapshot.paramMap.get('postulation_id');
this.fee = this.activatedRoute.snapshot.paramMap.get('fee');
this.final_amount = this.fee;
this.date_1 = this.activatedRoute.snapshot.paramMap.get('date_1');
this.date_2 = this.activatedRoute.snapshot.paramMap.get('date_2');
this.date_1_formatted = this.date_1 ? this.formatDate(this.date_1) : '';
this.date_2_formatted = this.date_2 ? this.formatDate(this.date_2) : '';
}
ngOnInit() {
this.paymentBypass = this.env.PAYMENT_BYPASS;
if (!this.paymentBypass) {
OpenPay.setId(this.env.MERCHANT_ID);
OpenPay.setApiKey(this.env.PUBLIC_API_KEY);
console.log(OpenPay.getSandboxMode());
var deviceDataId = OpenPay.deviceData.setup("card_form");
this.getcards();
}
}
newcard() {
this.navCtrl.navigateForward('/addcard');
}
getcards() {
this.ichambaService.getCard().subscribe(
data => {
this.cards = data;
}, error => {
this.alertService.presentToast(this.translateService.instant('alerts.error') + error['status']);
});
}
code_checker(ev: any) {
if (ev.target.value.length > 4){
console.log(ev.target.value.length);
ev.target.value = ev.target.value.slice(0, 4);
}
this.code_check = OpenPay.card.validateCVC(ev.target.value);
console.log(this.code_check);
}
selected_card(ev: any) {
this.card_id = ev.target.value;
console.log(this.card_id);
if (this.card_id == "Agregar tarjeta") {
console.log(this.card_id);
ev.target.value = null;
this.newcard();
}
private formatDate(iso: string): string {
return new Date((new Date(iso).toLocaleString('en-US') + ' UTC').replace(',', ''))
.toLocaleDateString('es-MX', { weekday: 'long', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' });
}
checkCoupon() {
if (this.coupon) {
this.loadingCtrl.create().then((overlay) => {
if (!this.coupon) return;
if (!this.selected_date) {
this.alertService.presentToast('Selecciona una fecha antes de aplicar el cupón.');
return;
}
this.loadingCtrl.create().then(overlay => {
this.loading = overlay;
this.loading.present();
});
this.ichambaService.checkCoupon(this.postulation_id, this.supplier_id, this.coupon).subscribe(
this.ichambaService.checkCoupon(this.postulation_id, this.supplier_id, this.selected_date, this.coupon).subscribe(
(data: any) => {
if (data) {
if (data['name'] == "used") {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('alerts.coupon_used'));
} else if (data['name'] == "expired") {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('alerts.coupon_expired'));
} else if (data['name'] == this.coupon) {
if (this.loading) this.loading.dismiss();
this.discount = (data['amount'] + (this.fee * (data['percentage']/100)));
console.log(this.discount);
if (this.discount > this.fee){
this.discount = this.fee
if (!data) {
this.alertService.presentToast(this.translateService.instant('alerts.nocoupon'));
return;
}
this.final_amount = (this.fee - this.discount);
console.log(this.final_amount);
this.alertService.presentToast(this.translateService.instant('alerts.coupon_valid'));
} else if (data['name'] == "success") {
if (this.loading) this.loading.dismiss();
if (data['name'] == 'used') {
this.alertService.presentToast(this.translateService.instant('alerts.coupon_used'));
} else if (data['name'] == 'expired') {
this.alertService.presentToast(this.translateService.instant('alerts.coupon_expired'));
} else if (data['name'] == 'success') {
this.events.publish('refreshpcontracts', 'data');
this.navCtrl.navigateRoot('/contracts/contracted');
this.alertService.presentToast(this.translateService.instant('alerts.hire'));
}
} else {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('alerts.nocoupon'));
} else if (data['name'] == this.coupon) {
this.discount = data['amount'] + (this.fee * (data['percentage'] / 100));
if (this.discount > this.fee) this.discount = this.fee;
this.final_amount = this.fee - this.discount;
this.alertService.presentToast(this.translateService.instant('alerts.coupon_valid'));
}
}, (error: any) => {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('alerts.error') + error['status']);
});
}
}
createContract(form: NgForm) {
// Si el bypass está activo, enviar con valores dummy
async createContract() {
if (!this.selected_date) {
this.alertService.presentToast('Selecciona una fecha para continuar.');
return;
}
if (this.paymentBypass) {
this.loadingCtrl.create().then((overlay) => {
this.loading = overlay;
this.loading.present();
});
this.ichambaService.createContract(this.postulation_id, this.supplier_id, 'BYPASS', this.coupon, '000', 'BYPASS').subscribe(
this.loading = await this.loadingCtrl.create();
await this.loading.present();
this.ichambaService.createContract(this.postulation_id, this.supplier_id, 'BYPASS', this.selected_date, this.coupon).subscribe(
(data: any) => {
if (data['type'] == "error") {
if (this.loading) this.loading.dismiss();
if (data['type'] == 'error') {
this.alertService.presentToast(data['message']);
} else if (data['name'] == "used") {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('contracts.coupon_used'));
} else if (data['name'] == "expired") {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('contracts.coupon_expired'));
} else {
if (this.loading) this.loading.dismiss();
this.events.publish('refreshpcontracts', 'data');
this.navCtrl.navigateRoot('/contracts/contracted');
this.alertService.presentToast(this.translateService.instant('alerts.hire'));
@@ -176,42 +124,53 @@ export class HirePage implements OnInit {
return;
}
if (this.card_id != "Agregar tarjeta") {
if (this.code_check) {
let code = form.value.code;
let device_id = (<HTMLInputElement>document.getElementById('deviceDataId')).value;
console.log(device_id);
this.loading = await this.loadingCtrl.create();
await this.loading.present();
this.loadingCtrl.create().then((overlay) => {
this.loading = overlay;
this.loading.present();
try {
const intentData = await firstValueFrom(
this.ichambaService.getPaymentIntent(this.postulation_id, this.supplier_id, this.coupon)
);
await Stripe.createPaymentSheet({
paymentIntentClientSecret: intentData.client_secret,
customerId: intentData.customer_id,
customerEphemeralKeySecret: intentData.ephemeral_key,
merchantDisplayName: 'JobHero',
});
this.ichambaService.createContract(this.postulation_id, this.supplier_id, this.card_id, this.coupon, code, device_id).subscribe(
(data: any) => {
if (data['type'] == "error") {
await this.loading.dismiss();
const result = await Stripe.presentPaymentSheet();
if (result.paymentResult !== PaymentSheetEventsEnum.Completed) return;
this.loading = await this.loadingCtrl.create();
await this.loading.present();
this.ichambaService.createContract(
this.postulation_id, this.supplier_id, intentData.payment_intent_id, this.selected_date!, this.coupon
).subscribe({
next: (data: any) => {
if (this.loading) this.loading.dismiss();
if (data['type'] == 'error') {
this.alertService.presentToast(data['message']);
} else if (data['name'] == "used") {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('contracts.coupon_used'));
} else if (data['name'] == "expired") {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('contracts.coupon_expired'));
} else {
if (this.loading) this.loading.dismiss();
this.events.publish('refreshpcontracts', 'data');
this.navCtrl.navigateRoot('/contracts/contracted');
this.alertService.presentToast(this.translateService.instant('alerts.hire'));
}
}, (error: any) => {
},
error: (error: any) => {
if (this.loading) this.loading.dismiss();
this.alertService.presentToast(this.translateService.instant('alerts.error') + error['status']);
});
//console.log(onSuccess.data.id);
//console.log(document.getElementById('deviceDataId').value);
}
} else {
this.alertService.presentToast(this.translateService.instant('alerts.select_card'));
});
} catch (error: any) {
await this.loading?.dismiss();
if (error?.paymentResult !== PaymentSheetEventsEnum.Canceled) {
this.alertService.presentToast(this.translateService.instant('alerts.error'));
}
}
}

View File

@@ -6,7 +6,8 @@ import { Routes, RouterModule } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { PendingPage } from './pending.page';
import { TranslateModule } from '@ngx-translate/core'
import { TranslateModule } from '@ngx-translate/core';
import { RepostulatePageModule } from '../../postulations/repostulate/repostulate.module';
const routes: Routes = [
{
@@ -21,7 +22,8 @@ const routes: Routes = [
FormsModule,
IonicModule,
RouterModule.forChild(routes),
TranslateModule
TranslateModule,
RepostulatePageModule,
],
declarations: [PendingPage]
})

View File

@@ -14,7 +14,7 @@
<ng-container *ngIf="loading">
<ion-card *ngFor="let _ of [1,2]">
<ion-item style="--border-color: #fff">
<ion-item class="no-separator">
<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: 85%"></ion-skeleton-text>
@@ -30,7 +30,7 @@
<ng-container *ngIf="!loading">
<ion-card *ngFor="let pcontract of pcontracts; let i = index">
<ion-item>
<ion-item class="no-separator">
<ion-label>
<h2 *ngIf="lang===true" class="ion-text-capitalize">{{ pcontract.category }}</h2>
<h2 *ngIf="lang===false" class="ion-text-capitalize">{{ pcontract.en_category }}</h2>
@@ -39,6 +39,16 @@
</ion-label>
<ion-button style="height: 3em; padding-left: 0.5em;" color="secondary" (click)="viewsuppliers(pcontract.id)">{{'contracts.viewsuppliers_1.1' | translate}}<br>{{'contracts.viewsuppliers_1.2' | translate}}</ion-button>
</ion-item>
<div style="display: flex; justify-content: flex-end; gap: 8px; padding: 0 1em 1em">
<ion-button fill="outline" color="medium" size="small" (click)="openRepostulate(pcontract)">
<ion-icon slot="start" name="refresh-outline"></ion-icon>
Repostular
</ion-button>
<ion-button fill="outline" color="danger" size="small" (click)="confirmCancel(pcontract)">
<ion-icon slot="start" name="trash-outline"></ion-icon>
Cancelar
</ion-button>
</div>
</ion-card>
</ng-container>
</ion-content>

View File

@@ -1,5 +1,5 @@
import { Component, ChangeDetectorRef } from '@angular/core';
import { ModalController, MenuController, NavController } from '@ionic/angular';
import { AlertController, ModalController, MenuController, NavController } from '@ionic/angular';
import { EventService } from '../../../services/event.service';
import { EnvService } from 'src/app/services/env.service';
import { AuthService } from 'src/app/services/auth.service';
@@ -7,6 +7,7 @@ import { IchambaService } from 'src/app/services/ichamba.service';
import { TranslateService } from '@ngx-translate/core';
import { LanguageService } from 'src/app/services/language.service';
import { AlertService } from 'src/app/services/alert.service';
import { RepostulateePage } from '../../postulations/repostulate/repostulate.page';
@Component({
selector: 'app-pending',
@@ -23,6 +24,7 @@ export class PendingPage {
constructor(
private modalController: ModalController,
private alertController: AlertController,
private menu: MenuController,
private navCtrl: NavController,
private events: EventService,
@@ -83,4 +85,45 @@ export class PendingPage {
this.navCtrl.navigateForward(['/viewsuppliers/', postulation_id]);
}
async confirmCancel(postulation: any) {
const alert = await this.alertController.create({
header: 'Cancelar postulación',
message: ' ',
buttons: [
{ text: 'No', role: 'cancel' },
{
text: 'Sí, cancelar',
role: 'destructive',
handler: () => this.doCancel(postulation.id)
}
]
});
await alert.present();
alert.querySelector('.alert-message')!.innerHTML = '¿Estás seguro de que deseas cancelar esta postulación de <strong>' + postulation.category + '</strong>?';
}
private doCancel(id: any) {
this.ichambaService.cancelPostulation(id).subscribe({
next: () => {
this.alertService.presentToast('Postulación cancelada.');
this.getpcontracts();
},
error: (error: any) => {
this.alertService.presentToast('Error al cancelar: ' + error['status']);
}
});
}
async openRepostulate(postulation: any) {
const modal = await this.modalController.create({
component: RepostulateePage,
componentProps: { postulation },
});
await modal.present();
const { data } = await modal.onDidDismiss();
if (data?.reposted) {
this.getpcontracts();
}
}
}

View File

@@ -6,31 +6,59 @@
<ion-title>{{'contracts.viewsuppliers_header' | translate}}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<h3 *ngIf="lang===true" class="ion-text-capitalize">{{ pcontractssuppliers_category[0] }}</h3>
<h3 *ngIf="lang===false" class="ion-text-capitalize">{{ pcontractssuppliers_en_category[0] }}</h3>
<p class="ion-text-capitalize">{{ pcontractssuppliers_address[0] }}</p>
<p class="ion-text-capitalize" style="margin-top: -0.6em">{{ pcontractssuppliers_dates[0] }}</p>
<h3 class="ion-text-capitalize">{{ headerCategory }}</h3>
<p class="ion-text-capitalize" style="margin-top: -0.6em">{{ headerAddress }}</p>
<ion-refresher slot="fixed" (ionRefresh)="refresh($event)">
<ion-refresher-content></ion-refresher-content>
</ion-refresher>
<ion-card *ngFor="let pcontractsupplier of pcontractssuppliers; let i = index">
<ion-card-header style="padding:0;height:8em" *ngIf="pcontractsupplier.supplier != null">
<img style="display:block;width:100%;height:100%" src="{{ pcontractsupplier.cover_photo }}" *ngIf="pcontractsupplier.supplier != null"/>
<!-- Sort chips -->
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px">
<ion-chip [color]="activeSort === 'date' ? 'primary' : 'medium'" (click)="sortBy('date')">
<ion-icon name="calendar-outline"></ion-icon>
<ion-label>Fecha más pronta</ion-label>
</ion-chip>
<ion-chip [color]="activeSort === 'fee' ? 'primary' : 'medium'" (click)="sortBy('fee')">
<ion-icon name="cash-outline"></ion-icon>
<ion-label>Más económico</ion-label>
</ion-chip>
<ion-chip [color]="activeSort === 'certified' ? 'primary' : 'medium'" (click)="sortBy('certified')">
<ion-icon name="checkmark-circle-outline"></ion-icon>
<ion-label>Certificados</ion-label>
</ion-chip>
</div>
<ion-card *ngFor="let s of sortedSuppliers">
<ion-card-header style="padding:0;height:8em" *ngIf="s.supplier != null">
<img style="display:block;width:100%;height:100%;object-fit:cover" [src]="s.cover_photo" *ngIf="s.cover_photo"/>
</ion-card-header>
<ion-item *ngIf="pcontractsupplier.supplier != null">
<ion-item *ngIf="s.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>
<img *ngIf="s.profile_photo" [src]="s.profile_photo" (error)="s.profile_photo=null">
<ion-icon *ngIf="!s.profile_photo" name="person-circle" style="width:100%;height:100%;color:var(--ion-color-medium)"></ion-icon>
</ion-avatar>
<ion-label>
<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">{{'contracts.viewsuppliers_tags' | translate}}: {{ pcontractsupplier.tags }}</p>
<p class="ion-text-wrap" *ngIf="pcontractsupplier.supplier != null">{{'contracts.viewsuppliers_fee' | translate}}: ${{ pcontractsupplier.fee }}</p>
<p class="ion-text-wrap" *ngIf="pcontractsupplier.supplier != null">{{'contracts.viewsuppliers_rate' | translate}}: {{ pcontractsupplier.score }} de 5 <ion-icon name="star"></ion-icon></p>
<h2 class="ion-text-wrap ion-text-capitalize">{{ s.supplier }}</h2>
<p class="ion-text-wrap">
<ion-icon name="checkmark-circle" color="secondary" *ngIf="s.membership"></ion-icon>
{{ s._certified }}
</p>
<p class="ion-text-wrap">{{'contracts.viewsuppliers_tags' | translate}}: {{ s.tags }}</p>
<p class="ion-text-wrap">{{'contracts.viewsuppliers_fee' | translate}}: ${{ s.fee }}</p>
<p class="ion-text-wrap" *ngIf="s.date1">
<ion-icon name="calendar-outline"></ion-icon> {{ s._dateFormatted }}
<span *ngIf="s.date2"> · opción 2 disponible</span>
</p>
<p class="ion-text-wrap">{{'contracts.viewsuppliers_rate' | translate}}: {{ s.score }} de 5 <ion-icon name="star"></ion-icon></p>
</ion-label>
<ion-button style="height: 3em; padding-left: 0.5em;" color="secondary" (click)="hiresupplier(pcontractsupplier.supplier_id, pcontractsupplier.id, pcontractsupplier.supplier, pcontractsupplier.fee)">{{'contracts.viewsuppliers_hire' | translate}}</ion-button>
<ion-button style="height: 3em; padding-left: 0.5em;" color="secondary"
(click)="hiresupplier(s.supplier_id, s.id, s.supplier, s.fee, s.date_1, s.date_2)">
{{'contracts.viewsuppliers_hire' | translate}}
</ion-button>
</ion-item>
</ion-card>
</ion-content>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, ChangeDetectorRef } from '@angular/core';
import { ModalController, MenuController, NavController } from '@ionic/angular';
import { EventService } from '../../../services/event.service';
import { ActivatedRoute, Router } from '@angular/router';
@@ -19,15 +19,13 @@ import { HirePage } from '../hire/hire.page';
export class ViewsuppliersPage implements OnInit {
postulation_id: string | null = null;
pcontractssuppliers: any[] = [];
pcontractssuppliers_dates: any[] = [];
pcontractssuppliers_certified: any[] = [];
pcontractssuppliers_category: any[] = [];
pcontractssuppliers_en_category: any[] = [];
pcontractssuppliers_address: any[] = [];
pcontractssuppliers_amount: any[] = [];
pcontractssuppliers_fee: any[] = [];
suppliers: any[] = [];
sortedSuppliers: any[] = [];
headerCategory: string = '';
headerAddress: string = '';
headerDate: string = '';
lang: boolean = false;
activeSort: string = '';
constructor(
private modalController: ModalController,
@@ -42,68 +40,83 @@ export class ViewsuppliersPage implements OnInit {
private translateService: TranslateService,
private languageService: LanguageService,
private env: EnvService,
private cdr: ChangeDetectorRef,
) {}
ngOnInit() {
this.postulation_id = this.activatedRoute.snapshot.paramMap.get('postulation_id');
this.getPostulants(this.postulation_id ?? '');
if (this.languageService.getDefaultLanguage() == 'es') {
this.lang = true;
} else {
this.lang = false
}
this.lang = this.languageService.getDefaultLanguage() === 'es';
this.loadPostulants();
}
refresh(event: any) {
this.ichambaService.getPostulants(this.postulation_id ?? '').subscribe(
(data: any) => {
this.pcontractssuppliers = data;
for (var i of this.pcontractssuppliers) {
if (this.languageService.getDefaultLanguage() == 'es') {
this.pcontractssuppliers_dates.push(new Date((new Date(i.date).toLocaleString('en-US') + ' UTC').replace("," ,"")).toLocaleDateString('es-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute:'numeric', timeZoneName: 'long'}));
this.pcontractssuppliers_certified.push(i.membership == 1 ? 'Certificado' : '');
} else {
this.pcontractssuppliers_dates.push(new Date((new Date(i.date).toLocaleString('en-US') + ' UTC').replace("," ,"")).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute:'numeric', timeZoneName: 'long'}));
this.pcontractssuppliers_certified.push(i.membership == 1 ? 'Certified' : '');
}
this.pcontractssuppliers_category.push(i.category);
this.pcontractssuppliers_en_category.push(i.en_category);
this.pcontractssuppliers_address.push(i.address);
this.pcontractssuppliers_amount.push(i.amount);
this.pcontractssuppliers_fee.push(i.fee);
}
this.ichambaService.getPostulants(this.postulation_id ?? '').subscribe({
next: (data: any) => {
this.processSuppliers(data);
event.target.complete();
}, error => {
this.alertService.presentToast("Por favor contacte a soporte técnico, Estatus:" + error['status']);
},
error: () => {
this.alertService.presentToast("Por favor contacte a soporte técnico.");
event.target.complete();
}
});
}
getPostulants(postulation_id: String) {
this.ichambaService.getPostulants(postulation_id).subscribe(
(data: any) => {
this.pcontractssuppliers = data;
for (var i of this.pcontractssuppliers) {
if (this.languageService.getDefaultLanguage() == 'es') {
this.pcontractssuppliers_dates.push(new Date((new Date(i.date).toLocaleString('en-US') + ' UTC').replace("," ,"")).toLocaleDateString('es-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute:'numeric', timeZoneName: 'long'}));
this.pcontractssuppliers_certified.push(i.membership == 1 ? 'Certificado' : '');
} else {
this.pcontractssuppliers_dates.push(new Date((new Date(i.date).toLocaleString('en-US') + ' UTC').replace("," ,"")).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute:'numeric', timeZoneName: 'long'}));
this.pcontractssuppliers_certified.push(i.membership == 1 ? 'Certified' : '');
loadPostulants() {
this.ichambaService.getPostulants(this.postulation_id ?? '').subscribe({
next: (data: any) => {
this.processSuppliers(data);
this.cdr.detectChanges();
},
error: () => {
this.alertService.presentToast("Por favor contacte a soporte técnico.");
}
this.pcontractssuppliers_category.push(i.category);
this.pcontractssuppliers_en_category.push(i.en_category);
this.pcontractssuppliers_address.push(i.address);
this.pcontractssuppliers_amount.push(i.amount);
this.pcontractssuppliers_fee.push(i.fee);
}
}, error => {
this.alertService.presentToast("Por favor contacte a soporte técnico, Estatus:" + error['status']);
});
}
hiresupplier(supplier_id: String, postulation_id: String, supplier: String, fee: Number) {
this.router.navigate(['/hire', {supplier: supplier, supplier_id: supplier_id, postulation_id: postulation_id, fee: fee}]);
processSuppliers(data: any[]) {
const locale = this.lang ? 'es-US' : 'en-US';
this.suppliers = data.map(s => ({
...s,
_certified: this.lang ? (s.membership == 1 ? 'Certificado' : '') : (s.membership == 1 ? 'Certified' : ''),
_dateFormatted: s.date1
? new Date((new Date(s.date1).toLocaleString('en-US') + ' UTC').replace(',', '')).toLocaleDateString(locale, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' })
: ''
}));
if (this.suppliers.length > 0) {
const first = this.suppliers[0];
this.headerCategory = this.lang ? first.category : first.en_category;
this.headerAddress = first.address;
}
this.sortedSuppliers = [...this.suppliers];
if (this.activeSort) this.applySort(this.activeSort);
}
sortBy(criteria: string) {
if (this.activeSort === criteria) {
this.activeSort = '';
this.sortedSuppliers = [...this.suppliers];
} else {
this.activeSort = criteria;
this.applySort(criteria);
}
this.cdr.detectChanges();
}
private applySort(criteria: string) {
this.sortedSuppliers = [...this.suppliers].sort((a, b) => {
if (criteria === 'date') {
const da = a.date1 ? new Date(a.date1).getTime() : Infinity;
const db = b.date1 ? new Date(b.date1).getTime() : Infinity;
return da - db;
}
if (criteria === 'fee') return (a.fee ?? Infinity) - (b.fee ?? Infinity);
if (criteria === 'certified') return (b.membership ?? 0) - (a.membership ?? 0);
return 0;
});
}
hiresupplier(supplier_id: String, postulation_id: String, supplier: String, fee: Number, date_1: String, date_2: String) {
this.router.navigate(['/hire', { supplier, supplier_id, postulation_id, fee, date_1, date_2 }]);
}
}

View File

@@ -2,16 +2,12 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Routes, RouterModule } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { CurrentPage } from './current.page';
import { PostulateOfferPageModule } from '../postulate-offer/postulate-offer.module';
const routes: Routes = [
{
path: '',
component: CurrentPage
}
{ path: '', component: CurrentPage }
];
@NgModule({
@@ -19,7 +15,8 @@ const routes: Routes = [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild(routes)
RouterModule.forChild(routes),
PostulateOfferPageModule,
],
declarations: [CurrentPage]
})

View File

@@ -14,7 +14,7 @@
<ng-container *ngIf="loading">
<ion-card *ngFor="let _ of [1,2]">
<ion-item style="--border-color: #fff">
<ion-item class="no-separator">
<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: 85%"></ion-skeleton-text>
@@ -30,15 +30,20 @@
<ng-container *ngIf="!loading">
<ng-container *ngFor="let postulation of postulations; let i = index">
<ion-card *ngIf="!postulation.already_post">
<ion-item>
<ion-card *ngIf="!postulation.already_post && postulation._timer !== 'Expirada'">
<ion-item class="no-separator">
<ion-label>
<h2 class="ion-text-capitalize">{{ postulation.category }}</h2>
<ion-text color="primary"><p class="ion-text-wrap" (click)="openMaps(postulation.lat, postulation.lng)"><b><u>{{postulation.address}}</u></b></p></ion-text>
<p class="ion-text-wrap">Referencias: {{postulation.references}}</p>
<p class="ion-text-wrap ion-text-capitalize">{{postulations_dates[i]}}</p>
<p class="ion-text-wrap">Detalles: {{postulation.details}}</p>
<p class="ion-text-wrap">Tiempo restante: {{postulation.time_limit}} minutos</p>
<p class="ion-text-wrap">
<ion-text [color]="postulation._timerCritical ? 'danger' : 'medium'">
<ion-icon name="time-outline" style="vertical-align: middle"></ion-icon>
{{ postulation._timer }}
</ion-text>
</p>
</ion-label>
<ion-button slot="end" style="height: 3em;" color="secondary" (click)="addpostulation(postulation.id)">Postularse</ion-button>
</ion-item>

View File

@@ -1,11 +1,12 @@
import { Component, ChangeDetectorRef } from '@angular/core';
import { MenuController, NavController } from '@ionic/angular';
import { MenuController, NavController, ModalController } from '@ionic/angular';
import { EventService } from '../../../services/event.service';
import { EnvService } from 'src/app/services/env.service';
import { AuthService } from 'src/app/services/auth.service';
import { IchambaService } from 'src/app/services/ichamba.service';
import { AlertService } from 'src/app/services/alert.service';
import { Browser } from '@capacitor/browser';
import { PostulateOfferPage } from '../postulate-offer/postulate-offer.page';
@Component({
selector: 'app-current',
@@ -18,6 +19,8 @@ export class CurrentPage {
postulations: any[] = [];
postulations_dates: any[] = [];
loading = true;
minFee: number = 0;
private timerInterval: any;
constructor(
private menu: MenuController,
@@ -26,6 +29,7 @@ export class CurrentPage {
private authService: AuthService,
private alertService: AlertService,
private ichambaService: IchambaService,
private modalCtrl: ModalController,
private env: EnvService,
private cdr: ChangeDetectorRef,
) {
@@ -37,6 +41,33 @@ export class CurrentPage {
ionViewWillEnter() {
this.loading = true;
this.getpostulations();
this.ichambaService.getParameters().subscribe((data: any) => {
this.minFee = data['min_fee'] ?? 0;
});
this.timerInterval = setInterval(() => {
this.postulations.forEach(p => this.computeTimer(p));
this.cdr.detectChanges();
}, 60000);
}
ionViewWillLeave() {
clearInterval(this.timerInterval);
}
private computeTimer(p: any) {
const remaining = Math.max(0, Math.floor(
(new Date(p.time_created).getTime() + 1000 * 60 * 1000 - Date.now()) / 60000
));
if (remaining <= 0) {
p._timer = 'Expirada';
} else if (remaining < 60) {
p._timer = `${remaining}m restantes`;
} else {
const h = Math.floor(remaining / 60);
const m = remaining % 60;
p._timer = `${h}h${m > 0 ? ' ' + m + 'm' : ''} restantes`;
}
p._timerCritical = remaining < 60;
}
refresh(event: any) {
@@ -46,6 +77,7 @@ export class CurrentPage {
this.postulations_dates = [];
for (var i of this.postulations) {
this.postulations_dates.push(new Date((new Date(i.date).toLocaleString('en-US') + ' UTC').replace(',', '')).toLocaleDateString('es-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZoneName: 'long' }));
this.computeTimer(i);
}
this.cdr.detectChanges();
event.target.complete();
@@ -62,6 +94,7 @@ export class CurrentPage {
this.postulations_dates = [];
for (var i of this.postulations) {
this.postulations_dates.push(new Date((new Date(i.date).toLocaleString('en-US') + ' UTC').replace(',', '')).toLocaleDateString('es-US', { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZoneName: 'long' }));
this.computeTimer(i);
}
this.loading = false;
this.cdr.detectChanges();
@@ -71,18 +104,26 @@ export class CurrentPage {
});
}
addpostulation(id: String) {
this.ichambaService.setPostulation(id).subscribe(
(data: any) => {
async addpostulation(id: string) {
const modal = await this.modalCtrl.create({
component: PostulateOfferPage,
componentProps: { postulationId: id, minFee: this.minFee }
});
await modal.present();
const { data } = await modal.onDidDismiss();
if (!data) return;
this.ichambaService.setPostulation(id, data.date1, data.date2, data.amount).subscribe({
next: (res: any) => {
this.getpostulations();
this.alertService.presentToast(data['message']);
}, (error: any) => {
this.alertService.presentToast(res['message']);
},
error: (error: any) => {
this.alertService.presentToast("Por favor contacte a soporte técnico, Estatus:" + error['status']);
}
});
}
async openMaps(lat: Number, lng: Number) {
await Browser.open({ url: 'http://maps.google.com/maps?q=' + lat + ',' + lng });
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { PostulateOfferPage } from './postulate-offer.page';
@NgModule({
imports: [CommonModule, FormsModule, IonicModule],
declarations: [PostulateOfferPage],
exports: [PostulateOfferPage]
})
export class PostulateOfferPageModule {}

View File

@@ -0,0 +1,48 @@
<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>Tu oferta</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-item>
<ion-label position="stacked">Fecha tentativa 1 *</ion-label>
<ion-datetime-button datetime="date1Picker" style="margin-top: 8px"></ion-datetime-button>
</ion-item>
<ion-modal [keepContentsMounted]="true">
<ng-template>
<ion-datetime id="date1Picker" presentation="date-time" [(ngModel)]="date1"
[min]="minDate" [showDefaultButtons]="true" doneText="Aceptar" cancelText="Cancelar">
</ion-datetime>
</ng-template>
</ion-modal>
<ion-item style="margin-top: 8px">
<ion-label position="stacked">Fecha tentativa 2 *</ion-label>
<ion-datetime-button datetime="date2Picker" style="margin-top: 8px"></ion-datetime-button>
</ion-item>
<ion-modal [keepContentsMounted]="true">
<ng-template>
<ion-datetime id="date2Picker" presentation="date-time" [(ngModel)]="date2"
[min]="minDate" [showDefaultButtons]="true" doneText="Aceptar" cancelText="Cancelar">
</ion-datetime>
</ng-template>
</ion-modal>
<ion-item style="margin-top: 8px">
<ion-label position="stacked">Monto a cobrar ($) *</ion-label>
<ion-input type="number" [(ngModel)]="amount" [placeholder]="'Mínimo $' + minFee"></ion-input>
</ion-item>
<ion-note style="padding: 4px 16px; display: block" color="medium">Cuota mínima del sistema: ${{ minFee }}</ion-note>
<ion-button expand="full" color="secondary" style="margin-top: 2em" (click)="submit()">
Enviar oferta
</ion-button>
</ion-content>

View File

@@ -0,0 +1,48 @@
import { Component, Input, OnInit, ChangeDetectorRef } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { AlertService } from 'src/app/services/alert.service';
@Component({
selector: 'app-postulate-offer',
templateUrl: './postulate-offer.page.html',
styleUrls: ['./postulate-offer.page.scss'],
standalone: false
})
export class PostulateOfferPage implements OnInit {
@Input() postulationId: any;
@Input() minFee: number = 0;
date1: string | null = null;
date2: string | null = null;
amount: number | null = null;
minDate: string = new Date().toISOString().slice(0, 10);
constructor(
private modalCtrl: ModalController,
private alertService: AlertService,
private cdr: ChangeDetectorRef,
) {}
ngOnInit() {}
submit() {
if (!this.date1 || !this.date2) {
this.alertService.presentToast('Debes seleccionar las dos fechas tentativas.');
return;
}
if (!this.amount || this.amount < this.minFee) {
this.alertService.presentToast(`El monto mínimo es $${this.minFee}.`);
return;
}
this.modalCtrl.dismiss({
date1: this.date1,
date2: this.date2,
amount: this.amount
});
}
dismiss() {
this.modalCtrl.dismiss(null);
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { RepostulateePage } from './repostulate.page';
import { AddPropertyPageModule } from '../../property/add-property/add-property.module';
@NgModule({
imports: [CommonModule, FormsModule, IonicModule, AddPropertyPageModule],
declarations: [RepostulateePage],
exports: [RepostulateePage],
})
export class RepostulatePageModule {}

View File

@@ -0,0 +1,70 @@
<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>Repostular</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-item>
<ion-label position="fixed">Categoría</ion-label>
<ion-input [value]="postulation?.category" disabled></ion-input>
</ion-item>
<ion-item>
<ion-label position="fixed">Propiedad</ion-label>
<ion-select [(ngModel)]="selectedPropertyId" placeholder="Selecciona una propiedad" interface="action-sheet">
<ion-select-option *ngFor="let p of properties" [value]="p.id">
{{ p.name }}: {{ p.address }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item button="true" (click)="openAddProperty()" lines="none">
<ion-icon name="add-circle-outline" slot="start" color="primary"></ion-icon>
<ion-label color="primary">Agregar propiedad</ion-label>
</ion-item>
<ion-item>
<ion-label position="fixed">Referencias</ion-label>
<ion-textarea [(ngModel)]="references" placeholder="Ej. Frente al parque, casa azul..." style="--highlight-height: 0"></ion-textarea>
</ion-item>
<ion-item>
<ion-label position="fixed">Comentarios</ion-label>
<ion-textarea [(ngModel)]="details" placeholder="Describe lo que necesitas..." style="--highlight-height: 0"></ion-textarea>
</ion-item>
<div style="padding: 12px 0">
<ion-label style="font-size: 0.9rem; color: var(--ion-color-medium); padding-left: 4px">
Fotos (opcional, máx. 3)
</ion-label>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px">
<div *ngFor="let photo of photos; let i = index" style="position: relative">
<img [src]="photo" style="width: 80px; height: 80px; object-fit: cover; border-radius: 8px">
<ion-button fill="clear" color="danger" size="small"
style="position: absolute; top: -8px; right: -8px; --padding-start: 0; --padding-end: 0"
(click)="removePhoto(i)">
<ion-icon slot="icon-only" name="close-circle"></ion-icon>
</ion-button>
</div>
<ion-button *ngIf="photos.length < 3" fill="outline" color="medium"
style="width: 80px; height: 80px; --border-radius: 8px" (click)="addPhoto()">
<ion-icon slot="icon-only" name="camera-outline"></ion-icon>
</ion-button>
</div>
</div>
<ion-note color="medium" style="display: block; padding: 4px 0 12px">
Se archivará la postulación actual y se creará una nueva. Los proveedores anteriores deberán volver a postularse.
</ion-note>
<ion-button expand="full" color="primary" (click)="submit()">
Repostular
</ion-button>
</ion-content>

View File

@@ -0,0 +1,130 @@
import { Component, Input, OnInit, ChangeDetectorRef } from '@angular/core';
import { LoadingController, ModalController } from '@ionic/angular';
import { AlertService } from 'src/app/services/alert.service';
import { IchambaService } from 'src/app/services/ichamba.service';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { AddPropertyPage } from '../../property/add-property/add-property.page';
@Component({
selector: 'app-repostulate',
templateUrl: './repostulate.page.html',
styleUrls: ['./repostulate.page.scss'],
standalone: false
})
export class RepostulateePage implements OnInit {
@Input() postulation: any;
properties: any[] = [];
selectedPropertyId: number | null = null;
references: string = '';
details: string = '';
photos: string[] = [];
loadingProperties = true;
constructor(
private modalCtrl: ModalController,
private alertService: AlertService,
private ichambaService: IchambaService,
private loadingCtrl: LoadingController,
private cdr: ChangeDetectorRef,
) {}
ngOnInit() {
this.references = this.postulation?.references ?? '';
this.details = this.postulation?.details ?? '';
this.loadProperties();
}
loadProperties() {
this.ichambaService.getProperties().subscribe({
next: (data: any[]) => {
this.properties = data;
this.selectedPropertyId = this.postulation?.property_id ?? (data[0]?.id ?? null);
this.loadingProperties = false;
this.cdr.detectChanges();
if (data.length === 0) this.openAddProperty();
},
error: () => {
this.loadingProperties = false;
this.cdr.detectChanges();
}
});
}
async openAddProperty() {
const modal = await this.modalCtrl.create({ component: AddPropertyPage });
await modal.present();
const { data } = await modal.onDidDismiss();
if (data?.property) {
this.properties.push(data.property);
this.selectedPropertyId = data.property.id;
this.cdr.detectChanges();
}
}
async addPhoto() {
if (this.photos.length >= 3) {
this.alertService.presentToast('Máximo 3 fotos.');
return;
}
try {
const image = await Camera.getPhoto({
quality: 70,
resultType: CameraResultType.Base64,
source: CameraSource.Prompt,
});
this.photos.push('data:image/jpeg;base64,' + image.base64String);
this.cdr.detectChanges();
} catch (e) {}
}
removePhoto(index: number) {
this.photos.splice(index, 1);
this.cdr.detectChanges();
}
async submit() {
if (!this.selectedPropertyId) {
this.alertService.presentToast('Selecciona una propiedad.');
return;
}
const loading = await this.loadingCtrl.create();
await loading.present();
this.ichambaService.cancelPostulation(this.postulation.id).subscribe({
next: () => {
this.ichambaService.addPostulation(
this.postulation.category,
this.selectedPropertyId!,
this.references,
this.details,
this.photos,
this.postulation.id
).subscribe({
next: (data: any) => {
loading.dismiss();
if (data['message'] === 'No providers') {
this.alertService.presentToast('No hay proveedores disponibles para esta categoría.');
} else {
this.alertService.presentToast('¡Postulación actualizada con éxito!');
this.modalCtrl.dismiss({ reposted: true });
}
},
error: (error: any) => {
loading.dismiss();
this.alertService.presentToast('Error al crear la postulación: ' + error['status']);
}
});
},
error: (error: any) => {
loading.dismiss();
this.alertService.presentToast('Error al cancelar la postulación: ' + error['status']);
}
});
}
dismiss() {
this.modalCtrl.dismiss(null);
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { AddPropertyPage } from './add-property.page';
@NgModule({
imports: [CommonModule, FormsModule, IonicModule],
declarations: [AddPropertyPage],
exports: [AddPropertyPage]
})
export class AddPropertyPageModule {}

View File

@@ -0,0 +1,63 @@
<style>
ion-list.dropdown-list {
position: absolute;
width: calc(100% - 32px);
max-height: 50%;
z-index: 999;
}
</style>
<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>Agregar Propiedad</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-item>
<ion-label position="floating">Nombre de la propiedad</ion-label>
<ion-input [(ngModel)]="name" placeholder="Ej. Mi casa, Oficina centro"></ion-input>
</ion-item>
<ion-item style="margin-top: 8px">
<ion-label>Ícono</ion-label>
<div slot="end" style="display:flex; gap: 16px; padding: 8px 0">
<ion-button [fill]="icon === 'home' ? 'solid' : 'outline'" color="primary" (click)="icon = 'home'">
<ion-icon slot="icon-only" name="home"></ion-icon>
</ion-button>
<ion-button [fill]="icon === 'briefcase' ? 'solid' : 'outline'" color="primary" (click)="icon = 'briefcase'">
<ion-icon slot="icon-only" name="briefcase"></ion-icon>
</ion-button>
</div>
</ion-item>
<ion-item>
<ion-label position="floating">Dirección</ion-label>
<ion-input
[(ngModel)]="addressAutocomplete"
(ionInput)="autocomplete($event)"
(ionFocus)="showif = true"
></ion-input>
</ion-item>
<ion-list *ngIf="showif && placesSearch && placesSearch.length > 0" class="dropdown-list">
<ion-item button="true" (click)="geoloc(place.place_id, place.description)" *ngFor="let place of placesSearch">
{{ place.description }}
</ion-item>
</ion-list>
<ion-item>
<ion-label position="floating">Número exterior (opcional)</ion-label>
<ion-input [(ngModel)]="myIntnumber" type="number"></ion-input>
</ion-item>
<ion-button expand="full" color="primary" style="margin-top: 2em" (click)="save()" [disabled]="saving">
Guardar propiedad
</ion-button>
</ion-content>

View File

@@ -0,0 +1,100 @@
import { Component, NgZone, ChangeDetectorRef } from '@angular/core';
import { ModalController, LoadingController } from '@ionic/angular';
import { IchambaService } from 'src/app/services/ichamba.service';
import { AlertService } from 'src/app/services/alert.service';
declare var google: any;
@Component({
selector: 'app-add-property',
templateUrl: './add-property.page.html',
styleUrls: ['./add-property.page.scss'],
standalone: false
})
export class AddPropertyPage {
name: string = '';
icon: string = 'home';
addressAutocomplete: string = '';
myAddress: string | null = null;
myPosition: any = {};
myIntnumber: string = '';
placesSearch: any = null;
showif = true;
saving = false;
constructor(
private modalCtrl: ModalController,
private loadingCtrl: LoadingController,
private ichambaService: IchambaService,
private alertService: AlertService,
private ngZone: NgZone,
private cdr: ChangeDetectorRef,
) {}
autocomplete(ev: any) {
if (!this.addressAutocomplete.trim().length) {
this.placesSearch = null;
return;
}
new google.maps.places.AutocompleteService().getPredictions(
{ input: this.addressAutocomplete },
(predictions: any) => {
this.ngZone.run(() => {
this.placesSearch = predictions;
this.cdr.detectChanges();
});
}
);
}
geoloc(place_id: string, place_description: string) {
this.myAddress = place_description;
this.addressAutocomplete = place_description;
this.showif = false;
new google.maps.Geocoder().geocode({ placeId: place_id }, (coordinates: any) => {
this.ngZone.run(() => {
this.myPosition = {
latitude: coordinates[0].geometry.location.lat(),
longitude: coordinates[0].geometry.location.lng()
};
this.cdr.detectChanges();
});
});
}
async save() {
if (!this.name.trim()) {
this.alertService.presentToast('Ingresa un nombre para la propiedad.');
return;
}
if (!this.myAddress || !this.myPosition.latitude) {
this.alertService.presentToast('Selecciona una dirección del listado.');
return;
}
if (!Number.isInteger(parseInt(this.myIntnumber))) {
this.myIntnumber = '';
}
this.saving = true;
const loading = await this.loadingCtrl.create();
await loading.present();
this.ichambaService.addProperty(
this.name, this.myAddress, this.myPosition.latitude,
this.myPosition.longitude, this.myIntnumber, this.icon
).subscribe({
next: (property: any) => {
loading.dismiss();
this.modalCtrl.dismiss({ property });
},
error: () => {
loading.dismiss();
this.saving = false;
this.alertService.presentToast('No se pudo guardar la propiedad.');
}
});
}
dismiss() {
this.modalCtrl.dismiss();
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { AssignTechnicianPage } from './assign-technician.page';
@NgModule({
imports: [
CommonModule,
IonicModule,
],
declarations: [AssignTechnicianPage],
exports: [AssignTechnicianPage]
})
export class AssignTechnicianPageModule {}

View File

@@ -0,0 +1,62 @@
import { Component, Input, OnInit, ChangeDetectorRef } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { IchambaService } from 'src/app/services/ichamba.service';
import { AlertService } from 'src/app/services/alert.service';
@Component({
selector: 'app-assign-technician',
templateUrl: './assign-technician.page.html',
styleUrls: ['./assign-technician.page.scss'],
standalone: false
})
export class AssignTechnicianPage implements OnInit {
@Input() contractId: any;
technicians: any[] = [];
loading = true;
assigning = false;
constructor(
private modalCtrl: ModalController,
private ichambaService: IchambaService,
private alertService: AlertService,
private cdr: ChangeDetectorRef,
) {}
ngOnInit() {
this.ichambaService.getTeamTechnicians().subscribe({
next: data => {
this.technicians = data;
this.loading = false;
this.cdr.detectChanges();
},
error: () => {
this.loading = false;
this.cdr.detectChanges();
}
});
}
assign(technician: any) {
if (this.assigning) return;
this.assigning = true;
this.cdr.detectChanges();
this.ichambaService.assignTechnician(this.contractId, technician.id).subscribe({
next: () => {
this.alertService.presentToast(`${technician.name} asignado al contrato`);
this.modalCtrl.dismiss({ assigned: true, technician });
},
error: () => {
this.assigning = false;
this.alertService.presentToast('No se pudo asignar el técnico.');
this.cdr.detectChanges();
}
});
}
dismiss() {
this.modalCtrl.dismiss();
}
}

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Routes, RouterModule } from '@angular/router';
import { IonicModule } from '@ionic/angular';
import { TeamPage } from './team.page';
const routes: Routes = [
{ path: '', component: TeamPage }
];
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild(routes)
],
declarations: [TeamPage]
})
export class TeamPageModule {}

View File

View File

@@ -38,20 +38,42 @@ export class IchambaService {
{ headers: headers });
}
addCard(token: String, device_id: String) {
getSetupIntent() {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post(this.env.API_URL + 'payments/addcard',
{token: token, device_id: device_id}, { headers: headers });
return this.http.post<any>(this.env.API_URL + 'payments/setup-intent', {}, { headers });
}
addPostulation(category: String, address: String, int_number: String, references: String, lat: Number, lng: Number, setdate: String, sethour:String, details: String) {
getPaymentIntent(postulation_id: String, supplier_id: String, coupon: String) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post<any>(this.env.API_URL + 'payments/intent',
{ postulation_id, supplier_id, coupon }, { headers });
}
getExtraPaymentIntent(contract_id: Number, amount: Number) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post<any>(this.env.API_URL + 'payments/extra-intent',
{ contract_id, amount }, { headers });
}
addPostulation(category: String, property_id: Number, references: String, details: String, photos: string[], related_postulation_id?: Number) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post(this.env.API_URL + 'create-postulation',
{category: category, address: address, int_number: int_number, references: references, lat: lat, lng: lng, setdate: setdate, sethour:sethour, details: details}, { headers: headers });
{ category, property_id, references, details, photos, ...(related_postulation_id ? { related_postulation_id } : {}) }, { headers });
}
cancelPostulation(id: any) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.delete(this.env.API_URL + 'postulations/' + id, { headers });
}
deleteCard(card_id: String) {
@@ -118,12 +140,40 @@ export class IchambaService {
{ headers: headers });
}
setPostulation(postulation_id: String) {
setPostulation(postulation_id: String, date1: String, date2: String, amount: Number) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post(this.env.API_URL + 'postulate',
{postulation_id: postulation_id}, { headers: headers });
{ postulation_id, date_1: date1, date_2: date2, amount }, { headers });
}
getProperties() {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.get<any[]>(this.env.API_URL + 'properties', { headers });
}
addProperty(name: String, address: String, lat: Number, lng: Number, int_number: String, icon: String) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post<any>(this.env.API_URL + 'properties', { name, address, lat, lng, int_number, icon }, { headers });
}
getContractDiscussion(contractId: any) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.get<any[]>(this.env.API_URL + 'contracts/' + contractId + '/comments', { headers });
}
sendContractMessage(contractId: any, comment: string) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post<any>(this.env.API_URL + 'contracts/' + contractId + '/comments', { comment }, { headers });
}
getPostulants(postulation_id: String) {
@@ -134,20 +184,20 @@ export class IchambaService {
{ headers: headers });
}
checkCoupon(postulation_id: String, supplier_id: String, coupon: String) {
checkCoupon(postulation_id: String, supplier_id: String, selected_date: String, coupon: String) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post(this.env.API_URL + 'contracts/coupon',
{ postulation_id: postulation_id, supplier_id: supplier_id, coupon: coupon}, { headers: headers });
{ postulation_id, supplier_id, selected_date, coupon }, { headers: headers });
}
createContract(postulation_id: String, supplier_id: String, card_id: String, coupon: String, code: String, device_id: String) {
createContract(postulation_id: String, supplier_id: String, payment_intent_id: String, selected_date: String, coupon: String) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post(this.env.API_URL + 'contracts/create',
{ postulation_id: postulation_id, supplier_id: supplier_id, card_id: card_id, coupon: coupon, code: code, device_id: device_id }, { headers: headers });
{ postulation_id, supplier_id, payment_intent_id, selected_date, coupon }, { headers });
}
cancelContract(contract_id: String) {
@@ -225,12 +275,12 @@ export class IchambaService {
{ contract_id: contract_id, lat: lat, lng: lng, description: description }, { headers: headers });
}
addExtra(contract_id: Number, amount: Number, card_id: String, code: String, device_id: String) {
addExtra(contract_id: Number, amount: Number, payment_intent_id: String) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post(this.env.API_URL + 'contracts/extra',
{ contract_id: contract_id, amount: amount, card_id: card_id, code: code, device_id: device_id }, { headers: headers });
{ contract_id, amount, payment_intent_id }, { headers });
}
addHero(name: String, rfc: String, categories: String, tags: String, address: String, lat: Number, lng: Number, bank: Number, bank_account: Number, fee: Number, reference_options: Number, reference: String) {
@@ -240,4 +290,43 @@ export class IchambaService {
return this.http.post(this.env.API_URL + 'add-hero',
{name: name, rfc: rfc, categories: categories, tags: tags, address: address, lat: lat, lng: lng, bank: bank, bank_account: bank_account, fee: fee, reference_options: reference_options, reference: reference}, { headers: headers });
}
getTeamTechnicians() {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.get<any[]>(this.env.API_URL + 'technicians', { headers });
}
findUserByEmail(email: string) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.get<any>(this.env.API_URL + 'technicians/find?email=' + encodeURIComponent(email), { headers });
}
addTechnicianByUserId(user_id: any) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post<any>(this.env.API_URL + 'technicians/add', { user_id }, { headers });
}
removeTechnician(technician_id: any) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.delete<any>(this.env.API_URL + 'technicians/' + technician_id, { headers });
}
assignTechnician(contract_id: any, technician_id: any) {
const headers = new HttpHeaders({
'Authorization': this.authService.token["token_type"]+" "+this.authService.token["access_token"]
});
return this.http.post<any>(
this.env.API_URL + 'contracts/assign-technician',
{ contract_id, technician_id },
{ headers }
);
}
}