Logo Mitie

Gestor de cuadrantes

MITIE OVD1 · cuadrantes, ubicación en planos, sustituciones, vacaciones y coberturas
Zonas: todas

DIRECTORIO GENERAL DE POSICIONES

Aquí se define el puesto habitual de cada trabajador. No depende del turno. El cambio se aplica al cuadrante desde la fecha efectiva indicada y se conserva un registro histórico de cada modificación.
Directorio general: puedes colocar de 1 a 4 personas por puesto. Todos los desplegables contienen toda la plantilla. Una persona solo puede pertenecer a un puesto habitual a la vez. Los cambios diarios o sustituciones hechos desde la ficha de turno siguen siendo excepciones de ese día y no modifican este directorio.

Registro de modificaciones de puesto

Relación histórica de quién cambió, desde qué puesto, a cuál y desde qué fecha tiene efecto.
Fecha efectivaRegistradoTrabajador/aLoginPuesto anteriorPuesto nuevoResponsable

NÓMINAS · contador mensual

Contador operativo a partir del cuadrante: días efectivos, domingos/festivos y horas nocturnas.
Criterios cargados: PLUS TRANSPORTE = día efectivo trabajado. Nocturnidad = horas ordinarias entre 22:00 y 06:00. Domingo/festivo = día trabajado que coincida con domingo o festivo. El contador es de apoyo y no sustituye la revisión final de nómina.
Trabajador/aLoginPLUS TRANSPORTE
días efectivos
Domingos trabajadosFestivos trabajadosDom./Fest. únicosHoras nocturnidad
22:00-06:00
Noches trabajadasHoras turno nocheHoras totales
Calendario Asturias 2026 cargado
Referencia convenio limpieza
24 de diciembre: día de descanso previsto por el convenio, con las salvedades organizativas del propio artículo. Además, existe un día de descanso a elección de la persona trabajadora.
Festivos locales / empresa
Añade aquí fiestas locales del municipio o días que deban computar como festivo en OVD1.

Permisos / días de convenio

Registra el permiso y comprueba automáticamente lo ya consumido en fechas anteriores.
Selecciona trabajador, tipo y fecha.
El convenio 2026-2027 establece un día de descanso a elección de la persona trabajadora, además del descanso asociado al 24 de diciembre. Para “Asuntos personales” y otros conceptos el límite queda configurable, porque no se fija aquí un cupo genérico que podamos aplicar a todos los casos.

Historial del trabajador

Fecha(s)TipoDíasObservacionesRegistrado

Aplicar ITE por periodo

Selecciona trabajador/a y fechas. El gestor marcará ITE en todo el periodo de una sola vez.
La operación queda registrada en el historial y se puede deshacer. Si después pulsas “Quitar ITE del periodo”, recuperará los cambios puntuales que hubiese antes cuando sea posible.

Añadir trabajador/a

La nueva fila se guardará en esta tablet o navegador y aparecerá también en los desplegables de reasignación y exportación.

Aplicar secuencia automática

La secuencia rellena el periodo automáticamente. Después, cada casilla sigue teniendo su desplegable para realizar cambios puntuales.

BloqueDías y horario/situación 1
2
3
4

Control de cobertura, horas y alertas

FechaDíaMañana/díaTardeNocheVac.ITEEstado
Trabajador/aGrupoContrato semanalHoras rangoObjetivo prorrateadoDiferenciaSemanas con exceso
Trabajador/aVacacionesITEDías convenio/permisosLibres

Organización de vacaciones

Los periodos planificados o aprobados aparecen automáticamente como VAC en el cuadrante. Las casillas continúan siendo editables para excepciones puntuales.
La tabla también detecta los periodos de vacaciones que ya venían marcados en el cuadrante original. Esos registros aparecen como ORIGINAL y se conservan sin alterar la plantilla de partida.

Añadir periodo

Tabla anual de vacaciones

Nombre y apellidosGrupoDesdeHastaMes(es)AñoDías naturalesDías de trabajo afectadosEstadoObservacionesAcciones

Historial de modificaciones

Se conserva un registro local con el responsable, la fecha y el tipo de operación. Las últimas acciones pueden deshacerse.

Organizar ubicaciones por día

Al elegir una fecha se carga automáticamente el personal que figura trabajando ese día en el cuadrante. Organízalo en P1, P2, P3 o P4. La cantina y las zonas exteriores se ubican dentro del plano de P1.
Personal previsto según cuadrante
La lista se actualiza sola al cambiar de fecha y muestra únicamente a quien tenga turno de trabajo. Selecciona una persona y pulsa en el plano para colocarla. También puedes arrastrar su marcador.
No hay plano cargado para esta planta. Puedes cargar una imagen JPG, PNG o WEBP y después situar al personal encima.
Consejo: usa las referencias de columnas/letras del plano para escribir puntos como “G-12 · Decant”, “Jackpot”, “SM Station”, “Cantina” o “Exteriores”. Cantina y exteriores se registran dentro de P1.
Ubicación seleccionada
Selecciona una persona.
Listado del turno filtrado
Aquí verás a las personas filtradas en este día y el puesto o zona donde les toca estar. Si filtras solo tarde, aquí se quedarán solo las de tarde.
Falta y sustitución
Marca la ausencia y elige quién cubre. Si el ausente ya estaba situado, su ubicación se copia al sustituto.

Reasignar / modificar celda

Enviar cuadrante personalizado

Prepara el correo de un trabajador con el periodo seleccionado y guarda su dirección para próximos envíos.

Exportar cuadrante individual

`; const win=window.open('','_blank'); if(!win)return alert('El navegador ha bloqueado la ventana de impresión.'); win.document.write(doc); win.document.close(); } function init() { migrateLegacyPlanAreas(); persistDailySheetBridge(); refreshDynamicOptions(); const minMonth = DATA.minDate.slice(0,7), maxMonth = DATA.maxDate.slice(0,7), nowIso=iso(new Date()); const initialDate=(nowIso>=DATA.minDate&&nowIso<=DATA.maxDate)?nowIso:DATA.minDate; $('#monthPicker').min = minMonth; $('#monthPicker').max = maxMonth; $('#monthPicker').value = initialDate.slice(0,7); $('#weekPicker').value = dateToWeekValue(parseDate(initialDate)); $('#exportFrom').value = DATA.minDate; $('#exportTo').value = DATA.maxDate; $('#sequenceFrom').min = DATA.minDate; $('#sequenceFrom').max = DATA.maxDate; $('#sequenceTo').min = DATA.minDate; $('#sequenceTo').max = DATA.maxDate; $('#sequenceFrom').value = DATA.minDate; $('#sequenceTo').value = DATA.maxDate; ['vacationFrom','vacationTo'].forEach(id=>{ $('#'+id).min=DATA.minDate; $('#'+id).max=DATA.maxDate; }); $('#vacationFrom').value=DATA.minDate; $('#vacationTo').value=DATA.minDate; refreshVacationYearFilter(); $('#legend').innerHTML = [ ['#ECEC8A','Turno de día / mañana'], ['#FFBF7F','Turno de tarde'], ['#8CBCEA','Turno de noche'], ['#00B050','ITE'], ['#FFFF00','Vacaciones'], ['#FFFFFF','Libre'] ].map(([c,t])=>`
${t}
`).join(''); renderZoneMultiFilter(); ['search','groupFilter','monthPicker','statusFilter','shiftFilter'].forEach(id => $('#'+id).addEventListener('input', () => { render(); if(id==='shiftFilter' && $('#locationDialog')?.open){ $('#locationShiftFilter').value=locationFilterFromMainShiftFilter($('#shiftFilter').value||''); selectedLocationWorkerId=null; refreshSubstitutionOptions(); renderLocationModule(); } })); $('#viewMode').addEventListener('change', () => { const mode=$('#viewMode').value; $('#weekPicker').style.display=mode==='week'?'inline-block':'none'; $('#dayPickerWrap').classList.toggle('active',mode==='day'); if(mode==='day') { const current=$('#dayPicker').value; const month=$('#monthPicker').value||DATA.minDate.slice(0,7); if(!current || !current.startsWith(month)) { const today=iso(new Date()); const candidate=(today>=DATA.minDate&&today<=DATA.maxDate&&today.startsWith(month))?today:month+'-01'; $('#dayPicker').value=candidateDATA.maxDate?DATA.maxDate:candidate); } if(!$('#shiftFilter').value) $('#shiftFilter').value='working'; } render(); }); $('#weekPicker').addEventListener('input', render); $('#dayPicker').min=DATA.minDate; $('#dayPicker').max=DATA.maxDate; $('#dayPicker').value=initialDate; $('#dayPicker').addEventListener('input',()=>{ const value=$('#dayPicker').value; if(value) $('#monthPicker').value=value.slice(0,7); render(); }); $('#zoneSelectAllBtn').addEventListener('click',()=>{ document.querySelectorAll('#zoneMultiOptions input[type="checkbox"]').forEach(cb=>cb.checked=false); updateZoneMultiSummary(); render(); if($('#locationDialog')?.open){refreshSubstitutionOptions();renderLocationModule();} }); $('#zoneClearBtn').addEventListener('click',()=>{ document.querySelectorAll('#zoneMultiOptions input[type="checkbox"]').forEach(cb=>{ cb.checked=/^P1\b/.test(cb.value)||['P2','P3','P4'].includes(cb.value); }); updateZoneMultiSummary(); render(); if($('#locationDialog')?.open){refreshSubstitutionOptions();renderLocationModule();} }); $('#iteBulkBtn').addEventListener('click', openIteDialog); $('#closeIteBtn').addEventListener('click', () => $('#iteDialog').close()); $('#applyIteBtn').addEventListener('click', applyIteRange); $('#clearIteBtn').addEventListener('click', clearIteRange); $('#controlBtn').addEventListener('click', openControlDialog); $('#locationBtn').addEventListener('click', openLocationDialog); $('#sendShiftToSheetBtn')?.addEventListener('click', sendVisibleShiftToDailySheet); $('#positionBoardBtn')?.addEventListener('click',openPositionBoard); $('#closePositionBoardBtn')?.addEventListener('click',()=>$('#positionBoardDialog').close()); $('#positionDate')?.addEventListener('input',renderPositionBoard); $('#positionWorkerSearch')?.addEventListener('input',renderPositionBoard); $('#loadPositionBoardBtn')?.addEventListener('click',renderPositionBoard); $('#clearPositionBoardBtn')?.addEventListener('click',clearPositionBoard); $('#savePositionBoardBtn')?.addEventListener('click',savePositionBoard); $('#positionHistorySearch')?.addEventListener('input',renderPositionHistory); $('#positionHistoryFrom')?.addEventListener('input',renderPositionHistory); $('#positionHistoryTo')?.addEventListener('input',renderPositionHistory); $('#clearPositionHistoryFiltersBtn')?.addEventListener('click',()=>{$('#positionHistorySearch').value='';$('#positionHistoryFrom').value='';$('#positionHistoryTo').value='';renderPositionHistory()}); $('#exportPositionHistoryBtn')?.addEventListener('click',exportPositionHistoryCSV); $('#payrollBtn')?.addEventListener('click',openPayrollDialog); $('#closePayrollBtn')?.addEventListener('click',()=>$('#payrollDialog').close()); $('#payrollMonth')?.addEventListener('input',renderPayroll); $('#payrollSearch')?.addEventListener('input',renderPayroll); $('#exportPayrollBtn')?.addEventListener('click',exportPayrollCSV); $('#addExtraHolidayBtn')?.addEventListener('click',addExtraHoliday); $('#permissionBtn')?.addEventListener('click',openPermissionDialog); $('#closePermissionBtn')?.addEventListener('click',()=>$('#permissionDialog').close()); ['permissionWorker','permissionType','permissionFrom','permissionTo'].forEach(id=>$('#'+id)?.addEventListener('input',updatePermissionStatus)); $('#savePermissionLimitsBtn')?.addEventListener('click',savePermissionLimits); $('#applyPermissionBtn')?.addEventListener('click',applyPermission); window.addEventListener('storage',e=>{if(e.key===FICHA_FEEDBACK_KEY)consumeFichaFeedback()}); consumeFichaFeedback(); $('#closeLocationBtn').addEventListener('click',()=>$('#locationDialog').close()); $('#locationDate').addEventListener('input',()=>{selectedLocationWorkerId=null;refreshSubstitutionOptions();renderLocationModule();}); $('#locationShiftFilter').addEventListener('change',renderLocationModule); $('#locationSearch').addEventListener('input',renderLocationModule); document.querySelectorAll('.location-floor-tab').forEach(btn=>btn.addEventListener('click',()=>setPlanArea(btn.dataset.area))); $('#locationPlanStage').addEventListener('click',handlePlanStageClick); $('#removeLocationBtn').addEventListener('click',removeSelectedLocation); $('#sendToDailySheetBtn').addEventListener('click',sendLocationsToDailySheet); $('#locationLabel').addEventListener('change',()=>{const date=$('#locationDate').value,workerId=selectedLocationWorkerId,loc=workerId?getLocationAssignment(workerId,date):null;if(loc)saveLocation(workerId,date,loc.area,loc.x,loc.y,$('#locationLabel').value);}); $('#locationMachine')?.addEventListener('change',()=>{const date=$('#locationDate').value,workerId=selectedLocationWorkerId,loc=workerId?getLocationAssignment(workerId,date):null;if(loc)saveLocation(workerId,date,loc.area,loc.x,loc.y,$('#locationLabel').value,{machine:$('#locationMachine').checked});}); $('#applySubstitutionBtn').addEventListener('click',applyLocationSubstitution); $('#uploadPlanBtn').addEventListener('click',()=>$('#planImageFile').click()); $('#planImageFile').addEventListener('change',e=>{if(e.target.files[0])resizeAndStorePlanImage(e.target.files[0]);e.target.value='';}); $('#resetPlanBtn').addEventListener('click',resetCurrentPlanImage); $('#vacationBtn').addEventListener('click', openVacationDialog); $('#emailBtn').addEventListener('click', openEmailDialog); $('#closeEmailBtn').addEventListener('click',()=>$('#emailDialog').close()); $('#emailWorker').addEventListener('change', syncEmailWorker); ['emailFrom','emailTo','emailSubject','emailMessage'].forEach(id=>$('#'+id).addEventListener('input', updateEmailPreview)); $('#emailAddress').addEventListener('input', updateEmailStatus); $('#saveEmailBtn').addEventListener('click', saveSelectedWorkerEmail); $('#downloadEmailAttachmentBtn').addEventListener('click', downloadEmailAttachment); $('#downloadEmlBtn').addEventListener('click', downloadEmlDraft); $('#openMailClientBtn').addEventListener('click', openDeviceMailDraft); $('#openGmailBtn').addEventListener('click', openGmailDraft); $('#closeVacationBtn').addEventListener('click',()=>$('#vacationDialog').close()); $('#saveVacationBtn').addEventListener('click', saveVacationPeriod); $('#cancelVacationEditBtn').addEventListener('click', resetVacationForm); ['vacationFilterYear','vacationFilterMonth','vacationFilterWorker','vacationFilterStatus'].forEach(id=>$('#'+id).addEventListener('input',renderVacations)); $('#exportVacationsBtn').addEventListener('click',exportVacationsCSV); $('#printVacationsBtn').addEventListener('click',printVacationsTable); $('#closeControlBtn').addEventListener('click', () => $('#controlDialog').close()); $('#saveControlSettingsBtn').addEventListener('click', saveControlSettings); document.querySelectorAll('.control-tab').forEach(btn=>btn.addEventListener('click',()=>{document.querySelectorAll('.control-tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.control-panel').forEach(x=>x.classList.remove('active'));btn.classList.add('active');$('#'+btn.dataset.panel).classList.add('active');})); $('#historyBtn').addEventListener('click',()=>{renderHistory();$('#historyDialog').showModal();}); $('#closeHistoryBtn').addEventListener('click',()=>$('#historyDialog').close()); $('#clearHistoryBtn').addEventListener('click',()=>{if(confirm('¿Vaciar el historial?')){actionHistory=[];storageSet(HISTORY_KEY,'[]');renderHistory();}}); $('#undoBtn').addEventListener('click', undoLastAction); $('#printBtn').addEventListener('click', printVisibleSchedule); $('#backupBtn').addEventListener('click', exportFullBackup); $('#importBtn').addEventListener('click',()=>$('#backupFile').click()); $('#backupFile').addEventListener('change',e=>{if(e.target.files[0])importFullBackup(e.target.files[0]);e.target.value='';}); $('#resetBtn').addEventListener('click', resetToOriginal); $('#addWorkerBtn').addEventListener('click', openAddWorkerDialog); $('#sequenceBtn').addEventListener('click', openSequenceDialog); $('#closeSequenceBtn').addEventListener('click', () => $('#sequenceDialog').close()); $('#sequencePreset').addEventListener('change', applySequencePreset); ['sequenceFrom','sequenceTo','seqDays1','seqDays2','seqDays3','seqDays4','seqCode1','seqCode2','seqCode3','seqCode4'].forEach(id => $('#'+id).addEventListener('input', updateSequencePreview)); $('#applySequenceBtn').addEventListener('click', applySequence); $('#clearSequenceBtn').addEventListener('click', clearSequenceRange); $('#closeAddWorkerBtn').addEventListener('click', () => $('#addWorkerDialog').close()); $('#saveNewWorkerBtn').addEventListener('click', addNewWorker); $('#exportChangesBtn').addEventListener('click', exportChangesJSON); $('#exportIndividualBtn').addEventListener('click', () => $('#exportDialog').showModal()); $('#doExportBtn').addEventListener('click', exportIndividual); $('#saveEditBtn').addEventListener('click', saveAdvancedEdit); $('#restoreBtn').addEventListener('click', restoreAdvancedEdit); render(); } function render() { const monthValue = $('#monthPicker').value; const dates = getVisibleDates(); const search = $('#search').value.trim().toLowerCase(); const group = $('#groupFilter').value; const changedOnly = $('#statusFilter').value === 'changed'; const shiftFilter = $('#shiftFilter').value; const selectedZones = getSelectedZoneFilters(); let workers = DATA.workers.filter(w => (!group || w.group === group) && (!search || w.name.toLowerCase().includes(search) || String(w.login||'').toLowerCase().includes(search))); if (changedOnly) workers = workers.filter(w => dates.some(d => isChanged(w.id, iso(d)))); if (shiftFilter) workers = workers.filter(w => workerMatchesShiftFilter(w.id,dates,shiftFilter)); if (selectedZones.length) workers = workers.filter(w => workerMatchesSelectedZones(w.id,dates)); const analysis = analyzeSchedule(dates, workers); const thead = $('#scheduleTable thead'); thead.innerHTML = `
Trabajador/a
${dates.map(d => { const ds=iso(d),c=analysis.coverage[ds]||coverageForDate(ds,workers),bad=c.day${String(d.getDate()).padStart(2,'0')}${weekdayEs(d)}${holiday?`★ ${esc(holiday)}`:''}
M ${c.day}T ${c.afternoon}N ${c.night}
`; }).join('')}`; const tbody = $('#scheduleTable tbody'); tbody.innerHTML = workers.map(w => { const profile = workerProfileForDates(w.id, dates); const rangeHours = dates.reduce((s,d)=>s+hoursForCode(getCode(w.id,iso(d))),0); const rowConflictCount = analysis.conflicts.filter(c=>c.worker===w.name).length; const badgeClass = profile==='day' ? 'badge-day' : profile==='afternoon' ? 'badge-afternoon' : profile==='night' ? 'badge-night' : 'badge-mixed'; const row = `
${esc(ovd1DisplayName(w))}${w.login?``:''}${(()=>{const zs=zonesForWorkerDates(w.id,dates);return zs.length?zs.map(z=>`${esc(z)}`).join(''):'SIN ZONA';})()} ${esc(w.group)}${w.role && w.role!==w.group ? ' · '+esc(w.role) : ''}
${shiftBadgeLabel(profile)}${w.custom ? 'Añadido' : ''}${rowConflictCount?''+rowConflictCount+' alerta(s)':''}
${rangeHours.toFixed(2)} h · contrato ${Number(w.contractHours||35).toFixed(2)} h/sem.
${w.custom ? `` : ''}
${dates.map(d => { const date=iso(d), code=getCode(w.id,date), bg=colorFor(code), fg=textColorFor(bg), changed=isChanged(w.id,date), sequenced=isSequenced(w.id,date), vacation=isVacationPlanned(w.id,date), note=getCellNote(w.id,date), conflict=analysis.cellConflicts[getKey(w.id,date)], loc=getLocationAssignment(w.id,date), clarification=getCellClarification(w.id,date), zoneSelection=getCellZoneSelection(w.id,date), cellComment=getCellComment(w.id,date); const options = DATA.codes.map(c => ``).join(''); const zoneDisplay=zoneSelection||'Seleccionar zona'; return `
Zona / puesto
Comentario
${loc?`⌖ ${esc(planAreaName(loc.area))}${clarification?' · '+esc(clarification):''}`:''}
`; }).join('')} `; return row; }).join(''); document.querySelectorAll('.shift-select').forEach(sel => sel.addEventListener('change', onShiftChange)); document.querySelectorAll('.cell-zone-display').forEach(btn => btn.addEventListener('click', openCellZoneEditor)); document.querySelectorAll('.cell-zone-ok').forEach(btn => btn.addEventListener('click', confirmCellZoneSelection)); document.querySelectorAll('.cell-comment-input').forEach(input => { input.addEventListener('input', onCellCommentInput); input.addEventListener('change', onCellCommentChange); }); document.querySelectorAll('.delete-worker').forEach(btn => btn.addEventListener('click', deleteCustomWorker)); const totalCells = workers.length * dates.length; const visibleChanges = workers.reduce((acc,w)=>acc+dates.filter(d=>isChanged(w.id,iso(d))).length,0); const visibleSequences = workers.reduce((acc,w)=>acc+dates.filter(d=>isSequenced(w.id,iso(d))).length,0); const visibleHours=workers.reduce((sum,w)=>sum+dates.reduce((s,d)=>s+hoursForCode(getCode(w.id,iso(d))),0),0); const visibleVacationCells=workers.reduce((sum,w)=>sum+dates.filter(d=>shiftType(getCode(w.id,iso(d)))==='vac').length,0); $('#summary').innerHTML = [ `${workers.length} trabajadores visibles`, `${dates.length} días en pantalla`, `${visibleHours.toFixed(1)} horas planificadas`, `${visibleChanges} celdas modificadas visibles`, `${visibleVacationCells} días VAC visibles`, selectedZones.length ? `Zonas filtradas: ${selectedZones.join(' + ')}` : 'Todas las zonas', `${analysis.conflicts.length} alertas` ].map(t => `
${t}
`).join(''); const strip=$('#alertStrip'); if(analysis.conflicts.length){strip.className='alert-strip danger';strip.textContent=`⚠ Se han detectado ${analysis.conflicts.length} avisos en la vista actual. Abre “Control y alertas” para revisarlos.`;}else{strip.className='alert-strip ok';strip.textContent='✓ No se han detectado conflictos en la vista actual.';} } function setSequenceBlocks(blocks) { blocks.forEach((block, index) => { const n = index + 1; $('#seqDays'+n).value = block.days; $('#seqCode'+n).value = block.code; }); updateSequencePreview(); } function applySequencePreset() { const preset = $('#sequencePreset').value; const presets = { rot6: [{days:7,code:'06:00/13:15'},{days:4,code:'LIBRE'},{days:7,code:'15:00/22:15'},{days:3,code:'LIBRE'}], rot8: [{days:7,code:'08:00/15:15'},{days:4,code:'LIBRE'},{days:7,code:'15:00/22:15'},{days:3,code:'LIBRE'}], fixed7: [{days:7,code:'07:00/15:00'},{days:4,code:'LIBRE'},{days:7,code:'07:00/15:00'},{days:3,code:'LIBRE'}], night22: [{days:7,code:'22:00/05:15+'},{days:4,code:'LIBRE'},{days:7,code:'22:00/05:15+'},{days:3,code:'LIBRE'}], night23: [{days:7,code:'23:00/06:15+'},{days:4,code:'LIBRE'},{days:7,code:'23:00/06:15+'},{days:3,code:'LIBRE'}] }; if (presets[preset]) setSequenceBlocks(presets[preset]); else updateSequencePreview(); } function readSequenceBlocks() { return [1,2,3,4].map(n => ({ days: Math.max(1, parseInt($('#seqDays'+n).value || '1', 10)), code: $('#seqCode'+n).value || 'LIBRE' })); } function updateSequencePreview() { const blocks = readSequenceBlocks(); const cycle = blocks.reduce((sum,b)=>sum+b.days,0); const from = $('#sequenceFrom').value; const to = $('#sequenceTo').value; let rangeDays = 0; if (from && to && from <= to) rangeDays = Math.floor((parseDate(to)-parseDate(from))/86400000)+1; const cycles = cycle ? Math.floor(rangeDays/cycle) : 0; const remainder = cycle ? rangeDays%cycle : 0; const text = blocks.map((b,i)=>`Bloque ${i+1}: ${b.days} días de ${b.code}`).join(' · '); $('#sequencePreview').innerHTML = `Ciclo de ${cycle} días. ${esc(text)}
En el rango seleccionado: ${rangeDays} días, ${cycles} ciclos completos y ${remainder} días del ciclo siguiente. La secuencia se repite desde la fecha de comienzo.`; } function openIteDialog() { refreshDynamicOptions(); const dates=getVisibleDates(); const first=dates.length ? iso(dates[0]) : DATA.minDate; const last=dates.length ? iso(dates[dates.length-1]) : DATA.minDate; $('#iteFrom').min=DATA.minDate; $('#iteFrom').max=DATA.maxDate; $('#iteTo').min=DATA.minDate; $('#iteTo').max=DATA.maxDate; $('#iteFrom').value=first; $('#iteTo').value=last; $('#iteNote').value=''; const search=$('#search').value.trim().toLowerCase(); const matched=DATA.workers.find(w=>w.name.toLowerCase().includes(search)); if(matched) $('#iteWorker').value=matched.id; $('#iteDialog').showModal(); } function validateIteRange() { const workerId=$('#iteWorker').value; const from=$('#iteFrom').value; const to=$('#iteTo').value; if(!workerId||!from||!to) { alert('Selecciona trabajador/a y fechas de inicio y fin.'); return null; } if(from>to) { alert('La fecha inicial no puede ser posterior a la fecha final.'); return null; } if(fromDATA.maxDate) { alert(`El periodo disponible va de ${DATA.minDate} a ${DATA.maxDate}.`); return null; } return {workerId,from,to}; } function applyIteRange() { const data=validateIteRange(); if(!data) return; const worker=getWorker(data.workerId); const note=$('#iteNote').value.trim(); const total=Math.floor((parseDate(data.to)-parseDate(data.from))/86400000)+1; if(!confirm(`¿Marcar ITE a ${worker.name} desde ${data.from} hasta ${data.to} (${total} días)?`)) return; recordSnapshot(`ITE de ${worker.name}: ${data.from} a ${data.to}`); for(let d=parseDate(data.from); d<=parseDate(data.to); d.setDate(d.getDate()+1)) { const ds=iso(d); const key=getKey(data.workerId,ds); const existing=changes[key] ? JSON.parse(JSON.stringify(changes[key])) : null; const previousChange=(existing && existing.status==='ITE_BLOQUE') ? (existing.itePreviousChange || null) : existing; const existingNote=existing?.status==='ITE_BLOQUE' ? '' : (existing?.note||''); const merged=[existingNote,note].filter(Boolean); const uniqueNote=[...new Set(merged)].join(' · '); changes[key]={ workerId:data.workerId, date:ds, code:'ITE', note:uniqueNote, status:'ITE_BLOQUE', itePreviousChange:previousChange, updatedAt:new Date().toISOString(), actor:currentActor() }; } saveStorage(); $('#iteDialog').close(); render(); alert(`ITE aplicado a ${worker.name} del ${data.from} al ${data.to}.`); } function clearIteRange() { const data=validateIteRange(); if(!data) return; const worker=getWorker(data.workerId); if(!confirm(`¿Quitar el ITE aplicado a ${worker.name} entre ${data.from} y ${data.to}?`)) return; recordSnapshot(`Quitar ITE de ${worker.name}: ${data.from} a ${data.to}`); let restored=0; for(let d=parseDate(data.from); d<=parseDate(data.to); d.setDate(d.getDate()+1)) { const ds=iso(d); const key=getKey(data.workerId,ds); const current=changes[key]; if(!current || current.code!=='ITE') continue; if(current.status==='ITE_BLOQUE' && current.itePreviousChange) { changes[key]=current.itePreviousChange; } else { delete changes[key]; } restored++; } saveStorage(); $('#iteDialog').close(); render(); alert(restored ? `ITE retirado en ${restored} día(s).` : 'No había ITE manual en ese periodo.'); } function openSequenceDialog() { refreshDynamicOptions(); const visibleMonth = $('#monthPicker').value || DATA.minDate.slice(0,7); const suggestedFrom = visibleMonth + '-01'; $('#sequenceFrom').value = suggestedFrom < DATA.minDate ? DATA.minDate : suggestedFrom; $('#sequenceTo').value = DATA.maxDate; const search = $('#search').value.trim().toLowerCase(); const matched = DATA.workers.find(w => w.name.toLowerCase().includes(search)); if (matched) $('#sequenceWorker').value = matched.id; $('#sequencePreset').value = 'rot6'; $('#keepManualChanges').checked = true; applySequencePreset(); $('#sequenceDialog').showModal(); } function validateSequenceRange() { const workerId = $('#sequenceWorker').value; const from = $('#sequenceFrom').value; const to = $('#sequenceTo').value; if (!workerId || !from || !to) { alert('Selecciona trabajador y fechas de inicio y fin.'); return null; } if (from > to) { alert('La fecha de comienzo no puede ser posterior a la fecha final.'); return null; } if (from < DATA.minDate || to > DATA.maxDate) { alert(`El periodo disponible va de ${DATA.minDate} a ${DATA.maxDate}.`); return null; } return {workerId,from,to}; } function applySequence() { const range = validateSequenceRange(); if (!range) return; const worker = getWorker(range.workerId); const blocks = readSequenceBlocks(); const cycle = blocks.reduce((sum,b)=>sum+b.days,0); if (!cycle) return; const keepManual = $('#keepManualChanges').checked; recordSnapshot(`Aplicar secuencia a ${worker.name} del ${range.from} al ${range.to}`); const sequenceId = 'S' + Date.now().toString(36).toUpperCase(); let cycleIndex = 0; const expanded = []; blocks.forEach(block => { for (let i=0;i $('#newWorkerName').focus(), 50); } function persistCustomWorkers() { const customWorkers = DATA.workers.filter(w => w.custom); storageSet(CUSTOM_WORKERS_KEY, JSON.stringify(customWorkers)); } function buildSchedule(defaultCode, applyAll) { const schedule = {}; if (!applyAll || defaultCode === 'LIBRE') return schedule; for (let d=parseDate(DATA.minDate); d<=parseDate(DATA.maxDate); d.setDate(d.getDate()+1)) { schedule[iso(d)] = defaultCode; } return schedule; } function addNewWorker() { const name = $('#newWorkerName').value.trim().toUpperCase(); const email = $('#newWorkerEmail').value.trim(); const newGroup = $('#newWorkerNewGroup').value.trim().toUpperCase(); const selectedGroup = $('#newWorkerGroup').value; const group = newGroup || selectedGroup || 'SIN GRUPO'; const role = $('#newWorkerRole').value.trim().toUpperCase(); const baseShift = $('#newWorkerBaseShift').value || 'LIBRE'; const contractHours = Number($('#newWorkerContractHours').value || 35); const applyAll = $('#applyBaseShift').checked; if (!name) { alert('Escribe el nombre y los apellidos del trabajador/a.'); $('#newWorkerName').focus(); return; } if (DATA.workers.some(w => w.name.trim().toUpperCase() === name)) { if (!confirm('Ya existe un trabajador con ese nombre. ¿Quieres añadir otra fila igualmente?')) return; } recordSnapshot('Añadir trabajador: '+name); const id = 'C' + Date.now().toString(36).toUpperCase() + Math.random().toString(36).slice(2,5).toUpperCase(); DATA.workers.push({ id, name, group, role, schedule: buildSchedule(baseShift, applyAll), dominantCode: applyAll ? baseShift : 'LIBRE', contractHours, custom: true, createdAt: new Date().toISOString() }); if (email) workerEmails[id] = email; storageSet(WORKER_EMAILS_KEY, JSON.stringify(workerEmails)); persistCustomWorkers(); refreshDynamicOptions(); $('#groupFilter').value = ''; $('#search').value = name; $('#addWorkerDialog').close(); render(); } function deleteCustomWorker(e) { const workerId = e.currentTarget.dataset.worker; const worker = getWorker(workerId); if (!worker || !worker.custom) return; if (!confirm(`¿Eliminar la fila añadida de ${worker.name}? También se borrarán los cambios guardados de esa fila.`)) return; recordSnapshot('Eliminar trabajador añadido: '+worker.name); DATA.workers = DATA.workers.filter(w => w.id !== workerId); Object.keys(changes).filter(key => key.startsWith(workerId+'|')).forEach(key => delete changes[key]); Object.keys(sequenceAssignments).filter(key => key.startsWith(workerId+'|')).forEach(key => delete sequenceAssignments[key]); vacationPeriods=vacationPeriods.filter(period=>period.workerId!==workerId); delete workerEmails[workerId]; storageSet(WORKER_EMAILS_KEY, JSON.stringify(workerEmails)); saveStorage(); persistSequences(); persistVacations(); persistCustomWorkers(); refreshDynamicOptions(); render(); } function refreshVacationYearFilter() { const years=new Set(); for(let y=Number(DATA.minDate.slice(0,4));y<=Number(DATA.maxDate.slice(0,4));y++) years.add(y); vacationPeriods.forEach(v=>{ if(v.from) years.add(Number(v.from.slice(0,4))); if(v.to) years.add(Number(v.to.slice(0,4))); }); const selected=$('#vacationFilterYear')?.value||''; $('#vacationFilterYear').innerHTML=''+[...years].sort().map(y=>``).join(''); if([...years].map(String).includes(selected)) $('#vacationFilterYear').value=selected; } function originalVacationPeriods() { const periods=[]; DATA.workers.filter(w=>!w.custom).forEach(worker=>{ const dates=Object.keys(worker.schedule||{}).filter(ds=>normalizeCode(worker.schedule[ds])==='VAC').sort(); let start=null,previous=null; const push=()=>{ if(!start)return; periods.push({id:`ORIG-${worker.id}-${start}`,workerId:worker.id,from:start,to:previous,status:'ORIGINAL',note:'Importado del cuadrante original',source:'ORIGINAL'}); }; dates.forEach(ds=>{ if(!start){start=previous=ds;return;} const next=parseDate(previous);next.setDate(next.getDate()+1); if(iso(next)===ds){previous=ds;return;} push();start=previous=ds; }); push(); }); return periods; } function getAllVacationPeriods() { return [...originalVacationPeriods(),...vacationPeriods].sort((a,b)=>a.from.localeCompare(b.from)||getWorker(a.workerId)?.name.localeCompare(getWorker(b.workerId)?.name||'')); } function vacationDays(period) { return dateRange(period.from,period.to).length; } function vacationWorkDays(period) { return dateRange(period.from,period.to).filter(d=>isWorkingCode(getBaseCode(period.workerId,iso(d)))).length; } function vacationMonths(period) { const names=[]; let d=parseDate(period.from), end=parseDate(period.to); d=new Date(d.getFullYear(),d.getMonth(),1,12); while(d<=end){ names.push(d.toLocaleDateString('es-ES',{month:'long'})); d.setMonth(d.getMonth()+1); } return [...new Set(names)].join(', '); } function vacationYears(period) { const a=period.from.slice(0,4),b=period.to.slice(0,4);return a===b?a:`${a}–${b}`; } function periodIntersectsFilter(period,year,month) { if(!year&&!month)return true; const y=Number(year||period.from.slice(0,4)); const start=month?new Date(y,Number(month)-1,1,12):new Date(y,0,1,12); const end=month?new Date(y,Number(month),0,12):new Date(y,11,31,12); return parseDate(period.from)<=end && parseDate(period.to)>=start; } function statusClass(status) { return status==='APROBADA'?'vac-approved':status==='PLANIFICADA'?'vac-planned':status==='SOLICITADA'?'vac-requested':status==='ORIGINAL'?'vac-original':'vac-cancelled'; } let visibleVacationRows=[]; function renderVacations() { refreshVacationYearFilter(); const year=$('#vacationFilterYear').value, month=$('#vacationFilterMonth').value, workerId=$('#vacationFilterWorker').value, status=$('#vacationFilterStatus').value; visibleVacationRows=getAllVacationPeriods().filter(period=>(!workerId||period.workerId===workerId)&&(!status||period.status===status)&&periodIntersectsFilter(period,year,month)); const active=visibleVacationRows.filter(v=>v.status==='ORIGINAL'||vacationReflectsInSchedule(v)); const uniqueWorkers=new Set(active.map(v=>v.workerId)).size; const natural=active.reduce((s,v)=>s+vacationDays(v),0), work=active.reduce((s,v)=>s+vacationWorkDays(v),0); $('#vacationMetrics').innerHTML=[['Periodos visibles',visibleVacationRows.length],['Trabajadores',uniqueWorkers],['Días naturales',natural],['Días de trabajo afectados',work]].map(([l,v])=>`
${esc(l)}${esc(v)}
`).join(''); $('#vacationTableBody').innerHTML=visibleVacationRows.length?visibleVacationRows.map(period=>{ const worker=getWorker(period.workerId)||{name:'Trabajador no disponible',group:''}; const actions=period.source==='ORIGINAL'?``:``; return `${esc(worker.name)}${esc(worker.group||'')}${period.from}${period.to}${esc(vacationMonths(period))}${esc(vacationYears(period))}${vacationDays(period)}${vacationWorkDays(period)}${esc(period.status)}${esc(period.note||'')}
${actions}
`; }).join(''):'No hay periodos que coincidan con los filtros.'; document.querySelectorAll('.vac-edit').forEach(btn=>btn.addEventListener('click',()=>editVacationPeriod(btn.dataset.id))); document.querySelectorAll('.vac-delete').forEach(btn=>btn.addEventListener('click',()=>deleteVacationPeriod(btn.dataset.id))); document.querySelectorAll('.vac-view').forEach(btn=>btn.addEventListener('click',()=>viewVacationInSchedule(btn.dataset.id))); } function resetVacationForm() { $('#vacationEditId').value=''; $('#vacationFormTitle').textContent='Añadir periodo'; $('#saveVacationBtn').textContent='Guardar periodo'; if(DATA.workers.length)$('#vacationWorker').value=DATA.workers[0].id; const visible=getVisibleDates(); $('#vacationFrom').value=visible.length?iso(visible[0]):DATA.minDate; $('#vacationTo').value=visible.length?iso(visible[visible.length-1]):DATA.minDate; $('#vacationStatus').value='PLANIFICADA'; $('#vacationNote').value=''; } function openVacationDialog() { refreshDynamicOptions();resetVacationForm();const visible=getVisibleDates();if(visible.length)$('#vacationFilterYear').value=String(visible[0].getFullYear());renderVacations();$('#vacationDialog').showModal(); } function saveVacationPeriod() { const id=$('#vacationEditId').value,workerId=$('#vacationWorker').value,from=$('#vacationFrom').value,to=$('#vacationTo').value,status=$('#vacationStatus').value,note=$('#vacationNote').value.trim(); const worker=getWorker(workerId);if(!worker)return alert('Selecciona un trabajador/a.');if(!from||!to||from>to)return alert('Revisa las fechas del periodo.'); if(fromDATA.maxDate)return alert(`El periodo debe estar dentro del cuadrante: ${DATA.minDate} a ${DATA.maxDate}.`); const overlaps=getAllVacationPeriods().filter(v=>v.workerId===workerId&&v.id!==id&&(v.status==='ORIGINAL'||vacationReflectsInSchedule(v))&&from<=v.to&&to>=v.from); if(overlaps.length&&!confirm(`Ya existe ${overlaps.length} periodo(s) solapado(s) para ${worker.name}. ¿Guardar igualmente?`))return; recordSnapshot(`${id?'Editar':'Añadir'} vacaciones de ${worker.name}: ${from} a ${to}`); if(id){const period=vacationPeriods.find(v=>v.id===id);if(!period)return alert('No se encontró el periodo.');Object.assign(period,{workerId,from,to,status,note,updatedAt:new Date().toISOString(),actor:currentActor()});} else vacationPeriods.push({id:'V'+Date.now().toString(36).toUpperCase(),workerId,from,to,status,note,createdAt:new Date().toISOString(),updatedAt:new Date().toISOString(),actor:currentActor(),source:'PLANIFICACION'}); persistVacations();refreshVacationYearFilter();resetVacationForm();renderVacations();render(); } function editVacationPeriod(id) { const period=vacationPeriods.find(v=>v.id===id);if(!period)return;$('#vacationEditId').value=period.id;$('#vacationFormTitle').textContent='Editar periodo';$('#saveVacationBtn').textContent='Actualizar periodo';$('#vacationWorker').value=period.workerId;$('#vacationFrom').value=period.from;$('#vacationTo').value=period.to;$('#vacationStatus').value=period.status;$('#vacationNote').value=period.note||''; } function deleteVacationPeriod(id) { const period=vacationPeriods.find(v=>v.id===id);if(!period)return;const worker=getWorker(period.workerId);if(!confirm(`¿Eliminar el periodo de ${worker?.name||'este trabajador'} del ${period.from} al ${period.to}?`))return;recordSnapshot(`Eliminar vacaciones de ${worker?.name||period.workerId}: ${period.from} a ${period.to}`);vacationPeriods=vacationPeriods.filter(v=>v.id!==id);persistVacations();resetVacationForm();renderVacations();render(); } function findVacationById(id) { return getAllVacationPeriods().find(v=>v.id===id); } function viewVacationInSchedule(id) { const period=findVacationById(id);if(!period)return;const worker=getWorker(period.workerId);$('#viewMode').value='month';$('#weekPicker').style.display='none';$('#dayPickerWrap').classList.remove('active');$('#monthPicker').value=period.from.slice(0,7);$('#groupFilter').value='';$('#search').value=worker?.name||'';$('#vacationDialog').close();render(); } function vacationRowsForExport() { return visibleVacationRows.length?visibleVacationRows:getAllVacationPeriods(); } function exportVacationsCSV() { const rows=[['Nombre y apellidos','Grupo','Fecha inicial','Fecha final','Meses','Año','Días naturales','Días de trabajo afectados','Estado','Observaciones']];vacationRowsForExport().forEach(v=>{const w=getWorker(v.workerId)||{};rows.push([w.name||'',w.group||'',v.from,v.to,vacationMonths(v),vacationYears(v),vacationDays(v),vacationWorkDays(v),v.status,v.note||'']);});const csv=rows.map(r=>r.map(x=>'"'+String(x??'').replaceAll('"','""')+'"').join(';')).join('\n');const blob=new Blob(['\ufeff'+csv],{type:'text/csv;charset=utf-8'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='MITIE_OVD1_vacaciones_'+new Date().toISOString().slice(0,10)+'.csv';a.click();URL.revokeObjectURL(a.href); } function printVacationsTable() { const rows=vacationRowsForExport().map(v=>{const w=getWorker(v.workerId)||{};return `${esc(w.name||'')}${esc(w.group||'')}${v.from}${v.to}${esc(vacationMonths(v))}${esc(vacationYears(v))}${vacationDays(v)}${vacationWorkDays(v)}${esc(v.status)}${esc(v.note||'')}`;}).join('');const doc=`Vacaciones MITIE OVD1

Organización de vacaciones · MITIE OVD1

${rows}
Nombre y apellidosGrupoDesdeHastaMesesAñoDías naturalesDías de trabajoEstadoObservaciones
⌂ PORTAL MITIE