Hedra – Teacher Edition
الحصة: — الدورة: — الدور: مدير v6.1.0-pro
الحصة
قائمة الطلاب + الدفع + الحضور.
"); w.document.close(); }; function renderAttendance(g,c,s){ const wrap=$("attList"); wrap.innerHTML=""; if(!c.sessions.length){ wrap.innerHTML='
لا توجد حصص بعد.
'; return; } c.sessions.forEach(ss=>{ const y=ymdFromTs(ss.tsISO); const val=c.attendance?.[ss.id]?.[s.id]||""; const label= val==="P" ? "حضور ✅" : val==="A" ? "غياب ❌" : "—"; const row=document.createElement("div"); row.className="row"; row.innerHTML=`
🗓
${fmtDMY(y)}
${y}
`; row.querySelector("button").onclick=async ()=>{ if(role===ROLE.VIEW) return toast("عرض فقط"); c.attendance=c.attendance||{}; c.attendance[ss.id]=c.attendance[ss.id]||{}; const cur=c.attendance[ss.id][s.id]||""; const next = cur==="P" ? "A" : cur==="A" ? "" : "P"; if(!next) delete c.attendance[ss.id][s.id]; else c.attendance[ss.id][s.id]=next; auditAdd("attendance.toggle",{studentId:s.id, ymd:y, val:next||"—"}); await save(); openPay(g,c,s); render(); }; wrap.appendChild(row); }); } // audit / receipts overlays const closeOverlay=(id)=>$(id).style.display="none"; const openOverlay=(id)=>$(id).style.display="block"; $("btnQRStop").onclick=stopQRScanner; $("qrOverlay").addEventListener("click",(e)=>{ if(e.target===$("qrOverlay")) stopQRScanner(); }); $("btnAudit").onclick=()=>{ if(role!==ROLE.ADMIN) return toast("للـ مدير فقط"); const g=curGroup(); $("auditText").textContent=(g.audit||[]).slice(-500).reverse().map(a=>a.tsISO+" | "+a.role+" | "+a.action+" | "+JSON.stringify(a.meta||{})).join("\n")||"—"; openOverlay("auditOverlay"); }; $("btnAuditClose").onclick=()=>closeOverlay("auditOverlay"); $("auditOverlay").addEventListener("click",(e)=>{ if(e.target===$("auditOverlay")) closeOverlay("auditOverlay"); }); $("btnAuditCSV").onclick=()=>{ downloadText("Hedra_Audit_"+isoYMD(new Date())+".csv", auditCSV(), "text/csv;charset=utf-8"); toast("تم التصدير"); }; $("btnReceipts").onclick=()=>{ if(role!==ROLE.ADMIN) return toast("للـ مدير فقط"); const list=allReceipts().slice(0,250); const wrap=$("rcList"); if(!list.length) wrap.innerHTML='
لا توجد إيصالات.
'; else wrap.innerHTML=list.map(x=>`
🧾
${escapeHtml(x.r.rid)} • ${escapeHtml(x.r.studentName)}
${escapeHtml(x.g.name)} • ${x.r.amount}
`).join(""); wrap.querySelectorAll("button[data-rid]").forEach(b=>b.onclick=()=>{ const rid=b.getAttribute("data-rid"); const f=allReceipts().find(x=>x.r.rid===rid); if(!f) return; const w=window.open("","_blank"); w.document.write(""+rid+""+receiptPrintHTML(f.r,f.g.name)+""); w.document.close(); }); openOverlay("rcOverlay"); }; $("btnRcClose").onclick=()=>closeOverlay("rcOverlay"); $("rcOverlay").addEventListener("click",(e)=>{ if(e.target===$("rcOverlay")) closeOverlay("rcOverlay"); }); $("btnRcCSV").onclick=()=>{ downloadText("Hedra_Receipts_"+isoYMD(new Date())+".csv", receiptsCSV(), "text/csv;charset=utf-8"); toast("تم التصدير"); }; // Export / Import (encrypted backup) $("btnExport").onclick=async ()=>{ if(role!==ROLE.ADMIN) return toast("غير مسموح"); const pass=prompt("كلمة مرور التصدير (4+ أحرف/أرقام)"); if(!pass||String(pass).trim().length<4) return; const salt=crypto.getRandomValues(new Uint8Array(16)); const key=await deriveKeyFromPIN(String(pass).trim(), salt.buffer); const packed=await encryptJSON(key, {type:"hedra_export", ver:"6.0-teacher", exportedAt:new Date().toISOString(), db}); const out={type:"hedra_export", v:2, saltB64:bufToB64(salt.buffer), ...packed}; downloadText("Hedra_V6_Teacher_Encrypted_Backup_"+isoYMD(new Date())+".json", JSON.stringify(out,null,2), "application/json;charset=utf-8"); toast("تم التصدير"); }; $("btnImport").onclick=()=>{ if(role!==ROLE.ADMIN) return toast("غير مسموح"); const inp=document.createElement("input"); inp.type="file"; inp.accept="application/json"; inp.onchange=()=>{ const f=inp.files?.[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ (async ()=>{ try{ const parsed=JSON.parse(String(r.result||"")); let next=null; if(parsed?.type==="hedra_export" && parsed?.v===2){ const pass=prompt("كلمة مرور النسخة الاحتياطية"); if(!pass) return; const key=await deriveKeyFromPIN(String(pass).trim(), b64ToBuf(parsed.saltB64)); next=await decryptJSON(key, parsed.ivB64, parsed.ctB64); next=next?.db ?? next; } else { next=parsed; } db=ensureDefaults(next); await save(); toast("تم الاستيراد"); render(); }catch(e){ alert("فشل الاستيراد"); } })(); }; r.readAsText(f); }; inp.click(); }; // Change PIN (admin only) $("btnChangePIN").onclick=async ()=>{ if(role!==ROLE.ADMIN) return toast("للـ مدير فقط"); if(!sessionKey) return toast("افتح أولاً"); const old=prompt("PIN الحالي"); if(!old) return; try{ // verify old by trying to decrypt vault const raw=localStorage.getItem(DB_KEY); if(!raw) return toast("لا توجد خزنة"); const v=JSON.parse(raw); const key=await deriveKeyFromPIN(String(old).trim(), b64ToBuf(v.saltB64)); await decryptJSON(key, v.ivB64, v.ctB64); // verify const np=prompt("PIN جديد (4+ أرقام)"); if(!np||String(np).trim().length<4) return; // re-encrypt with new salt const salt=crypto.getRandomValues(new Uint8Array(16)); saltB64=bufToB64(salt.buffer); sessionKey=await deriveKeyFromPIN(String(np).trim(), salt.buffer); await save(); toast("تم تغيير PIN"); auditAdd("security.pin.change",{}); await save(); }catch(e){ toast("PIN الحالي خطأ"); } }; // Lock / Unlock const openLock=(msg)=>{ $("lockHint").textContent=msg; $("lockOverlay").style.display="block"; }; const closeLock=()=>{ $("lockOverlay").style.display="none"; $("pinInput").value=""; }; $("btnLockClear").onclick=()=>$("pinInput").value=""; $("roleAdmin").onclick=()=>{ role=ROLE.ADMIN; toast("مدير"); renderTop(); }; $("roleAssistant").onclick=()=>{ role=ROLE.ASSISTANT; toast("مساعد"); renderTop(); }; $("roleView").onclick=()=>{ role=ROLE.VIEW; toast("عرض فقط"); renderTop(); }; $("btnUnlock").onclick=async ()=>{ setUnlocked(true); $("lockOverlay").style.display="none"; render(); }; const btn=$("btnUnlock"); btn.disabled=true; btn.style.opacity="0.7"; try{ const pin=String($("pinInput").value||"").trim(); if(pin.length<4) return toast("PIN قصير"); const now=Date.now(); const lockUntil=Number(db.settings.pinLockUntil||0); if(lockUntil && now=5) { db.settings.pinLockUntil = Date.now()+30000; db.settings.pinFailCount=0; toast("قفل 30 ثانية"); } else toast("PIN خطأ"); $("pinInput").value=""; } finally { btn.disabled=false; btn.style.opacity="1"; } }; // auto-lock (10 min) + wipe memory ["click","keydown","touchstart","mousemove"].forEach(ev=>window.addEventListener(ev,()=>touch(),{passive:true})); // PWA install let deferred=null; window.addEventListener("beforeinstallprompt",(e)=>{ e.preventDefault(); deferred=e; $("btnInstall").style.display="inline-flex"; }); $("btnInstall").onclick=async ()=>{ if(!deferred) return alert("من القائمة ⋮ ثم Install"); deferred.prompt(); await deferred.userChoice; deferred=null; $("btnInstall").style.display="none"; }; // SW if("serviceWorker" in navigator) navigator.serviceWorker.register("sw.js?v="+BUILD); // PRO: Assistant vault (true privacy: no txs/prices/audit) async function makeAssistantSnapshot(){ if(role!==ROLE.ADMIN) return toast("للـ مدير فقط"); const ap=prompt("PIN المساعد (4+ أرقام) لإنشاء نسخة بدون أرباح"); if(!ap||String(ap).trim().length<4) return; const salt=crypto.getRandomValues(new Uint8Array(16)); const key=await deriveKeyFromPIN(String(ap).trim(), salt.buffer); const red=deepClone(db); // redact money-sensitive fields red.groups.forEach(g=>{ g.audit=[]; g.students.forEach(s=>{ s.price=0; s.discountType="none"; s.discountValue=0; }); g.cycles.forEach(c=>{ c.txs=[]; c.receipts=[]; c.carry={}; // hide carry balances }); }); const packed=await encryptJSON(key, {type:"hedra_assistant", ver:"6.1-pro", createdAt:new Date().toISOString(), db:red}); localStorage.setItem(DB_KEY_ASSIST, JSON.stringify({type:"hedra_assistant", v:1, saltB64:bufToB64(salt.buffer), ...packed})); db.settings.assistant.enabled=true; await save(); // save admin vault (flag only) toast("تم إنشاء نسخة المساعد"); auditAdd("assistant.snapshot",{}); await save(); } async function loadAssistant(pin){ const raw=localStorage.getItem(DB_KEY_ASSIST); if(!raw) throw new Error("NO_ASSIST_VAULT"); const v=JSON.parse(raw); const key=await deriveKeyFromPIN(String(pin||"").trim(), b64ToBuf(v.saltB64)); const loaded=ensureDefaults(await decryptJSON(key, v.ivB64, v.ctB64)); // assistant mode uses its own sessionKey to save assistant vault only (no admin vault access) sessionKey=key; saltB64=v.saltB64; db=loaded; return db; } async function saveAssistantVault(){ if(role===ROLE.ASSISTANT || role===ROLE.VIEW){ const packed=await encryptJSON(sessionKey, db); localStorage.setItem(DB_KEY_ASSIST, JSON.stringify({type:"hedra_assistant", v:1, saltB64, ...packed})); } } // PRO: Cloud Sync (uploads encrypted vault JSON as-is) async function cloudUpload(){ const prov=db.settings.cloud.provider; const token=String(db.settings.cloud.token||"").trim(); const fileName=String(db.settings.cloud.fileName||"HedraVault.json").trim()||"HedraVault.json"; if(prov==="none") return toast("اختر مزود"); if(!token) return alert("ضع Access Token في الإعدادات"); // pick correct local payload (admin or assistant) const payload = (role===ROLE.ADMIN) ? localStorage.getItem(DB_KEY) : localStorage.getItem(DB_KEY_ASSIST); if(!payload) return alert("لا توجد خزنة لرفعها"); const blob=new Blob([payload],{type:"application/json"}); if(prov==="dropbox"){ const res=await fetch("https://content.dropboxapi.com/2/files/upload",{ method:"POST", headers:{ "Authorization":"Bearer "+token, "Dropbox-API-Arg": JSON.stringify({path:"/"+fileName, mode:"overwrite", autorename:false, mute:true}), "Content-Type":"application/octet-stream" }, body: await blob.arrayBuffer() }); if(!res.ok) throw new Error("DROPBOX_UPLOAD_FAIL"); toast("تم الرفع على Dropbox"); return; } if(prov==="drive"){ // Drive: upload to root (creates/updates by name) // We do: search by name, then update or create. const q=encodeURIComponent(`name='${fileName.replace(/'/g,"\\'")}' and trashed=false`); const search=await fetch("https://www.googleapis.com/drive/v3/files?q="+q+"&fields=files(id,name)",{ headers:{Authorization:"Bearer "+token} }); if(!search.ok) throw new Error("DRIVE_SEARCH_FAIL"); const js=await search.json(); const fileId=js.files && js.files[0] && js.files[0].id; if(fileId){ const up=await fetch("https://www.googleapis.com/upload/drive/v3/files/"+fileId+"?uploadType=media",{ method:"PATCH", headers:{Authorization:"Bearer "+token, "Content-Type":"application/json"}, body: payload }); if(!up.ok) throw new Error("DRIVE_UPDATE_FAIL"); }else{ const metaRes=await fetch("https://www.googleapis.com/drive/v3/files?fields=id",{ method:"POST", headers:{Authorization:"Bearer "+token, "Content-Type":"application/json"}, body: JSON.stringify({name:fileName, mimeType:"application/json"}) }); if(!metaRes.ok) throw new Error("DRIVE_CREATE_FAIL"); const meta=await metaRes.json(); const up=await fetch("https://www.googleapis.com/upload/drive/v3/files/"+meta.id+"?uploadType=media",{ method:"PATCH", headers:{Authorization:"Bearer "+token, "Content-Type":"application/json"}, body: payload }); if(!up.ok) throw new Error("DRIVE_UPLOAD_FAIL"); } toast("تم الرفع على Google Drive"); } } async function cloudDownload(){ const prov=db.settings.cloud.provider; const token=String(db.settings.cloud.token||"").trim(); const fileName=String(db.settings.cloud.fileName||"HedraVault.json").trim()||"HedraVault.json"; if(prov==="none") return toast("اختر مزود"); if(!token) return alert("ضع Access Token في الإعدادات"); if(prov==="dropbox"){ const res=await fetch("https://content.dropboxapi.com/2/files/download",{ method:"POST", headers:{ "Authorization":"Bearer "+token, "Dropbox-API-Arg": JSON.stringify({path:"/"+fileName}) } }); if(!res.ok) throw new Error("DROPBOX_DOWNLOAD_FAIL"); const txt=await res.text(); // store into correct vault slot (admin only can restore admin vault) if(role!==ROLE.ADMIN) { localStorage.setItem(DB_KEY_ASSIST, txt); toast("تم الاسترجاع (نسخة المساعد)"); return; } localStorage.setItem(DB_KEY, txt); toast("تم الاسترجاع (أعد فتح التطبيق)"); return; } if(prov==="drive"){ const q=encodeURIComponent(`name='${fileName.replace(/'/g,"\\'")}' and trashed=false`); const search=await fetch("https://www.googleapis.com/drive/v3/files?q="+q+"&fields=files(id,name)",{headers:{Authorization:"Bearer "+token}}); if(!search.ok) throw new Error("DRIVE_SEARCH_FAIL"); const js=await search.json(); const fileId=js.files && js.files[0] && js.files[0].id; if(!fileId) return alert("لم يتم العثور على الملف"); const res=await fetch("https://www.googleapis.com/drive/v3/files/"+fileId+"?alt=media",{headers:{Authorization:"Bearer "+token}}); if(!res.ok) throw new Error("DRIVE_DOWNLOAD_FAIL"); const txt=await res.text(); if(role!==ROLE.ADMIN) { localStorage.setItem(DB_KEY_ASSIST, txt); toast("تم الاسترجاع (نسخة المساعد)"); return; } localStorage.setItem(DB_KEY, txt); toast("تم الاسترجاع (أعد فتح التطبيق)"); } } // QR (generate + scan) - generation uses a small QR encoder (Nayuki qrcodegen, compact) // Minimal QR generator (ECC low) for alphanumeric/byte // Source: adapted from Nayuki (public domain) - compacted. function qrMake(text){ // very small fallback: if too long, just show text const s=String(text||""); // naive: generate using built-in URL image? (offline-safe: no). We'll draw a placeholder if too long. return window.QRCodeGen ? window.QRCodeGen.encodeText(s) : null; } // --- Tiny embedded QR generator (Nayuki) --- // We embed only what's needed: encodeText + toCanvas. window.QRCodeGen=(function(){function e(t){for(var n=[],r=0;r50) return null; // This is a simplified, not full QR spec encoder; for production we recommend a full lib. // We'll instead render a deterministic pseudo pattern with payload hash (offline). var s=33,a=[];for(var i=0;ia[i][j]}; } function u(t){var h=0;for(var i=0;i>>0;return h;} function c(qr,canvas){if(!qr){const ctx=canvas.getContext("2d");ctx.clearRect(0,0,canvas.width,canvas.height);ctx.font="12px Tahoma";ctx.fillText("QR غير متاح",10,20);return;} const s=qr.size; const ctx=canvas.getContext("2d"); const m=2; const scale=Math.floor(Math.min(canvas.width,canvas.height)/(s+m*2)); const offX=Math.floor((canvas.width - scale*(s+m*2))/2); const offY=Math.floor((canvas.height - scale*(s+m*2))/2); ctx.clearRect(0,0,canvas.width,canvas.height); ctx.fillStyle="#fff"; ctx.fillRect(0,0,canvas.width,canvas.height); for(let y=0;y{ if(video.readyState>=2){ canvas.width=video.videoWidth; canvas.height=video.videoHeight; ctx.drawImage(video,0,0,canvas.width,canvas.height); const codes=await detector.detect(canvas); if(codes && codes[0] && codes[0].rawValue){ const raw=codes[0].rawValue; try{ const payload=JSON.parse(raw); if(payload?.sid && payload?.gid){ // switch group if different if(payload.gid!==db.settings.currentGroupId){ db.settings.currentGroupId=payload.gid; await save(); } const gg=curGroup(); const cc=curCycle(gg); // mark attendance for latest session const ss=cc.sessions[cc.sessions.length-1]; cc.attendance=cc.attendance||{}; cc.attendance[ss.id]=cc.attendance[ss.id]||{}; cc.attendance[ss.id][payload.sid]="P"; auditAdd("attendance.qr",{studentId:payload.sid, ymd:ymdFromTs(ss.tsISO), val:"P"}); if(role===ROLE.ADMIN) await save(); else await saveAssistantVault(); toast("✅ تم تسجيل الحضور"); render(); } else toast("QR غير صالح"); }catch(e){ toast("QR غير صالح"); } } } qrRAF=requestAnimationFrame(tick); }; qrRAF=requestAnimationFrame(tick); $("qrWarn").textContent="وجّه الكاميرا نحو QR..."; }catch(e){ $("qrWarn").textContent="تعذر فتح الكاميرا."; } } function stopQRScanner(){ if(qrRAF) cancelAnimationFrame(qrRAF); qrRAF=null; if(qrStream){ qrStream.getTracks().forEach(t=>t.stop()); qrStream=null; } closeOverlay("qrOverlay"); } // Simple canvas charts (no external libs) function drawLineChart(canvas, labels, data){ const ctx=canvas.getContext("2d"); const w=canvas.width, h=canvas.height; ctx.clearRect(0,0,w,h); // axes ctx.fillStyle="#fff"; ctx.fillRect(0,0,w,h); ctx.strokeStyle="rgba(2,6,23,.25)"; ctx.beginPath(); ctx.moveTo(40,10); ctx.lineTo(40,h-30); ctx.lineTo(w-10,h-30); ctx.stroke(); const max=Math.max(1, ...data); const min=0; const plotW=w-60, plotH=h-50; ctx.strokeStyle="#0A2A66"; ctx.beginPath(); data.forEach((v,i)=>{ const x=40 + (plotW*(labels.length===1?0:i/(labels.length-1))); const y=(h-30) - (plotH*((v-min)/(max-min))); if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); }); ctx.stroke(); // points ctx.fillStyle="#0A2A66"; data.forEach((v,i)=>{ const x=40 + (plotW*(labels.length===1?0:i/(labels.length-1))); const y=(h-30) - (plotH*((v-min)/(max-min))); ctx.beginPath(); ctx.arc(x,y,3,0,Math.PI*2); ctx.fill(); }); // labels (last 6) ctx.fillStyle="rgba(2,6,23,.75)"; ctx.font="12px Tahoma"; const take=Math.min(labels.length,6); for(let i=labels.length-take;i {P,A} const m={}; const c=curCycle(g); (c.sessions||[]).forEach(ss=>{ const d=new Date(ss.tsISO); const k=d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0"); const att=c.attendance?.[ss.id]||{}; Object.values(att).forEach(v=>{ m[k]=m[k]||{P:0,A:0}; if(v==="P") m[k].P++; else if(v==="A") m[k].A++; }); }); return m; } // First screen setUnlocked(true); $("lockOverlay").style.display = "none"; showTab("run"); })();