Bruno's Staff Planner

Connexion sécurisée pour les collaborateurs et l'administration.

Connexion nécessaire pour accéder à l'application.

Mon espace collaborateur

Mon planning publié

Chargement…

Mes demandes

Les formulaires sécurisés de disponibilités, vacances et échanges seront activés dans la prochaine étape.

Touchez un collaborateur pour consulter ses données, ses vacances et ses congés.
Année civile
Droit, pris et restant pour l'année sélectionnée. Le restant inclut l'ajustement manuel. Touchez une ligne pour voir le détail des dates.
Les heures sont celles du planning, calculées selon les réglages. Les coûts sont des estimations brutes (heures × salaire horaire).
Gestion des demandes
Démo locale sur cet appareil. Les demandes ne sont pas envoyées entre téléphones. Connexion Google, synchronisation et notifications nécessitent le déploiement cloud sécurisé.
Espace collaborateur — démonstration locale
Collaborateur
Démonstration locale sans comptes ni synchronisation. Le choix d’un collaborateur ici ne constitue pas une authentification. Ne partagez pas cette page avec les employés avant la mise en place de l’accès sécurisé.
Pizzeria
Services
Affichage
Sauvegarde locale
Données enregistrées sur cet appareil uniquement. Exportez régulièrement une sauvegarde JSON. Pas de synchronisation cloud ni de connexion sécurisée dans cette version.
Enregistré
`; const w = window.open('', '_blank'); if(!w){ flash('Autorisez les pop-up pour exporter le PDF'); return; } w.document.open(); w.document.write(page); w.document.close(); } $('exportPdf').onclick = exportPdf; function exportCsv(){ const emps = activeEmps(), n = daysIn(year,month), pl = planFor(year,month); const rows = [['Collaborateur', ...Array.from({length:n},(_,i)=>i+1), 'Heures du mois']]; emps.forEach(e=>{ const r = [e.name]; for(let d=1; d<=n; d++){ const arr=cellEntries(e.id,d); r.push(arr.map(v=>info(v.typeId,year,month,d).label).join(' + ')); } r.push(fmtH(monthStats(e.id,year,month).hours)); rows.push(r); }); const csv = rows.map(r=>r.map(c=>`"${String(c).replace(/"/g,'""')}"`).join(';')).join('\n'); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob(['\ufeff'+csv],{type:'text/csv;charset=utf-8'})); a.download = `planning-${ymKey(year,month)}.csv`; a.click(); } $('exportCsv').onclick = exportCsv; $('backupJson').onclick=()=>{const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(state,null,2)],{type:'application/json'}));a.download='brunos-planner-sauvegarde-'+new Date().toISOString().slice(0,10)+'.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)}; $('restoreJson').onclick=()=>$('restoreFile').click(); $('restoreFile').onchange=async e=>{const f=e.target.files[0];if(!f)return;try{const data=JSON.parse(await f.text());if(!Array.isArray(data.employees)||!data.plans||!data.settings)throw Error('Format invalide');if(!confirm('Remplacer toutes les données locales par cette sauvegarde ?'))return;state=data;saveState();renderAll()}catch(err){alert('Import impossible : '+err.message)}e.target.value=''}; // ---------- Demandes : prototype local (pas de portail distant) ---------- function reqDate(y,m,d){return `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`} function parseReqDate(v){if(!/^\d{4}-\d{2}-\d{2}$/.test(v))return null;const [y,m,d]=v.split('-').map(Number);const dt=new Date(y,m-1,d);return dt.getFullYear()===y&&dt.getMonth()===m-1&&dt.getDate()===d?{y,m:m-1,d}:null} function reqName(id){return state.employees.find(e=>e.id===id)?.name||'Collaborateur supprimé'} function requestSummary(r){if(r.type==='swap')return `${reqName(r.eid)} ↔ ${reqName(r.peer)} · ${esc(r.from)} / ${esc(r.to)}`;return `${reqName(r.eid)} · ${esc(r.from)}${r.to!==r.from?' → '+esc(r.to):''}`} function renderDemandes(){ const target=$('requestList');const requests=[...state.requests].reverse(); target.innerHTML=`Demandes (${requests.filter(r=>r.status==='pending'||r.status==='accepted').length} en attente)`+(requests.length?requests.map(r=>`
${r.type==='swap'?'Échange':r.type==='vac'?'Vacances':'Indisponibilité'} · ${esc(r.status==='pending'?'En attente':r.status==='accepted'?'Collègue d’accord — validation requise':r.status==='approved'?'Approuvé':'Refusé')}
${requestSummary(r)}
${r.status==='pending'&&r.type==='swap'?` `:''}${r.status==='pending'&&r.type!=='swap'||r.status==='accepted'?` `:''}
`).join(''):'

Aucune demande.

'); target.querySelectorAll('[data-req]').forEach(b=>b.onclick=()=>actRequest(b.dataset.req,b.dataset.act)); } function actRequest(id,act){ const r=state.requests.find(x=>x.id===id);if(!r)return; if(act==='reject'){r.status='rejected';if(r.type==='conge'){Object.values(state.plans).forEach(p=>Object.keys(p).forEach(k=>{const entries=normalizeEntry(p[k]);if(entries.some(v=>v.requestId===r.id)){const remaining=entries.filter(v=>v.requestId!==r.id);if(remaining.length)p[k]=remaining;else delete p[k]}}))}} else if(act==='accept'&&r.type==='swap'){r.status='accepted'} else if(act==='approve'){ if(r.type==='swap'){ if(r.status!=='accepted')return alert('Accord du collègue requis.'); const a=parseReqDate(r.from),b=parseReqDate(r.to);if(!a||!b)return; const pa=planFor(a.y,a.m),pb=planFor(b.y,b.m),ka=r.eid+'|'+a.d,kb=r.peer+'|'+b.d; const va=normalizeEntry(pa[ka],a.y,a.m,a.d),vb=normalizeEntry(pb[kb],b.y,b.m,b.d); if(!va.some(x=>!['conge','vac','reduit'].includes(x.typeId)))return alert('Le service initial n’existe plus.'); if(!confirm('Valider définitivement cet échange ? Les deux services seront intervertis.'))return; if(vb.length)pa[ka]=vb;else delete pa[ka];if(va.length)pb[kb]=va;else delete pb[kb]; }else{ const a=parseReqDate(r.from),b=parseReqDate(r.to);if(!a||!b)return; const cur=new Date(a.y,a.m,a.d),end=new Date(b.y,b.m,b.d);if(cur>end)return; while(cur<=end){const y=cur.getFullYear(),m=cur.getMonth(),d=cur.getDate();const p=planFor(y,m),key=r.eid+'|'+d;const old=normalizeEntry(p[key],y,m,d);if(old.some(x=>!['conge','vac','reduit'].includes(x.typeId))&&!confirm(`Remplacer le service existant du ${reqDate(y,m,d)} ?`))return;cur.setDate(cur.getDate()+1)} cur.setTime(new Date(a.y,a.m,a.d).getTime());while(cur<=end){const y=cur.getFullYear(),m=cur.getMonth(),d=cur.getDate();planFor(y,m)[r.eid+'|'+d]=[{typeId:r.type,requestId:r.id}];cur.setDate(cur.getDate()+1)} } r.status='approved'; } saveState();renderDemandes(); } // Portail de démonstration : calendrier tactile, sans connexion distante. let staffMode='conge', staffDays=new Set(), staffMonth=null, staffSwapDate='', staffVacationFrom='', staffVacationTo=''; function staffNextMonth(){const n=new Date();return new Date(n.getFullYear(),n.getMonth()+1,1)} function staffCurrentMonth(){const n=new Date();return new Date(n.getFullYear(),n.getMonth(),1)} function staffMonthKey(){return `${staffMonth.getFullYear()}-${String(staffMonth.getMonth()+1).padStart(2,'0')}`} function staffCalendar(selected,clickName){ const y=staffMonth.getFullYear(),m=staffMonth.getMonth(),offset=(new Date(y,m,1).getDay()+6)%7,count=new Date(y,m+1,0).getDate(); let html=`
${monthLabel(y,m)}
`; html+=['L','M','M','J','V','S','D'].map(x=>`${x}`).join(''); for(let i=0;i'; for(let d=1;d<=count;d++){ const date=reqDate(y,m,d),chosen=selected(date),entries=cellEntries($('staffEmp').value,d,y,m),service=entries.find(x=>!['conge','vac','reduit'].includes(x.typeId)); const label=staffMode==='swap'?(service?esc(META[service.typeId]?.label||service.typeId):'—'):(staffMode==='conge'&&chosen?'Congé':''); html+=``; } html+='
';return html; } function renderStaff(){ const sel=$('staffEmp'),prev=sel.value;sel.innerHTML=activeEmps().map(e=>``).join('');if([...sel.options].some(o=>o.value===prev))sel.value=prev; document.querySelectorAll('[data-staff-mode]').forEach(b=>b.classList.toggle('prim',b.dataset.staffMode===staffMode)); if(!staffMonth)staffMonth=staffMode==='conge'?staffNextMonth():staffCurrentMonth(); const fields=$('staffFields');let html=''; if(staffMode==='conge'){ html=`Mes disponibilités — ${esc(reqName(sel.value))}

Touchez les jours indisponibles pour les marquer Congé. Touchez de nouveau pour les retirer.

`; html+=staffCalendar(date=>staffDays.has(date)); html+=`

${staffDays.size} jour(s) sélectionné(s). Les Congés apparaîtront dans le planning dès l'envoi, en attente de validation.

`; }else if(staffMode==='swap'){ html=`Demander un échange — ${esc(reqName(sel.value))}

Sélectionnez dans le calendrier le jour de votre service, puis le collègue et la date de son service.

`; html+=staffCalendar(date=>staffSwapDate===date); html+=`
Mon service${staffSwapDate||'Choisir un jour'}
Collègue
Son service (date)

L'accord du collègue et la validation de l'administrateur sont obligatoires.

`; }else{ html=`Demander des vacances — ${esc(reqName(sel.value))}

Choisissez la date de début (Du) et la date de fin (Au). Le planning ne changera qu'après approbation.

Du
Au
`; html+=staffCalendar(date=>!!staffVacationFrom&&!!staffVacationTo&&date>=staffVacationFrom&&date<=staffVacationTo); } fields.innerHTML=html; $('staffPrev').onclick=()=>{staffMonth=new Date(staffMonth.getFullYear(),staffMonth.getMonth()-1,1);renderStaff()}; $('staffNext').onclick=()=>{staffMonth=new Date(staffMonth.getFullYear(),staffMonth.getMonth()+1,1);renderStaff()}; fields.querySelectorAll('[data-staff-day]').forEach(btn=>btn.onclick=()=>{ const date=btn.dataset.staffDay; if(staffMode==='conge'){staffDays.has(date)?staffDays.delete(date):staffDays.add(date)} else if(staffMode==='swap'){staffSwapDate=date} else {if(!staffVacationFrom||staffVacationTo||date{staffVacationFrom=e.target.value;renderStaff()}; $('reqTo').onchange=e=>{staffVacationTo=e.target.value;renderStaff()}; } } $('openStaff').onclick=()=>{staffMode='conge';staffMonth=staffNextMonth();staffDays=new Set();show('staff')}; $('backAdmin').onclick=()=>show('demandes'); document.querySelectorAll('[data-staff-mode]').forEach(b=>b.onclick=()=>{staffMode=b.dataset.staffMode;staffMonth=staffMode==='conge'?staffNextMonth():staffCurrentMonth();renderStaff()}); $('staffEmp').onchange=()=>{staffDays=new Set();staffSwapDate='';staffVacationFrom='';staffVacationTo='';renderStaff()}; $('submitRequest').onclick=()=>{ const eid=$('staffEmp').value,type=staffMode;if(!eid)return alert('Choisissez un collaborateur.'); let from='',to='',peer=null; if(type==='conge'){ if(!staffDays.size)return alert('Sélectionnez au moins un jour indisponible.'); const dates=[...staffDays].sort();const conflicts=dates.filter(date=>{const a=parseReqDate(date);return cellEntries(eid,a.d,a.y,a.m).some(x=>!['conge','reduit'].includes(x.typeId))}); if(conflicts.length)return alert('Un service ou des vacances existent déjà sur : '+conflicts.join(', ')+'. Contactez le responsable.'); dates.forEach(date=>{ const a=parseReqDate(date),id='r'+Date.now()+Math.random().toString(36).slice(2,8); state.requests.push({id,eid,type,from:date,to:date,peer:null,status:'pending',createdAt:new Date().toISOString()}); planFor(a.y,a.m)[eid+'|'+a.d]=[{typeId:'conge',requestId:id,pending:true}]; });staffDays.clear(); }else{ from=type==='swap'?staffSwapDate:staffVacationFrom; to=$('reqTo')?.value||'';peer=type==='swap'?$('reqPeer')?.value:null; const a=parseReqDate(from),b=parseReqDate(to);if(!a||!b)return alert('Choisissez des dates valides.'); if(type==='vac'){ if(from>to||((new Date(b.y,b.m,b.d)-new Date(a.y,a.m,a.d))/86400000)>60)return alert('Période invalide (maximum 61 jours).'); }else{ if(!peer||peer===eid)return alert('Choisissez un collègue différent.'); const va=cellEntries(eid,a.d,a.y,a.m),vb=cellEntries(peer,b.d,b.y,b.m); if(!va.some(x=>!['conge','vac','reduit'].includes(x.typeId)))return alert('Vous ne possédez pas de service à cette date.'); if(!vb.some(x=>!['conge','vac','reduit'].includes(x.typeId)))return alert('Le collègue ne possède pas de service à cette date.'); } state.requests.push({id:'r'+Date.now()+Math.random().toString(36).slice(2,8),eid,type,from,to,peer,status:'pending',createdAt:new Date().toISOString()}); staffSwapDate='';staffVacationFrom='';staffVacationTo=''; } saveState();alert('Demande enregistrée sur cet appareil (démonstration).');renderStaff(); }; // ---------- Feuilles et notifications ---------- function openSheet(id){ $(id).classList.add('open'); $('scrim').classList.add('open'); } function closeSheets(){ ['sheetShift','sheetEmp'].forEach(id=>$(id).classList.remove('open')); $('scrim').classList.remove('open'); } $('scrim').onclick = closeSheets; let _tt; function flash(msg){ const t = $('toast'); t.textContent = msg; t.style.display = 'block'; clearTimeout(_tt); _tt = setTimeout(()=>t.style.display='none', 1400); } // Adapter la hauteur de la grille à l'espace réellement disponible, // y compris après rotation du téléphone et modification des barres du navigateur. function resizePlanningGrid(){ const wrap=document.querySelector('#v-planning .grid-wrap'); const nav=$('tabbar'); if(!wrap||!nav)return; const top=wrap.getBoundingClientRect().top; const bottom=nav.getBoundingClientRect().top; const available=Math.max(120,Math.floor(bottom-top-8)); wrap.style.height=available+'px'; } window.addEventListener('resize',resizePlanningGrid); window.addEventListener('orientationchange',()=>requestAnimationFrame(resizePlanningGrid)); if(window.visualViewport)window.visualViewport.addEventListener('resize',resizePlanningGrid); // Démarrage renderAll(); requestAnimationFrame(resizePlanningGrid); if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(()=>{});