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:
SIO Admin
2026-01-17 23:01:31 +00:00
parent 9261cc3abd
commit ddbfc3de6a
20 changed files with 1545 additions and 89 deletions

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}
}
}

View File

@@ -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;
}
}

View 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>

View 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;
}
}
}
}
}

View 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;
}
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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;
}
}
}
}
}

View File

@@ -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';
}
}
}

View File

@@ -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>

View File

@@ -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);