diff --git a/src/app/views/pages/agenda/agenda-medica/agenda-medica.component.html b/src/app/views/pages/agenda/agenda-medica/agenda-medica.component.html new file mode 100644 index 0000000..038af90 --- /dev/null +++ b/src/app/views/pages/agenda/agenda-medica/agenda-medica.component.html @@ -0,0 +1,77 @@ +
+
+ + Cargando servicios... +
+ +
+ event_busy + No hay servicios programados para esta fecha +
+ +
+ + {{ getTotalServicios() }} servicios programados | + {{ operadores.length }} operadores + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ schedule + Hora + + person + {{ operador }} +
+ {{ celda.horaFormateada }} + +
+
+ access_time + {{ servicio.hora_servicio }} +
+
+ business + {{ servicio.denominacion_cliente }} +
+
+ timelapse + {{ servicio.duracion_servicio }} +
+
+ + {{ servicio.nombre_estatus }} + +
+
+
+ Disponible +
+
+
diff --git a/src/app/views/pages/agenda/agenda-medica/agenda-medica.component.scss b/src/app/views/pages/agenda/agenda-medica/agenda-medica.component.scss new file mode 100644 index 0000000..6960d14 --- /dev/null +++ b/src/app/views/pages/agenda/agenda-medica/agenda-medica.component.scss @@ -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; +} diff --git a/src/app/views/pages/agenda/agenda-medica/agenda-medica.component.ts b/src/app/views/pages/agenda/agenda-medica/agenda-medica.component.ts new file mode 100644 index 0000000..564eba0 --- /dev/null +++ b/src/app/views/pages/agenda/agenda-medica/agenda-medica.component.ts @@ -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(); + 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; + } +} diff --git a/src/app/views/pages/agenda/agenda.module.ts b/src/app/views/pages/agenda/agenda.module.ts index 84e5dfd..7f9ec4e 100644 --- a/src/app/views/pages/agenda/agenda.module.ts +++ b/src/app/views/pages/agenda/agenda.module.ts @@ -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, diff --git a/src/app/views/pages/agenda/calendario/calendario.component.html b/src/app/views/pages/agenda/calendario/calendario.component.html index 2fcb820..95a068a 100644 --- a/src/app/views/pages/agenda/calendario/calendario.component.html +++ b/src/app/views/pages/agenda/calendario/calendario.component.html @@ -1,15 +1,15 @@
- - - @@ -20,38 +20,11 @@
- {{ (viewDate | calendarDate:(view + 'ViewTitle'):'es-MX') | uppercase }} -
- -
- - + {{ viewDate | date:'dd MMMM yyyy':'':'es-MX' | uppercase }}
-
- - + - - -
diff --git a/src/app/views/pages/agenda/calendario/calendario.component.ts b/src/app/views/pages/agenda/calendario/calendario.component.ts index ae32b75..44103d3 100644 --- a/src/app/views/pages/agenda/calendario/calendario.component.ts +++ b/src/app/views/pages/agenda/calendario/calendario.component.ts @@ -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 = 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: ""+ agenda.nombre_operador + "
" + agenda.hora_servicio + "
" + agenda.duracion_servicio + "
" + agenda.denominacion_cliente + "
" + 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(); } } diff --git a/src/app/views/pages/agenda/por-operador/por-operador.component.html b/src/app/views/pages/agenda/por-operador/por-operador.component.html new file mode 100644 index 0000000..49824c8 --- /dev/null +++ b/src/app/views/pages/agenda/por-operador/por-operador.component.html @@ -0,0 +1,54 @@ +
+
+ + Cargando servicios... +
+ +
+ event_busy + No hay servicios programados para esta fecha +
+ +
+ + {{ getTotalServicios() }} servicios programados | + {{ operadoresAgrupados.length }} operadores + +
+ + + + + + person + {{ operador.nombre }} + + + {{ operador.servicios.length }} servicio(s) + + + +
+
+
+ schedule + {{ servicio.hora_servicio }} +
+
+ timelapse + {{ servicio.duracion_servicio }} +
+
+ business + {{ servicio.denominacion_cliente }} +
+
+ + {{ servicio.nombre_estatus }} + +
+
+
+
+
+
diff --git a/src/app/views/pages/agenda/por-operador/por-operador.component.scss b/src/app/views/pages/agenda/por-operador/por-operador.component.scss new file mode 100644 index 0000000..659f56b --- /dev/null +++ b/src/app/views/pages/agenda/por-operador/por-operador.component.scss @@ -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; + } + } + } + } +} diff --git a/src/app/views/pages/agenda/por-operador/por-operador.component.ts b/src/app/views/pages/agenda/por-operador/por-operador.component.ts new file mode 100644 index 0000000..84b4b26 --- /dev/null +++ b/src/app/views/pages/agenda/por-operador/por-operador.component.ts @@ -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; + } +} diff --git a/src/app/views/pages/agenda/timeline/timeline.component.html b/src/app/views/pages/agenda/timeline/timeline.component.html new file mode 100644 index 0000000..5c43873 --- /dev/null +++ b/src/app/views/pages/agenda/timeline/timeline.component.html @@ -0,0 +1,67 @@ +
+
+ + Cargando servicios... +
+ +
+ event_busy + No hay servicios programados para esta fecha +
+ +
+ + {{ getTotalServicios() }} servicios programados | + {{ getOperadores().length }} operadores + +
+ +
+ +
+
Operador
+
+
+ {{ hora }} +
+
+
+ + +
+
+
+ person + {{ operador }} +
+
+ +
+
+ + +
+
+ {{ servicio.denominacion_cliente }} + {{ servicio.hora_servicio }} +
+
+
+
+
+ + +
+
+ + {{ servicio.nombre_estatus }} +
+
+
+
diff --git a/src/app/views/pages/agenda/timeline/timeline.component.scss b/src/app/views/pages/agenda/timeline/timeline.component.scss new file mode 100644 index 0000000..ded3a3b --- /dev/null +++ b/src/app/views/pages/agenda/timeline/timeline.component.scss @@ -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; + } + } + } + } +} diff --git a/src/app/views/pages/agenda/timeline/timeline.component.ts b/src/app/views/pages/agenda/timeline/timeline.component.ts new file mode 100644 index 0000000..12d4a73 --- /dev/null +++ b/src/app/views/pages/agenda/timeline/timeline.component.ts @@ -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(); + 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; + } +} diff --git a/src/app/views/pages/servicios/servicios.module.ts b/src/app/views/pages/servicios/servicios.module.ts index 5428b16..8f87d49 100644 --- a/src/app/views/pages/servicios/servicios.module.ts +++ b/src/app/views/pages/servicios/servicios.module.ts @@ -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, diff --git a/src/app/views/pages/servicios/solicitudes-servicio/historial-cambios/historial-cambios.component.html b/src/app/views/pages/servicios/solicitudes-servicio/historial-cambios/historial-cambios.component.html new file mode 100644 index 0000000..8d8b5ba --- /dev/null +++ b/src/app/views/pages/servicios/solicitudes-servicio/historial-cambios/historial-cambios.component.html @@ -0,0 +1,48 @@ +
+
+ history +

Historial de Cambios

+
+ +
+ + Cargando historial... +
+ +
+ error_outline + {{ error }} +
+ +
+ inbox + No hay cambios registrados +
+ +
+
+
+ {{ getIconoAccion(item.accion) }} +
+
+
+ {{ item.usuario_completo }} + {{ item.tiempo_transcurrido }} +
+
+ + Creó el servicio + + + Cambió {{ item.campo_legible }} + de {{ item.valor_anterior || 'Sin valor' }} + a {{ item.valor_nuevo }} + + + Eliminó el servicio + +
+
+
+
+
diff --git a/src/app/views/pages/servicios/solicitudes-servicio/historial-cambios/historial-cambios.component.scss b/src/app/views/pages/servicios/solicitudes-servicio/historial-cambios/historial-cambios.component.scss new file mode 100644 index 0000000..6c9afa0 --- /dev/null +++ b/src/app/views/pages/servicios/solicitudes-servicio/historial-cambios/historial-cambios.component.scss @@ -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; + } + } + } + } +} diff --git a/src/app/views/pages/servicios/solicitudes-servicio/historial-cambios/historial-cambios.component.ts b/src/app/views/pages/servicios/solicitudes-servicio/historial-cambios/historial-cambios.component.ts new file mode 100644 index 0000000..dc3f1df --- /dev/null +++ b/src/app/views/pages/servicios/solicitudes-servicio/historial-cambios/historial-cambios.component.ts @@ -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(`${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'; + } + } +} diff --git a/src/app/views/pages/servicios/solicitudes-servicio/solicitudes-servicio-edit/solicitudes-servicio-edit.component.html b/src/app/views/pages/servicios/solicitudes-servicio/solicitudes-servicio-edit/solicitudes-servicio-edit.component.html index e72a582..dad94ab 100644 --- a/src/app/views/pages/servicios/solicitudes-servicio/solicitudes-servicio-edit/solicitudes-servicio-edit.component.html +++ b/src/app/views/pages/servicios/solicitudes-servicio/solicitudes-servicio-edit/solicitudes-servicio-edit.component.html @@ -42,7 +42,7 @@ - + {{filtro.denominacion}} {{(filtro.requiere_factura) ? '(Requiere factura)' : '(No requiere factura)'}} @@ -348,6 +348,16 @@
+ + + + history + Historial de Cambios + + + + +
diff --git a/src/app/views/pages/servicios/solicitudes-servicio/solicitudes-servicio-edit/solicitudes-servicio-edit.component.ts b/src/app/views/pages/servicios/solicitudes-servicio/solicitudes-servicio-edit/solicitudes-servicio-edit.component.ts index 308ca12..5e0465b 100644 --- a/src/app/views/pages/servicios/solicitudes-servicio/solicitudes-servicio-edit/solicitudes-servicio-edit.component.ts +++ b/src/app/views/pages/servicios/solicitudes-servicio/solicitudes-servicio-edit/solicitudes-servicio-edit.component.ts @@ -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); diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 7450c22..2b83fd8 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -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/' }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 1364621..db52228 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -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/' };