PIKARO STOCKGestion torréfaction & traçabilité
`);w.document.close()} function printLabelDirect(id){label(id);setTimeout(function(){printLab()},500)} function showLot(id){let l=lt(id),c=cf(l[1]),u=D.roasts.filter(r=>r.i.some(x=>x[0]==id));modal(`

${c[1]} — ${l[2]}

Origine
${c[2]}

Réception
${fr(l[3])}

Reçu
${kg(l[4])}

1ère utilisation
${l[6]?fr(l[6]):'—'}

Stock
${kg(gs(l))}

Utilisations

${u.map(r=>{let x=r.i.find(x=>x[0]==id);return `

${r.n} — ${r.p} — ${kg(x[1])}

`}).join('')||'Aucune utilisation.'}`)} function traceLot(id){let l=lt(id),u=D.roasts.filter(r=>r.i.some(x=>x[0]==id));tl.innerHTML=`

Stock actuel : ${kg(gs(l))}

1ère utilisation : ${l[6]?fr(l[6]):'—'}

${u.map(r=>`

${r.n} — ${r.p} — ${kg(r.i.find(x=>x[0]==id)[1])}

`).join('')}`} function traceRoast(id){let r=D.roasts.find(x=>x.id==id);const gh=(r.greenHumidity??r.humidity??''),gd=(r.greenDensity??''),co=(r.colorimetry??'');trc.innerHTML=`

${r.p} — ${r.n}

Vert ${kg(r.g)} · Torréfié ${kg(r.o)} · Stock ${kg(r.rem)}

Humidité vert : ${gh!==''?gh+' %':'—'} · Densité vert : ${gd!==''?gd+' g/L':'—'} · Colorimétrie torréfié : ${co!==''?co:'—'}

${r.i.map(x=>`

${cf(lt(x[0])[1])[1]} — lot ${lt(x[0])[2]} — ${kg(x[1])}

`).join('')}`} function scan(){modal(`

📱 Scanner le QR du bidon

Le QR de chaque bidon est unique. Pour la V1 locale, sélectionne le bidon ici ; la caméra peut être branchée ensuite sur le même identifiant.

`)} function modal(s){box.innerHTML=s;modalEl.classList.add('open')}function close(){modalEl.classList.remove('open')}const modalEl=document.getElementById('modal');modalEl.onclick=e=>{if(e.target===modalEl)close()};function resetDemo(){if(confirm('Réinitialiser la démonstration ?')){localStorage.removeItem(K);location.reload()}}go('home'); const F={green:{q:'',origin:'',status:''},roast:{q:'',op:'',from:'',to:''},move:{q:'',who:'',from:'',to:''}}; function ptxt(v){return String(v??'')}function pnorm(v){return ptxt(v).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'')}function phas(v,q){return !q||pnorm(v).includes(pnorm(q))} function csvDownload(name,headers,rows){const e=v=>'"'+ptxt(v).replace(/"/g,'""')+'"',body='\ufeff'+[headers].concat(rows).map(r=>r.map(e).join(';')).join('\n'),blob=new Blob([body],{type:'text/csv;charset=utf-8;'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(a.href),500)} function setGreenFilters(){F.green.q=fqgreen.value;F.green.origin=forigin.value;F.green.status=fstatus.value}function setRoastFilters(){F.roast.q=fqroast.value;F.roast.op=frop.value;F.roast.from=frfrom.value;F.roast.to=frto.value}function setMoveFilters(){F.move.q=fqmove.value;F.move.who=fmwho.value;F.move.from=fmfrom.value;F.move.to=fmto.value} function filteredLots(){return D.lots.filter(l=>{const c=cf(l[1]),rem=gs(l),st=rem<0?'Anomalie':rem===0?'Épuisé':'En stock';return(!F.green.q||[c&&c[1],c&&c[2],l[2],l[8],l[10]].some(v=>phas(v,F.green.q)))&&(!F.green.origin||(c&&c[2])===F.green.origin)&&(!F.green.status||st===F.green.status)})} function filteredRoasts(){return D.roasts.filter(r=>(!F.roast.q||phas(r.n,F.roast.q)||phas(r.p,F.roast.q))&&(!F.roast.op||r.op===F.roast.op)&&(!F.roast.from||r.d>=F.roast.from)&&(!F.roast.to||r.d<=F.roast.to))} function filteredMoves(){return D.moves.filter(m=>{const r=D.roasts.find(x=>x.id===m.r)||{};return(!F.move.q||phas(r.n,F.move.q)||phas(r.p,F.move.q))&&(!F.move.who||m.by===F.move.who)&&(!F.move.from||m.d>=F.move.from)&&(!F.move.to||m.d<=F.move.to)})} function exportGreen(){csvDownload('pikaro_cafes_verts.csv',['Lot PIKARO','Lot fournisseur','Café','Origine','Fournisseur','Réception','Reçu kg','Utilisé kg','Stock kg','Coût/kg'],filteredLots().map(l=>[l[8]||'',l[2],cf(l[1])[1],cf(l[1])[2],l[10]||'',l[3],l[4],used(l[0]),gs(l),l[5]||0]))} function exportRoasts(){csvDownload('pikaro_torrefactions.csv',['Batch','Date','Produit','Opérateur','Vert kg','Torréfié kg','Rendement %','Humidité café vert %','Densité café vert g/L','Colorimétrie café torréfié','Stock kg'],filteredRoasts().map(r=>[r.n,r.d,r.p,r.op||'',r.g,r.o,r.g?(r.o/r.g*100).toFixed(1):'',r.greenHumidity??r.humidity??'',r.greenDensity??'',r.colorimetry||'',r.rem]))} function exportMoves(){csvDownload('pikaro_destockages.csv',['Date','Heure','Batch','Produit','Quantité kg','Destination','Déstocké par','Référence'],filteredMoves().map(m=>{const r=D.roasts.find(x=>x.id===m.r)||{};return[m.d,m.time||'',r.n||'',r.p||'',m.q,m.t||'',m.by||'',m.ref||'']}))} function openCoffeeRefs(){modal('

Référentiel cafés verts

Ajoute ici un nouveau café. Il sera disponible dans les futures réceptions.

'+D.coffees.map(c=>'').join('')+'
CaféOrigineLots reçus
'+c[1]+''+c[2]+''+D.lots.filter(l=>l[1]===c[0]).length+'
')} function saveCoffeeRef(){const n=cn.value.trim(),o=co.value.trim();if(!n||!o){msg('Nom et origine obligatoires');return}if(D.coffees.some(c=>pnorm(c[1])===pnorm(n)&&pnorm(c[2])===pnorm(o))){msg('Cette référence existe déjà');return}D.coffees.push(['C'+Date.now(),n,o]);save();close();msg('Référence ajoutée');recv()} function qrPayload(r){return 'PIKARO|BATCH='+r.n+'|ID='+r.id+'|PRODUIT='+r.p} function openQRValue(v){const r=D.roasts.find(x=>v===x.n||v.includes('ID='+x.id)||v.includes('BATCH='+x.n)||v==='PIKARO-STOCK:'+x.id);if(!r){msg('QR / batch non reconnu');return}close();openOut(r.id)} async function cameraQR(){if(location.protocol==='file:'){msg('La caméra nécessite le site en HTTPS. Utilise le n° batch pour ce test local.');return}if(!('BarcodeDetector' in window)||!navigator.mediaDevices){msg('Scan caméra non disponible sur ce navigateur');return}try{const stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:'environment'}});modal('

Scanner le QR

');const v=qrv;v.srcObject=stream;const det=new BarcodeDetector({formats:['qr_code']});const loop=async()=>{if(!document.getElementById('qrv')){stream.getTracks().forEach(t=>t.stop());return}const c=await det.detect(v);if(c.length){stream.getTracks().forEach(t=>t.stop());openQRValue(c[0].rawValue);return}requestAnimationFrame(loop)};loop()}catch(e){msg('Accès caméra impossible')}} ; recv=function(){base('Réceptions','Lots et références de café vert','
');const origins=[...new Set(D.coffees.map(c=>c[2]))].sort(),rows=filteredLots().map(l=>''+(l[8]||'—')+''+cf(l[1])[1]+''+cf(l[1])[2]+''+l[2]+''+(l[10]||'—')+''+fr(l[3])+''+kg(l[4])+''+kg(gs(l))+'').join('');app.innerHTML+='
'+rows+'
Lot PIKAROCaféOrigineLot fournisseurFournisseurRéceptionReçuStock
'}; openRecv=function(){const opts=D.coffees.map(c=>'').join('');modal('

Nouvelle réception

Le lot interne PIKARO est généré automatiquement.

');rc.onchange=()=>rp.value=cf(rc.value)[2];rc.onchange()}; stock=function(){base('État de stock','Stocks verts et torréfiés filtrables.');const origins=[...new Set(D.coffees.map(c=>c[2]))].sort(),green=filteredLots().map(l=>''+(l[8]||'—')+''+cf(l[1])[1]+''+cf(l[1])[2]+''+l[2]+''+kg(gs(l))+'').join(''),rr=filteredRoasts().filter(r=>r.rem>0).map(r=>''+r.p+''+r.n+''+fr(r.d)+''+(r.op||'—')+''+kg(r.rem)+'').join('');app.innerHTML+='
Vert total
'+kg(tg())+'
Torréfié total
'+kg(tr())+'
Lots verts
'+D.lots.length+'
Bidons actifs
'+D.roasts.filter(r=>r.rem>0).length+'

🌱 Café vert

'+green+'
Lot PIKAROCaféOrigineLot fournisseurStock

🔥 Café torréfié

'+rr+'
ProduitBatchDateOpérateurStock
'}; roast=function(){base('Suivi torréfaction','Batchs planifiés et historique filtrable.','
');const pending=D.plans.filter(x=>x.st==='Planifié'||x.st==='En cours').map(x=>''+x.n+''+fr(x.d)+''+x.p+''+kg(x.g)+''+(x.st==='En cours'?'':'')+'').join(''),hist=filteredRoasts().slice().reverse().map(r=>''+r.n+''+fr(r.d)+''+r.p+''+(r.op||'—')+''+(r.duration||'—')+''+(r.g?(r.o/r.g*100).toFixed(1)+'%':'—')+''+kg(r.rem)+'').join('');app.innerHTML+='

Batchs planifiés à torréfier

'+pending+'
N°DateProduitPrévu

Historique

'+hist+'
N°DateProduitOpérateurDuréeRendementStock
'}; out=function(){base('Déstockage','Sorties avec opérateur et historique filtrable.');const active=D.roasts.filter(r=>r.rem>0).map(r=>''+r.p+''+r.n+''+kg(r.rem)+'').join(''),hist=filteredMoves().slice().reverse().map(m=>{const r=D.roasts.find(x=>x.id===m.r)||{};return''+fr(m.d)+' '+(m.time||'')+''+(r.n||'—')+''+(r.p||'—')+''+kg(m.q)+''+(m.t||'—')+''+(m.by||'Ancien mouvement')+''}).join('');app.innerHTML+='
'+active+'
ProduitBatchStock

Historique

'+hist+'
DateBatchProduitQuantitéDestinationPar
'}; label=function(id){const r=D.roasts.find(x=>x.id==id);modal('

Étiquette bidon

PIKARO
'+r.p+'
'+r.n+'
'+fr(r.d)+'
'+kg(r.o)+' NET

QR du batch '+r.n+'

');setTimeout(()=>{const el=document.getElementById('qr');if(typeof QRCode==='undefined'){el.innerHTML='QR indisponible : connexion internet requise pour ce test local.';return}new QRCode(el,{text:qrPayload(r),width:160,height:160,correctLevel:QRCode.CorrectLevel.M})},30)}; scan=function(){modal('

Scanner un QR

Sur la version locale, tu peux saisir le batch. Une fois hébergé en HTTPS, la caméra peut scanner directement.

')}; ; /* ===== V2.3.4 TEST : KPI + référentiels + commentaire ===== */ function roastStats(rows){ const n=rows.length, green=rows.reduce((a,r)=>a+(Number(r.g)||0),0), roasted=rows.reduce((a,r)=>a+(Number(r.o)||0),0), loss=green-roasted; return {n,green,roasted,loss,lossPct:green?loss/green*100:0,yieldPct:green?roasted/green*100:0}; } function kpiHtml(st){ return '
'+ '
Batchs
'+st.n+'
'+ '
Café vert torréfié
'+kg(st.green)+'
'+ '
Café torréfié obtenu
'+kg(st.roasted)+'
'+ '
Perte torréfaction
'+kg(st.loss)+' ('+st.lossPct.toFixed(1)+'%)
'+ '
Rendement
'+st.yieldPct.toFixed(1)+'%
' } function productStatsHtml(rows){ const m={};rows.forEach(r=>{if(!m[r.p])m[r.p]={n:0,g:0,o:0};m[r.p].n++;m[r.p].g+=Number(r.g)||0;m[r.p].o+=Number(r.o)||0}); const data=Object.entries(m).map(([p,x])=>({p,n:x.n,g:x.g,o:x.o,loss:x.g-x.o,y:x.g?(x.o/x.g)*100:0})); window._pikaroProductStats272=data; const opts=data.map(x=>x.p).sort((a,b)=>a.localeCompare(b,'fr')).map(p=>'').join(''); return '
Analyse par produitFiltrer puis exporter la vue affichée
'+ '
'+ '
'+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ data.map(x=>'').join('')+ '
PRODUITBATCHSVERT TORRÉFIÉCAFÉ OBTENUPERTERENDEMENT
'+x.p+''+x.n+''+kg(x.g)+''+kg(x.o)+''+kg(x.loss)+''+x.y.toFixed(1)+'%
' } function resetProductStats273(){ ['pfProd272','pfBatch272','pfGreen272','pfOut272','pfLoss272','pfYield272'].forEach(id=>{const e=document.getElementById(id);if(e)e.value=''}); filterProductStats272() } function filterProductStats272(){ const t=document.getElementById('productStats272');if(!t)return; const p=(document.getElementById('pfProd272')?.value||'').toUpperCase(); const vals=['pfBatch272','pfGreen272','pfOut272','pfLoss272','pfYield272'].map(id=>(document.getElementById(id)?.value||'').trim().replace(',','.')); [...t.querySelectorAll('tbody tr')].forEach(r=>{ const nums=[r.dataset.n,r.dataset.g,r.dataset.o,r.dataset.loss,r.dataset.y]; let ok=!p||String(r.dataset.p||'').toUpperCase()===p; vals.forEach((q,i)=>{if(q && !String(nums[i]).includes(q))ok=false}); r.style.display=ok?'':'none' }) } function exportProductStats272(){ const t=document.getElementById('productStats272');if(!t)return; const rows=[...t.querySelectorAll('tbody tr')].filter(r=>r.style.display!=='none').map(r=>[...r.children].map(td=>(td.textContent||'').trim())); const headers=['PRODUIT','BATCHS','VERT TORRÉFIÉ','CAFÉ OBTENU','PERTE','RENDEMENT']; const cell=v=>'"'+String(v??'').replace(/"/g,'""')+'"'; const csv='\ufeff'+[headers,...rows].map(r=>r.map(cell).join(';')).join('\r\n'); const blob=new Blob([csv],{type:'text/csv;charset=utf-8;'}),a=document.createElement('a'); a.href=URL.createObjectURL(blob);a.download='PIKARO_STATS_PRODUITS_'+today()+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),500) } function refUsed(id){return D.lots.some(l=>l[1]===id)} function deleteCoffeeRef(id){ const c=D.coffees.find(x=>x[0]===id);if(!c)return; if(refUsed(id)){msg('Suppression impossible : cette référence possède déjà un lot / historique.');return} if(confirm('Supprimer définitivement '+c[1]+' ?')){D.coffees=D.coffees.filter(x=>x[0]!==id);save();openCoffeeRefs()} } function openCoffeeRefs(){ modal('

Référentiel cafés verts

Ajoute une référence quand tu le souhaites. Une référence déjà utilisée ne peut pas être supprimée afin de préserver la traçabilité.

'+ '
'+ '
'+ ''+ D.coffees.map(c=>'').join('')+'
CaféOrigineLots reçus
'+c[1]+''+c[2]+''+D.lots.filter(l=>l[1]===c[0]).length+''+(refUsed(c[0])?'Historique conservé':'')+'
') } function ensureProducts(){ if(!D.products)D.products=[...new Set([...(D.roasts||[]).map(r=>r.p),...(D.plans||[]).map(p=>p.p)])].filter(Boolean).map((n,i)=>({id:'P'+i,n,active:true})); /* Toute référence de café vert ayant réellement du stock devient automatiquement une référence active "à torréfier". Ex. NAMISUNI apparaît dès qu'un lot est en stock. */ const stockedCoffees=(D.coffees||[]).filter(c=>(D.lots||[]).some(l=>l[1]===c[0] && gs(l)>0)); stockedCoffees.forEach(c=>{ let p=D.products.find(x=>pnorm(x.n)===pnorm(c[1])); if(!p){ D.products.push({ id:'PG-'+c[0], n:c[1], type:'Origine pure', active:true, autoGreen:true, sourceCoffeeId:c[0] }); }else{ p.active=true; if(!p.type)p.type='Origine pure'; p.autoGreen=true; if(!p.sourceCoffeeId)p.sourceCoffeeId=c[0]; } }); } ensureProducts();save(); function openProductRefs(){ ensureProducts(); modal('

Référentiel cafés à torréfier

Cette liste alimente le plan et les nouvelles torréfactions. Tout café vert actuellement en stock y est ajouté automatiquement comme origine pure.

'+ '
'+ '
'+ ''+ D.products.map(p=>{const auto=!!p.autoGreen&&!!(p.sourceCoffeeId&&D.lots.some(l=>l[1]===p.sourceCoffeeId&&gs(l)>0));return ''}).join('')+'
RéférenceTypeStatut
'+p.n+''+(p.type||'—')+''+(auto?'Active · stock vert':(p.active===false?'Archivée':'Active'))+''+(auto?'Automatique':'')+'
') } function addProductRef(){const n=pn.value.trim();if(!n){msg('Nom obligatoire');return}if(D.products.some(p=>pnorm(p.n)===pnorm(n))){msg('Cette référence existe déjà');return}D.products.push({id:'P'+Date.now(),n,type:pt.value,active:true});save();openProductRefs()} function toggleProductRef(id){ const p=D.products.find(x=>x.id===id);if(!p)return; if(p.sourceCoffeeId && D.lots.some(l=>l[1]===p.sourceCoffeeId && gs(l)>0)){ p.active=true;save();msg('Cette référence reste active car ce café vert est actuellement en stock.');openProductRefs();return } p.active=p.active===false?true:false;save();openProductRefs() } function addProductRefButton(){ const h=document.querySelector('h1');if(!h)return; const title=h.textContent; if((title.includes('Plan')||title.includes('Suivi torréfaction'))&&!document.getElementById('prodrefbtn')){ const b=document.createElement('button');b.id='prodrefbtn';b.className='btn light';b.textContent='🔥 Référentiel cafés à torréfier';b.onclick=openProductRefs; const top=h.parentElement;top.appendChild(b) } } const oldGo234=go;go=function(p){oldGo234(p);setTimeout(addProductRefButton,0)}; ; /* ===== V2.3.5 : état de stock simplifié ===== */ let stockViewGreen='in'; function greenStockRows(){ return D.lots.filter(l=>{ const c=cf(l[1]), rem=gs(l); const q=F.green.q, origin=F.green.origin; const viewOk=stockViewGreen==='all'||(stockViewGreen==='in'&&rem>0)||(stockViewGreen==='out'&&rem===0); return viewOk && (!q||[c&&c[1],c&&c[2],l[2],l[8],l[10]].some(v=>phas(v,q))) && (!origin||(c&&c[2])===origin); }); } function greenStockExport(){ csvDownload('pikaro_etat_stock_vert.csv', ['Café','Origine','Lot PIKARO','Lot fournisseur','Reçu kg','Consommé kg','Stock restant kg'], greenStockRows().map(l=>[cf(l[1])[1],cf(l[1])[2],l[8]||'',l[2]||'',l[4]||0,used(l[0]),gs(l)])) } function roastedStockExport(){ csvDownload('pikaro_etat_stock_torrefie.csv', ['Produit','Batch','Torréfié kg','Déstocké kg','Stock restant kg','Date','Opérateur'], filteredRoasts().filter(r=>r.rem>0).map(r=>[r.p,r.n,r.o||0,(Number(r.o)||0)-(Number(r.rem)||0),r.rem,r.d,r.op||''])) } stock=function(){ base('État de stock','État physique des cafés actuellement disponibles.'); const origins=[...new Set(D.coffees.map(c=>c[2]))].sort(); const greenRows=greenStockRows(); const roastedRows=filteredRoasts().filter(r=>r.rem>0); const activeGreenLots=D.lots.filter(l=>gs(l)>0).length; const activeRoasts=D.roasts.filter(r=>(Number(r.rem)||0)>0).length; app.innerHTML+=`
Stock café vert
${kg(tg())}
Stock café torréfié
${kg(tr())}
Lots verts actifs
${activeGreenLots}
Batchs torréfiés en stock
${activeRoasts}

🌱 Café vert

Stock physique par lot
${greenRows.length?greenRows.map(l=>``).join(''):``}
CAFÉORIGINELOT PIKAROLOT FOURNISSEURREÇUCONSOMMÉSTOCK RESTANT
${cf(l[1])[1]}${cf(l[1])[2]}${l[8]||'—'}${l[2]||'—'}${kg(l[4])}${kg(used(l[0]))}${kg(gs(l))}
Aucun lot correspondant aux filtres.

🔥 Café torréfié

Stock physique restant par batch
${roastedRows.length?roastedRows.map(r=>{const produced=Number(r.o)||0,rem=Number(r.rem)||0,out=Math.max(0,produced-rem);return ``}).join(''):``}
PRODUITBATCHTORRÉFIÉDÉSTOCKÉSTOCK RESTANTDATE
${r.p}${r.n}${kg(produced)}${kg(out)}${kg(rem)}${fr(r.d)}
Aucun batch correspondant aux filtres.
`; }; ; /* ===== V2.3.5a : commentaires de déstockage visibles ===== */ function moveComment(m){ /* Compatibilité avec les différents noms utilisés dans les versions précédentes */ return (m && (m.comment || m.commentaire || m.notes || m.note || m.ref || m.reference)) || ''; } function openMoveComment(i){ const rows=filteredMoves().slice().reverse(),m=rows[i],r=m&&D.roasts.find(x=>x.id===m.r); if(!m)return; modal('

Commentaire du déstockage

'+ '

Batch : '+(r?r.n:'—')+'

'+ '

Produit : '+(r?r.p:'—')+'

'+ '

Date : '+fr(m.d)+' '+(m.time||'')+'

'+ '

Déstocké par : '+(m.by||'—')+'

'+ '
'+ '
'+(moveComment(m)||'Aucun commentaire')+'
') } /* Remplace uniquement l'affichage de l'écran déstockage. Les données et la logique de déstockage existantes restent inchangées. */ out=function(){ base('Déstockage','Sorties avec opérateur, commentaire et historique filtrable.'); const active=D.roasts.filter(r=>r.rem>0).map(r=> ''+r.p+''+r.n+''+kg(r.rem)+'' ).join(''); const moves=filteredMoves().slice().reverse(); const hist=moves.map((m,i)=>{ const r=D.roasts.find(x=>x.id===m.r)||{},c=moveComment(m); return ''+ ''+fr(m.d)+' '+(m.time||'')+''+ ''+(r.n||'—')+''+ ''+(r.p||'—')+''+ ''+kg(m.q)+''+ ''+(m.t||'—')+''+ ''+(m.by||'Ancien mouvement')+''+ ''+(c?c:'—')+''+ ''+(c?'':'')+''+ '' }).join(''); app.innerHTML+='
'+ ''+ ''+active+'
PRODUITBATCHSTOCK
'+ '

Historique

'+ '
'+ '
'+ '
'+ '
'+ ''+ '
'+ ''+ hist+'
DATEBATCHPRODUITQUANTITÉDESTINATIONPARCOMMENTAIRE / RÉFÉRENCE
' }; /* Export CSV : inclut explicitement le commentaire, quelle que soit l'ancienne clé */ exportMoves=function(){ csvDownload('pikaro_destockages.csv', ['Date','Heure','Batch','Produit','Quantité kg','Destination','Déstocké par','Commentaire / référence'], filteredMoves().map(m=>{ const r=D.roasts.find(x=>x.id===m.r)||{}; return [m.d,m.time||'',r.n||'',r.p||'',m.q,m.t||'',m.by||'',moveComment(m)] }) ) }; ; /* ===== V2.3.6 : référentiel produits réellement utilisé + saisies en MAJUSCULES ===== */ /* Toutes les saisies texte sont stockées en majuscules, pas seulement affichées. */ function upperField(el){ if(!el || el.tagName!=='INPUT' && el.tagName!=='TEXTAREA') return; if(['number','date','time','datetime-local','email','url','file'].includes((el.type||'').toLowerCase())) return; const a=el.selectionStart,b=el.selectionEnd; el.value=el.value.toLocaleUpperCase('fr-FR'); try{el.setSelectionRange(a,b)}catch(e){} } document.addEventListener('input',e=>upperField(e.target),true); document.addEventListener('change',e=>upperField(e.target),true); /* Nettoie aussi les nouvelles références avant sauvegarde. */ const _saveCoffeeRef236=saveCoffeeRef; saveCoffeeRef=function(){ if(document.getElementById('cn')) cn.value=cn.value.trim().toLocaleUpperCase('fr-FR'); if(document.getElementById('co')) co.value=co.value.trim().toLocaleUpperCase('fr-FR'); return _saveCoffeeRef236() }; const _addProductRef236=addProductRef; addProductRef=function(){ if(document.getElementById('pn')) pn.value=pn.value.trim().toLocaleUpperCase('fr-FR'); return _addProductRef236() }; function activeProductNames(){ ensureProducts(); return D.products.filter(p=>p.active!==false).map(p=>p.n); } function productOptions(selected=''){ return activeProductNames().map(n=>'').join(''); } /* Après ouverture d'un formulaire de planification/torréfaction, remplace la liste produit historique par le référentiel administrable. */ function injectProductReferenceIntoOpenForm(){ ensureProducts(); const active=activeProductNames(); if(!active.length)return; /* On cherche le select produit dans la modale via son libellé et/ou les IDs historiques. */ const modalBox=document.querySelector('#modal .box, .modal .box, #modal .modalbox, .modalbox') || document.querySelector('[role="dialog"]'); const scope=modalBox||document; const selects=[...scope.querySelectorAll('select')]; let target=null; for(const sel of selects){ const id=(sel.id||'').toLowerCase(); if(['pp','rp','product','produit','planproduct','roastproduct'].includes(id)){target=sel;break} const parent=sel.parentElement; const lab=parent&&parent.querySelector('label'); if(lab && /produit|café|reference|référence/i.test(lab.textContent)){target=sel;break} } if(!target)return; const previous=target.value; target.innerHTML=productOptions(previous); if(active.includes(previous))target.value=previous; target.dispatchEvent(new Event('change',{bubbles:true})); } /* Wrappers : les fonctions métier originales restent intactes. */ if(typeof openPlan==='function'){ const _openPlan236=openPlan; openPlan=function(){const r=_openPlan236.apply(this,arguments);setTimeout(injectProductReferenceIntoOpenForm,0);return r} } if(typeof openRoast==='function'){ const _openRoast236=openRoast; openRoast=function(){const r=_openRoast236.apply(this,arguments);setTimeout(injectProductReferenceIntoOpenForm,0);return r} } if(typeof startPlan==='function'){ const _startPlan236=startPlan; startPlan=function(){const r=_startPlan236.apply(this,arguments);setTimeout(injectProductReferenceIntoOpenForm,0);return r} } /* Le référentiel indique clairement qu'il alimente le plan. */ const _openProductRefs236=openProductRefs; openProductRefs=function(){ _openProductRefs236(); const h=[...document.querySelectorAll('h2')].find(x=>x.textContent.includes('Référentiel cafés à torréfier')); if(h){ const p=h.nextElementSibling; if(p)p.textContent='LISTE ACTIVE UTILISÉE DANS LE PLAN DE TORRÉFACTION ET LES NOUVELLES TORRÉFACTIONS.'; } } ; /* ===== V2.3.7 : édition sécurisée des torréfactions ===== */ function editRoast(id){ const r=D.roasts.find(x=>x.id===id); if(!r)return; ensureProducts(); const prod=[...new Set([r.p,...D.products.filter(p=>p.active!==false).map(p=>p.n)])]; modal('

Modifier la torréfaction

Batch : '+r.n+' — numéro verrouillé pour préserver la traçabilité.

'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '

Le poids vert et la composition des lots sont verrouillés ici : les modifier directement fausserait le stock vert et la traçabilité.

'+ '
') } function saveRoastEdit(id){ const r=D.roasts.find(x=>x.id===id); if(!r)return; const oldO=Number(r.o)||0, oldRem=Number(r.rem)||0; const alreadyOut=Math.max(0,oldO-oldRem); const newO=parseFloat(String(document.getElementById('erout').value).replace(',','.')); if(!erd.value||!erp.value||!Number.isFinite(newO)||newO<=0){msg('Date, produit et poids torréfié obligatoires');return} if(newO+1e-9p.querySelector('h2')&&p.querySelector('h2').textContent.trim()==='Historique') || panels[panels.length-1]; const table=hist&&hist.querySelector('table'); if(!table)return; const trs=[...table.querySelectorAll('tr')]; if(!trs.length)return; if(![...trs[0].children].some(c=>c.textContent.trim()==='MODIFIER'))trs[0].insertAdjacentHTML('beforeend','Modifier'); trs.slice(1).forEach(tr=>{ if(tr.querySelector('.edit237'))return; const batch=tr.children[0]?.textContent.trim(); const r=D.roasts.find(x=>x.n===batch); if(r)tr.insertAdjacentHTML('beforeend','') }) }; ; /* V2.3.8a — correction réelle : le menu appelle HOME(), pas DASH() */ let homePeriod='month'; function homePeriodRows(){ const a=D.roasts||[],n=new Date(); return a.filter(r=>{if(homePeriod==='all')return true;const d=new Date((r.d||'')+'T12:00:00');if(isNaN(d))return false; if(homePeriod==='today')return d.toDateString()===n.toDateString(); if(homePeriod==='month')return d.getFullYear()===n.getFullYear()&&d.getMonth()===n.getMonth(); if(homePeriod==='year')return d.getFullYear()===n.getFullYear(); if(homePeriod==='week'){let x=new Date(n);x.setHours(0,0,0,0);x.setDate(x.getDate()-((x.getDay()+6)%7));let e=new Date(x);e.setDate(e.getDate()+7);return d>=x&&dgs(l)>0).slice().sort((x,y)=>gs(x)-gs(y)); const rows=a.map(l=>{ const c=cf(l[1])||['','','']; return ` ${l[8]||'—'} ${c[1]||'—'} ${c[2]||'—'} ${l[2]||'—'} ${l[10]||'—'} ${fr(l[3])} ${kg(l[4])} ${kg(gs(l))} `; }).join(''); const total=a.reduce((t,l)=>t+gs(l),0); modal(`

LOTS VERTS ACTIFS

${a.length} lot(s) actif(s) · stock disponible total ${kg(total)}

${rows||''}
LOT PIKAROCAFÉORIGINELOT FOURNISSEURFOURNISSEURRÉCEPTIONREÇUSTOCK
AUCUN LOT ACTIF
`); } function homeDetail(kind){ const a=homePeriodRows().slice().reverse(), names={green:'CAFÉ VERT TORRÉFIÉ',roasted:'CAFÉ TORRÉFIÉ PRODUIT',loss:'PERTES DE TORRÉFACTION',batches:'BATCHS TORRÉFIÉS'}; let rows=''; if(kind==='green') rows=a.map(r=>`${fr(r.d)}${r.n}${r.p}${r.op||'—'}${kg(r.g)}`).join(''); if(kind==='roasted') rows=a.map(r=>`${fr(r.d)}${r.n}${r.p}${kg(r.g)}${kg(r.o)}`).join(''); if(kind==='loss') rows=a.map(r=>{let g=+r.g||0,o=+r.o||0,l=g-o;return `${r.n}${r.p}${kg(g)}${kg(o)}${kg(l)}${g?(l/g*100).toFixed(1):'0.0'}%`}).join(''); if(kind==='batches') rows=a.map(r=>`${r.n}${fr(r.d)}${r.p}${r.op||'—'}${kg(r.g)}${kg(r.o)}`).join(''); let head=kind==='green'?'DATEBATCHPRODUITOPÉRATEURVERT': kind==='roasted'?'DATEBATCHPRODUITVERTTORRÉFIÉ': kind==='loss'?'BATCHPRODUITVERTTORRÉFIÉPERTE%': 'BATCHDATEPRODUITOPÉRATEURVERTTORRÉFIÉ'; modal(`

${names[kind]}

${head}${rows||''}
AUCUNE DONNÉE SUR CETTE PÉRIODE
`) } const homeBase238=home; home=function(){ homeBase238(); const a=homePeriodRows(),st=roastStats(a); const p=document.createElement('div'); p.className='panel'; p.innerHTML=`

🔥 PRODUCTION DÉTAILLÉE

CHOISIS UNE PÉRIODE PUIS CLIQUE SUR UNE CARTE.

${[['today',"AUJOURD’HUI"],['week','CETTE SEMAINE'],['month','CE MOIS'],['year','CETTE ANNÉE'],['all','TOUT']].map(x=>``).join('')}
CAFÉ VERT TORRÉFIÉ
${kg(st.green)}
VOIR LE DÉTAIL →
CAFÉ TORRÉFIÉ PRODUIT
${kg(st.roasted)}
VOIR LE DÉTAIL →
PERTE TORRÉFACTION
${kg(st.loss)} (${st.lossPct.toFixed(1)}%)
VOIR LE DÉTAIL →
NOMBRE DE BATCHS
${st.n}
VOIR LE DÉTAIL →

PRODUCTION PAR CAFÉ

${productStatsHtml(a)}`; app.appendChild(p) }; home(); /* redessine immédiatement le tableau de bord avec le nouveau bloc */ ; /* ===== V2.3.9 : réception en sacs + centre d'alertes ===== */ /* --- Réception café vert : nombre de sacs x poids/sac, avec poids réel optionnel --- */ openRecv=function(){ const opts=D.coffees.map(c=>'').join(''); modal('

NOUVELLE RÉCEPTION

'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '

SI LE POIDS RÉEL EST RENSEIGNÉ, IL DEVIENT LE STOCK D’ENTRÉE. SINON : NOMBRE DE SACS × POIDS PAR SAC.

'+ '

LE LOT INTERNE PIKARO EST GÉNÉRÉ AUTOMATIQUEMENT.

'+ '
'); rc.onchange=()=>rp.value=cf(rc.value)[2];rc.onchange() } function calcRecvWeight(){ const n=Number(rbags.value)||0,w=Number(rbagw.value)||0; rtheo.value=(n*w).toFixed(2)+' KG' } function saveRecv239(){ const bags=Number(rbags.value)||0, bagw=Number(rbagw.value)||0, theo=bags*bagw; const real=Number(String(rq.value||'').replace(',','.'))||0, qty=real>0?real:theo; if(!rc.value||!rl.value.trim()||qty<=0||!rd.value){msg('CAFÉ, LOT FOURNISSEUR, CONDITIONNEMENT ET DATE OBLIGATOIRES');return} const internal=typeof nextGreenLot==='function'?nextGreenLot():('PV-'+new Date().getFullYear()+'-'+String(D.lots.length+1).padStart(3,'0')); const l=['L'+Date.now(),rc.value,rl.value.trim().toLocaleUpperCase('fr-FR'),rd.value,qty,Number(rco.value)||0,'','',''+internal,'',rs.value.trim().toLocaleUpperCase('fr-FR'),rph.value.trim().toLocaleUpperCase('fr-FR')]; l.bags=bags;l.bagWeight=bagw;l.theoreticalWeight=theo;l.realWeight=real||null; D.lots.push(l);save();close();msg('RÉCEPTION ENREGISTRÉE : '+qty.toFixed(2)+' KG');recv() } /* --- Alertes opérationnelles --- */ function pikaroAlerts(){ const A=[],now=new Date();now.setHours(12,0,0,0); /* Stock vert : seuil de départ 30 kg. */ D.lots.forEach(l=>{const rem=gs(l),c=cf(l[1]);if(rem<0)A.push({level:'red',title:'ANOMALIE STOCK VERT',text:(c?.[1]||'CAFÉ')+' — '+(l[8]||l[2])+' : '+kg(rem)}); else if(rem>0&&rem<=30)A.push({level:'orange',title:'STOCK VERT FAIBLE',text:(c?.[1]||'CAFÉ')+' — '+(l[8]||l[2])+' : '+kg(rem)+' RESTANTS'})}); /* Batch torréfié ancien >21 jours et encore en stock. */ D.roasts.forEach(r=>{if((Number(r.rem)||0)<=0)return;const d=new Date(r.d+'T12:00:00'),age=Math.floor((now-d)/86400000);if(age>21)A.push({level:'orange',title:'CAFÉ TORRÉFIÉ ANCIEN',text:r.p+' — '+r.n+' : '+kg(r.rem)+' EN STOCK DEPUIS '+age+' JOURS'}); const g=Number(r.g)||0,o=Number(r.o)||0;if(g>0&&o/g<.80)A.push({level:'red',title:'RENDEMENT À VÉRIFIER',text:r.p+' — '+r.n+' : '+(o/g*100).toFixed(1)+' %'})}); /* Planification en retard. */ (D.plans||[]).forEach(p=>{if((p.st==='Planifié'||p.st==='En cours')&&p.dÀ SURVEILLER

CONTRÔLES AUTOMATIQUES DE PRODUCTION ET DE STOCK.

'+ (A.length?A.map(a=>'
'+(a.level==='red'?'🔴':'🟠')+' '+a.title+'
'+a.text+'
').join(''):'
✅ AUCUNE ALERTE ACTUELLEMENT
')) } /* Ajoute un centre d'alertes au vrai tableau de bord, sans dépendre du libellé historique. */ const homeBefore239=home; home=function(){ homeBefore239(); const A=pikaroAlerts(),red=A.filter(x=>x.level==='red').length; const p=document.createElement('div');p.className='panel'; p.innerHTML='

⚠️ À SURVEILLER

ANOMALIES, STOCKS FAIBLES, CAFÉS ANCIENS ET PLANIFICATION.
'+ '
'+ (A.length?A.slice(0,3).map(a=>'

'+(a.level==='red'?'🔴':'🟠')+' '+a.title+' — '+a.text+'

').join(''):'

✅ AUCUN POINT CRITIQUE ACTUELLEMENT.

'); app.appendChild(p) } home(); ; /* ===== V2.4.0 : LOT-AAAA-NNNN + chrono atelier + synchronisation cloud optionnelle ===== */ /* Numéro de lot interne : LOT-2026-0001, robuste même après suppression d'une ligne. */ internalLot=function(){ const y=String(new Date().getFullYear()), re=new RegExp('^LOT-'+y+'-(\\d{4})$'); let max=0;(D.lots||[]).forEach(l=>{const m=String(l[8]||'').match(re);if(m)max=Math.max(max,Number(m[1])||0)}); return 'LOT-'+y+'-'+String(max+1).padStart(4,'0') }; if(typeof nextGreenLot==='function') nextGreenLot=internalLot; /* --- Chronomètre de torréfaction persistant --- */ function hhmmssFromDate(d){return [d.getHours(),d.getMinutes(),d.getSeconds()].map(x=>String(x).padStart(2,'0')).join(':')} function durationBetween(a,b){let x=new Date(a),y=new Date(b),sec=Math.max(0,Math.floor((y-x)/1000));return String(Math.floor(sec/3600)).padStart(2,'0')+':'+String(Math.floor(sec%3600/60)).padStart(2,'0')+':'+String(sec%60).padStart(2,'0')} function launchRoastTimer(id){ const p=D.plans.find(x=>x.id===id);if(!p)return; if(!p.startedAt)p.startedAt=new Date().toISOString(); p.st='En cours';save();roast() } function stopRoastTimer(id){ const p=D.plans.find(x=>x.id===id);if(!p||!p.startedAt)return; p.endedAt=new Date().toISOString();p.st='En cours';save(); openRoast(p); setTimeout(()=>{ const a=new Date(p.startedAt),b=new Date(p.endedAt); if(document.getElementById('st'))st.value=hhmmssFromDate(a); if(document.getElementById('et'))et.value=hhmmssFromDate(b); if(document.getElementById('dur'))dur.value=durationBetween(p.startedAt,p.endedAt); },0) } function timerText(p){return p.startedAt&&!p.endedAt?durationBetween(p.startedAt,new Date().toISOString()):''} setInterval(()=>{document.querySelectorAll('[data-live-timer]').forEach(el=>{const p=D.plans.find(x=>x.id===el.dataset.liveTimer);if(p&&p.startedAt&&!p.endedAt)el.textContent=timerText(p)})},1000); function installTimerButtons(){ const tables=[...app.querySelectorAll('table')]; tables.forEach(t=>[...t.querySelectorAll('tr')].slice(1).forEach(tr=>{ const batch=(tr.children[0]?.textContent||'').trim(),p=D.plans.find(x=>x.n===batch); if(!p||p.st==='Torréfié'||p.st==='Annulé')return; const btn=[...tr.querySelectorAll('button')].find(b=>/Démarrer|Lancer|Arrêter/i.test(b.textContent)); if(!btn)return; if(!p.startedAt){ btn.textContent='▶ LANCER LA TORRÉFACTION';btn.onclick=()=>launchRoastTimer(p.id) }else if(!p.endedAt){ btn.textContent='■ ARRÊTER LA TORRÉFACTION';btn.onclick=()=>stopRoastTimer(p.id); if(!tr.querySelector('[data-live-timer]'))btn.insertAdjacentHTML('beforebegin',''+timerText(p)+'') }else{ btn.textContent='✍ TERMINER / VALIDER';btn.onclick=()=>stopRoastTimer(p.id) } })) } const roastTimerBase=roast;roast=function(){roastTimerBase();installTimerButtons()}; /* --- Cloud optionnel : le local reste toujours prioritaire pour continuer à travailler hors connexion. --- */ const CLOUD_KEY='pikaro-cloud-config-v1'; let cloudCfg=JSON.parse(localStorage.getItem(CLOUD_KEY)||'null')||{url:'',key:'',row:'main'}; let syncState='LOCAL',syncBusy=false,syncTimer=null; function cloudConfigured(){return !!(cloudCfg.url&&cloudCfg.key)} function cloudHeaders(){return {'apikey':cloudCfg.key,'Authorization':'Bearer '+cloudCfg.key,'Content-Type':'application/json','Prefer':'resolution=merge-duplicates,return=minimal'}} function cloudEndpoint(){return cloudCfg.url.replace(/\/$/,'')+'/rest/v1/pikaro_state'} function syncLabel(){ if(!cloudConfigured())return '⚪ LOCAL UNIQUEMENT'; if(!navigator.onLine)return '🟠 HORS CONNEXION — DONNÉES CONSERVÉES EN LOCAL'; return syncState } function refreshSyncBadges(){document.querySelectorAll('.sync-badge').forEach(x=>x.textContent=syncLabel())} function scheduleCloudPush(){if(!cloudConfigured())return;clearTimeout(syncTimer);syncTimer=setTimeout(pushCloud,800)} const saveLocal240=save; save=function(){ D._meta=D._meta||{};D._meta.updatedAt=new Date().toISOString(); saveLocal240();scheduleCloudPush();refreshSyncBadges() }; async function pushCloud(){ if(!cloudConfigured()||!navigator.onLine||syncBusy)return; syncBusy=true;syncState='🔄 SYNCHRONISATION…';refreshSyncBadges(); try{ const body={id:cloudCfg.row||'main',state:D,updated_at:D._meta?.updatedAt||new Date().toISOString()}; const r=await fetch(cloudEndpoint(),{method:'POST',headers:cloudHeaders(),body:JSON.stringify(body)}); if(!r.ok)throw new Error('HTTP '+r.status); syncState='🟢 SYNCHRONISÉ À '+new Date().toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}); }catch(e){syncState='🔴 SYNCHRO IMPOSSIBLE — LOCAL CONSERVÉ'} finally{syncBusy=false;refreshSyncBadges()} } async function pullCloud(force=false){ if(!cloudConfigured()||!navigator.onLine||syncBusy)return; syncBusy=true;syncState='🔄 VÉRIFICATION CLOUD…';refreshSyncBadges(); try{ const u=cloudEndpoint()+'?id=eq.'+encodeURIComponent(cloudCfg.row||'main')+'&select=state,updated_at'; const r=await fetch(u,{headers:cloudHeaders()});if(!r.ok)throw new Error('HTTP '+r.status); const rows=await r.json(); if(rows.length&&rows[0].state){ const remote=rows[0].state,rt=new Date(rows[0].updated_at||remote?._meta?.updatedAt||0).getTime(),lt=new Date(D._meta?.updatedAt||0).getTime(); if(force||rt>lt){D=remote;saveLocal240();syncState='🟢 CLOUD RÉCUPÉRÉ À '+new Date().toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});go('home');return} } syncState='🟢 À JOUR — '+new Date().toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}); }catch(e){syncState='🔴 SYNCHRO IMPOSSIBLE — LOCAL CONSERVÉ'} finally{syncBusy=false;refreshSyncBadges()} } function cloudSettings(){ modal('

SYNCHRONISATION PIKARO

LE LOGICIEL CONTINUE À FONCTIONNER EN LOCAL SANS INTERNET. CES PARAMÈTRES ACTIVENT LA COPIE CLOUD POUR LA CONSULTATION À DISTANCE.

'+ '
'+ '
'+ '
'+ '
') } function saveCloudSettings(){ cloudCfg={url:cu.value.trim(),key:ck.value.trim(),row:(cr.value.trim()||'main')};localStorage.setItem(CLOUD_KEY,JSON.stringify(cloudCfg));close();pushCloud();home() } window.addEventListener('online',()=>{pullCloud();scheduleCloudPush()}); window.addEventListener('offline',refreshSyncBadges); document.addEventListener('visibilitychange',()=>{if(!document.hidden)pullCloud()}); setInterval(()=>pullCloud(),60000); /* Bloc de statut sur le tableau de bord */ const homeCloudBase=home; home=function(){ homeCloudBase(); const p=document.createElement('div');p.className='panel'; p.innerHTML='

☁️ SYNCHRONISATION

'+syncLabel()+'
'+ '

À L’ATELIER : SAISIE LOCALE MÊME SANS INTERNET. À DISTANCE : LES DONNÉES CLOUD SONT DISPONIBLES DÈS QUE L’ATELIER SE RESYNCHRONISE.

'; app.appendChild(p);refreshSyncBadges() }; /* Premier affichage et tentative de récupération distante. */ home();setTimeout(()=>pullCloud(),500); ; /* ===== V2.4.1 : chrono directement dans la fenêtre Suivi de torréfaction ===== */ let modalRoastPlanId=null, modalTimerInterval=null; function currentModalPlan(){ return modalRoastPlanId ? D.plans.find(x=>x.id===modalRoastPlanId) : null } function setRoastTimeFields(p){ if(!p)return; if(p.startedAt && document.getElementById('st')) st.value=hhmmssFromDate(new Date(p.startedAt)); if(p.endedAt && document.getElementById('et')) et.value=hhmmssFromDate(new Date(p.endedAt)); const d=document.getElementById('dur'); if(d && p.startedAt) d.value=durationBetween(p.startedAt,p.endedAt||new Date().toISOString()) } function refreshModalRoastTimer(){ const p=currentModalPlan(),b=document.getElementById('modalRoastTimerBtn'),status=document.getElementById('modalRoastTimerStatus'); if(!p||!b)return; setRoastTimeFields(p); if(!p.startedAt){ b.textContent='▶ LANCER LA TORRÉFACTION';b.className='btn'; if(status)status.textContent='PRÊT À DÉMARRER'; } else if(!p.endedAt){ b.textContent='■ ARRÊTER LA TORRÉFACTION';b.className='btn'; if(status)status.textContent='🔥 TORRÉFACTION EN COURS — '+durationBetween(p.startedAt,new Date().toISOString()); } else { b.textContent='↻ REPRENDRE LE CHRONO';b.className='btn light'; if(status)status.textContent='TERMINÉE — DURÉE '+durationBetween(p.startedAt,p.endedAt); } } function modalRoastTimerAction(){ const p=currentModalPlan();if(!p)return; if(!p.startedAt){ p.startedAt=new Date().toISOString();p.endedAt=null;p.st='En cours';save(); }else if(!p.endedAt){ p.endedAt=new Date().toISOString();save(); }else{ /* reprise volontaire après arrêt accidentel : conserve le début et enlève seulement la fin */ p.endedAt=null;p.st='En cours';save(); } refreshModalRoastTimer() } function installModalRoastTimer(p){ modalRoastPlanId=p?.id||null;if(!p)return; const box=document.querySelector('#modal .box,.modal .box,#modal .modalbox,.modalbox,[role="dialog"]')||document; const h=[...box.querySelectorAll('h2')].find(x=>/Suivi de torréfaction/i.test(x.textContent)); if(!h)return; let zone=document.getElementById('modalRoastTimerZone'); if(!zone){ zone=document.createElement('div');zone.id='modalRoastTimerZone'; zone.style.cssText='margin:16px 0;padding:16px;border:1px solid #d8d0bd;border-radius:12px;background:#f8f5ed'; zone.innerHTML='
PRÊT À DÉMARRER
LES HEURES DE DÉBUT, DE FIN ET LA DURÉE SONT RENSEIGNÉES AUTOMATIQUEMENT.
'; h.insertAdjacentElement('afterend',zone) } ['st','et','dur'].forEach(id=>{const e=document.getElementById(id);if(e){e.readOnly=true;e.title='RENSEIGNÉ AUTOMATIQUEMENT PAR LE CHRONOMÈTRE'}}) clearInterval(modalTimerInterval);modalTimerInterval=setInterval(refreshModalRoastTimer,1000);refreshModalRoastTimer() } /* Branche le chrono sur la fenêtre qui s'ouvre depuis un batch planifié. */ const openRoast241=openRoast; openRoast=function(p){ const r=openRoast241.apply(this,arguments); setTimeout(()=>installModalRoastTimer(p),0); return r }; /* On retire le pilotage Lancer/Arrêter directement dans le tableau : le tableau sert à ouvrir le suivi, le pilotage se fait dans la fenêtre. */ installTimerButtons=function(){}; /* Si une fenêtre est fermée, arrête seulement le rafraîchissement visuel ; le chrono métier reste basé sur startedAt. */ const close241=close; close=function(){clearInterval(modalTimerInterval);modalTimerInterval=null;modalRoastPlanId=null;return close241.apply(this,arguments)} ; /* ===== V2.4.2 : composition réelle obligatoire AVANT lancement ===== */ function getModalComposition242(){ const rows=[...document.querySelectorAll('#comp .comp')]; return rows.map(row=>{ const sel=row.querySelector('select'); const nums=[...row.querySelectorAll('input[type="number"]')]; const qty=nums.length ? Number(String(nums[0].value||'').replace(',','.')) : 0; const lot=D.lots.find(l=>l[0]===sel?.value); return {lotId:sel?.value||'',qty,lot,row} }) } function compositionStatus242(){ const c=getModalComposition242(); if(!c.length)return {ok:false,msg:'AJOUTE AU MOINS UN CAFÉ / LOT.',total:0}; let total=0; for(const x of c){ if(!x.lotId||!x.lot)return {ok:false,msg:'SÉLECTIONNE UN LOT DE CAFÉ VERT POUR CHAQUE LIGNE.',total}; if(!(x.qty>0))return {ok:false,msg:'INDIQUE LE NOMBRE DE KG POUR CHAQUE LOT.',total}; const available=Number(gs(x.lot))||0; if(x.qty>available+1e-9)return {ok:false,msg:'STOCK INSUFFISANT : '+(cf(x.lot[1])?.[1]||'CAFÉ')+' / '+(x.lot[8]||x.lot[2])+' — '+available.toFixed(2)+' KG DISPONIBLES.',total}; total+=x.qty } return {ok:total>0,msg:total>0?'COMPOSITION PRÊTE — '+total.toFixed(2)+' KG DE CAFÉ VERT.':'POIDS VERT OBLIGATOIRE.',total} } function refreshLaunchGuard242(){ const p=currentModalPlan(),b=document.getElementById('modalRoastTimerBtn'),status=document.getElementById('modalRoastTimerStatus'); if(!p||!b)return; if(p.startedAt){refreshModalRoastTimer();return} const v=compositionStatus242(); b.disabled=!v.ok; b.style.opacity=v.ok?'1':'.45'; b.style.cursor=v.ok?'pointer':'not-allowed'; b.textContent='▶ LANCER LA TORRÉFACTION'; if(status){status.textContent=v.ok?'✅ '+v.msg:'⚠️ '+v.msg} const total=document.getElementById('gv'); if(total)total.value=v.total.toFixed(2) } function freezeComposition242(){ const v=compositionStatus242();if(!v.ok)return v; return {ok:true,total:v.total,composition:getModalComposition242().map(x=>({lot:x.lotId,qty:x.qty}))} } /* Remplace l'action chrono : impossible de démarrer sans composition valide. */ modalRoastTimerAction=function(){ const p=currentModalPlan();if(!p)return; if(!p.startedAt){ const f=freezeComposition242(); if(!f.ok){msg(f.msg);refreshLaunchGuard242();return} p.startedAt=new Date().toISOString();p.endedAt=null;p.st='En cours'; p.frozenComposition=f.composition;p.frozenGreenWeight=f.total; save(); }else if(!p.endedAt){ p.endedAt=new Date().toISOString();save(); }else{ p.endedAt=null;p.st='En cours';save(); } refreshModalRoastTimer() }; /* Au lancement, composition figée : empêche de changer lot/kg pendant le chrono. */ const refreshTimer242=refreshModalRoastTimer; refreshModalRoastTimer=function(){ refreshTimer242(); const p=currentModalPlan();if(!p)return; if(p.startedAt){ document.querySelectorAll('#comp select,#comp input').forEach(e=>{e.disabled=true}); const add=[...document.querySelectorAll('button')].find(b=>/Ajouter café|Ajouter.*lot/i.test(b.textContent)); if(add)add.disabled=true } }; /* Surveille les changements de composition et active/désactive le bouton en temps réel. */ document.addEventListener('input',e=>{if(e.target.closest&&e.target.closest('#comp'))refreshLaunchGuard242()},true); document.addEventListener('change',e=>{if(e.target.closest&&e.target.closest('#comp'))refreshLaunchGuard242()},true); const installModal242=installModalRoastTimer; installModalRoastTimer=function(p){ installModal242(p); setTimeout(refreshLaunchGuard242,0) }; ; /* ===== V2.4.3 : poids torréfié uniquement APRÈS arrêt ===== */ function applyRoastPhase243(){ const p=currentModalPlan(); if(!p)return; const out=document.getElementById('og') || document.getElementById('ro') || document.querySelector('input[type="number"][id*="out"]'); /* Recherche de secours via le label "Poids torréfié" */ let roasted=out; if(!roasted){ const lab=[...document.querySelectorAll('label')].find(x=>/Poids torréfié/i.test(x.textContent)); roasted=lab && lab.parentElement.querySelector('input'); } const hum=document.getElementById('hum') || document.getElementById('rh'); const den=document.getElementById('den'); const col=document.getElementById('col') || document.getElementById('rc'); const validate=[...document.querySelectorAll('button')].find(b=>/Valider la torréfaction/i.test(b.textContent)); const finished=!!(p.startedAt && p.endedAt); /* Humidité et densité concernent le café vert : elles restent optionnelles et saisissables avant le lancement. */ [hum,den].forEach(e=>{if(e){e.disabled=false;e.style.opacity='1';e.style.background=''}}); /* Poids torréfié et colorimétrie ne sont renseignés qu'après l'arrêt. */ [roasted,col].forEach(e=>{ if(!e)return; e.disabled=!finished; e.style.opacity=finished?'1':'.5'; e.style.background=finished?'':'#f1f1ed'; }); if(roasted) roasted.placeholder=finished?'SAISIR LE POIDS OBTENU':'DISPONIBLE APRÈS ARRÊT'; if(validate){ validate.disabled=!finished; validate.style.opacity=finished?'1':'.45'; validate.style.cursor=finished?'pointer':'not-allowed'; validate.title=finished?'':'ARRÊTE D’ABORD LA TORRÉFACTION'; } } const refresh243=refreshModalRoastTimer; refreshModalRoastTimer=function(){refresh243();applyRoastPhase243()}; const action243=modalRoastTimerAction; modalRoastTimerAction=function(){action243();setTimeout(applyRoastPhase243,0)}; const install243=installModalRoastTimer; installModalRoastTimer=function(p){install243(p);setTimeout(applyRoastPhase243,0)}; ; /* V2.4.4 - activation correcte du bouton LANCER */ function comp244(){ const c=document.getElementById('comp'); if(!c)return []; return [...c.querySelectorAll('.comp')].map(row=>{ const sel=row.querySelector('select'), ins=[...row.querySelectorAll('input')]; const q=ins.find(i=>!i.readOnly&&!i.disabled)||ins[0]; return {lotId:sel?.value||'',qty:parseFloat(String(q?.value||'').replace(',','.'))||0,lot:D.lots.find(l=>l[0]===sel?.value)} }) } compositionStatus242=function(){ const a=comp244();if(!a.length)return {ok:false,msg:'AJOUTE AU MOINS UN CAFÉ / LOT.',total:0}; let total=0; for(const x of a){ if(!x.lot)return {ok:false,msg:'SÉLECTIONNE UN LOT POUR CHAQUE CAFÉ.',total}; if(x.qty<=0)return {ok:false,msg:'INDIQUE LES KG POUR CHAQUE LOT.',total}; const av=Math.max(0,Number(gs(x.lot))||0); if(x.qty>av+1e-9)return {ok:false,msg:'STOCK INSUFFISANT : '+av.toFixed(2)+' KG DISPONIBLES.',total}; total+=x.qty } return {ok:true,msg:'COMPOSITION PRÊTE — '+total.toFixed(2)+' KG.',total} }; freezeComposition242=function(){const v=compositionStatus242();return v.ok?{ok:true,total:v.total,composition:comp244().map(x=>({lot:x.lotId,qty:x.qty}))}:v}; function forceLaunch244(){ const p=currentModalPlan(),b=document.getElementById('modalRoastTimerBtn'),st=document.getElementById('modalRoastTimerStatus');if(!p||!b||p.startedAt)return; const v=compositionStatus242();b.disabled=!v.ok;b.style.opacity=v.ok?'1':'.45';b.style.cursor=v.ok?'pointer':'not-allowed'; if(st)st.textContent=(v.ok?'✅ ':'⚠️ ')+v.msg; const g=document.getElementById('gv');if(g)g.value=v.total.toFixed(2) } ['input','change','click'].forEach(ev=>document.addEventListener(ev,e=>{if(e.target.closest?.('#comp')||/Ajouter café|Ajouter.*lot/i.test(e.target.textContent||''))setTimeout(forceLaunch244,20)},true)); const install244=installModalRoastTimer;installModalRoastTimer=function(p){install244(p);setTimeout(forceLaunch244,50)}; ; /* V2.4.5 — correction sur les vrais IDs de la fenêtre : #lines, #gt, #go */ function rows245(){ const c=document.getElementById('lines'); if(!c)return []; return [...c.querySelectorAll('.line')].map(row=>{ const sel=row.querySelector('select'); const inputs=[...row.querySelectorAll('input')]; /* La quantité saisie est le premier input éditable ; le stock affiché est readonly. */ const q=inputs.find(i=>!i.readOnly&&!i.disabled); const lot=D.lots.find(l=>l[0]===sel?.value); return {lotId:sel?.value||'', qty:parseFloat(String(q?.value||'').replace(',','.'))||0, lot} }) } function status245(){ const a=rows245(); if(!a.length)return {ok:false,msg:'AJOUTE AU MOINS UN CAFÉ / LOT.',total:0}; let total=0; for(const x of a){ if(!x.lot)return {ok:false,msg:'SÉLECTIONNE UN LOT POUR CHAQUE CAFÉ.',total}; if(x.qty<=0)return {ok:false,msg:'INDIQUE LES KG POUR CHAQUE LOT.',total}; const av=Math.max(0,Number(gs(x.lot))||0); if(x.qty>av+1e-9)return {ok:false,msg:'STOCK INSUFFISANT — '+av.toFixed(2)+' KG DISPONIBLES.',total}; total+=x.qty; } return {ok:true,msg:'COMPOSITION PRÊTE — '+total.toFixed(2)+' KG DE CAFÉ VERT.',total} } compositionStatus242=status245; freezeComposition242=function(){ const v=status245(); return v.ok?{ok:true,total:v.total,composition:rows245().map(x=>({lot:x.lotId,qty:x.qty}))}:v }; function launchState245(){ const p=currentModalPlan(), b=document.getElementById('modalRoastTimerBtn'), stt=document.getElementById('modalRoastTimerStatus'); if(!p||!b)return; if(!p.startedAt){ const v=status245(); b.disabled=!v.ok;b.style.opacity=v.ok?'1':'.45';b.style.cursor=v.ok?'pointer':'not-allowed'; b.textContent='▶ LANCER LA TORRÉFACTION'; if(stt)stt.textContent=(v.ok?'✅ ':'⚠️ ')+v.msg; const gt=document.getElementById('gt');if(gt)gt.value=v.total.toFixed(2); } applyPhase245(); } function applyPhase245(){ const p=currentModalPlan();if(!p)return; const finished=!!(p.startedAt&&p.endedAt); const go=document.getElementById('go'), hum=document.getElementById('hum'), den=document.getElementById('den'), col=document.getElementById('col'); /* Mesures café vert : optionnelles et saisissables avant / pendant le batch. Elles ne bloquent jamais le lancement. */ [hum,den].forEach(e=>{if(e){e.disabled=false;e.style.opacity='1';e.style.background=''}}); /* Mesures après torréfaction : disponibles seulement une fois le chrono arrêté. */ [go,col].forEach(e=>{if(e){e.disabled=!finished;e.style.opacity=finished?'1':'.5';e.style.background=finished?'':'#f1f1ed'}}); if(go)go.placeholder=finished?'SAISIR LE POIDS OBTENU':'DISPONIBLE APRÈS ARRÊT'; if(col)col.placeholder=finished?'SAISIR LA COLORIMÉTRIE':'DISPONIBLE APRÈS ARRÊT'; const val=[...document.querySelectorAll('button')].find(b=>/Valider la torréfaction/i.test(b.textContent)); if(val){val.disabled=!finished;val.style.opacity=finished?'1':'.45';val.style.cursor=finished?'pointer':'not-allowed'} } ['input','change'].forEach(ev=>document.addEventListener(ev,e=>{ if(e.target.closest?.('#lines'))setTimeout(launchState245,0) },true)); document.addEventListener('click',e=>{ if(/Ajouter café|Ajouter.*lot/i.test(e.target.textContent||'')||e.target.closest?.('#lines'))setTimeout(launchState245,30) },true); function hydrateGreenMeasures245(p){ const hum=document.getElementById('hum'),den=document.getElementById('den'); if(!p)return; if(hum&&hum.value==='')hum.value=p.greenHumidity??p.humidity??''; if(den&&den.value==='')den.value=p.greenDensity??''; } function persistGreenMeasures245(){ const p=currentModalPlan();if(!p)return; const hum=document.getElementById('hum'),den=document.getElementById('den'); p.greenHumidity=hum?hum.value:(p.greenHumidity??''); p.greenDensity=den?den.value:(p.greenDensity??''); save() } document.addEventListener('change',e=>{if(e.target&&(/^(hum|den)$/.test(e.target.id)))persistGreenMeasures245()},true); const inst245=installModalRoastTimer; installModalRoastTimer=function(p){inst245(p);setTimeout(()=>{hydrateGreenMeasures245(p);launchState245()},60)}; const act245=modalRoastTimerAction; modalRoastTimerAction=function(){persistGreenMeasures245();act245();setTimeout(()=>{refreshModalRoastTimer();launchState245()},20)}; ; /* ===== V2.5.0 : PLAN COMPLET -> SUIVI SANS DOUBLE SAISIE ===== */ function planLine250(selected='',qty=''){ const lots=D.lots.filter(l=>gs(l)>0 || l[0]===selected); return `
` } function bindPlan250(){ document.querySelectorAll('#planlines .plancomp').forEach(x=>{ const s=x.querySelector('.pls'),q=x.querySelector('.plq'); s.onchange=()=>{availPlan250(x);calcPlan250()}; q.oninput=calcPlan250;availPlan250(x) });calcPlan250() } function availPlan250(x){ const l=lt(x.querySelector('.pls').value); x.querySelector('.pla').value=l?gs(l).toFixed(2)+' kg dispo':'—' } function calcPlan250(){ const total=[...document.querySelectorAll('#planlines .plq')].reduce((a,x)=>a+(parseFloat(String(x.value).replace(',','.'))||0),0); const e=document.getElementById('pg250');if(e)e.value=total.toFixed(2) } function addPlanLine250(){document.getElementById('planlines').insertAdjacentHTML('beforeend',planLine250());bindPlan250()} function readPlanComp250(){ return [...document.querySelectorAll('#planlines .plancomp')].map(x=>({ lot:x.querySelector('.pls').value, qty:parseFloat(String(x.querySelector('.plq').value||'').replace(',','.'))||0 })).filter(x=>x.lot&&x.qty>0) } openPlan=function(editId=''){ ensureProducts(); const old=editId?D.plans.find(x=>x.id===editId):null; const d=old?.d||today(), product=old?.p||activeProductNames()[0]||'', op=old?.op||D.operators[0]||''; const comp=(old?.plannedComposition||old?.composition||[]); modal(`

${old?'MODIFIER':'PLANIFIER'} UN BATCH

PRÉPARE ICI LE BATCH COMPLET. LA COMPOSITION SERA REPRISE AUTOMATIQUEMENT DANS LE SUIVI DE TORRÉFACTION.

COMPOSITION PRÉVUE

${comp.length?comp.map(x=>planLine250(x.lot,x.qty)).join(''):planLine250()}
`); pd.onchange=()=>{if(!old)pnum.value=next(pd.value)};bindPlan250() }; function savePlan250(editId=''){ const comp=readPlanComp250(), total=comp.reduce((a,x)=>a+x.qty,0); if(!pd.value||!pp.value||!comp.length||total<=0){msg('DATE, PRODUIT ET COMPOSITION PRÉVUE OBLIGATOIRES');return} const req={};comp.forEach(x=>req[x.lot]=(req[x.lot]||0)+x.qty); for(const id in req){const l=lt(id),av=l?Math.max(0,gs(l)):0;if(req[id]>av+1e-9){msg('STOCK INSUFFISANT POUR '+(l?(l[8]||l[2]):id)+' : '+av.toFixed(2)+' KG');return}} if(editId){ const x=D.plans.find(z=>z.id===editId);if(!x)return; Object.assign(x,{d:pd.value,t:pt.value,p:pp.value,g:total,op:pop.value,plannedComposition:comp}); }else{ D.plans.push({id:'P'+Date.now(),n:next(pd.value),d:pd.value,t:pt.value,p:pp.value,g:total,op:pop.value,st:'Planifié',plannedComposition:comp}) } save();close();msg(editId?'PLAN MODIFIÉ':'BATCH PLANIFIÉ');plan() } function planCompText250(x){ const c=x.plannedComposition||[]; return c.length?c.map(z=>{const l=lt(z.lot);return `${l?cf(l[1])[1]:'LOT'} ${l?(l[8]||l[2]):z.lot} : ${Number(z.qty).toFixed(2)} KG`}).join('
'):'—' } plan=function(){ base('Plan de torréfaction','Prépare complètement les batchs pour éviter toute double saisie. Affichage du plus récent au plus ancien.',`
`); app.innerHTML+=`
${D.plans.length?`${D.plans.slice().sort((a,b)=>{const da=(a.d||'')+(a.t||'');const db=(b.d||'')+(b.t||'');if(db!==da)return db.localeCompare(da);return String(b.n||'').localeCompare(String(a.n||''),undefined,{numeric:true})}).map(x=>``).join('')}
BATCHDATE / HEUREPRODUITCOMPOSITION PRÉVUETOTALOPÉRATEURSTATUT
${x.n}${fr(x.d)} ${x.t||''}${x.p}${planCompText250(x)}${kg(x.g||0)}${x.op||'—'}${x.st}${x.st==='Planifié'?` `:''}${x.st!=='Torréfié'&&x.st!=='Annulé'?``:''}
`:'AUCUN BATCH PLANIFIÉ.'}
` }; /* Injecte la composition prévue dans le vrai formulaire de suivi. */ function injectPlanIntoRoast250(pl){ if(!pl)return; const box=document.getElementById('lines');if(!box)return; const comp=pl.frozenComposition||pl.plannedComposition||[]; if(comp.length){ box.innerHTML=comp.map(x=>line()).join(''); [...box.querySelectorAll('.line')].forEach((row,i)=>{ const x=comp[i],sel=row.querySelector('.ls'),q=row.querySelector('.lq'); if(sel&&x){sel.value=x.lot;q.value=x.qty} }); bind() } const gt=document.getElementById('gt');if(gt)gt.value=(comp.reduce((a,x)=>a+(Number(x.qty)||0),0)||pl.g||0).toFixed(2); let note=document.getElementById('planTransfer250'); if(!note){ note=document.createElement('div');note.id='planTransfer250';note.style.cssText='margin:12px 0;padding:10px;border:1px solid #d8d0bd;border-radius:10px;background:#f8f5ed'; const h=[...document.querySelectorAll('h3')].find(x=>/Composition réelle/i.test(x.textContent)); if(h)h.insertAdjacentElement('afterend',note) } if(note)note.innerHTML='✓ REPRIS DU PLAN DE TORRÉFACTION
VÉRIFIE LES LOTS ET LES KG. TU PEUX CORRIGER LE RÉEL AVANT DE LANCER.'; if(typeof forceLaunch244==='function')setTimeout(forceLaunch244,20); if(typeof launchState245==='function')setTimeout(launchState245,30) } const openRoast250=openRoast; openRoast=function(pl){ const r=openRoast250.apply(this,arguments); if(pl)setTimeout(()=>injectPlanIntoRoast250(pl),20); return r }; /* Enregistre aussi l'écart prévu/réel dans le batch final. */ const saveRoast250=saveRoast; saveRoast=function(planId){ const p=planId?D.plans.find(x=>x.id===planId):null; const before=D.roasts.length; const result=saveRoast250.apply(this,arguments); if(D.roasts.length>before){ const r=D.roasts[D.roasts.length-1]; if(p){ r.plannedGreen=Number(p.g)||0; r.plannedComposition=(p.plannedComposition||[]).map(x=>({lot:x.lot,qty:x.qty})); r.planConform=Math.abs((Number(r.g)||0)-(Number(p.g)||0))<0.001 && JSON.stringify((r.i||[]).map(x=>[x[0],Number(x[1])]))===JSON.stringify((p.plannedComposition||[]).map(x=>[x.lot,Number(x.qty)])); save() } } return result }; ; /* ===== V2.5.1 : impression réelle QR / A5 ===== */ function printCurrentA5(){ /* Marque la fenêtre modale comme seule zone à imprimer */ const modalBox=document.querySelector('#modal .box,.modal .box,#modal .modalbox,.modalbox,[role="dialog"]'); if(!modalBox){window.print();return} modalBox.classList.add('pikaro-a5-print'); document.body.classList.add('pikaro-printing-a5'); setTimeout(()=>window.print(),80); } window.addEventListener('afterprint',()=>{ document.body.classList.remove('pikaro-printing-a5'); document.querySelectorAll('.pikaro-a5-print').forEach(x=>x.classList.remove('pikaro-a5-print')) }); /* Ajoute systématiquement un bouton IMPRIMER dans la fenêtre ouverte par QR / A5 */ const label251=label; label=function(id){ const r=label251.apply(this,arguments); setTimeout(()=>{ const box=document.querySelector('#modal .box,.modal .box,#modal .modalbox,.modalbox,[role="dialog"]'); if(!box)return; if(!box.querySelector('.print-a5-251')){ const d=document.createElement('div'); d.className='right no-print'; d.style.marginTop='16px'; d.innerHTML=''; box.appendChild(d) } },20); return r }; ; /* ===== V2.5.2 : LOTS INTERNES CAFE VERT = LOT-AAAA-NNNN ===== */ function normalizeGreenLots252(){ const years={}; /* Conserve les vrais LOT-AAAA-NNNN déjà présents et repère le max annuel */ (D.lots||[]).forEach(l=>{ const v=String(l[8]||'').toUpperCase(),m=v.match(/^LOT-(\d{4})-(\d{4})$/); if(m)years[m[1]]=Math.max(years[m[1]]||0,Number(m[2])) }); /* Migre uniquement les anciens formats internes PV-AAAA-NNN / LOT-AA-NNNN. Le lot fournisseur l[2] reste strictement inchangé. */ (D.lots||[]).forEach(l=>{ const old=String(l[8]||'').toUpperCase(); if(/^LOT-\d{4}-\d{4}$/.test(old))return; const d=String(l[3]||''); const y=(d.match(/^(\d{4})-/)||[])[1] || String(new Date().getFullYear()); if(/^PV-\d{4}-\d+$/i.test(old) || /^LOT-\d{2}-\d+$/i.test(old) || !old){ years[y]=(years[y]||0)+1; l[8]='LOT-'+y+'-'+String(years[y]).padStart(4,'0') } }); saveLocal240 ? saveLocal240() : localStorage.setItem('pikaro-stock',JSON.stringify(D)); } internalLot=function(){ const y=String(new Date().getFullYear());let max=0; (D.lots||[]).forEach(l=>{const m=String(l[8]||'').match(new RegExp('^LOT-'+y+'-(\\d{4})$'));if(m)max=Math.max(max,Number(m[1]))}); return 'LOT-'+y+'-'+String(max+1).padStart(4,'0') }; nextGreenLot=internalLot; /* Corrige aussi saveRecv239 qui appelait encore l'ancien générateur selon les versions. */ const saveRecv252=saveRecv239; saveRecv239=function(){ /* nextGreenLot pointe désormais obligatoirement vers LOT-AAAA-NNNN */ return saveRecv252.apply(this,arguments) }; normalizeGreenLots252(); ; /* V2.6.0 — améliorations de lisibilité uniquement, sans modifier le métier */ (function(){ function polish(){ /* Rend les quantités et numéros plus faciles à scanner visuellement */ document.querySelectorAll('table td').forEach(td=>{ const t=(td.textContent||'').trim(); if(/^-?\d+(?:[.,]\d+)?\s*kg$/i.test(t)) td.style.fontWeight='700'; if(/^(LOT-\d{4}-\d{4}|20\d{2}-\d{2}-[A-Z]-\d{2})$/i.test(t)){ td.style.fontWeight='800'; td.style.fontVariantNumeric='tabular-nums'; } }); /* Titres de page : séparation plus nette avec le contenu */ document.querySelectorAll('h1').forEach(h=>h.style.marginTop='0'); } const obs=new MutationObserver(()=>requestAnimationFrame(polish)); obs.observe(document.body,{childList:true,subtree:true}); window.addEventListener('load',polish); setTimeout(polish,100); })(); ; /* ===== V2.6.3 : le produit du PLAN devient la référence du SUIVI ===== */ /* Répare les anciennes torréfactions liées à un plan : si batch identique, le produit affiché dans le suivi = produit du plan. */ function syncRoastProducts263(){ let changed=false; (D.roasts||[]).forEach(r=>{ const batch=String(r.n||r.batch||'').trim(); if(!batch)return; const p=(D.plans||[]).find(x=>String(x.n||'').trim()===batch); if(p && p.p && r.p!==p.p){ r.p=p.p; changed=true; } }); if(changed) save(); } /* Lorsqu'on ouvre un batch planifié, verrouille le produit du suivi sur celui défini dans le plan afin d'éviter toute divergence. */ function forcePlannedProduct263(pl){ if(!pl||!pl.p)return; const product=document.getElementById('rp') || document.getElementById('prod') || [...document.querySelectorAll('select')].find(x=>x.previousElementSibling && /Produit/i.test(x.previousElementSibling.textContent||'')); if(product){ /* Si l'option n'existe pas encore, on l'ajoute sans modifier le référentiel. */ if(![...product.options].some(o=>o.value===pl.p || o.textContent===pl.p)){ product.add(new Option(pl.p,pl.p)); } product.value=pl.p; product.disabled=true; product.title='PRODUIT REPRIS AUTOMATIQUEMENT DU PLAN DE TORRÉFACTION'; } } /* Le wrapper V2.5 injecte déjà composition et kg. On complète avec le produit. */ const inject263=injectPlanIntoRoast250; injectPlanIntoRoast250=function(pl){ inject263(pl); forcePlannedProduct263(pl); }; /* Sécurité à l'enregistrement : un batch provenant du plan conserve toujours le produit planifié. */ const saveRoast263=saveRoast; saveRoast=function(planId){ const p=planId ? D.plans.find(x=>x.id===planId) : null; const before=(D.roasts||[]).length; const result=saveRoast263.apply(this,arguments); if(p && D.roasts && D.roasts.length){ /* Retrouve prioritairement la torréfaction du même batch. */ const r=[...D.roasts].reverse().find(x=>String(x.n||x.batch||'')===String(p.n||'')) || (D.roasts.length>before ? D.roasts[D.roasts.length-1] : null); if(r && r.p!==p.p){r.p=p.p;save()} } return result }; syncRoastProducts263(); ; /* ===== V2.6.4 : déstockage = uniquement stocks > 0 + filtre Destination ===== */ /* 1) On masque systématiquement les batchs torréfiés épuisés de la zone "à déstocker". Ils restent dans les historiques/traçabilité. */ function hideZeroStockRows264(){ const headings=[...document.querySelectorAll('th')].map(x=>(x.textContent||'').trim().toUpperCase()); const stockIndex=headings.indexOf('STOCK'); if(stockIndex<0)return; document.querySelectorAll('table tr').forEach(tr=>{ const cells=[...tr.querySelectorAll('td')]; if(!cells.length || !cells[stockIndex])return; const txt=(cells[stockIndex].textContent||'').replace(',','.').replace(/[^\d.-]/g,''); const stock=parseFloat(txt); const hasDestock=[...tr.querySelectorAll('button')].some(b=>/DÉSTOCKER|DESTOCKER/i.test(b.textContent||'')); if(hasDestock && Number.isFinite(stock) && stock<=0) tr.style.display='none'; }); } /* 2) Filtre Destination dynamique dans l'historique de déstockage. Les options sont construites à partir des destinations réellement présentes. */ function installDestinationFilter264(){ const histTitle=[...document.querySelectorAll('h2,h3')].find(h=>/^HISTORIQUE$/i.test((h.textContent||'').trim())); if(!histTitle)return; const panel=histTitle.closest('.panel')||histTitle.parentElement; if(!panel || panel.querySelector('#destFilter264'))return; const table=[...panel.querySelectorAll('table')].find(t=>[...t.querySelectorAll('th')].some(th=>/DESTINATION/i.test(th.textContent||''))); if(!table)return; const ths=[...table.querySelectorAll('th')]; const destIdx=ths.findIndex(th=>/DESTINATION/i.test(th.textContent||'')); if(destIdx<0)return; const destinations=[...new Set([...table.querySelectorAll('tbody tr, tr')].map(tr=>{ const td=tr.querySelectorAll('td')[destIdx]; return td?(td.textContent||'').trim():''; }).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'fr')); const filters=[...panel.querySelectorAll('input,select')]; const anchor=filters.length?filters[filters.length-1].parentElement:null; const wrap=document.createElement('div'); wrap.style.cssText='min-width:220px;flex:1'; wrap.innerHTML=''; if(anchor && anchor.parentElement) anchor.parentElement.insertBefore(wrap,anchor.nextSibling); else panel.insertBefore(wrap,table); const sel=wrap.querySelector('select'); sel.addEventListener('change',()=>{ const wanted=sel.value.toUpperCase(); [...table.querySelectorAll('tr')].forEach(tr=>{ const cells=tr.querySelectorAll('td'); if(!cells.length)return; const d=(cells[destIdx]?.textContent||'').trim().toUpperCase(); tr.dataset.dest264 = (!wanted || d===wanted) ? 'show':'hide'; tr.style.display=tr.dataset.dest264==='show'?'':'none'; }); }); } /* Réapplique après chaque rendu de page et après un déstockage. */ function refresh264(){ hideZeroStockRows264(); installDestinationFilter264(); } const obs264=new MutationObserver(()=>setTimeout(refresh264,20)); obs264.observe(document.body,{childList:true,subtree:true}); window.addEventListener('load',()=>setTimeout(refresh264,100)); setTimeout(refresh264,100); ; /* ===== V2.7.1 — filtres ligne produit + export Excel-compatible ===== */ (function(){ function csvCell(v){return '"'+String(v??'').replace(/"/g,'""')+'"'} function downloadCSV271(filename,headers,rows){ const txt='\ufeff'+[headers,...rows].map(r=>r.map(csvCell).join(';')).join('\r\n'); const blob=new Blob([txt],{type:'text/csv;charset=utf-8;'}); const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=filename;a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),500); } function enhanceTables271(){ document.querySelectorAll('table').forEach((table,idx)=>{ const hs=[...table.querySelectorAll('thead th')].map(x=>(x.textContent||'').trim().toUpperCase()); /* cible en priorité le tableau PRODUIT/BATCHS/VERT TORRÉFIÉ/CAFÉ OBTENU/PERTE/RENDEMENT */ if(table.id==='productStats272')return; if(!(hs.includes('PRODUIT')&&hs.includes('BATCHS')&&hs.includes('RENDEMENT')))return; if(table.dataset.filters271)return; table.dataset.filters271='1'; const thead=table.querySelector('thead'); const fr=document.createElement('tr');fr.className='table-filter-row271'; hs.forEach((h,i)=>{ const th=document.createElement('th'); if(h==='PRODUIT'){ const vals=[...new Set([...table.querySelectorAll('tbody tr')].map(r=>(r.children[i]?.textContent||'').trim()).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'fr')); th.innerHTML=''; } else { th.innerHTML=''; } fr.appendChild(th); }); thead.appendChild(fr); const controls=[...fr.querySelectorAll('input,select')]; function apply(){ const rows=[...table.querySelectorAll('tbody tr')]; rows.forEach(r=>{ let ok=true; controls.forEach(c=>{ const q=(c.value||'').trim().toUpperCase(); if(!q)return; const txt=(r.children[Number(c.dataset.col)]?.textContent||'').trim().toUpperCase(); if(c.tagName==='SELECT'){if(txt!==q)ok=false}else if(!txt.includes(q))ok=false; }); r.style.display=ok?'':'none'; }); } controls.forEach(c=>c.addEventListener(c.tagName==='SELECT'?'change':'input',apply)); const toolbar=document.createElement('div');toolbar.className='table-tools271'; toolbar.innerHTML='Filtres par colonne
'; table.parentNode.insertBefore(toolbar,table); toolbar.querySelector('.reset271').onclick=()=>{controls.forEach(c=>c.value='');apply()}; toolbar.querySelector('.export271').onclick=()=>{ const headers=hs; const rows=[...table.querySelectorAll('tbody tr')].filter(r=>r.style.display!=='none').map(r=>[...r.children].map(td=>(td.textContent||'').trim())); const d=new Date().toISOString().slice(0,10); downloadCSV271('pikaro-statistiques-produits-'+d+'.csv',headers,rows); }; }); } const ob=new MutationObserver(()=>requestAnimationFrame(enhanceTables271)); ob.observe(document.body,{childList:true,subtree:true}); window.addEventListener('load',enhanceTables271);setTimeout(enhanceTables271,150); })(); ; (function(){ function isMobile290(){ return window.matchMedia('(max-width: 820px)').matches; } function enhanceTables290(){ if(!isMobile290()) return; document.querySelectorAll('#app table, #modal table').forEach(function(table){ if(table.dataset.mobile290==='1') return; var headers=[].slice.call(table.querySelectorAll('tr:first-child th')).map(function(th){return (th.textContent||'').trim()}); if(!headers.length) return; table.classList.add('pk-mobile-cards-table'); [].slice.call(table.querySelectorAll('tr')).slice(1).forEach(function(tr){ [].slice.call(tr.children).forEach(function(td,i){ td.setAttribute('data-label',headers[i]||''); }); }); table.dataset.mobile290='1'; }); } function setActive290(page){ document.querySelectorAll('.pk-mobile-nav button').forEach(function(b){ b.classList.toggle('active',b.dataset.page===page); }); } function goMobile290(page){ if(page==='more'){ modal('

PLUS

'); return; } go(page); setActive290(page); setTimeout(enhanceTables290,30); } function installNav290(){ if(!isMobile290()) return; if(document.querySelector('.pk-mobile-nav')) return; var nav=document.createElement('div'); nav.className='pk-mobile-nav'; nav.innerHTML= ''+ ''+ ''+ ''+ ''+ ''; nav.querySelectorAll('button').forEach(function(b){ b.onclick=function(){goMobile290(b.dataset.page)}; }); document.body.appendChild(nav); setActive290('home'); } var app=document.getElementById('app'); if(app){ new MutationObserver(function(){ if(isMobile290()) requestAnimationFrame(enhanceTables290); }).observe(app,{childList:true,subtree:true}); } var modalBox=document.getElementById('box'); if(modalBox){ new MutationObserver(function(){ if(isMobile290()) requestAnimationFrame(enhanceTables290); }).observe(modalBox,{childList:true,subtree:true}); } window.addEventListener('resize',function(){ installNav290(); enhanceTables290(); }); window.addEventListener('load',function(){ installNav290(); enhanceTables290(); }); setTimeout(function(){installNav290();enhanceTables290()},100); })(); ; /* ===== V2.10.0 — SAUVEGARDE, RESTAURATION & SÉCURITÉ CLOUD ===== */ const APP_VERSION='3.1.0',PK_LAST_BACKUP='pikaro-last-backup',PK_LAST_SYNC='pikaro-last-sync',PK_SNAPS='pikaro-snapshots',PK_FLASH='pikaro-flash'; let pkPendingRemote=null,pkPendingKind='conflict'; function pkSummary(d){d=d||{};const n=a=>Array.isArray(a)?a.length:0;return n(d.coffees)+' cafés · '+n(d.lots)+' lots · '+n(d.roasts)+' torréfactions · '+n(d.moves)+' sorties · '+n(d.plans)+' plans'} function pkValid(d){return !!d&&typeof d==='object'&&['coffees','lots','roasts','moves'].every(k=>Array.isArray(d[k]))} function pkT(iso){const t=new Date(iso||0).getTime();return isNaN(t)?0:t} function pkWhen(iso){if(!iso)return 'jamais';const t=new Date(iso);if(isNaN(t))return 'jamais';return t.toLocaleDateString('fr-FR')+' '+t.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'})} function pkDaysSince(iso){const t=pkT(iso);return t?Math.floor((Date.now()-t)/86400000):Infinity} function pkEsc(v){return String(v==null?'':v).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))} function pkSetSync(iso){if(iso)localStorage.setItem(PK_LAST_SYNC,iso)} /* --- Copies automatiques locales (12 max) --- */ function pkSnaps(){try{const a=JSON.parse(localStorage.getItem(PK_SNAPS)||'[]');return Array.isArray(a)?a:[]}catch(e){return[]}} function pkSnap(label,data){ try{ const src=data||D,list=pkSnaps(); list.unshift({id:'S'+Date.now()+Math.floor(Math.random()*1000),label:label,at:new Date().toISOString(),sum:pkSummary(src),data:JSON.parse(JSON.stringify(src))}); const keep=list.slice(0,12); while(keep.length){try{localStorage.setItem(PK_SNAPS,JSON.stringify(keep));return true}catch(e){keep.pop()}} }catch(e){} return false } function pkAutoSnap(){ const day=new Date().toISOString().slice(0,10); if(pkSnaps().some(x=>x.label==='auto'&&String(x.at).slice(0,10)===day))return; if(pkValid(D)&&(D.roasts.length+D.lots.length+D.moves.length)>0)pkSnap('auto',D) } /* --- Export / import --- */ function pkDownload(name,text){const b=new Blob([text],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;document.body.appendChild(a);a.click();setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove()},500)} function exportBackup(silent){ const p={app:'PIKARO-STOCK',version:APP_VERSION,exportedAt:new Date().toISOString(),data:D}; pkDownload('pikaro-stock-sauvegarde-'+today()+'.json',JSON.stringify(p,null,1)); localStorage.setItem(PK_LAST_BACKUP,p.exportedAt); if(!silent)msg('Sauvegarde téléchargée'); pkBanner() } function pkReadBackup(text){const j=JSON.parse(text),d=(j&&j.app==='PIKARO-STOCK'&&j.data)?j.data:j;if(!pkValid(d))throw new Error('format');return{data:d,at:j.exportedAt||null}} function restoreFromFile(inp){ const f=inp.files&&inp.files[0];if(!f)return; const r=new FileReader(); r.onload=()=>{try{const b=pkReadBackup(String(r.result));pkConfirmRestore(b.data,'le fichier « '+f.name+' »'+(b.at?' (exporté le '+pkWhen(b.at)+')':''))}catch(e){msg('Fichier invalide : ce n’est pas une sauvegarde PIKARO STOCK')}inp.value=''}; r.onerror=()=>msg('Lecture du fichier impossible'); r.readAsText(f) } function pkConfirmRestore(data,label){ if(!confirm('Restaurer '+label+' ?\n\nContenu : '+pkSummary(data)+'\nActuellement : '+pkSummary(D)+'\n\nVos données actuelles sont d’abord mises de côté automatiquement.'))return; pkSnap('avant-restauration',D); D=JSON.parse(JSON.stringify(data));D._meta=D._meta||{}; save(); sessionStorage.setItem(PK_FLASH,'Données restaurées : '+pkSummary(D)); location.reload() } function restoreSnap(id){const s=pkSnaps().find(x=>x.id===id);if(!s){msg('Copie introuvable');return}pkConfirmRestore(s.data,'la copie « '+pkSnapLabel(s.label)+' » du '+pkWhen(s.at))} function pkSnapLabel(l){return({'auto':'Automatique','avant-restauration':'Avant une restauration','avant-reinitialisation':'Avant réinitialisation','avant-reprise-cloud':'Version locale avant reprise du cloud','cloud-ecrase':'Version cloud avant écrasement'})[l]||l} /* --- Réinitialisation : confirmation forte + sauvegarde préalable --- */ resetDemo=function(){ const t=prompt('⚠️ Cette action EFFACE toutes les données de cet appareil.\nUne sauvegarde est téléchargée juste avant.\n\nTape EFFACER pour confirmer :'); if(t===null)return; if(String(t).trim().toUpperCase()!=='EFFACER'){msg('Réinitialisation annulée');return} exportBackup(true);pkSnap('avant-reinitialisation',D); localStorage.removeItem(K);localStorage.removeItem(PK_LAST_SYNC); setTimeout(()=>location.reload(),700) }; /* --- Centre de sauvegarde --- */ function backupCenter(){ const snaps=pkSnaps(); modal('

💾 SAUVEGARDE

'+ '

Données actuelles : '+pkEsc(pkSummary(D))+'
Dernière sauvegarde téléchargée : '+pkEsc(pkWhen(localStorage.getItem(PK_LAST_BACKUP)))+'

'+ '
'+ '
'+ '

Copies automatiques de cet appareil

'+ (snaps.length?snaps.map(s=>'
'+pkEsc(pkSnapLabel(s.label))+'
'+pkEsc(pkWhen(s.at))+' — '+pkEsc(s.sum)+'
').join(''):'

Aucune copie pour le moment (une copie est créée automatiquement chaque jour d’utilisation).

')+ '

Synchronisation cloud

'+ '

Zone sensible

') } /* --- Rappel hebdomadaire sur l'accueil --- */ function pkBanner(){ const old=document.getElementById('pkBackupBanner');if(old)old.remove(); const d=pkDaysSince(localStorage.getItem(PK_LAST_BACKUP));if(d<7)return; const host=document.getElementById('app');if(!host||!host.querySelector('.cards')||!host.querySelector('.sub'))return; host.querySelector('.sub').insertAdjacentHTML('afterend','
⚠️ '+(d===Infinity?'Aucune sauvegarde téléchargée à ce jour.':'Dernière sauvegarde il y a '+d+' jours.')+'
Les données sont uniquement dans ce navigateur : sauvegarde-les chaque semaine.
') } const pkHomeBase=home; home=function(){const r=pkHomeBase.apply(this,arguments);try{pkBanner()}catch(e){}return r}; /* --- Réception : alerte doublon de lot fournisseur --- */ const pkSaveRecvBase=saveRecv239; saveRecv239=function(){ const c=(document.getElementById('rc')||{}).value,lot=((document.getElementById('rl')||{}).value||'').trim().toLocaleUpperCase('fr-FR'); if(c&&lot&&D.lots.some(l=>l[1]===c&&String(l[2]).toLocaleUpperCase('fr-FR')===lot)){ if(!confirm('Ce lot fournisseur existe déjà pour ce café.\nL’enregistrer quand même (doublon) ?'))return } return pkSaveRecvBase.apply(this,arguments) }; /* --- Cloud : plus d'écrasement silencieux --- */ async function pkRemote(){ const r=await fetch(cloudEndpoint()+'?id=eq.'+encodeURIComponent(cloudCfg.row||'main')+'&select=state,updated_at',{headers:cloudHeaders()}); if(!r.ok)throw new Error('HTTP '+r.status); const rows=await r.json(); if(rows&&rows.length&&rows[0].state)return{exists:true,state:rows[0].state,updated_at:rows[0].updated_at||(rows[0].state._meta&&rows[0].state._meta.updatedAt)||null}; return{exists:false} } function pkLocalChanged(){return pkT(D._meta&&D._meta.updatedAt)>pkT(localStorage.getItem(PK_LAST_SYNC))+1000} function pkRemoteChanged(rem){return pkT(rem.updated_at)>pkT(localStorage.getItem(PK_LAST_SYNC))+1000} function pkClock(){return new Date().toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'})} function pkAdopt(rem,msgTxt){ pkSnap('avant-reprise-cloud',D); D=rem.state;if(!D.plans)D.plans=[];if(!D.operators)D.operators=['Adrien','Thomas','Angelo']; saveLocal240();pkSetSync(rem.updated_at);pkPendingRemote=null; syncState='🟢 '+msgTxt+' À '+pkClock() } function pkConflict(rem,kind){ pkPendingRemote=rem;pkPendingKind=kind||'conflict'; syncState='🟠 CONFLIT CLOUD — TOUCHER POUR RÉSOUDRE';refreshSyncBadges(); if(!modalEl.classList.contains('open'))pkConflictModal() } function pkConflictModal(){ const rem=pkPendingRemote;if(!rem)return; const first=pkPendingKind==='first'; modal('

⚠️ '+(first?'LE CLOUD CONTIENT DÉJÀ DES DONNÉES':'CONFLIT DE SYNCHRONISATION')+'

'+ '

'+(first?'Avant de connecter cet appareil, choisis quelle version garder.':'Le cloud a été modifié depuis un autre appareil et cet appareil a aussi des modifications non synchronisées.')+' Rien n’est perdu : la version écartée reste dans les copies automatiques.

'+ '

Cet appareil — modifié le '+pkEsc(pkWhen(D._meta&&D._meta.updatedAt))+'
'+pkEsc(pkSummary(D))+'

'+ '

Cloud — modifié le '+pkEsc(pkWhen(rem.updated_at))+'
'+pkEsc(pkSummary(rem.state))+'

'+ '
') } function pkTakeCloud(){const rem=pkPendingRemote;if(!rem)return;pkAdopt(rem,'CLOUD RÉCUPÉRÉ');refreshSyncBadges();close();go('home')} async function pkKeepLocal(){ const rem=pkPendingRemote;if(!rem)return; pkSnap('cloud-ecrase',rem.state); D._meta=D._meta||{};D._meta.updatedAt=new Date().toISOString();saveLocal240(); pkPendingRemote=null;close(); await pushCloud(true);home() } pullCloud=async function(force){ if(!cloudConfigured()||!navigator.onLine||syncBusy)return; syncBusy=true;syncState='🔄 VÉRIFICATION CLOUD…';refreshSyncBadges(); let conflict=null,took=false; try{ const rem=await pkRemote(); if(rem.exists){ const known=localStorage.getItem(PK_LAST_SYNC),rt=pkT(rem.updated_at),lt=pkT(D._meta&&D._meta.updatedAt); if(!known){ if(rt>lt+1000){pkAdopt(rem,'CLOUD RÉCUPÉRÉ');took=true}else pkSetSync(rem.updated_at) }else if(force===true&&pkLocalChanged()){conflict=rem} else if(force===true||pkRemoteChanged(rem)){ if(!force&&pkLocalChanged())conflict=rem;else{pkAdopt(rem,'CLOUD RÉCUPÉRÉ');took=true} } } if(!took&&!conflict)syncState='🟢 À JOUR — '+pkClock() }catch(e){syncState='🔴 SYNCHRO IMPOSSIBLE — LOCAL CONSERVÉ'} finally{syncBusy=false;refreshSyncBadges()} if(conflict)pkConflict(conflict); else if(took)go('home') }; pushCloud=async function(force){ if(!cloudConfigured()||!navigator.onLine||syncBusy)return; syncBusy=true;syncState='🔄 SYNCHRONISATION…';refreshSyncBadges(); let conflict=null; try{ if(force!==true){ const rem=await pkRemote(); if(rem.exists){ const known=localStorage.getItem(PK_LAST_SYNC); if(known?pkRemoteChanged(rem):pkT(rem.updated_at)>pkT(D._meta&&D._meta.updatedAt)+1000)conflict=rem } } if(!conflict){ const body={id:cloudCfg.row||'main',state:D,updated_at:(D._meta&&D._meta.updatedAt)||new Date().toISOString()}; const r=await fetch(cloudEndpoint(),{method:'POST',headers:cloudHeaders(),body:JSON.stringify(body)}); if(!r.ok)throw new Error('HTTP '+r.status); pkSetSync(body.updated_at); syncState='🟢 SYNCHRONISÉ À '+pkClock() } }catch(e){syncState='🔴 SYNCHRO IMPOSSIBLE — LOCAL CONSERVÉ'} finally{syncBusy=false;refreshSyncBadges()} if(conflict)pkConflict(conflict) }; saveCloudSettings=async function(){ cloudCfg={url:document.getElementById('cu').value.trim(),key:document.getElementById('ck').value.trim(),row:(document.getElementById('cr').value.trim()||'main')}; localStorage.setItem(CLOUD_KEY,JSON.stringify(cloudCfg));localStorage.removeItem(PK_LAST_SYNC); close(); if(!cloudConfigured()){home();return} syncBusy=true;syncState='🔄 VÉRIFICATION CLOUD…';refreshSyncBadges(); let rem=null,err=false; try{rem=await pkRemote()}catch(e){err=true} syncBusy=false; if(err){syncState='🔴 SYNCHRO IMPOSSIBLE — LOCAL CONSERVÉ';refreshSyncBadges();home();return} if(rem&&rem.exists){ if(rem.state._meta&&D._meta&&rem.state._meta.updatedAt&&rem.state._meta.updatedAt===D._meta.updatedAt){pkSetSync(rem.updated_at);syncState='🟢 À JOUR — '+pkClock();refreshSyncBadges();home();return} pkConflict(rem,'first');return } await pushCloud();home() }; /* clic sur le badge de synchro en conflit = rouvre la fenêtre de résolution */ document.addEventListener('click',e=>{const b=e.target&&e.target.closest&&e.target.closest('.sync-badge');if(b&&pkPendingRemote)pkConflictModal()}); /* --- Démarrage --- */ (function(){ function boot(){ try{pkAutoSnap()}catch(e){} try{pkBanner()}catch(e){} const f=sessionStorage.getItem(PK_FLASH); if(f){sessionStorage.removeItem(PK_FLASH);setTimeout(()=>{msg(f);if(cloudConfigured())pushCloud()},700)} } if(document.readyState==='complete')boot();else window.addEventListener('load',boot) })(); /* ===== PIKARO STOCK V3.1 — COUCHE STABLE CONSOLIDÉE ===== */ (function(){ function mobileNavActiveV3(page){ document.querySelectorAll('.pk-mobile-nav button').forEach(function(b){b.classList.toggle('active',b.dataset.page===page)}) } /* Routeur unique : mêmes écrans, activation cohérente desktop/mobile. */ go=function(page){ document.querySelectorAll('nav button').forEach(function(b){b.classList.remove('active')}); const pages=['home','recv','plan','roast','stock','out','trace']; const idx=pages.indexOf(page), desktop=[...document.querySelectorAll('nav button')]; if(idx>=0&&desktop[idx])desktop[idx].classList.add('active'); const fn={home:home,recv:recv,plan:plan,roast:roast,stock:stock,out:out,trace:trace}[page]; if(typeof fn==='function')fn(); mobileNavActiveV3(page); if(typeof addProductRefButton==='function')setTimeout(addProductRefButton,0) }; /* Tableau de bord consolidé : évite l'empilement des anciennes surcharges HOME. */ home=function(){ base('Tableau de bord','Vue atelier'); const activeLots=(D.lots||[]).filter(l=>gs(l)>0).length; const latest=(D.roasts||[]).slice().sort((a,b)=>String(b.d||'').localeCompare(String(a.d||''))||String(b.n||'').localeCompare(String(a.n||''))).slice(0,5); const alerts=typeof pikaroAlerts==='function'?pikaroAlerts():[]; const running=(D.plans||[]).filter(p=>p.startedAt&&!p.endedAt&&p.st!=='Torréfié'&&p.st!=='Annulé'); app.innerHTML+=`
Café vert
${kg(tg())}
VOIR LE DÉTAIL →
Café torréfié
${kg(tr())}
VOIR LE DÉTAIL →
Lots actifs
${activeLots}
VOIR LE DÉTAIL →
Torréfactions
${(D.roasts||[]).length}
VOIR LE DÉTAIL →
`; if(running.length){ app.innerHTML+=`

🔥 TORRÉFACTION EN COURS

${running.length} batch${running.length>1?'s':''} actuellement lancé${running.length>1?'s':''}
${running.map(p=>`

${p.n} — ${p.p} —

`).join('')}
` } app.innerHTML+=`

⚠️ À SURVEILLER

Stocks, rendement, ancienneté et planning.
${alerts.length?alerts.slice(0,3).map(a=>`

${a.level==='red'?'🔴':'🟠'} ${a.title} — ${a.text}

`).join(''):'

✅ AUCUN POINT CRITIQUE ACTUELLEMENT.

'}

Dernières torréfactions

${latest.length?latest.map(r=>`

${r.n} — ${r.p} — ${kg(r.rem)}

`).join(''):'

Aucune torréfaction.

'}
`; const a=homePeriodRows(),st=roastStats(a); app.innerHTML+=`

🔥 PRODUCTION DÉTAILLÉE

CHOISIS UNE PÉRIODE PUIS CLIQUE SUR UNE CARTE.

${[['today','AUJOURD’HUI'],['week','CETTE SEMAINE'],['month','CE MOIS'],['year','CETTE ANNÉE'],['all','TOUT']].map(x=>``).join('')}
CAFÉ VERT TORRÉFIÉ
${kg(st.green)}
VOIR LE DÉTAIL →
CAFÉ TORRÉFIÉ PRODUIT
${kg(st.roasted)}
VOIR LE DÉTAIL →
PERTE TORRÉFACTION
${kg(st.loss)} (${st.lossPct.toFixed(1)}%)
VOIR LE DÉTAIL →
NOMBRE DE BATCHS
${st.n}
VOIR LE DÉTAIL →

PRODUCTION PAR CAFÉ

${productStatsHtml(a)}
`; app.innerHTML+=`

☁️ SYNCHRONISATION

${syncLabel()}

À L’ATELIER : SAISIE LOCALE MÊME SANS INTERNET. À DISTANCE : LES DONNÉES CLOUD SONT DISPONIBLES DÈS QUE L’ATELIER SE RESYNCHRONISE.

`; try{pkBanner()}catch(e){};refreshSyncBadges(); }; /* Suivi consolidé : statut EN COURS cliquable + historique modifiable. */ roast=function(){ base('Suivi torréfaction','Batchs planifiés et historique filtrable.','
'); const pending=(D.plans||[]).filter(x=>x.st==='Planifié'||x.st==='En cours').slice().sort((a,b)=>(String(b.d||'')+String(b.t||'')).localeCompare(String(a.d||'')+String(a.t||''))).map(x=>`${x.n}${fr(x.d)} ${x.t||''}${x.p}${kg(x.g)}${x.op||'—'}${x.st==='En cours'?``:``}`).join(''); const hist=filteredRoasts().slice().sort((a,b)=>String(b.d||'').localeCompare(String(a.d||''))||String(b.n||'').localeCompare(String(a.n||''))).map(r=>{const gh=r.greenHumidity??r.humidity??'',gd=r.greenDensity??'',co=r.colorimetry??'';return `${r.n}${fr(r.d)}${r.p}${r.op||'—'}${r.duration||'—'}${gh!==''?gh+' %':'—'}${gd!==''?gd+' g/L':'—'}${co!==''?co:'—'}${r.g?(r.o/r.g*100).toFixed(1)+'%':'—'}${kg(r.rem)} `}).join(''); app.innerHTML+=`

Batchs planifiés à torréfier

${pending||''}
N°DateProduitPrévuOpérateurAction
Aucun batch planifié en attente.

Historique

${hist||''}
N°DateProduitOpérateurDuréeHumidité vertDensité vertColorimétrieRendementStockActions
Aucune torréfaction.
`; }; /* État de stock consolidé : un seul rendu. */ stock=function(){ base('État de stock','État physique des cafés actuellement disponibles.'); const origins=[...new Set(D.coffees.map(c=>c[2]))].sort(),greenRows=greenStockRows(),roastedRows=filteredRoasts().filter(r=>(Number(r.rem)||0)>0); app.innerHTML+=`
Stock café vert
${kg(tg())}
Stock café torréfié
${kg(tr())}
Lots verts actifs
${D.lots.filter(l=>gs(l)>0).length}
Batchs torréfiés en stock
${D.roasts.filter(r=>(Number(r.rem)||0)>0).length}

🌱 Café vert

Stock physique par lot
${greenRows.length?greenRows.map(l=>``).join(''):''}
CAFÉORIGINELOT PIKAROLOT FOURNISSEURREÇUCONSOMMÉSTOCK RESTANT
${cf(l[1])[1]}${cf(l[1])[2]}${l[8]||'—'}${l[2]||'—'}${kg(l[4])}${kg(used(l[0]))}${kg(gs(l))}
Aucun lot correspondant aux filtres.

🔥 Café torréfié

Stock physique restant par batch
${roastedRows.length?roastedRows.map(r=>{const produced=Number(r.o)||0,rem=Number(r.rem)||0,out=Math.max(0,produced-rem);return ``}).join(''):''}
PRODUITBATCHTORRÉFIÉDÉSTOCKÉSTOCK RESTANTDATE
${r.p}${r.n}${kg(produced)}${kg(out)}${kg(rem)}${fr(r.d)}
Aucun batch correspondant aux filtres.
`; }; /* Déstockage consolidé, avec destination dans le filtre et dans l'export. */ if(F.move.dest===undefined)F.move.dest=''; out=function(){ base('Déstockage','Sorties avec opérateur, commentaire et historique filtrable.'); const active=(D.roasts||[]).filter(r=>(Number(r.rem)||0)>0).map(r=>`${r.p}${r.n}${kg(r.rem)}`).join(''); const destinations=[...new Set((D.moves||[]).map(m=>m.t).filter(Boolean))].sort(); const moves=filteredMoves().filter(m=>!F.move.dest||m.t===F.move.dest).slice().reverse(); const hist=moves.map((m,i)=>{const r=D.roasts.find(x=>x.id===m.r)||{},c=moveComment(m);return `${fr(m.d)} ${m.time||''}${r.n||'—'}${r.p||'—'}${kg(m.q)}${m.t||'—'}${m.by||'Ancien mouvement'}${c||'—'}${c?``:''}`}).join(''); app.innerHTML+=`
${active||''}
PRODUITBATCHSTOCK
Aucun stock torréfié disponible.

Historique

${hist||''}
DATEBATCHPRODUITQUANTITÉDESTINATIONPARCOMMENTAIRE / RÉFÉRENCE
Aucun déstockage correspondant aux filtres.
`; }; exportMoves=function(){ const rows=filteredMoves().filter(m=>!F.move.dest||m.t===F.move.dest); csvDownload('pikaro_destockages.csv',['Date','Heure','Batch','Produit','Quantité kg','Destination','Déstocké par','Commentaire / référence'],rows.map(m=>{const r=D.roasts.find(x=>x.id===m.r)||{};return[m.d,m.time||'',r.n||'',r.p||'',m.q,m.t||'',m.by||'',moveComment(m)]})) }; /* Normalisation non destructive des conteneurs de données. */ D.coffees=Array.isArray(D.coffees)?D.coffees:[];D.lots=Array.isArray(D.lots)?D.lots:[];D.roasts=Array.isArray(D.roasts)?D.roasts:[];D.moves=Array.isArray(D.moves)?D.moves:[];D.plans=Array.isArray(D.plans)?D.plans:[];D.operators=Array.isArray(D.operators)&&D.operators.length?D.operators:['Adrien','Thomas','Angelo']; ensureProducts();save(); document.title='PIKARO STOCK V3.5'; home(); })(); /* ===== PIKARO STOCK V3.5 — CAFÉS VERTS EN STOCK AJOUTÉS AUTOMATIQUEMENT ===== */ (function(){ function escV32(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})} function planQrPayloadV32(p){return 'PIKARO|BATCH='+p.n+'|PRODUIT='+p.p} function tempLabelMarkupV32(p,qrId){ return '
'+ '
PIKARO
'+ '
IDENTIFICATION ATELIER
'+ '
'+escV32(p.p)+'
'+ '
'+escV32(p.n)+'
'+ '
'+escV32(fr(p.d))+(p.t?' · '+escV32(p.t):'')+'
'+ '
POIDS NET__________ kg
'+ '
'+ '
À COMPLÉTER APRÈS TORRÉFACTION
'+ '
' } window.openPlanTempLabel=function(id){ const p=(D.plans||[]).find(x=>x.id===id);if(!p){msg('Batch planifié introuvable');return} modal('

🏷 Étiquette atelier avant torréfaction

'+tempLabelMarkupV32(p,'pkTempQrPreviewV32')+ '

Préimprime cette étiquette avant la production. À la sortie du torréfacteur, écris simplement le poids net au feutre. Le QR identifie le batch et fonctionnera ensuite pour retrouver la torréfaction enregistrée.

'+ '
'); setTimeout(function(){ const el=document.getElementById('pkTempQrPreviewV32'); if(!el)return; if(typeof QRCode==='undefined'){el.innerHTML='QR indisponible';return} new QRCode(el,{text:planQrPayloadV32(p),width:150,height:150,correctLevel:QRCode.CorrectLevel.M}) },40) }; function popupHeadV32(title,pageCss){ return ''+escV32(title)+''+ '