feat: Actualizacion sistema SIO Frontend
- Modificacion modulo agenda con vista diaria simplificada - Nuevo componente historial de cambios en servicios - Actualizacion de environments para produccion - Nuevos componentes: agenda-medica, por-operador, timeline Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<div class="agenda-medica-container">
|
||||
<div *ngIf="isLoading" class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="loading-text">Cargando servicios...</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && serviciosRaw.length === 0" class="no-data">
|
||||
<mat-icon>event_busy</mat-icon>
|
||||
<span>No hay servicios programados para esta fecha</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && serviciosRaw.length > 0" class="resumen">
|
||||
<span class="resumen-text">
|
||||
<strong>{{ getTotalServicios() }}</strong> servicios programados |
|
||||
<strong>{{ operadores.length }}</strong> operadores
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && operadores.length > 0" class="agenda-wrapper">
|
||||
<table class="agenda-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="hora-header">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
Hora
|
||||
</th>
|
||||
<th *ngFor="let operador of operadores; trackBy: trackByOperador" class="operador-header">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>{{ operador }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let celda of horasDelDia; trackBy: trackByHora" class="hora-row">
|
||||
<td class="hora-cell">
|
||||
<span class="hora-text">{{ celda.horaFormateada }}</span>
|
||||
</td>
|
||||
|
||||
<ng-container *ngFor="let operador of operadores; trackBy: trackByOperador">
|
||||
<!-- Si la celda está ocupada por un servicio anterior, no renderizar -->
|
||||
<ng-container *ngIf="!esCeldaOcupada(celda, operador)">
|
||||
<td class="servicio-cell"
|
||||
*ngIf="getServicio(celda, operador) as servicio; else celdaVacia"
|
||||
[attr.rowspan]="servicio.rowSpan"
|
||||
[style.background-color]="servicio.color_estatus + '20'">
|
||||
<div class="servicio-card" [style.border-left-color]="servicio.color_estatus">
|
||||
<div class="servicio-hora">
|
||||
<mat-icon>access_time</mat-icon>
|
||||
{{ servicio.hora_servicio }}
|
||||
</div>
|
||||
<div class="servicio-cliente">
|
||||
<mat-icon>business</mat-icon>
|
||||
{{ servicio.denominacion_cliente }}
|
||||
</div>
|
||||
<div class="servicio-duracion">
|
||||
<mat-icon>timelapse</mat-icon>
|
||||
{{ servicio.duracion_servicio }}
|
||||
</div>
|
||||
<div class="servicio-estatus">
|
||||
<span class="estatus-badge" [style.background-color]="servicio.color_estatus">
|
||||
{{ servicio.nombre_estatus }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<ng-template #celdaVacia>
|
||||
<td class="servicio-cell celda-vacia">
|
||||
<span class="disponible">Disponible</span>
|
||||
</td>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,292 @@
|
||||
.agenda-medica-container {
|
||||
padding: 20px;
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
|
||||
.loading-text {
|
||||
margin-top: 15px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.resumen {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
|
||||
.resumen-text {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.agenda-wrapper {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.agenda-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 600px;
|
||||
|
||||
thead {
|
||||
tr {
|
||||
background: #5867dd;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 15px 10px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
border-right: 1px solid rgba(255,255,255,0.2);
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&.hora-header {
|
||||
width: 100px;
|
||||
min-width: 100px;
|
||||
background: #4a5bcf;
|
||||
}
|
||||
|
||||
&.operador-header {
|
||||
min-width: 200px;
|
||||
|
||||
mat-icon {
|
||||
display: block;
|
||||
margin: 0 auto 5px;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
.hora-row {
|
||||
&:nth-child(even) {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
border: 1px solid #eee;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.hora-cell {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
width: 100px;
|
||||
min-width: 100px;
|
||||
|
||||
.hora-text {
|
||||
font-size: 13px;
|
||||
color: #5867dd;
|
||||
}
|
||||
}
|
||||
|
||||
.servicio-cell {
|
||||
padding: 8px;
|
||||
min-height: 80px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&.celda-vacia {
|
||||
.disponible {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: #ccc;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
padding: 20px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.servicio-card {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
border-left: 4px solid;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
height: 100%;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 6px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.servicio-hora {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.servicio-cliente {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
|
||||
mat-icon {
|
||||
color: #5867dd;
|
||||
}
|
||||
}
|
||||
|
||||
.servicio-duracion {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.servicio-estatus {
|
||||
margin-top: 8px;
|
||||
|
||||
.estatus-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 992px) {
|
||||
.agenda-medica-container {
|
||||
.agenda-table {
|
||||
thead th {
|
||||
padding: 10px 5px;
|
||||
font-size: 12px;
|
||||
|
||||
&.operador-header {
|
||||
min-width: 150px;
|
||||
|
||||
span {
|
||||
max-width: 100px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
.hora-cell {
|
||||
padding: 8px 5px;
|
||||
|
||||
.hora-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.servicio-card {
|
||||
padding: 6px;
|
||||
|
||||
> div {
|
||||
margin-bottom: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.servicio-hora {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.servicio-cliente {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.servicio-duracion {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.servicio-estatus .estatus-badge {
|
||||
font-size: 8px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Altura fija por hora para mejor visualización
|
||||
.agenda-table tbody .servicio-cell {
|
||||
height: 90px;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {Component, Input, OnChanges, SimpleChanges} from '@angular/core';
|
||||
|
||||
interface ServicioAgenda {
|
||||
nombre_operador: string;
|
||||
hora_servicio: string;
|
||||
duracion_servicio: string;
|
||||
denominacion_cliente: string;
|
||||
start_day: string;
|
||||
end_day: string;
|
||||
color_estatus: string;
|
||||
nombre_estatus: string;
|
||||
}
|
||||
|
||||
interface ServicioCelda extends ServicioAgenda {
|
||||
horaInicio: number;
|
||||
duracionHoras: number;
|
||||
rowSpan: number;
|
||||
}
|
||||
|
||||
interface CeldaHora {
|
||||
hora: number;
|
||||
horaFormateada: string;
|
||||
servicios: { [operador: string]: ServicioCelda | null };
|
||||
ocupado: { [operador: string]: boolean };
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'kt-agenda-medica',
|
||||
templateUrl: './agenda-medica.component.html',
|
||||
styleUrls: ['./agenda-medica.component.scss']
|
||||
})
|
||||
export class AgendaMedicaComponent implements OnChanges {
|
||||
|
||||
@Input() serviciosRaw: ServicioAgenda[] = [];
|
||||
@Input() isLoading: boolean = false;
|
||||
|
||||
operadores: string[] = [];
|
||||
horasDelDia: CeldaHora[] = [];
|
||||
horaInicio: number = 7;
|
||||
horaFin: number = 21;
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.serviciosRaw) {
|
||||
this.procesarDatos();
|
||||
}
|
||||
}
|
||||
|
||||
procesarDatos(): void {
|
||||
// Obtener operadores únicos
|
||||
const operadoresSet = new Set<string>();
|
||||
this.serviciosRaw.forEach(s => operadoresSet.add(s.nombre_operador || 'Sin asignar'));
|
||||
this.operadores = Array.from(operadoresSet).sort();
|
||||
|
||||
// Generar estructura de horas
|
||||
this.horasDelDia = [];
|
||||
for (let hora = this.horaInicio; hora <= this.horaFin; hora++) {
|
||||
const celda: CeldaHora = {
|
||||
hora: hora,
|
||||
horaFormateada: this.formatearHora(hora),
|
||||
servicios: {},
|
||||
ocupado: {}
|
||||
};
|
||||
|
||||
// Inicializar cada operador
|
||||
this.operadores.forEach(op => {
|
||||
celda.servicios[op] = null;
|
||||
celda.ocupado[op] = false;
|
||||
});
|
||||
|
||||
this.horasDelDia.push(celda);
|
||||
}
|
||||
|
||||
// Colocar servicios en las celdas
|
||||
this.serviciosRaw.forEach(servicio => {
|
||||
const operador = servicio.nombre_operador || 'Sin asignar';
|
||||
const startDate = new Date(servicio.start_day);
|
||||
const endDate = new Date(servicio.end_day);
|
||||
|
||||
const horaInicioServicio = startDate.getHours();
|
||||
const duracionMinutos = (endDate.getTime() - startDate.getTime()) / (1000 * 60);
|
||||
const duracionHoras = Math.ceil(duracionMinutos / 60);
|
||||
|
||||
// Encontrar la celda de inicio
|
||||
const celdaIndex = this.horasDelDia.findIndex(c => c.hora === horaInicioServicio);
|
||||
if (celdaIndex >= 0) {
|
||||
const servicioConInfo: ServicioCelda = {
|
||||
...servicio,
|
||||
horaInicio: horaInicioServicio,
|
||||
duracionHoras: duracionHoras,
|
||||
rowSpan: Math.min(duracionHoras, this.horasDelDia.length - celdaIndex)
|
||||
};
|
||||
|
||||
this.horasDelDia[celdaIndex].servicios[operador] = servicioConInfo;
|
||||
|
||||
// Marcar celdas siguientes como ocupadas
|
||||
for (let i = 1; i < servicioConInfo.rowSpan && (celdaIndex + i) < this.horasDelDia.length; i++) {
|
||||
this.horasDelDia[celdaIndex + i].ocupado[operador] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatearHora(hora: number): string {
|
||||
if (hora === 0) return '12:00 AM';
|
||||
if (hora < 12) return `${hora}:00 AM`;
|
||||
if (hora === 12) return '12:00 PM';
|
||||
return `${hora - 12}:00 PM`;
|
||||
}
|
||||
|
||||
getServicio(celda: CeldaHora, operador: string): ServicioCelda | null {
|
||||
return celda.servicios[operador];
|
||||
}
|
||||
|
||||
esCeldaOcupada(celda: CeldaHora, operador: string): boolean {
|
||||
return celda.ocupado[operador];
|
||||
}
|
||||
|
||||
getTotalServicios(): number {
|
||||
return this.serviciosRaw.length;
|
||||
}
|
||||
|
||||
trackByHora(index: number, celda: CeldaHora): number {
|
||||
return celda.hora;
|
||||
}
|
||||
|
||||
trackByOperador(index: number, operador: string): string {
|
||||
return operador;
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,14 @@ import {HTTP_INTERCEPTORS} from '@angular/common/http';
|
||||
import {ActionNotificationComponent, DeleteEntityDialogComponent} from '../../partials/content/crud';
|
||||
import {NgxMatSelectSearchModule} from 'ngx-mat-select-search';
|
||||
import {CalendarioComponent} from "./calendario/calendario.component";
|
||||
import {PorOperadorComponent} from "./por-operador/por-operador.component";
|
||||
import {TimelineComponent} from "./timeline/timeline.component";
|
||||
import {AgendaMedicaComponent} from "./agenda-medica/agenda-medica.component";
|
||||
import {CalendarCommonModule, CalendarModule, DateAdapter} from "angular-calendar";
|
||||
import {adapterFactory} from "angular-calendar/date-adapters/date-fns";
|
||||
|
||||
@NgModule({
|
||||
declarations: [CalendarioComponent],
|
||||
declarations: [CalendarioComponent, PorOperadorComponent, TimelineComponent, AgendaMedicaComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
PartialsModule,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<kt-portlet>
|
||||
<kt-portlet-header [title]="titulo" [class]="'kt-portlet__head--lg'">
|
||||
<div ktPortletTools>
|
||||
<button class="btn btn-primary kt-margin-r-10" mwlCalendarPreviousView [view]="view" [(viewDate)]="viewDate" (click)="btnChangeDate()" [disabled]="isLoading">
|
||||
<button class="btn btn-primary kt-margin-r-10" (click)="irAnterior()" [disabled]="isLoading">
|
||||
<i class="la la-angle-left"></i>
|
||||
<span class="kt-hidden-mobile ml-2">Anterior</span>
|
||||
</button>
|
||||
<button class="btn btn-primary kt-margin-r-10" mwlCalendarToday [(viewDate)]="viewDate" (click)="btnChangeDate()" [disabled]="isLoading">
|
||||
<button class="btn btn-primary kt-margin-r-10" (click)="irHoy()" [disabled]="isLoading">
|
||||
<i class="la la-minus"></i>
|
||||
<span class="kt-hidden-mobile ml-2">Hoy</span>
|
||||
</button>
|
||||
<button class="btn btn-primary kt-margin-r-10" mwlCalendarNextView [view]="view" [(viewDate)]="viewDate" (click)="btnChangeDate()" [disabled]="isLoading">
|
||||
<button class="btn btn-primary kt-margin-r-10" (click)="irSiguiente()" [disabled]="isLoading">
|
||||
<span class="kt-hidden-mobile mr-2">Siguiente</span>
|
||||
<i class="la la-angle-right"></i>
|
||||
</button>
|
||||
@@ -20,38 +20,11 @@
|
||||
|
||||
<div class="div-info-date mb-3">
|
||||
<div class="div-info-span">
|
||||
<span>{{ (viewDate | calendarDate:(view + 'ViewTitle'):'es-MX') | uppercase }}</span>
|
||||
</div>
|
||||
|
||||
<div class="div-info-buttons">
|
||||
<button class="btn btn-warning kt-margin-r-10" (click)="setView(CalendarView.Week)" [class.active]="view === CalendarView.Week" [disabled]="isLoading">
|
||||
<span>Semana</span>
|
||||
</button>
|
||||
<button class="btn btn-warning kt-margin-r-10" (click)="setView(CalendarView.Day)" [class.active]="view === CalendarView.Day" [disabled]="isLoading">
|
||||
<span>Día</span>
|
||||
</button>
|
||||
<span>{{ viewDate | date:'dd MMMM yyyy':'':'es-MX' | uppercase }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div [ngSwitch]="view"
|
||||
style="text-transform:uppercase; color: black; padding: 5px; width: 100%;" class="pl-4 pr-4">
|
||||
<mwl-calendar-week-view
|
||||
*ngSwitchCase="CalendarView.Week"
|
||||
[viewDate]="viewDate"
|
||||
[events]="events"
|
||||
[refresh]="refresh"
|
||||
(beforeViewRender)="beforeViewRender($event)"
|
||||
[hourSegments]="4">
|
||||
</mwl-calendar-week-view>
|
||||
<kt-agenda-medica [serviciosRaw]="serviciosRaw" [isLoading]="isLoading"></kt-agenda-medica>
|
||||
|
||||
<mwl-calendar-day-view
|
||||
*ngSwitchCase="CalendarView.Day"
|
||||
[viewDate]="viewDate"
|
||||
[events]="events"
|
||||
[refresh]="refresh"
|
||||
(beforeViewRender)="beforeViewRender($event)"
|
||||
[hourSegments]="4">
|
||||
</mwl-calendar-day-view>
|
||||
</div>
|
||||
</kt-portlet-body>
|
||||
</kt-portlet>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
|
||||
import {CalendarEvent, CalendarView} from 'angular-calendar';
|
||||
import {Subject} from "rxjs";
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {ApiService} from "../../../../core/api/api.service";
|
||||
|
||||
@Component({
|
||||
@@ -11,62 +9,51 @@ import {ApiService} from "../../../../core/api/api.service";
|
||||
|
||||
export class CalendarioComponent implements OnInit {
|
||||
|
||||
view: CalendarView = CalendarView.Week;
|
||||
CalendarView = CalendarView;
|
||||
viewDate: Date = new Date();
|
||||
events: CalendarEvent[] = [];
|
||||
refresh: Subject<any> = new Subject();
|
||||
clicked = true;
|
||||
serviciosRaw: any[] = [];
|
||||
titulo = "Agenda"
|
||||
isLoading = true;
|
||||
|
||||
constructor(private api: ApiService, private ref: ChangeDetectorRef) {
|
||||
constructor(private api: ApiService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.cargarServicios();
|
||||
}
|
||||
|
||||
beforeViewRender(event): void {
|
||||
cargarServicios(): void {
|
||||
const startDay = this.viewDate;
|
||||
|
||||
let startDay = new Date(event.period.start)
|
||||
let endDay = new Date(event.period.end)
|
||||
|
||||
if(this.clicked){
|
||||
|
||||
let data = {
|
||||
start_day: startDay.getFullYear() + '-' + (startDay.getMonth() + 1) + '-' + startDay.getDate(),
|
||||
end_day: (this.view == CalendarView.Week)? endDay.getFullYear() + '-' + (endDay.getMonth() + 1) + '-' + endDay.getDate() : null
|
||||
}
|
||||
this.isLoading = true;
|
||||
this.api.agenda.create(data).subscribe(res => {
|
||||
this.events = []
|
||||
res.forEach(agenda => {
|
||||
let evento = {
|
||||
start: new Date(agenda.start_day),
|
||||
end: new Date(agenda.end_day),
|
||||
title: "<b>"+ agenda.nombre_operador + "</b><br/> " + agenda.hora_servicio + "<br/> " + agenda.duracion_servicio + "<br/> " + agenda.denominacion_cliente + "<br/> " + agenda.nombre_estatus,
|
||||
color: {
|
||||
primary: agenda.color_estatus,
|
||||
secondary: agenda.color_estatus
|
||||
}
|
||||
};
|
||||
this.events.push(evento)
|
||||
})
|
||||
this.refresh.next()
|
||||
this.isLoading = false
|
||||
}, () => {
|
||||
this.isLoading = false
|
||||
})
|
||||
this.clicked = false;
|
||||
let data = {
|
||||
start_day: startDay.getFullYear() + '-' + (startDay.getMonth() + 1) + '-' + startDay.getDate(),
|
||||
end_day: startDay.getFullYear() + '-' + (startDay.getMonth() + 1) + '-' + startDay.getDate()
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.api.agenda.create(data).subscribe(res => {
|
||||
this.serviciosRaw = res;
|
||||
this.isLoading = false;
|
||||
}, () => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
btnChangeDate(){
|
||||
this.clicked = true
|
||||
btnChangeDate(): void {
|
||||
this.cargarServicios();
|
||||
}
|
||||
|
||||
setView(view: CalendarView) {
|
||||
this.clicked = true;
|
||||
this.view = view;
|
||||
irAnterior(): void {
|
||||
this.viewDate = new Date(this.viewDate.setDate(this.viewDate.getDate() - 1));
|
||||
this.cargarServicios();
|
||||
}
|
||||
|
||||
irHoy(): void {
|
||||
this.viewDate = new Date();
|
||||
this.cargarServicios();
|
||||
}
|
||||
|
||||
irSiguiente(): void {
|
||||
this.viewDate = new Date(this.viewDate.setDate(this.viewDate.getDate() + 1));
|
||||
this.cargarServicios();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<div class="por-operador-container">
|
||||
<div *ngIf="isLoading" class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="loading-text">Cargando servicios...</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && operadoresAgrupados.length === 0" class="no-data">
|
||||
<mat-icon>event_busy</mat-icon>
|
||||
<span>No hay servicios programados para esta fecha</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && operadoresAgrupados.length > 0" class="resumen">
|
||||
<span class="resumen-text">
|
||||
<strong>{{ getTotalServicios() }}</strong> servicios programados |
|
||||
<strong>{{ operadoresAgrupados.length }}</strong> operadores
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<mat-accordion *ngIf="!isLoading" multi="true">
|
||||
<mat-expansion-panel *ngFor="let operador of operadoresAgrupados" [expanded]="operador.expanded">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon class="operador-icon">person</mat-icon>
|
||||
<span class="operador-nombre">{{ operador.nombre }}</span>
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
<span class="servicios-count">{{ operador.servicios.length }} servicio(s)</span>
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<div class="servicios-list">
|
||||
<div *ngFor="let servicio of operador.servicios" class="servicio-item">
|
||||
<div class="servicio-hora">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
<span>{{ servicio.hora_servicio }}</span>
|
||||
</div>
|
||||
<div class="servicio-duracion">
|
||||
<mat-icon>timelapse</mat-icon>
|
||||
<span>{{ servicio.duracion_servicio }}</span>
|
||||
</div>
|
||||
<div class="servicio-cliente">
|
||||
<mat-icon>business</mat-icon>
|
||||
<span>{{ servicio.denominacion_cliente }}</span>
|
||||
</div>
|
||||
<div class="servicio-estatus">
|
||||
<span class="estatus-badge" [style.background-color]="servicio.color_estatus">
|
||||
{{ servicio.nombre_estatus }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
</div>
|
||||
@@ -0,0 +1,156 @@
|
||||
.por-operador-container {
|
||||
padding: 20px;
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
|
||||
.loading-text {
|
||||
margin-top: 15px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.resumen {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
|
||||
.resumen-text {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-expansion-panel {
|
||||
margin-bottom: 10px;
|
||||
|
||||
.operador-icon {
|
||||
margin-right: 10px;
|
||||
color: #5867dd;
|
||||
}
|
||||
|
||||
.operador-nombre {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.servicios-count {
|
||||
background-color: #5867dd;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.servicios-list {
|
||||
.servicio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 20px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 6px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.servicio-hora {
|
||||
min-width: 100px;
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.servicio-duracion {
|
||||
min-width: 80px;
|
||||
|
||||
span {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.servicio-cliente {
|
||||
flex: 1;
|
||||
|
||||
span {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.servicio-estatus {
|
||||
.estatus-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.por-operador-container {
|
||||
.servicios-list {
|
||||
.servicio-item {
|
||||
flex-wrap: wrap;
|
||||
|
||||
> div {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.servicio-cliente {
|
||||
width: 100%;
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {Component, Input, OnChanges, SimpleChanges} from '@angular/core';
|
||||
|
||||
interface ServicioAgenda {
|
||||
nombre_operador: string;
|
||||
hora_servicio: string;
|
||||
duracion_servicio: string;
|
||||
denominacion_cliente: string;
|
||||
start_day: string;
|
||||
end_day: string;
|
||||
color_estatus: string;
|
||||
nombre_estatus: string;
|
||||
}
|
||||
|
||||
interface OperadorAgrupado {
|
||||
nombre: string;
|
||||
servicios: ServicioAgenda[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'kt-por-operador',
|
||||
templateUrl: './por-operador.component.html',
|
||||
styleUrls: ['./por-operador.component.scss']
|
||||
})
|
||||
export class PorOperadorComponent implements OnChanges {
|
||||
|
||||
@Input() serviciosRaw: ServicioAgenda[] = [];
|
||||
@Input() isLoading: boolean = false;
|
||||
|
||||
operadoresAgrupados: OperadorAgrupado[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.serviciosRaw) {
|
||||
this.agruparPorOperador();
|
||||
}
|
||||
}
|
||||
|
||||
agruparPorOperador(): void {
|
||||
const grupos: { [key: string]: ServicioAgenda[] } = {};
|
||||
|
||||
this.serviciosRaw.forEach(servicio => {
|
||||
const operador = servicio.nombre_operador || 'Sin asignar';
|
||||
if (!grupos[operador]) {
|
||||
grupos[operador] = [];
|
||||
}
|
||||
grupos[operador].push(servicio);
|
||||
});
|
||||
|
||||
this.operadoresAgrupados = Object.keys(grupos).map(nombre => ({
|
||||
nombre: nombre,
|
||||
servicios: grupos[nombre],
|
||||
expanded: true
|
||||
}));
|
||||
}
|
||||
|
||||
getTotalServicios(): number {
|
||||
return this.serviciosRaw.length;
|
||||
}
|
||||
}
|
||||
67
src/app/views/pages/agenda/timeline/timeline.component.html
Normal file
67
src/app/views/pages/agenda/timeline/timeline.component.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<div class="timeline-container">
|
||||
<div *ngIf="isLoading" class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span class="loading-text">Cargando servicios...</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && serviciosRaw.length === 0" class="no-data">
|
||||
<mat-icon>event_busy</mat-icon>
|
||||
<span>No hay servicios programados para esta fecha</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && serviciosRaw.length > 0" class="resumen">
|
||||
<span class="resumen-text">
|
||||
<strong>{{ getTotalServicios() }}</strong> servicios programados |
|
||||
<strong>{{ getOperadores().length }}</strong> operadores
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && serviciosRaw.length > 0" class="timeline-wrapper">
|
||||
<!-- Header con horas -->
|
||||
<div class="timeline-header">
|
||||
<div class="operador-label-header">Operador</div>
|
||||
<div class="horas-container">
|
||||
<div class="hora-mark" *ngFor="let hora of horasDelDia">
|
||||
<span>{{ hora }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filas por operador -->
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-row" *ngFor="let operador of getOperadores()">
|
||||
<div class="operador-label">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>{{ operador }}</span>
|
||||
</div>
|
||||
<div class="timeline-track">
|
||||
<!-- Lineas de hora -->
|
||||
<div class="hora-line" *ngFor="let hora of horasDelDia; let i = index"
|
||||
[style.left.%]="(i / (horasDelDia.length - 1)) * 100">
|
||||
</div>
|
||||
|
||||
<!-- Servicios -->
|
||||
<div class="servicio-block"
|
||||
*ngFor="let servicio of getServiciosPorOperador(operador)"
|
||||
[style.left.%]="servicio.leftPosition"
|
||||
[style.width.%]="servicio.width"
|
||||
[style.background-color]="servicio.color_estatus"
|
||||
[matTooltip]="servicio.hora_servicio + ' - ' + servicio.denominacion_cliente + ' (' + servicio.duracion_servicio + ')'">
|
||||
<div class="servicio-content">
|
||||
<span class="cliente-nombre">{{ servicio.denominacion_cliente }}</span>
|
||||
<span class="servicio-hora">{{ servicio.hora_servicio }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leyenda -->
|
||||
<div class="timeline-legend">
|
||||
<div class="legend-item" *ngFor="let servicio of serviciosRaw | slice:0:5">
|
||||
<span class="legend-color" [style.background-color]="servicio.color_estatus"></span>
|
||||
<span class="legend-text">{{ servicio.nombre_estatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
250
src/app/views/pages/agenda/timeline/timeline.component.scss
Normal file
250
src/app/views/pages/agenda/timeline/timeline.component.scss
Normal file
@@ -0,0 +1,250 @@
|
||||
.timeline-container {
|
||||
padding: 20px;
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
|
||||
.loading-text {
|
||||
margin-top: 15px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.resumen {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
|
||||
.resumen-text {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-wrapper {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
background: #5867dd;
|
||||
color: white;
|
||||
padding: 12px 0;
|
||||
border-bottom: 2px solid #4a5bcf;
|
||||
|
||||
.operador-label-header {
|
||||
width: 180px;
|
||||
min-width: 180px;
|
||||
padding: 0 15px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.horas-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
.hora-mark {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
span {
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
.timeline-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #eee;
|
||||
min-height: 60px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.operador-label {
|
||||
width: 180px;
|
||||
min-width: 180px;
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fafafa;
|
||||
border-right: 1px solid #eee;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
color: #5867dd;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-track {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
padding: 8px 0;
|
||||
|
||||
.hora-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.servicio-block {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.servicio-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
|
||||
.cliente-nombre {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.servicio-hora {
|
||||
font-size: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid #eee;
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.legend-color {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timeline-container {
|
||||
.timeline-header {
|
||||
.operador-label-header {
|
||||
width: 100px;
|
||||
min-width: 100px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.horas-container .hora-mark {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-body .timeline-row {
|
||||
.operador-label {
|
||||
width: 100px;
|
||||
min-width: 100px;
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-track .servicio-block .servicio-content {
|
||||
.cliente-nombre {
|
||||
font-size: 9px;
|
||||
}
|
||||
.servicio-hora {
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
src/app/views/pages/agenda/timeline/timeline.component.ts
Normal file
99
src/app/views/pages/agenda/timeline/timeline.component.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {Component, Input, OnChanges, SimpleChanges} from '@angular/core';
|
||||
|
||||
interface ServicioAgenda {
|
||||
nombre_operador: string;
|
||||
hora_servicio: string;
|
||||
duracion_servicio: string;
|
||||
denominacion_cliente: string;
|
||||
start_day: string;
|
||||
end_day: string;
|
||||
color_estatus: string;
|
||||
nombre_estatus: string;
|
||||
}
|
||||
|
||||
interface ServicioTimeline extends ServicioAgenda {
|
||||
leftPosition: number;
|
||||
width: number;
|
||||
topPosition: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'kt-timeline',
|
||||
templateUrl: './timeline.component.html',
|
||||
styleUrls: ['./timeline.component.scss']
|
||||
})
|
||||
export class TimelineComponent implements OnChanges {
|
||||
|
||||
@Input() serviciosRaw: ServicioAgenda[] = [];
|
||||
@Input() isLoading: boolean = false;
|
||||
|
||||
serviciosTimeline: ServicioTimeline[] = [];
|
||||
horasDelDia: string[] = [];
|
||||
horaInicio: number = 7;
|
||||
horaFin: number = 21;
|
||||
|
||||
constructor() {
|
||||
this.generarHorasDelDia();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.serviciosRaw) {
|
||||
this.procesarServicios();
|
||||
}
|
||||
}
|
||||
|
||||
generarHorasDelDia(): void {
|
||||
this.horasDelDia = [];
|
||||
for (let i = this.horaInicio; i <= this.horaFin; i++) {
|
||||
const hora = i < 12 ? `${i}:00 AM` : (i === 12 ? '12:00 PM' : `${i - 12}:00 PM`);
|
||||
this.horasDelDia.push(hora);
|
||||
}
|
||||
}
|
||||
|
||||
procesarServicios(): void {
|
||||
const totalHoras = this.horaFin - this.horaInicio;
|
||||
const operadoresMap: { [key: string]: number } = {};
|
||||
let operadorIndex = 0;
|
||||
|
||||
this.serviciosTimeline = this.serviciosRaw.map(servicio => {
|
||||
const startDate = new Date(servicio.start_day);
|
||||
const endDate = new Date(servicio.end_day);
|
||||
|
||||
const horaInicio = startDate.getHours() + startDate.getMinutes() / 60;
|
||||
const horaFin = endDate.getHours() + endDate.getMinutes() / 60;
|
||||
|
||||
const leftPosition = ((horaInicio - this.horaInicio) / totalHoras) * 100;
|
||||
const width = ((horaFin - horaInicio) / totalHoras) * 100;
|
||||
|
||||
// Asignar fila por operador
|
||||
const operador = servicio.nombre_operador || 'Sin asignar';
|
||||
if (operadoresMap[operador] === undefined) {
|
||||
operadoresMap[operador] = operadorIndex++;
|
||||
}
|
||||
const topPosition = operadoresMap[operador];
|
||||
|
||||
return {
|
||||
...servicio,
|
||||
leftPosition: Math.max(0, leftPosition),
|
||||
width: Math.max(2, width),
|
||||
topPosition
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getOperadores(): string[] {
|
||||
const operadores = new Set<string>();
|
||||
this.serviciosRaw.forEach(s => operadores.add(s.nombre_operador || 'Sin asignar'));
|
||||
return Array.from(operadores);
|
||||
}
|
||||
|
||||
getServiciosPorOperador(operador: string): ServicioTimeline[] {
|
||||
return this.serviciosTimeline.filter(s =>
|
||||
(s.nombre_operador || 'Sin asignar') === operador
|
||||
);
|
||||
}
|
||||
|
||||
getTotalServicios(): number {
|
||||
return this.serviciosRaw.length;
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,11 @@ import { SolicitudesServicioListComponent } from './solicitudes-servicio/solicit
|
||||
import { SolicitudesServicioEditComponent } from './solicitudes-servicio/solicitudes-servicio-edit/solicitudes-servicio-edit.component';
|
||||
import { ModalSetLitrajeComponent } from './solicitudes-servicio/modal-set-litraje/modal-set-litraje.component';
|
||||
import { ModalEncuestaComponent } from './solicitudes-servicio/modal-encuesta/modal-encuesta.component';
|
||||
import { HistorialCambiosComponent } from './solicitudes-servicio/historial-cambios/historial-cambios.component';
|
||||
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
|
||||
|
||||
@NgModule({
|
||||
declarations: [SolicitudesServicioListComponent, SolicitudesServicioEditComponent, ModalSetLitrajeComponent, ModalEncuestaComponent],
|
||||
declarations: [SolicitudesServicioListComponent, SolicitudesServicioEditComponent, ModalSetLitrajeComponent, ModalEncuestaComponent, HistorialCambiosComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
PartialsModule,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<div class="historial-container">
|
||||
<div class="historial-header">
|
||||
<mat-icon>history</mat-icon>
|
||||
<h4>Historial de Cambios</h4>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isLoading" class="loading-container">
|
||||
<mat-spinner diameter="30"></mat-spinner>
|
||||
<span>Cargando historial...</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error" class="error-container">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && !error && historial.length === 0" class="empty-container">
|
||||
<mat-icon>inbox</mat-icon>
|
||||
<span>No hay cambios registrados</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!isLoading && historial.length > 0" class="historial-list">
|
||||
<div class="historial-item" *ngFor="let item of historial">
|
||||
<div class="item-icon" [style.background-color]="getColorAccion(item.accion)">
|
||||
<mat-icon>{{ getIconoAccion(item.accion) }}</mat-icon>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-header">
|
||||
<span class="usuario">{{ item.usuario_completo }}</span>
|
||||
<span class="tiempo">{{ item.tiempo_transcurrido }}</span>
|
||||
</div>
|
||||
<div class="item-body">
|
||||
<span *ngIf="item.accion === 'crear'" class="accion-texto">
|
||||
Creó el servicio
|
||||
</span>
|
||||
<span *ngIf="item.accion === 'actualizar'" class="accion-texto">
|
||||
Cambió <strong>{{ item.campo_legible }}</strong>
|
||||
de <span class="valor-anterior">{{ item.valor_anterior || 'Sin valor' }}</span>
|
||||
a <span class="valor-nuevo">{{ item.valor_nuevo }}</span>
|
||||
</span>
|
||||
<span *ngIf="item.accion === 'eliminar'" class="accion-texto">
|
||||
Eliminó el servicio
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,161 @@
|
||||
.historial-container {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
.historial-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #5867dd;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #5867dd;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-container {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.historial-list {
|
||||
.historial-item {
|
||||
display: flex;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
|
||||
.usuario {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tiempo {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.item-body {
|
||||
.accion-texto {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
|
||||
strong {
|
||||
color: #5867dd;
|
||||
}
|
||||
|
||||
.valor-anterior {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
text-decoration: line-through;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.valor-nuevo {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.historial-container {
|
||||
.historial-list .historial-item {
|
||||
.item-content .item-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.tiempo {
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-content .item-body .accion-texto {
|
||||
display: block;
|
||||
|
||||
.valor-anterior,
|
||||
.valor-nuevo {
|
||||
display: inline-block;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {Component, Input, OnInit} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {environment} from '../../../../../../environments/environment';
|
||||
|
||||
interface HistorialItem {
|
||||
id: number;
|
||||
campo: string;
|
||||
campo_legible: string;
|
||||
valor_anterior: string;
|
||||
valor_nuevo: string;
|
||||
accion: string;
|
||||
usuario_completo: string;
|
||||
tiempo_transcurrido: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'kt-historial-cambios',
|
||||
templateUrl: './historial-cambios.component.html',
|
||||
styleUrls: ['./historial-cambios.component.scss']
|
||||
})
|
||||
export class HistorialCambiosComponent implements OnInit {
|
||||
|
||||
@Input() servicioDetId: number;
|
||||
|
||||
historial: HistorialItem[] = [];
|
||||
isLoading = false;
|
||||
error: string = null;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.servicioDetId) {
|
||||
this.cargarHistorial();
|
||||
}
|
||||
}
|
||||
|
||||
cargarHistorial(): void {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
this.http.get<any>(`${environment.API}atencionclientes/solicitud_servicios/${this.servicioDetId}/historial`)
|
||||
.subscribe(
|
||||
(response) => {
|
||||
this.historial = response.data || response;
|
||||
this.isLoading = false;
|
||||
},
|
||||
(err) => {
|
||||
this.error = 'Error al cargar el historial';
|
||||
this.isLoading = false;
|
||||
console.error('Error:', err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getIconoAccion(accion: string): string {
|
||||
switch (accion) {
|
||||
case 'crear':
|
||||
return 'add_circle';
|
||||
case 'actualizar':
|
||||
return 'edit';
|
||||
case 'eliminar':
|
||||
return 'delete';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
getColorAccion(accion: string): string {
|
||||
switch (accion) {
|
||||
case 'crear':
|
||||
return '#4caf50';
|
||||
case 'actualizar':
|
||||
return '#2196f3';
|
||||
case 'eliminar':
|
||||
return '#f44336';
|
||||
default:
|
||||
return '#9e9e9e';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@
|
||||
<mat-option>
|
||||
<ngx-mat-select-search (keyup)="onKeyUp($event, 'client')" (keydown)="onKeyDown('client')" (ngModelChange)="clearSelect($event)" [clearSearchInput]="false" [searching]="isLoadingInputSearch" [noEntriesFoundLabel]="changeNoFoundLabel()" placeholderLabel="Buscar..." i18n-placeholderLabel></ngx-mat-select-search>
|
||||
</mat-option>
|
||||
<mat-option *ngFor="let filtro of filteredClients|async" [value]="filtro.id" [disabled]="filtro.id == 1">
|
||||
<mat-option *ngFor="let filtro of filteredClients|async" [value]="filtro.id">
|
||||
{{filtro.denominacion}} {{(filtro.requiere_factura) ? '(Requiere factura)' : '(No requiere factura)'}}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
@@ -348,6 +348,16 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab *ngIf="!isNew">
|
||||
<ng-template mat-tab-label>
|
||||
<mat-icon class="mr-2">history</mat-icon>
|
||||
Historial de Cambios
|
||||
</ng-template>
|
||||
<ng-template matTabContent>
|
||||
<kt-historial-cambios [servicioDetId]="servicioDetId"></kt-historial-cambios>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</kt-portlet-body>
|
||||
</kt-portlet>
|
||||
|
||||
@@ -79,7 +79,7 @@ export class SolicitudesServicioEditComponent implements OnInit {
|
||||
|
||||
//CAMPO VALIDAR DISPONIBILIDAD
|
||||
solicitudServicioId = '';
|
||||
servicioDetId = '';
|
||||
servicioDetId: number = null;
|
||||
isLoadingDisponibilidad = [];
|
||||
|
||||
servicioID = null;
|
||||
@@ -160,6 +160,11 @@ export class SolicitudesServicioEditComponent implements OnInit {
|
||||
|
||||
servicio.servicios = ser;
|
||||
|
||||
// Asignar el ID del primer servicio detalle para el historial
|
||||
if (ser.length > 0 && ser[0].id) {
|
||||
this.servicioDetId = Number(ser[0].id);
|
||||
}
|
||||
|
||||
//servicio.origen_id = this.origenes.find((item) => item.id == servicio.origen_id);
|
||||
//servicio.forma_pago_id = this.formasPago.find((item) => item.id == servicio.forma_pago_id);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
isMockEnabled: false, // You have to switch this, when your real back-end is done
|
||||
isMockEnabled: false,
|
||||
authTokenKey: 'drence9d66b410c149d5992a30073637e4L5',
|
||||
|
||||
API : "http://64.23.240.99/v1/api/",
|
||||
STORAGE: 'http://64.23.240.99/storage/'
|
||||
API : "https://sio-api.consultoria-as.com/api/",
|
||||
STORAGE: 'https://sio-api.consultoria-as.com/storage/'
|
||||
};
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
isMockEnabled: true, // You have to switch this, when your real back-end is done
|
||||
authTokenKey: 'drence9d66b410c149d5992a30073637e4d5',
|
||||
isMockEnabled: false,
|
||||
authTokenKey: 'drence9d66b410c149d5992a30073637e4L5',
|
||||
|
||||
API : "http://64.23.240.99/v1/api/",
|
||||
STORAGE: 'http://64.23.240.99/storage/'
|
||||
API : "https://sio-api.consultoria-as.com/api/",
|
||||
STORAGE: 'https://sio-api.consultoria-as.com/storage/'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user