utn-cli 2.1.38 → 2.1.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "utn-cli",
3
- "version": "2.1.38",
3
+ "version": "2.1.39",
4
4
  "description": "Herramienta CLI unificada para la gestión de plantillas en SIGU.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -881,4 +881,18 @@ Router.get('/obtenerTarjetas', async (solicitud, respuesta, next) => {
881
881
  }
882
882
  });
883
883
 
884
+ Router.get('/calendarioPublico/:Anio?', async (solicitud, respuesta, next) => {
885
+ try {
886
+ try {
887
+ return respuesta.json({ body: await Miscelaneo.obtenerCalendarioPublico(solicitud.params.Anio), error: undefined });
888
+ } catch (error) {
889
+ const MensajeDeError = 'No fue posible obtener el calendario público';
890
+ console.error(new ManejadorDeErrores(MensajeDeError, ManejadorDeErrores.obtenerNumeroDeLinea(), true, `Dirección IP: ${solicitud.ip}`));
891
+ return respuesta.status(500).json({ body: undefined, error: MensajeDeError });
892
+ }
893
+ } catch (error) {
894
+ next(error);
895
+ }
896
+ });
897
+
884
898
  module.exports = Router;
@@ -2633,6 +2633,131 @@ class Miscelaneo {
2633
2633
  })
2634
2634
  .sort((a, b) => a['Posición'] - b['Posición']);
2635
2635
  }
2636
+
2637
+ async obtenerCalendarioPublico(Anio) {
2638
+ const anio = parseInt(Anio) || new Date().getFullYear();
2639
+
2640
+ const filas = await ejecutarConsultaSIGU(`
2641
+ SELECT
2642
+ c.CalendarioId, c.Anio, c.Version, c.Titulo,
2643
+ p.PeriodoId, p.TipoPeriodo, p.Nombre AS NombrePeriodo,
2644
+ p.Descripcion AS DescripcionPeriodo,
2645
+ p.FechaInicio AS PeriodoInicio, p.FechaFin AS PeriodoFin,
2646
+ p.Orden AS OrdenPeriodo,
2647
+ a.ActividadId, a.Descripcion AS DescripcionActividad,
2648
+ a.FechaInicio AS ActividadInicio, a.FechaFin AS ActividadFin,
2649
+ a.EsDestacada, a.Orden AS OrdenActividad,
2650
+ CASE
2651
+ WHEN a.FechaInicio IS NULL AND a.FechaFin IS NULL THEN 'sin_fecha'
2652
+ WHEN a.FechaFin < CURDATE() THEN 'pasada'
2653
+ WHEN a.FechaInicio > CURDATE() THEN 'proxima'
2654
+ ELSE 'en_curso'
2655
+ END AS EstadoActividad,
2656
+ CASE
2657
+ WHEN a.FechaInicio IS NULL OR a.FechaFin IS NULL THEN NULL
2658
+ WHEN a.FechaFin < CURDATE() THEN 100
2659
+ WHEN a.FechaInicio > CURDATE() THEN 0
2660
+ ELSE GREATEST(0, LEAST(100, ROUND(
2661
+ DATEDIFF(CURDATE(), a.FechaInicio) /
2662
+ NULLIF(DATEDIFF(a.FechaFin, a.FechaInicio), 0) * 100
2663
+ )))
2664
+ END AS PorcentajeActividad,
2665
+ CASE
2666
+ WHEN a.FechaInicio IS NULL THEN NULL
2667
+ WHEN a.FechaInicio > CURDATE() THEN DATEDIFF(a.FechaInicio, CURDATE())
2668
+ ELSE NULL
2669
+ END AS DiasParaInicioActividad
2670
+ FROM \`SIGU\`.\`SIGU_CalendarioInstitucional\` c
2671
+ JOIN \`SIGU\`.\`SIGU_CalendarioPeriodo\` p ON c.CalendarioId = p.CalendarioId
2672
+ LEFT JOIN \`SIGU\`.\`SIGU_CalendarioActividad\` a ON p.PeriodoId = a.PeriodoId
2673
+ WHERE c.Estado = 1 AND c.Anio = ?
2674
+ ORDER BY p.Orden ASC, p.PeriodoId ASC, a.Orden ASC, a.ActividadId ASC
2675
+ `, [anio]);
2676
+
2677
+ if (!filas.length) return null;
2678
+
2679
+ const hoy = new Date();
2680
+ hoy.setHours(0, 0, 0, 0);
2681
+
2682
+ const inicioAnio = new Date(filas[0].Anio, 0, 1);
2683
+ const finAnio = new Date(filas[0].Anio + 1, 0, 1);
2684
+ const porcentajeAnio = Math.max(0, Math.min(100, Math.round((hoy - inicioAnio) / (finAnio - inicioAnio) * 100)));
2685
+
2686
+ const calendario = {
2687
+ CalendarioId: filas[0].CalendarioId,
2688
+ Anio: filas[0].Anio,
2689
+ Version: filas[0].Version,
2690
+ Titulo: filas[0].Titulo,
2691
+ PorcentajeAnio: porcentajeAnio,
2692
+ Periodos: []
2693
+ };
2694
+
2695
+ const periodosMap = new Map();
2696
+
2697
+ for (const fila of filas) {
2698
+ if (!periodosMap.has(fila.PeriodoId)) {
2699
+ let estadoPeriodo = 'sin_fecha';
2700
+ let porcentajePeriodo = null;
2701
+ let diasParaInicioPeriodo = null;
2702
+
2703
+ if (fila.PeriodoInicio && fila.PeriodoFin) {
2704
+ const s = (v) => {
2705
+ const d = typeof v === 'string' ? v : new Date(v).toISOString();
2706
+ const [y, m, dd] = d.slice(0, 10).split('-').map(Number);
2707
+ const fecha = new Date(y, m - 1, dd);
2708
+ fecha.setHours(0, 0, 0, 0);
2709
+ return fecha;
2710
+ };
2711
+ const inicio = s(fila.PeriodoInicio);
2712
+ const fin = s(fila.PeriodoFin);
2713
+
2714
+ if (fin < hoy) {
2715
+ estadoPeriodo = 'pasada';
2716
+ porcentajePeriodo = 100;
2717
+ } else if (inicio > hoy) {
2718
+ estadoPeriodo = 'proxima';
2719
+ porcentajePeriodo = 0;
2720
+ diasParaInicioPeriodo = Math.ceil((inicio - hoy) / 86400000);
2721
+ } else {
2722
+ estadoPeriodo = 'en_curso';
2723
+ const total = fin - inicio;
2724
+ porcentajePeriodo = total > 0
2725
+ ? Math.max(0, Math.min(100, Math.round((hoy - inicio) / total * 100)))
2726
+ : 0;
2727
+ }
2728
+ }
2729
+
2730
+ periodosMap.set(fila.PeriodoId, {
2731
+ PeriodoId: fila.PeriodoId,
2732
+ TipoPeriodo: fila.TipoPeriodo,
2733
+ NombrePeriodo: fila.NombrePeriodo,
2734
+ DescripcionPeriodo: fila.DescripcionPeriodo,
2735
+ PeriodoInicio: fila.PeriodoInicio,
2736
+ PeriodoFin: fila.PeriodoFin,
2737
+ EstadoPeriodo: estadoPeriodo,
2738
+ PorcentajePeriodo: porcentajePeriodo,
2739
+ DiasParaInicioPeriodo: diasParaInicioPeriodo,
2740
+ Actividades: []
2741
+ });
2742
+ }
2743
+
2744
+ if (fila.ActividadId !== null) {
2745
+ periodosMap.get(fila.PeriodoId).Actividades.push({
2746
+ ActividadId: fila.ActividadId,
2747
+ DescripcionActividad: fila.DescripcionActividad,
2748
+ ActividadInicio: fila.ActividadInicio,
2749
+ ActividadFin: fila.ActividadFin,
2750
+ EsDestacada: Boolean(fila.EsDestacada),
2751
+ EstadoActividad: fila.EstadoActividad,
2752
+ PorcentajeActividad: fila.PorcentajeActividad,
2753
+ DiasParaInicioActividad: fila.DiasParaInicioActividad
2754
+ });
2755
+ }
2756
+ }
2757
+
2758
+ calendario.Periodos = [...periodosMap.values()];
2759
+ return calendario;
2760
+ }
2636
2761
  }
2637
2762
 
2638
2763
  module.exports = new Miscelaneo();
@@ -0,0 +1,236 @@
1
+ .titulo-dialog {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 8px;
5
+ }
6
+
7
+ .titulo-dialog small {
8
+ font-size: 0.65em;
9
+ opacity: 0.6;
10
+ font-weight: normal;
11
+ }
12
+
13
+ /* ── Buscador ── */
14
+ .buscador {
15
+ width: 100%;
16
+ margin-bottom: 4px;
17
+ }
18
+
19
+ .aviso-resultados {
20
+ font-size: 0.8em;
21
+ color: #555;
22
+ margin: 0 0 10px;
23
+ }
24
+
25
+ mark {
26
+ background: #FFF176;
27
+ color: inherit;
28
+ border-radius: 2px;
29
+ padding: 0 1px;
30
+ }
31
+
32
+ /* ── Barra de progreso anual ── */
33
+ .barra-anio {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 10px;
37
+ margin-bottom: 16px;
38
+ padding: 10px 12px;
39
+ background: #f5f5f5;
40
+ border-radius: 6px;
41
+ }
42
+
43
+ .barra-anio mat-progress-bar {
44
+ flex: 1;
45
+ border-radius: 4px;
46
+ }
47
+
48
+ .barra-anio-label {
49
+ font-size: 0.8em;
50
+ white-space: nowrap;
51
+ color: #555;
52
+ }
53
+
54
+ .barra-anio-pct {
55
+ font-size: 0.8em;
56
+ font-weight: 600;
57
+ white-space: nowrap;
58
+ color: #333;
59
+ }
60
+
61
+ /* ── Paneles ── */
62
+ .panel {
63
+ margin-bottom: 8px;
64
+ border-radius: 6px !important;
65
+ }
66
+
67
+ .panel-en-curso {
68
+ border-left: 4px solid #4CAF50;
69
+ }
70
+
71
+ .panel-proximas {
72
+ border-left: 4px solid #1976D2;
73
+ }
74
+
75
+ .panel-pasadas {
76
+ border-left: 4px solid #9E9E9E;
77
+ }
78
+
79
+ .icono-estado {
80
+ margin-right: 6px;
81
+ font-size: 18px;
82
+ height: 18px;
83
+ width: 18px;
84
+ }
85
+
86
+ .icono-en-curso { color: #4CAF50; }
87
+ .icono-proximas { color: #1976D2; }
88
+ .icono-pasadas { color: #9E9E9E; }
89
+
90
+ .panel-vacio {
91
+ font-size: 0.85em;
92
+ color: #888;
93
+ margin: 4px 0 8px;
94
+ font-style: italic;
95
+ }
96
+
97
+ /* ── Tarjeta de período ── */
98
+ .periodo-card {
99
+ border-radius: 4px;
100
+ padding: 10px 12px;
101
+ margin-bottom: 8px;
102
+ background: #fafafa;
103
+ border: 1px solid #e0e0e0;
104
+ }
105
+
106
+ .periodo-en-curso { border-left: 3px solid #4CAF50; }
107
+ .periodo-proximas { border-left: 3px solid #1976D2; }
108
+ .periodo-pasadas { border-left: 3px solid #BDBDBD; }
109
+
110
+ .periodo-encabezado {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 8px;
114
+ flex-wrap: wrap;
115
+ margin-bottom: 4px;
116
+ }
117
+
118
+ .periodo-nombre {
119
+ font-weight: 600;
120
+ font-size: 0.92em;
121
+ }
122
+
123
+ .periodo-tipo {
124
+ font-size: 0.75em;
125
+ background: #e8e8e8;
126
+ color: #555;
127
+ padding: 1px 6px;
128
+ border-radius: 10px;
129
+ }
130
+
131
+ .dias-chip {
132
+ font-size: 0.75em;
133
+ background: #1976D2;
134
+ color: #fff;
135
+ padding: 1px 8px;
136
+ border-radius: 10px;
137
+ margin-left: auto;
138
+ }
139
+
140
+ .periodo-rango {
141
+ font-size: 0.8em;
142
+ color: #666;
143
+ margin-bottom: 6px;
144
+ }
145
+
146
+ .periodo-barra {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 8px;
150
+ margin-bottom: 8px;
151
+ }
152
+
153
+ .periodo-barra mat-progress-bar {
154
+ flex: 1;
155
+ border-radius: 4px;
156
+ }
157
+
158
+ .periodo-pct {
159
+ font-size: 0.75em;
160
+ white-space: nowrap;
161
+ color: #555;
162
+ }
163
+
164
+ /* ── Actividades ── */
165
+ .actividades {
166
+ margin-top: 6px;
167
+ border-top: 1px solid #eeeeee;
168
+ padding-top: 6px;
169
+ }
170
+
171
+ .actividad-fila {
172
+ display: flex;
173
+ align-items: center;
174
+ gap: 6px;
175
+ padding: 3px 0;
176
+ font-size: 0.82em;
177
+ flex-wrap: wrap;
178
+ }
179
+
180
+ .act-icono {
181
+ font-size: 13px;
182
+ height: 14px;
183
+ width: 14px;
184
+ flex-shrink: 0;
185
+ }
186
+
187
+ .act-desc {
188
+ flex: 1;
189
+ min-width: 0;
190
+ }
191
+
192
+ .act-fecha {
193
+ color: #777;
194
+ white-space: nowrap;
195
+ }
196
+
197
+ .act-dias {
198
+ font-size: 0.85em;
199
+ background: #E3F2FD;
200
+ color: #1565C0;
201
+ padding: 0 6px;
202
+ border-radius: 8px;
203
+ white-space: nowrap;
204
+ }
205
+
206
+ /* Colores por estado de actividad */
207
+ .act-en_curso .act-icono { color: #4CAF50; }
208
+ .act-en_curso .act-desc { color: #2E7D32; font-weight: 500; }
209
+
210
+ .act-proxima .act-icono { color: #1976D2; }
211
+
212
+ .act-pasada .act-icono { color: #9E9E9E; }
213
+ .act-pasada .act-desc { color: #9E9E9E; }
214
+ .act-pasada .act-fecha { color: #BDBDBD; }
215
+
216
+ .act-sin_fecha .act-icono { color: #BDBDBD; }
217
+
218
+ /* Textos grises para períodos pasados */
219
+ .gris { color: #9E9E9E; }
220
+
221
+ /* Estados de carga / error */
222
+ .aviso-error {
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 6px;
226
+ color: #c62828;
227
+ padding: 12px 0;
228
+ font-size: 0.9em;
229
+ }
230
+
231
+ .aviso-vacio {
232
+ color: #888;
233
+ font-size: 0.9em;
234
+ padding: 16px 0;
235
+ font-style: italic;
236
+ }
@@ -0,0 +1,172 @@
1
+ <h2 mat-dialog-title class="titulo-dialog">
2
+ <mat-icon>calendar_month</mat-icon>
3
+ <span *ngIf="cargando">Cargando...</span>
4
+ <span *ngIf="!cargando && calendario">{{ calendario.Titulo }}&nbsp;<small>{{ calendario.Version }}</small></span>
5
+ <span *ngIf="!cargando && !calendario">Calendario institucional</span>
6
+ </h2>
7
+
8
+ <mat-dialog-content>
9
+
10
+ <mat-progress-bar *ngIf="cargando" mode="indeterminate"></mat-progress-bar>
11
+
12
+ <div *ngIf="error" class="aviso-error">
13
+ <mat-icon>error_outline</mat-icon> {{ error }}
14
+ </div>
15
+
16
+ <div *ngIf="!cargando && !error && !calendario" class="aviso-vacio">
17
+ No hay un calendario activo para el año actual.
18
+ </div>
19
+
20
+ <ng-container *ngIf="!cargando && !error && calendario">
21
+
22
+ <div class="barra-anio">
23
+ <span class="barra-anio-label">Año {{ calendario.Anio }}</span>
24
+ <mat-progress-bar mode="determinate" [value]="calendario.PorcentajeAnio" color="primary"></mat-progress-bar>
25
+ <span class="barra-anio-pct">{{ calendario.PorcentajeAnio }}%</span>
26
+ </div>
27
+
28
+ <!-- ── Buscador ── -->
29
+ <mat-form-field appearance="outline" class="buscador">
30
+ <mat-label>Buscar actividad</mat-label>
31
+ <mat-icon matPrefix>search</mat-icon>
32
+ <input matInput [value]="terminoBusqueda" (input)="onBusqueda($event)" placeholder="Ej: matrícula, exámenes...">
33
+ <button *ngIf="busquedaActiva" matSuffix mat-icon-button (click)="limpiarBusqueda()" aria-label="Limpiar">
34
+ <mat-icon>close</mat-icon>
35
+ </button>
36
+ </mat-form-field>
37
+
38
+ <p *ngIf="busquedaActiva" class="aviso-resultados">
39
+ <ng-container *ngIf="totalResultados > 0">
40
+ {{ totalResultados }} resultado(s) para «{{ terminoBusqueda }}»
41
+ </ng-container>
42
+ <ng-container *ngIf="totalResultados === 0">
43
+ Sin resultados para «{{ terminoBusqueda }}»
44
+ </ng-container>
45
+ </p>
46
+
47
+ <!-- ── EN CURSO ── -->
48
+ <mat-expansion-panel [expanded]="true" class="panel panel-en-curso"
49
+ *ngIf="!busquedaActiva || periodosEnCursoFiltrados.length > 0">
50
+ <mat-expansion-panel-header>
51
+ <mat-panel-title>
52
+ <mat-icon class="icono-estado icono-en-curso">radio_button_checked</mat-icon>
53
+ En curso
54
+ </mat-panel-title>
55
+ <mat-panel-description>{{ periodosEnCurso.length }} período(s)</mat-panel-description>
56
+ </mat-expansion-panel-header>
57
+
58
+ <p *ngIf="periodosEnCurso.length === 0" class="panel-vacio">Sin períodos en curso actualmente.</p>
59
+
60
+ <ng-container *ngFor="let periodo of periodosEnCursoFiltrados">
61
+ <div class="periodo-card periodo-en-curso">
62
+ <div class="periodo-encabezado">
63
+ <span class="periodo-nombre">{{ periodo.NombrePeriodo }}</span>
64
+ <span class="periodo-tipo">{{ periodo.TipoPeriodo }}</span>
65
+ </div>
66
+ <div class="periodo-rango">
67
+ {{ formatearFecha(periodo.PeriodoInicio) }} → {{ formatearFecha(periodo.PeriodoFin) }}
68
+ </div>
69
+ <div class="periodo-barra" *ngIf="!busquedaActiva">
70
+ <mat-progress-bar mode="determinate" [value]="periodo.PorcentajePeriodo" color="accent"></mat-progress-bar>
71
+ <span class="periodo-pct">{{ periodo.PorcentajePeriodo }}% transcurrido</span>
72
+ </div>
73
+ <div class="actividades" *ngIf="actividadesFiltradas(periodo.Actividades).length">
74
+ <div *ngFor="let a of actividadesFiltradas(periodo.Actividades)"
75
+ class="actividad-fila" [ngClass]="'act-' + a.EstadoActividad">
76
+ <mat-icon class="act-icono">{{ a.EsDestacada ? 'star' : 'circle' }}</mat-icon>
77
+ <span class="act-desc" [innerHTML]="resaltarTexto(a.DescripcionActividad)"></span>
78
+ <span class="act-fecha" *ngIf="a.ActividadInicio">
79
+ {{ formatearFecha(a.ActividadInicio) }}<span *ngIf="a.ActividadFin"> – {{ formatearFecha(a.ActividadFin) }}</span>
80
+ </span>
81
+ <span class="act-dias" *ngIf="a.DiasParaInicioActividad !== null && a.DiasParaInicioActividad !== undefined">
82
+ en {{ a.DiasParaInicioActividad }}d
83
+ </span>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </ng-container>
88
+ </mat-expansion-panel>
89
+
90
+ <!-- ── PRÓXIMAS ── -->
91
+ <mat-expansion-panel [expanded]="true" class="panel panel-proximas"
92
+ *ngIf="!busquedaActiva || periodosProximosFiltrados.length > 0">
93
+ <mat-expansion-panel-header>
94
+ <mat-panel-title>
95
+ <mat-icon class="icono-estado icono-proximas">schedule</mat-icon>
96
+ Próximas
97
+ </mat-panel-title>
98
+ <mat-panel-description>{{ periodosProximos.length }} período(s)</mat-panel-description>
99
+ </mat-expansion-panel-header>
100
+
101
+ <p *ngIf="periodosProximos.length === 0" class="panel-vacio">Sin períodos próximos.</p>
102
+
103
+ <ng-container *ngFor="let periodo of periodosProximosFiltrados">
104
+ <div class="periodo-card periodo-proximas">
105
+ <div class="periodo-encabezado">
106
+ <span class="periodo-nombre">{{ periodo.NombrePeriodo }}</span>
107
+ <span class="periodo-tipo">{{ periodo.TipoPeriodo }}</span>
108
+ <span class="dias-chip" *ngIf="periodo.DiasParaInicioPeriodo !== null && periodo.DiasParaInicioPeriodo !== undefined">
109
+ en {{ periodo.DiasParaInicioPeriodo }} día(s)
110
+ </span>
111
+ </div>
112
+ <div class="periodo-rango">
113
+ {{ formatearFecha(periodo.PeriodoInicio) }} → {{ formatearFecha(periodo.PeriodoFin) }}
114
+ </div>
115
+ <div class="actividades" *ngIf="actividadesFiltradas(periodo.Actividades).length">
116
+ <div *ngFor="let a of actividadesFiltradas(periodo.Actividades)"
117
+ class="actividad-fila" [ngClass]="'act-' + a.EstadoActividad">
118
+ <mat-icon class="act-icono">{{ a.EsDestacada ? 'star' : 'circle' }}</mat-icon>
119
+ <span class="act-desc" [innerHTML]="resaltarTexto(a.DescripcionActividad)"></span>
120
+ <span class="act-fecha" *ngIf="a.ActividadInicio">
121
+ {{ formatearFecha(a.ActividadInicio) }}<span *ngIf="a.ActividadFin"> – {{ formatearFecha(a.ActividadFin) }}</span>
122
+ </span>
123
+ <span class="act-dias" *ngIf="a.DiasParaInicioActividad !== null && a.DiasParaInicioActividad !== undefined">
124
+ en {{ a.DiasParaInicioActividad }}d
125
+ </span>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </ng-container>
130
+ </mat-expansion-panel>
131
+
132
+ <!-- ── PASADAS ── -->
133
+ <mat-expansion-panel [expanded]="busquedaActiva" class="panel panel-pasadas"
134
+ *ngIf="!busquedaActiva || periodosPasadosFiltrados.length > 0">
135
+ <mat-expansion-panel-header>
136
+ <mat-panel-title>
137
+ <mat-icon class="icono-estado icono-pasadas">check_circle_outline</mat-icon>
138
+ Pasadas
139
+ </mat-panel-title>
140
+ <mat-panel-description>{{ periodosPasados.length }} período(s)</mat-panel-description>
141
+ </mat-expansion-panel-header>
142
+
143
+ <p *ngIf="periodosPasados.length === 0" class="panel-vacio">Sin períodos pasados.</p>
144
+
145
+ <ng-container *ngFor="let periodo of periodosPasadosFiltrados">
146
+ <div class="periodo-card periodo-pasadas">
147
+ <div class="periodo-encabezado">
148
+ <span class="periodo-nombre gris">{{ periodo.NombrePeriodo }}</span>
149
+ <span class="periodo-tipo">{{ periodo.TipoPeriodo }}</span>
150
+ </div>
151
+ <div class="periodo-rango gris">
152
+ {{ formatearFecha(periodo.PeriodoInicio) }} → {{ formatearFecha(periodo.PeriodoFin) }}
153
+ </div>
154
+ <div class="actividades" *ngIf="actividadesFiltradas(periodo.Actividades).length">
155
+ <div *ngFor="let a of actividadesFiltradas(periodo.Actividades)" class="actividad-fila act-pasada">
156
+ <mat-icon class="act-icono">check</mat-icon>
157
+ <span class="act-desc" [innerHTML]="resaltarTexto(a.DescripcionActividad)"></span>
158
+ <span class="act-fecha" *ngIf="a.ActividadInicio">
159
+ {{ formatearFecha(a.ActividadInicio) }}<span *ngIf="a.ActividadFin"> – {{ formatearFecha(a.ActividadFin) }}</span>
160
+ </span>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </ng-container>
165
+ </mat-expansion-panel>
166
+
167
+ </ng-container>
168
+ </mat-dialog-content>
169
+
170
+ <mat-dialog-actions align="end">
171
+ <button mat-button (click)="cerrar()">Cerrar</button>
172
+ </mat-dialog-actions>
@@ -0,0 +1,106 @@
1
+ import { Component, OnInit, inject } from '@angular/core';
2
+ import { HttpClient } from '@angular/common/http';
3
+ import { MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle } from '@angular/material/dialog';
4
+ import { MatButtonModule } from '@angular/material/button';
5
+ import { MatIconModule } from '@angular/material/icon';
6
+ import { MatProgressBarModule } from '@angular/material/progress-bar';
7
+ import { MatExpansionModule } from '@angular/material/expansion';
8
+ import { MatFormFieldModule } from '@angular/material/form-field';
9
+ import { MatInputModule } from '@angular/material/input';
10
+ import { CommonModule } from '@angular/common';
11
+ import { DatosGlobalesService } from '../../../datos-globales.service';
12
+
13
+ @Component({
14
+ selector: 'app-calendario-publico',
15
+ templateUrl: './calendario-publico.component.html',
16
+ styleUrl: './calendario-publico.component.css',
17
+ imports: [
18
+ CommonModule,
19
+ MatDialogContent, MatDialogActions, MatDialogTitle,
20
+ MatButtonModule, MatIconModule,
21
+ MatProgressBarModule, MatExpansionModule,
22
+ MatFormFieldModule, MatInputModule
23
+ ]
24
+ })
25
+ export class CalendarioPublicoComponent implements OnInit {
26
+ readonly dialogRef = inject(MatDialogRef<CalendarioPublicoComponent>);
27
+
28
+ cargando = true;
29
+ error = '';
30
+ calendario: any = null;
31
+ terminoBusqueda = '';
32
+
33
+ get busquedaActiva(): boolean {
34
+ return this.terminoBusqueda.trim().length > 0;
35
+ }
36
+
37
+ get totalResultados(): number {
38
+ return this.calendario?.Periodos?.reduce((acc: number, p: any) =>
39
+ acc + this.actividadesFiltradas(p.Actividades).length, 0) ?? 0;
40
+ }
41
+
42
+ private porEstado(estado: string): any[] {
43
+ return this.calendario?.Periodos?.filter((p: any) => p.EstadoPeriodo === estado) ?? [];
44
+ }
45
+
46
+ get periodosEnCurso(): any[] { return this.porEstado('en_curso'); }
47
+ get periodosProximos(): any[] { return this.porEstado('proxima'); }
48
+ get periodosPasados(): any[] { return this.porEstado('pasada'); }
49
+
50
+ get periodosEnCursoFiltrados(): any[] { return this.periodosEnCurso.filter(p => this.periodoConResultados(p)); }
51
+ get periodosProximosFiltrados(): any[] { return this.periodosProximos.filter(p => this.periodoConResultados(p)); }
52
+ get periodosPasadosFiltrados(): any[] { return this.periodosPasados.filter(p => this.periodoConResultados(p)); }
53
+
54
+ actividadesFiltradas(actividades: any[]): any[] {
55
+ if (!this.busquedaActiva) return actividades ?? [];
56
+ const t = this.terminoBusqueda.trim().toLowerCase();
57
+ return (actividades ?? []).filter(a => a.DescripcionActividad?.toLowerCase().includes(t));
58
+ }
59
+
60
+ periodoConResultados(periodo: any): boolean {
61
+ return !this.busquedaActiva || this.actividadesFiltradas(periodo.Actividades).length > 0;
62
+ }
63
+
64
+ resaltarTexto(texto: string): string {
65
+ if (!this.busquedaActiva || !texto) return texto;
66
+ const escaped = texto.replace(/[&<>"']/g, c =>
67
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[c]!)
68
+ );
69
+ const regex = new RegExp(`(${this.terminoBusqueda.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
70
+ return escaped.replace(regex, '<mark>$1</mark>');
71
+ }
72
+
73
+ onBusqueda(event: Event): void {
74
+ this.terminoBusqueda = (event.target as HTMLInputElement).value;
75
+ }
76
+
77
+ limpiarBusqueda(): void {
78
+ this.terminoBusqueda = '';
79
+ }
80
+
81
+ constructor(private http: HttpClient, private datosGlobalesService: DatosGlobalesService) {}
82
+
83
+ ngOnInit(): void {
84
+ this.http.get(this.datosGlobalesService.ObtenerURL() + 'misc/calendarioPublico').subscribe({
85
+ next: (datos: any) => {
86
+ this.calendario = datos.body;
87
+ this.cargando = false;
88
+ },
89
+ error: () => {
90
+ this.error = 'No fue posible cargar el calendario.';
91
+ this.cargando = false;
92
+ }
93
+ });
94
+ }
95
+
96
+ cerrar(): void {
97
+ this.dialogRef.close();
98
+ }
99
+
100
+ formatearFecha(fecha: any): string {
101
+ if (!fecha) return '—';
102
+ const s = typeof fecha === 'string' ? fecha : new Date(fecha).toISOString();
103
+ const [y, m, d] = s.slice(0, 10).split('-').map(Number);
104
+ return new Date(y, m - 1, d).toLocaleDateString('es-CR', { day: 'numeric', month: 'short' });
105
+ }
106
+ }