PageSourceSearch

https://campus.ibernex.es//lib/javascript/Educativa/Modules/Aula/V…viarCertificadosMasivamente.js?v=1780286490

js ibernex.es collected 2026-10-08 05:33:05 UTC 25,504 bytes, 663 lines download raw bytes

1/***********************************************************/
2/*                                                         */
3/* Plataforma e-ducativa - Argentina                       */
4/*                                                         */
5/* Copyright (c) 2026 de e-ducativa Educación Virtual S.A. */
6/*                                                         */
7/***********************************************************/
8/**
9 * EnviarCertificadosMasivamente - Herramienta para envio masivo de certificados
10 *
11 * @module Educativa/Modules/Aula/View/Herramienta/EnviarCertificadosMasivamente
12 * Copyright (c) 2026 Educativa
13 */
14
15import { DoubleList } from 'Educativa/Modules/Components/DoubleList';
16import { EducativaCalendar } from 'Educativa/Modules/Base';
17
18/**
19 * Vista principal de la herramienta
20 */
21export class EnviarCertificadosMasivamenteView {
22    /**
23     * @typedef {Object} EnviarCertificadosOptions
24     * @property {string} container - ID del contenedor principal
25     * @property {number} id_herramienta - ID de la herramienta
26     * @property {number} id_cursoActual - ID del curso actual
27     * @property {Array} cursos - Lista de cursos disponibles
28     * @property {string} csrf_token - Token CSRF
29     */
30
31    /**
32     * @param {EnviarCertificadosOptions} args - Opciones de configuracion
33     */
34    constructor( args ) {
35        this.container = document.getElementById( args.container );
36        this.idHerramienta = args.id_herramienta;
37        this.idCursoActual = args.id_cursoActual;
38        this.cursos = args.cursos || [];
39        this.csrfToken = args.csrf_token || '';
40
41        this.formData = {};
42        this.doubleList = null;
43        this._tooltipDiv = null;
44
45        this._init();
46    }
47
48    _init() {
49        this._showFormView();
50    }
51
52
53    // =========================================================================
54    // VISTA: FORMULARIO
55    // =========================================================================
56
57    _showFormView() {
58        if ( this._tooltipDiv ) {
59            this._tooltipDiv.remove();
60            this._tooltipDiv = null;
61        }
62
63        this.container.innerHTML = '';
64        this.container.classList.remove( 'results_view' );
65
66        // Titulo
67        const title = document.createElement( 'h2' );
68        title.textContent = Dict.termcap( 'ENVIAR_CERTIFICADOS_MASIVAMENTE' );
69        this.container.appendChild( title );
70
71        // Formulario
72        const form = document.createElement( 'form' );
73        form.id = 'form_enviar_certificados';
74        form.className = 'EventForm';
75        form.method = 'POST';
76        form.enctype = 'multipart/form-data';
77
78        // Tip informativo
79        form.appendChild( this._createTipRow() );
80
81        // Campo: Fuente de usuarios
82        form.appendChild( this._createFuenteUsuariosRow() );
83
84        // Campo: Cursos
85        form.appendChild( this._createCursosRow() );
86
87        // Campo: Filtros adicionales (radio buttons mutuamente excluyentes)
88        form.appendChild( this._createFiltrosAdicionalesRow() );
89
90        // Error area (antes de botones)
91        const errorArea = document.createElement( 'div' );
92        errorArea.id = 'submit_error';
93        errorArea.className = 'submit_error double_list_hidden';
94        errorArea.textContent = Dict.termcap( 'POR_FAVOR_REVISA_EL_FORMULARIO' );
95        form.appendChild( errorArea );
96
97        // Botones de accion
98        form.appendChild( this._createActionsRow() );
99
100        form.addEventListener( 'submit', ( e ) => {
101            e.preventDefault();
102            this._handleFormSubmit();
103        } );
104
105        this.container.appendChild( form );
106
107        this._initDoubleList();
108    }
109
110    _createTipRow() {
111        const row = document.createElement( 'div' );
112        row.className = 'row';
113
114        const noLabel = document.createElement( 'div' );
115        noLabel.className = 'no_label';
116
117        const tip = document.createElement( 'div' );
118        tip.className = 'aclaracion';
119        tip.innerHTML = '<i class="fa-regular fa-circle-info info_icon"></i> ' + Dict.termcap(
120            'SELECCIONE_LOS_CURSOS_Y_LA_FUENTE_DE_USUARIOS_PARA_ENVIAR_CERTIFICADOS'
121        );
122        noLabel.appendChild( tip );
123        row.appendChild( noLabel );
124
125        return row;
126    }
127
128    _createFuenteUsuariosRow() {
129        const row = document.createElement( 'div' );
130        row.className = 'row obligatorio';
131        row.id = 'line_form_fuente_usuarios';
132
133        // Label
134        const label = document.createElement( 'label' );
135        label.textContent = Dict.termcap( 'FUENTE_DE_USUARIOS' );
136        row.appendChild( label );
137
138        // Contenedor derecho
139        const rightCont = document.createElement( 'div' );
140        rightCont.className = 'right_cont';
141
142        const control = document.createElement( 'div' );
143        control.className = 'multi_control';
144
145        // Opcion Archivo: row column que agrupa label del radio + file input
146        const rowArchivo = document.createElement( 'div' );
147        rowArchivo.className = 'row_multicontrol column';
148
149        const labelArchivo = document.createElement( 'label' );
150        labelArchivo.className = 'inline_label';
151
152        const radioArchivo = document.createElement( 'input' );
153        radioArchivo.type = 'radio';
154        radioArchivo.name = 'fuente_usuarios';
155        radioArchivo.id = 'fuente_archivo';
156        radioArchivo.value = 'archivo';
157        radioArchivo.checked = true;
158        labelArchivo.appendChild( radioArchivo );
159
160        labelArchivo.appendChild( document.createTextNode( Dict.termcap( 'ARCHIVO' ) ) );
161
162        // Tooltip con icono de ayuda
163        const tooltipBtn = document.createElement( 'span' );
164        tooltipBtn.className = 'tooltip-button';
165        labelArchivo.appendChild( tooltipBtn );
166
167        const tooltipDiv = document.createElement( 'div' );
168        tooltipDiv.className = 'tooltip';
169        tooltipDiv.style.display = 'none';
170        tooltipDiv.innerHTML = Dict.termcap(
171            'ARCHIVO_DE_TEXTO_PLANO_TIPOS_QUE_CONTIENE_UN_USERNAME_POR_LINEA'
172        ).replace( '#{tipos}', '(.csv, .txt)' );
173        document.body.appendChild( tooltipDiv );
174        this._tooltipDiv = tooltipDiv;
175
176        tooltipBtn.addEventListener( 'mouseenter', () => {
177            const rect = tooltipBtn.getBoundingClientRect();
178            // Mostrar invisible para calcular dimensiones reales
179            tooltipDiv.style.visibility = 'hidden';
180            tooltipDiv.style.display = '';
181            const height = tooltipDiv.offsetHeight;
182            tooltipDiv.style.left = rect.left + 'px';
183            tooltipDiv.style.top = ( window.scrollY + rect.top - height - 5 ) + 'px';
184            tooltipDiv.style.visibility = '';
185        } );
186        tooltipBtn.addEventListener( 'mouseleave', () => {
187            tooltipDiv.style.display = 'none';
188        } );
189
190        rowArchivo.appendChild( labelArchivo );
191
192        // File input como subrow dentro del row_multicontrol column
193        const fileContainer = document.createElement( 'div' );
194        fileContainer.id = 'file_input_container';
195        fileContainer.className = 'subrow_multicontrol no_stretch';
196        fileContainer.innerHTML = `
197            <input type="file" name="archivo_usernames"
198                   id="archivo_usernames" accept=".csv,.txt" size="140">
199        `;
200        rowArchivo.appendChild( fileContainer );
201
202        control.appendChild( rowArchivo );
203
204        // Opcion Todos
205        const rowTodos = document.createElement( 'div' );
206        rowTodos.className = 'row_multicontrol';
207
208        const labelTodos = document.createElement( 'label' );
209        labelTodos.className = 'inline_label';
210        labelTodos.innerHTML = `
211            <input type="radio" name="fuente_usuarios" id="fuente_todos" value="todos">
212            ${Dict.termcap( 'TODOS_LOS_USUARIOS_CON_PERMISO_CURSA' )}
213        `;
214        rowTodos.appendChild( labelTodos );
215        control.appendChild( rowTodos );
216
217        // Error: sin archivo
218        const error = document.createElement( 'span' );
219        error.className = 'error_txt double_list_hidden';
220        error.id = 'error_fuente_usuarios';
221        error.textContent = Dict.termcap( 'DEBE_SELECCIONAR_UN_ARCHIVO' );
222        control.appendChild( error );
223
224        // Error: tipo de archivo invalido
225        const errorTipo = document.createElement( 'span' );
226        errorTipo.className = 'error_txt double_list_hidden';
227        errorTipo.id = 'error_archivo_tipo';
228        errorTipo.textContent = Dict.termcap( 'TIPO_ARCHIVO_NO_VALIDO' );
229        control.appendChild( errorTipo );
230
231        rightCont.appendChild( control );
232        row.appendChild( rightCont );
233
234        // Eventos
235        setTimeout( () => {
236            const radioArchivo = document.getElementById( 'fuente_archivo' );
237            const radioTodos = document.getElementById( 'fuente_todos' );
238            const fileInput = document.getElementById( 'file_input_container' );
239
240            if ( radioArchivo && radioTodos && fileInput ) {
241                radioArchivo.addEventListener( 'change', () => {
242                    fileInput.style.display = '';
243                } );
244                radioTodos.addEventListener( 'change', () => {
245                    fileInput.style.display = 'none';
246                } );
247            }
248        }, 0 );
249
250        return row;
251    }
252
253    _createCursosRow() {
254        const row = document.createElement( 'div' );
255        row.className = 'row obligatorio';
256        row.id = 'line_form_cursos';
257
258        // Label
259        const label = document.createElement( 'label' );
260        label.className = 'flex_start';
261        label.textContent = Dict.termcap( 'AULAS' );
262        row.appendChild( label );
263
264        // Contenedor derecho
265        const rightCont = document.createElement( 'div' );
266        rightCont.className = 'right_cont';
267
268        const control = document.createElement( 'div' );
269        control.id = 'cursos_multi_control';
270        rightCont.appendChild( control );
271
272        // Error
273        const error = document.createElement( 'span' );
274        error.className = 'error_txt double_list_hidden';
275        error.id = 'error_cursos';
276        error.textContent = Dict.termcap( 'DEBE_SELECCIONAR_AL_MENOS_UN_AULA' );
277        rightCont.appendChild( error );
278
279        row.appendChild( rightCont );
280
281        return row;
282    }
283
284    _createFiltrosAdicionalesRow() {
285        const row = document.createElement( 'div' );
286        row.className = 'row';
287        row.id = 'line_form_filtros_adicionales';
288
289        // Label
290        const label = document.createElement( 'label' );
291        label.className = 'flex_start';
292        label.textContent = Dict.termcap( 'FILTROS_ADICIONALES' );
293        row.appendChild( label );
294
295        // Contenedor derecho
296        const rightCont = document.createElement( 'div' );
297        rightCont.className = 'right_cont';
298
299        const control = document.createElement( 'div' );
300        control.className = 'multi_control';
301
302        // Opcion 1: Sin filtro adicional (default)
303        const rowNinguno = document.createElement( 'div' );
304        rowNinguno.className = 'row_multicontrol';
305        const opcionNinguno = document.createElement( 'label' );
306        opcionNinguno.className = 'inline_label';
307        opcionNinguno.innerHTML = `
308            <input type="radio" name="filtro_adicional" id="filtro_ninguno" value="ninguno" checked>
309            ${Dict.termcap( 'SIN_FILTRO_ADICIONAL' )}
310        `;
311        rowNinguno.appendChild( opcionNinguno );
312        control.appendChild( rowNinguno );
313
314        // Opcion 2: Solo finalizados
315        const rowFinalizados = document.createElement( 'div' );
316        rowFinalizados.className = 'row_multicontrol';
317        const opcionFinalizados = document.createElement( 'label' );
318        opcionFinalizados.className = 'inline_label';
319        opcionFinalizados.innerHTML = `
320            <input type="radio" name="filtro_adicional" id="filtro_finalizados" value="finalizados">
321            ${Dict.termcap( 'SOLO_USUARIOS_QUE_FINALIZARON' )}
322        `;
323        rowFinalizados.appendChild( opcionFinalizados );
324        control.appendChild( rowFinalizados );
325
326        // Fechas: wrapper para toggle JS (sin clase visual)
327        const fechasContainer = document.createElement( 'div' );
328        fechasContainer.id = 'fechas_container';
329        fechasContainer.className = 'double_list_hidden';
330
331        const rowFechaDesde = document.createElement( 'div' );
332        rowFechaDesde.className = 'subrow_multicontrol no_stretch';
333        rowFechaDesde.innerHTML = `
334            <label for="fecha_desde">${Dict.termcap( 'FECHA_DESDE' )}:</label>
335            <input type="text" name="fecha_desde" id="fecha_desde"
336                   placeholder="dd/mm/aaaa" maxlength="10" autocomplete="off">
337            <span class="error_txt double_list_hidden" id="error_fecha_desde">
338                ${Dict.termcap( 'FECHA_INVALIDA' )}
339            </span>
340        `;
341        fechasContainer.appendChild( rowFechaDesde );
342
343        const rowFechaHasta = document.createElement( 'div' );
344        rowFechaHasta.className = 'subrow_multicontrol no_stretch';
345        rowFechaHasta.innerHTML = `
346            <label for="fecha_hasta">${Dict.termcap( 'FECHA_HASTA' )}:</label>
347            <input type="text" name="fecha_hasta" id="fecha_hasta"
348                   placeholder="dd/mm/aaaa" maxlength="10" autocomplete="off">
349            <span class="error_txt double_list_hidden" id="error_fecha_hasta">
350                ${Dict.termcap( 'FECHA_INVALIDA' )}
351            </span>
352        `;
353        fechasContainer.appendChild( rowFechaHasta );
354
355        const errorRango = document.createElement( 'span' );
356        errorRango.className = 'error_txt double_list_hidden';
357        errorRango.id = 'error_fecha_rango';
358        errorRango.textContent = Dict.termcap( 'FECHA_DESDE_MAYOR_HASTA' );
359        fechasContainer.appendChild( errorRango );
360
361        control.appendChild( fechasContainer );
362
363        // Inicializar calendarios cuando el contenedor sea visible
364        this._calendarsInitialized = false;
365
366        // Opcion 3: Avance completo
367        const rowAvance = document.createElement( 'div' );
368        rowAvance.className = 'row_multicontrol';
369        const opcionAvance = document.createElement( 'label' );
370        opcionAvance.className = 'inline_label';
371        opcionAvance.innerHTML = `
372            <input type="radio" name="filtro_adicional" id="filtro_avance" value="avance_completo">
373            ${Dict.termcap( 'SOLO_USUARIOS_CON_AVANCE_COMPLETO' )}
374        `;
375        rowAvance.appendChild( opcionAvance );
376        control.appendChild( rowAvance );
377
378        rightCont.appendChild( control );
379        row.appendChild( rightCont );
380
381        // Evento para mostrar/ocultar fechas segun seleccion
382        setTimeout( () => {
383            const radios = document.querySelectorAll( 'input[name="filtro_adicional"]' );
384            const fechasCont = document.getElementById( 'fechas_container' );
385
386            radios.forEach( radio => {
387                radio.addEventListener( 'change', () => {
388                    if ( fechasCont ) {
389                        const esFinalizados = radio.value === 'finalizados';
390                        fechasCont.classList.toggle( 'double_list_hidden', !esFinalizados );
391
392                        // Inicializar calendarios la primera vez que se muestran
393                        if ( esFinalizados && !this._calendarsInitialized ) {
394                            this._calendarsInitialized = true;
395                            new EducativaCalendar( {
396                                inputField:  'fecha_desde',
397                                ifFormat:    '%d/%m/%Y',
398                                weekNumbers: false,
399                                align:       'bR',
400                                showsTime:   false
401                            } ).render();
402                            new EducativaCalendar( {
403                                inputField:  'fecha_hasta',
404                                ifFormat:    '%d/%m/%Y',
405                                weekNumbers: false,
406                                align:       'bR',
407                                showsTime:   false
408                            } ).render();
409                        }
410                    }
411                } );
412            } );
413        }, 0 );
414
415        return row;
416    }
417
418    _createActionsRow() {
419        const row = document.createElement( 'div' );
420        row.className = 'row actions edu-btn-group';
421
422        const submitBtn = document.createElement( 'button' );
423        submitBtn.type = 'submit';
424        submitBtn.className = 'edu-btn edu-btn-primary';
425        submitBtn.textContent = Dict.termcap( 'CONTINUAR' );
426
427        row.appendChild( submitBtn );
428
429        return row;
430    }
431
432    _initDoubleList() {
433        const container = document.getElementById( 'cursos_multi_control' );
434        if ( !container ) return;
435
436        const items = this.cursos.map( curso => ( {
437            value: curso.id_curso,
438            title: curso.title,
439            class: curso.activo === 0 ? 'inactivo' : '',
440            position: curso.position || 0
441        } ) );
442
443        this.doubleList = new DoubleList( {
444            container: 'cursos_multi_control',
445            id: 'cursos',
446            items: items,
447            selected: [],
448            titleLeft: Dict.termcap( 'DISPONIBLES' ),
449            titleRight: Dict.termcap( 'SELECCIONADOS' ),
450            showSearch: true,
451            showInactiveFilter: true,
452            inactiveFilterLabel: Dict.termcap( 'MOSTRAR_AULAS_DESACTIVADAS' )
453        } );
454    }
455
456    _handleFormSubmit() {
457        this._clearErrors();
458
459        if ( !this._validateForm() ) {
460            this._showFormError();
461            return;
462        }
463
464        this._collectFormData();
465        this._submitToConfirmacion();
466    }
467
468    _validateForm() {
469        let valid = true;
470
471        const fuenteArchivo = document.getElementById( 'fuente_archivo' );
472        const archivoInput = document.getElementById( 'archivo_usernames' );
473
474        if ( fuenteArchivo && fuenteArchivo.checked ) {
475            const noFile = !archivoInput ||
476                !archivoInput.files ||
477                archivoInput.files.length === 0;
478
479            if ( noFile ) {
480                this._showFieldError( 'error_fuente_usuarios' );
481                valid = false;
482            } else {
483                const ext = archivoInput.files[ 0 ].name
484                    .split( '.' ).pop().toLowerCase();
485                if ( ext !== 'csv' && ext !== 'txt' ) {
486                    this._showFieldError( 'error_archivo_tipo' );
487                    valid = false;
488                }
489            }
490        }
491
492        if ( !this.doubleList || !this.doubleList.validate() ) {
493            this._showFieldError( 'error_cursos' );
494            valid = false;
495        }
496
497        // Validar fechas si el filtro de finalizados esta activo
498        const filtroSeleccionado = document.querySelector( 'input[name="filtro_adicional"]:checked' );
499        if ( filtroSeleccionado && filtroSeleccionado.value === 'finalizados' ) {
500            const fechaDesde = document.getElementById( 'fecha_desde' );
501            const fechaHasta = document.getElementById( 'fecha_hasta' );
502
503            const desdeValida = !fechaDesde.value || this._isValidDate( fechaDesde.value );
504            const hastaValida = !fechaHasta.value || this._isValidDate( fechaHasta.value );
505
506            if ( fechaDesde.value && !desdeValida ) {
507                this._showFieldError( 'error_fecha_desde' );
508                valid = false;
509            }
510            if ( fechaHasta.value && !hastaValida ) {
511                this._showFieldError( 'error_fecha_hasta' );
512                valid = false;
513            }
514
515            // Validar que fecha desde no sea mayor a fecha hasta
516            if ( desdeValida && hastaValida && fechaDesde.value && fechaHasta.value ) {
517                const desde = this._parseDateDmy( fechaDesde.value );
518                const hasta = this._parseDateDmy( fechaHasta.value );
519                if ( desde > hasta ) {
520                    this._showFieldError( 'error_fecha_rango' );
521                    valid = false;
522                }
523            }
524        }
525
526        return valid;
527    }
528
529    /**
530     * Valida que una fecha en formato dd/mm/aaaa sea real
531     * @param {string} dateStr - Fecha en formato dd/mm/aaaa
532     * @return {boolean}
533     */
534    _isValidDate( dateStr ) {
535        const match = dateStr.match( /^(\d{2})\/(\d{2})\/(\d{4})$/ );
536        if ( !match ) return false;
537
538        const day = parseInt( match[1], 10 );
539        const month = parseInt( match[2], 10 );
540        const year = parseInt( match[3], 10 );
541
542        if ( month < 1 || month > 12 ) return false;
543        if ( year < 1900 || year > 2100 ) return false;
544
545        // Verificar dia valido para el mes (Date usa mes 0-indexed)
546        const date = new Date( year, month - 1, day );
547        return date.getFullYear() === year &&
548               date.getMonth() === month - 1 &&
549               date.getDate() === day;
550    }
551
552    /**
553     * Parsea dd/mm/aaaa a objeto Date para comparacion
554     * @param {string} dateStr - Fecha en formato dd/mm/aaaa
555     * @return {Date}
556     */
557    _parseDateDmy( dateStr ) {
558        const [ day, month, year ] = dateStr.split( '/' ).map( Number );
559        return new Date( year, month - 1, day );
560    }
561
562    _collectFormData() {
563        const fuenteArchivo = document.getElementById( 'fuente_archivo' );
564        const archivoInput = document.getElementById( 'archivo_usernames' );
565        const fechaDesde = document.getElementById( 'fecha_desde' );
566        const fechaHasta = document.getElementById( 'fecha_hasta' );
567
568        // Obtener filtro adicional seleccionado (radio buttons)
569        const filtroSeleccionado = document.querySelector( 'input[name="filtro_adicional"]:checked' );
570        const filtroValor = filtroSeleccionado ? filtroSeleccionado.value : 'ninguno';
571
572        const esArchivo = fuenteArchivo && fuenteArchivo.checked;
573        const esFinalizados = filtroValor === 'finalizados';
574        const esAvanceCompleto = filtroValor === 'avance_completo';
575
576        this.formData = {
577            fuente_usuarios: esArchivo ? 'archivo' : 'todos',
578            archivo: esArchivo && archivoInput ? archivoInput.files[0] : null,
579            cursos: this.doubleList.getSelectedItems(),
580            cursosIds: this.doubleList.getSelected(),
581            solo_finalizados: esFinalizados,
582            fecha_desde: esFinalizados && fechaDesde ? fechaDesde.value : '',
583            fecha_hasta: esFinalizados && fechaHasta ? fechaHasta.value : '',
584            avance_completo: esAvanceCompleto
585        };
586    }
587
588    _showFieldError( elementId ) {
589        const el = document.getElementById( elementId );
590        if ( el ) el.classList.remove( 'double_list_hidden' );
591    }
592
593    _showFormError() {
594        const errorArea = document.getElementById( 'submit_error' );
595        if ( errorArea ) errorArea.classList.remove( 'double_list_hidden' );
596    }
597
598    _clearErrors() {
599        this.container.querySelectorAll( '.error_txt' ).forEach( el => {
600            el.classList.add( 'double_list_hidden' );
601        } );
602        const errorArea = document.getElementById( 'submit_error' );
603        if ( errorArea ) errorArea.classList.add( 'double_list_hidden' );
604    }
605
606    // =========================================================================
607    // ENVIO A CONFIRMACION (server-side)
608    // =========================================================================
609
610    _submitToConfirmacion() {
611        // Crear formulario temporal fuera del DOM actual
612        const tempForm = document.createElement( 'form' );
613        tempForm.method = 'post';
614        tempForm.enctype = 'multipart/form-data';
615        tempForm.action = 'admin_herramientas.cgi';
616        tempForm.style.display = 'none';
617
618        // Campos del formulario
619        const fields = {
620            'wAccion': 'ejecutar',
621            'id_herramienta': this.idHerramienta,
622            'wid_cursoActual': this.idCursoActual,
623            'csrf_token': this.csrfToken,
624            'fuente_usuarios': this.formData.fuente_usuarios,
625            'solo_finalizados': this.formData.solo_finalizados ? '1' : '0',
626            'fecha_desde': this.formData.fecha_desde || '',
627            'fecha_hasta': this.formData.fecha_hasta || '',
628            'avance_completo': this.formData.avance_completo ? '1' : '0'
629        };
630
631        // Agregar campos ocultos
632        for ( const [name, value] of Object.entries( fields ) ) {
633            const input = document.createElement( 'input' );
634            input.type = 'hidden';
635            input.name = name;
636            input.value = value;
637            tempForm.appendChild( input );
638        }
639
640        // Agregar cursos seleccionados
641        this.formData.cursosIds.forEach( id => {
642            const input = document.createElement( 'input' );
643            input.type = 'hidden';
644            input.name = 'cursos';
645            input.value = id;
646            tempForm.appendChild( input );
647        } );
648
649        // Mover el input file original al formulario temporal
650        const originalFileInput = document.querySelector( 'input[name="archivo_usernames"]' );
651        if ( originalFileInput && originalFileInput.files.length > 0 ) {
652            // Clonar no funciona con files, hay que mover el elemento
653            tempForm.appendChild( originalFileInput );
654        }
655
656        // Agregar al body y enviar
657        document.body.appendChild( tempForm );
658
659        tempForm.submit();
660    }
661
662}
663

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.