نظام الحضور
الحضور والانصراف
⚠ تعذّر الاتصال بقاعدة البيانات — تأكد من تفعيل Firestore Database في مشروع Firebase
لم يُحدَّد الموقع بعد
--:--
جارٍ التحقق من الموقع…
اختر اسمك من القائمة أدناه
التحقق بالبصمة أو الوجه — يُسجَّل الوقت والموقع تلقائيًا
اختر اسمك — مرة واحدة فقط، ولن يُطلب منك بعدها

في الدوام الآن 0

أنهوا دوامهم 0

لم يحضروا بعد 0

في إجازة 0

إضافة موظف جديد

بعد الإضافة، أرسل رابط التطبيق للموظف ليفتحه من جواله، يختار اسمه، ويوثّق نفسه ببصمته أو وجهه.

قائمة الموظفين

تسجيل إجازة

الأيام المسجَّلة كإجازة لا تُحتسب غيابًا في التقرير الشهري.

الإجازات المسجَّلة

التقرير الشهري

الموظفأيام الحضورإجازاتالغيابمرات التأخيرمجموع التأخيرانصراف مبكروقت إضافيإجمالي الساعاتالراتبالخصمالصافيتنبيه
الموظفالتاريخحضورانصرافعدد الساعاتالموقع

الإنذارات الصادرة

تصدر تلقائيًا عند بلوغ الموظف عتبات التأخير، ويظهر إقراره بالاطّلاع.
الموظفالمستوىالشهرمرات التأخيرالإقرار

محاولات مرفوضة

تسجيل بكل محاولة رُفضت (خارج النطاق، أو دقة موقع ضعيفة، أو جهاز غير مطابق).
الموظفالتاريخالوقتالسبب

موقع المكتب

لتحديد الموقع بدقة تامة: افتح خرائط جوجل، اضغط مطوّلًا على مبنى المكتب، وانسخ الرقمين الظاهرين إلى الحقلين أعلاه.
لم يتم تحديد إحداثيات المكتب بعد.

العطل الرسمية

أيام لا تُحتسب غيابًا على أي موظف (الأعياد والإجازات الوطنية).

فترة السماح

تأخير ضمن هذه الفترة لا يُحتسب تأخيرًا. بعد تجاوزها يُحتسب التأخير من وقت بدء الدوام كاملًا.

تاريخ بدء تشغيل النظام

لا يُحتسب أي غياب قبل هذا التاريخ. اضبطه على اليوم الذي بدأ فيه الموظفون التسجيل فعليًا.

عتبات الإنذار

عدد مرات التأخير في الشهر التي يصدر عندها كل مستوى. اكتبها مفصولة بفاصلة، مثال: 3,5,7

أيام العطلة الأسبوعية

الأيام المحددة لا تُحتسب غيابًا في التقرير الشهري.

وقت بدء الدوام

هوية النظام

اسم الشركة مثبَّت ولا يُعدَّل من داخل النظام — للتعديل يُرجى مراجعة الجهة المزوّدة.

الإشعارات

تنبيه عند تسجيل الحضور والانصراف، وتذكير عند اقتراب موعد انصرافك.

تثبيت التطبيق

ثبّت النظام على الشاشة الرئيسية ليفتح كتطبيق مستقل بلا شريط عنوان.
إن لم يظهر الزر: افتح قائمة المتصفح ← «إضافة إلى الشاشة الرئيسية».

ترحيل الرواتب

الرواتب انتقلت إلى مجموعة مغلقة لا يقرأها إلا المدير. اضغط لنقل الرواتب القديمة المخزّنة داخل ملفات الموظفين.

النسخ الاحتياطي

نزّل نسخة كاملة من البيانات واحفظها خارج النظام. الاستعادة تضيف المفقود ولا تمسح الموجود.

الرواتب والخصومات

الخصم يُحتسب على الغياب فقط: الراتب ÷ عدد أيام الشهر × أيام الغياب.

الخصم على الإنذارات

الإنذارات الأولى تنبيه فقط، والخصم يبدأ من المستوى الذي تحدده.
الخصم المحسوب اقتراح للمراجعة — الاعتماد النهائي قرار الإدارة.

رقم المدير

يُستخدم لإرسال تقرير الأمس على واتساب. مع رمز الدولة وبدون صفر أو +.

حساب المدير

'08:00'; let lateCount=0, lateMins=0; recs.forEach(r=>{ const lm = minutesLate(r.checkIn, r.startTime || startFor); if(lm>0){ lateCount++; lateMins+=lm; } }); const leaveDays = leaveDaysInMonth(empId, month); const net = Math.max(0, workdaysElapsed(month) - holidayWorkdaysInMonth(month)); const absent = Math.max(0, net - recs.length - leaveDays); const company = companyName(); const lines = [ `السلام عليكم ${emp.name}`, '', `تنبيه بخصوص الالتزام بالدوام عن شهر ${month}:`, ]; if(absent>0) lines.push(`• أيام غياب: ${absent}`); if(lateCount>0) lines.push(`• مرات التأخير: ${lateCount} — بمجموع ${minsToHrs(lateMins)}`); lines.push(''); lines.push(`علمًا أن فترة السماح ${graceMinutes()} دقيقة من بداية الدوام.`); lines.push('نأمل الالتزام بمواعيد الدوام، وفي حال وجود عذر يُرجى إبلاغ الإدارة.'); lines.push(''); lines.push(company); openWhatsApp(emp.phone, lines.join('\n')); } /* ===== الرواتب ===== الخصم على الغياب فقط. الأجر اليومي = الراتب ÷ أيام الشهر (30 افتراضيًا). الناتج اقتراح للمراجعة، والاعتماد قرار الإدارة. */ /* معاينة نصية: تُظهر للمدير أثر إعداداته قبل أن يفاجأ به آخر الشهر */ function renderWarnDeductPreview(){ const el = document.getElementById('warnDeductPreview'); if(!el) return; const on = document.getElementById('warnDeductEnabled').checked; if(!on){ el.textContent = 'الخصم على الإنذارات مُعطّل — لن يُحتسب أي خصم بسبب التأخير.'; return; } const th = warnThresholds(); const from = parseInt(document.getElementById('warnDeductFrom').value)||3; const days = parseFloat(document.getElementById('warnDeductDays').value)||1; if(from > th.length){ el.textContent = `تنبيه: العتبات المعرَّفة ${th.length} مستويات فقط (${th.join('، ')}) — البدء من المستوى ${from} يعني ألّا يُخصم على أحد إطلاقًا.`; return; } const lateNeeded = th[from-1]; el.innerHTML = `القاعدة الحالية: من تأخّر ${lateNeeded} مرات في الشهر يُخصم عليه ${days} يوم، ` + `ويزيد الخصم ${days} يوم مع كل مستوى تالٍ. ` + `مستويات التأخير: ${th.join('، ')} مرة.`; } /* الراتب يُقرأ من مجموعة الرواتب، ويبقى القديم في ملف الموظف مقروءًا حتى يُنقل — فلا تنقطع التقارير أثناء الترحيل. */ function salaryOf(emp){ const v = salariesCache[emp.id]; if(v != null) return v; return (emp && emp.salary != null) ? emp.salary : null; } function currencyLabel(){ return settingsCache.currency !== undefined ? settingsCache.currency : 'ريال'; } function salaryDivisor(){ const v = parseInt(settingsCache.salaryDivisor); return (v >= 26 && v <= 31) ? v : 30; } function money(n){ if(n === null || n === undefined || !isFinite(n)) return '—'; const cur = currencyLabel(); const num = Math.round(n * 100) / 100; const txt = num.toLocaleString('en-US', {minimumFractionDigits:0, maximumFractionDigits:2}); return cur ? `${txt} ${cur}` : txt; } /* ===== خصم الإنذارات ===== الإنذارات الأولى تنبيه فقط، والخصم يبدأ من مستوى محدد فصاعدًا. مثال: العتبات 3,5,7 والبدء من المستوى 3 ⇒ من تأخر 7 مرات يُخصم عليه. */ function warnDeductEnabled(){ return settingsCache.warnDeductEnabled === true; } function warnDeductFrom(){ const v = parseInt(settingsCache.warnDeductFrom); return (v >= 1 && v <= 6) ? v : 3; } function warnDeductDays(){ const v = parseFloat(settingsCache.warnDeductDays); return (isFinite(v) && v > 0 && v <= 5) ? v : 1; } /* سقف الخصم كنسبة من الراتب — حماية من خصم مبالغ فيه */ function deductCapPct(){ const v = parseFloat(settingsCache.deductCapPct); return (isFinite(v) && v > 0 && v <= 100) ? v : 50; } /* عدد مستويات الإنذار الخاضعة للخصم لهذا الموظف في هذا الشهر */ function billableWarnLevels(empId, monthStr){ if(!warnDeductEnabled()) return 0; const from = warnDeductFrom(); const level = levelForCount(lateCountInMonth(empId, monthStr)); return Math.max(0, level - from + 1); } /* يرجع null إذا لم يُسجَّل راتب للموظف */ function payFor(emp, absentDays, monthStr){ const salary = parseFloat(salaryOf(emp)); if(!isFinite(salary) || salary <= 0) return null; const daily = salary / salaryDivisor(); const absenceDed = daily * Math.max(0, absentDays); const levels = monthStr ? billableWarnLevels(emp.id, monthStr) : 0; const warnDed = daily * warnDeductDays() * levels; const cap = salary * (deductCapPct()/100); const rawTotal = absenceDed + warnDed; const deduction = Math.min(rawTotal, cap); const capped = rawTotal > cap; return { salary, daily, absenceDed, warnDed, warnLevels: levels, deduction, capped, net: salary - deduction }; } function renderSummary(){ const body=document.getElementById('summaryBody'); if(!body) return; const month = document.getElementById('reportMonth').value.trim() || currentMonthStr(); const holidayDays = holidayWorkdaysInMonth(month); const off = weekendDays(); body.innerHTML=''; employeesForMonth(month).forEach(emp=>{ const recs = recsForMonth(month).filter(r=>r.empId===emp.id); const present = recs.length; const empWorkdays = workdaysForEmployee(emp, month); const leaveDays = leaveDaysForEmployee(emp, month); const absent = Math.max(0, empWorkdays - present - leaveDays); let lateCount=0, lateMins=0, totalMins=0, earlyMins=0, earlyCount=0, overMins=0; recs.forEach(r=>{ const ref = r.startTime || shiftStartFor(emp, new Date(r.date+'T00:00:00')); const lm = minutesLate(r.checkIn, ref); if(lm>0){ lateCount++; lateMins+=lm; } const sEnd = shiftEndForRecord(r, emp); const em = minutesEarly(r.checkOut, sEnd, ref); if(em>0){ earlyCount++; earlyMins+=em; } overMins += minutesOvertime(r.checkOut, sEnd, ref); totalMins += workedMinutes(r.checkIn, r.checkOut); }); const pay = payFor(emp, absent, month); const tr=document.createElement('tr'); tr.innerHTML=`${emp.name} ${present} ${leaveDays||'—'} ${absent>0? `${absent}` : '0'} ${lateCount} ${minsToHrs(lateMins)} ${earlyMins>0? `${minsToHrs(earlyMins)}
${earlyCount} مرة
` : '—'} ${overMins>0? `${minsToHrs(overMins)}` : '—'} ${minsToHrs(totalMins)} ${pay? money(pay.salary) : ''} ${pay? (pay.deduction>0 ? `${money(pay.deduction)}` + `
` + `${pay.absenceDed>0? 'غياب '+money(pay.absenceDed) : ''}` + `${pay.absenceDed>0 && pay.warnDed>0 ? ' · ' : ''}` + `${pay.warnDed>0? 'إنذارات '+money(pay.warnDed) : ''}` + `${pay.capped? ' · بلغ السقف' : ''}
` : '0') : ''} ${pay? `${money(pay.net)}` : ''} ${(absent>0||lateCount>0) ? `` : ''}`; body.appendChild(tr); }); body.querySelectorAll('[data-warn]').forEach(b=>b.addEventListener('click', ()=>{ sendWhatsAppWarning(b.dataset.warn, b.dataset.month); })); const fridayWorkers = employeesCache.filter(e=>fridayMode(e)==='always').length; const fridayOnDemand = employeesCache.filter(e=>fridayMode(e)==='ondemand').length; document.getElementById('summaryNote').textContent = employeesCache.length===0 ? 'لا يوجد موظفون بعد.' : `أيام العمل تُحسب لكل موظف حسب جدوله` + (holidayDays? ` — خُصم ${holidayDays} يوم عطلة رسمية`:'') + ` — العطلة الأسبوعية: ${off.map(d=>WEEKDAY_NAMES[d]).join('، ')||'لا يوجد'}` + (systemStartDate()? ` · بدء الاحتساب من ${systemStartDate()}`:'') + (fridayWorkers? ` · ${fridayWorkers} يعمل الجمعة بدوام ثابت`:'') + (fridayOnDemand? ` · ${fridayOnDemand} الجمعة عنده عند الطلب`:''); } document.getElementById('reportMonth').addEventListener('input', async ()=>{ const m = activeReportMonth(); renderSummary(); // عرض فوري بما هو محمَّل await loadMonth(m); // ثم جلب الشهر كاملًا renderSummary(); }); document.getElementById('thisMonth').addEventListener('click', async ()=>{ document.getElementById('reportMonth').value = currentMonthStr(); renderSummary(); await loadMonth(currentMonthStr()); renderSummary(); }); document.getElementById('exportReport').addEventListener('click', async ()=>{ if(!excelReady()) return; const month = activeReportMonth(); if(employeesCache.length===0){ showToast('لا يوجد موظفون', false); return; } showToast('جارٍ تحميل بيانات الشهر…', true); await loadMonth(month); // الشهر كاملًا لا نافذة الأيام الأخيرة const wb = XLSX.utils.book_new(); wb.Props = { Title:`تقرير الحضور ${month}` }; /* --- ورقة 1: الملخص الشهري --- */ const sum = [ [`تقرير الحضور والانصراف — شهر ${month}`], [`تاريخ الإصدار: ${todayStr()}`], [], ['الموظف','بداية الدوام','أيام العمل','أيام الحضور','إجازات','الغياب','مرات التأخير','مجموع التأخير','مرات الانصراف المبكر','مجموع الانصراف المبكر','الوقت الإضافي','إجمالي الساعات','متوسط الساعات/يوم','الراتب','خصم الغياب','خصم الإنذارات','إجمالي الخصم','الصافي'] ]; employeesForMonth(month).forEach(emp=>{ const recs = recsForMonth(month).filter(r=>r.empId===emp.id); let lateCount=0, lateMins=0, totalMins=0, earlyMins=0, earlyCount=0, overMins=0; recs.forEach(r=>{ const ref = r.startTime || shiftStartFor(emp, new Date(r.date+'T00:00:00')); const lm = minutesLate(r.checkIn, ref); if(lm>0){ lateCount++; lateMins+=lm; } const sEnd = shiftEndForRecord(r, emp); const em = minutesEarly(r.checkOut, sEnd, ref); if(em>0){ earlyCount++; earlyMins+=em; } overMins += minutesOvertime(r.checkOut, sEnd, ref); totalMins += workedMinutes(r.checkIn, r.checkOut); }); const lv = leaveDaysForEmployee(emp, month); const wd = workdaysForEmployee(emp, month); const absent = Math.max(0, wd - recs.length - lv); const avg = recs.length ? Math.round(totalMins/recs.length) : 0; const pay = payFor(emp, absent, month); sum.push([ emp.name, fmt12(emp.shiftStart || settingsCache.startTime || '08:00'), wd, recs.length, lv, absent, lateCount, minsToHrs(lateMins), earlyCount, minsToHrs(earlyMins), minsToHrs(overMins), minsToHrs(totalMins), minsToHrs(avg), pay? Math.round(pay.salary*100)/100 : '—', pay? Math.round(pay.absenceDed*100)/100 : '—', pay? Math.round(pay.warnDed*100)/100 : '—', pay? Math.round(pay.deduction*100)/100 : '—', pay? Math.round(pay.net*100)/100 : '—' ]); }); const wsSum = sheetFrom(sum, [20,14,11,12,9,9,12,14,16,18,14,15,16,14,14,15,14,14]); wsSum['!merges'] = [ {s:{r:0,c:0}, e:{r:0,c:17}}, {s:{r:1,c:0}, e:{r:1,c:17}} ]; delete wsSum['!autofilter']; XLSX.utils.book_append_sheet(wb, wsSum, 'الملخص الشهري'); /* --- ورقة 2: السجل التفصيلي --- */ const monthRecs = recsForMonth(month) .sort((a,b)=> (a.date+(a.checkIn||'')) < (b.date+(b.checkIn||'')) ? 1 : -1); XLSX.utils.book_append_sheet(wb, recordsSheet(monthRecs), 'السجل التفصيلي'); /* --- ورقة 3: الإجازات --- */ const lvRows = [['الموظف','النوع','من تاريخ','إلى تاريخ','عدد أيام العمل']]; leavesCache .filter(l => l.from.startsWith(month+'-') || l.to.startsWith(month+'-')) .sort((a,b)=> a.from < b.from ? 1 : -1) .forEach(l=>{ const emp = employeesCache.find(e=>e.id===l.empId); let days=0; eachDate(l.from, l.to, (ds,dow)=>{ if(ds.startsWith(month+'-') && emp && isWorkdayFor(emp,dow)) days++; }); lvRows.push([l.empName, l.type, l.from, l.to, days]); }); XLSX.utils.book_append_sheet(wb, sheetFrom(lvRows, [20,14,13,13,16]), 'الإجازات'); /* --- ورقة 4: المحاولات المرفوضة --- */ const rj = [['الموظف','التاريخ','الوقت','السبب','التفاصيل']]; rejectedCache.forEach(r=>{ const t = r.at && r.at.toDate ? r.at.toDate() : null; const ds = t ? dateKeyIn(t) : ''; if(ds && !ds.startsWith(month+'-')) return; rj.push([r.empName, ds || '—', t? fmt12(hhmmIn(t)) : '—', r.reason, r.detail||'']); }); XLSX.utils.book_append_sheet(wb, sheetFrom(rj, [20,13,12,22,18]), 'المحاولات المرفوضة'); /* --- ورقة 5: العطل الرسمية --- */ const hd = [['اسم العطلة','من تاريخ','إلى تاريخ']]; holidaysCache .slice() .sort((a,b)=> a.from < b.from ? 1 : -1) .forEach(h=> hd.push([h.name, h.from, h.to])); XLSX.utils.book_append_sheet(wb, sheetFrom(hd, [26,13,13]), 'العطل الرسمية'); saveBook(wb, `تقرير_الحضور_${month}.xlsx`); showToast('تم تصدير التقرير الشامل', true); }); /* اختيار أيام العطلة في الإعدادات */ function renderWeekendPicker(){ const wrap=document.getElementById('weekendPicker'); if(!wrap) return; const off=weekendDays(); wrap.innerHTML=''; WEEKDAY_NAMES.forEach((name,idx)=>{ const b=document.createElement('button'); b.type='button'; b.className='btn '+(off.includes(idx)?'':'secondary'); b.style.cssText='padding:8px 12px; font-size:12px;'; b.textContent=name; b.dataset.day=idx; b.addEventListener('click', ()=>{ b.classList.toggle('secondary'); }); wrap.appendChild(b); }); } document.getElementById('saveGrace').addEventListener('click', async ()=>{ const v = parseInt(document.getElementById('graceMinutes').value); if(isNaN(v) || v<0 || v>120){ showToast('أدخل رقمًا بين 0 و 120 دقيقة', false); return; } try{ await settingsDoc().set({ graceMinutes: v }, {merge:true}); showToast(v>0? `تم ضبط فترة السماح على ${v} دقيقة` : 'تم إلغاء فترة السماح', true); renderRecords(); renderSummary(); }catch(e){ showToast('تعذّر الحفظ', false); } }); document.getElementById('saveEarlyGrace').addEventListener('click', async ()=>{ const v = parseInt(document.getElementById('earlyGrace').value); if(isNaN(v) || v<0 || v>60){ showToast('أدخل رقمًا بين 0 و 60 دقيقة', false); return; } try{ await settingsDoc().set({ earlyGraceMinutes: v }, {merge:true}); showToast('تم حفظ سماح الانصراف المبكر', true); renderRecords(); renderSummary(); }catch(e){ showToast('تعذّر الحفظ', false); } }); document.getElementById('saveOvertime').addEventListener('click', async ()=>{ const v = parseInt(document.getElementById('overtimeThreshold').value); if(isNaN(v) || v<0 || v>120){ showToast('أدخل رقمًا بين 0 و 120 دقيقة', false); return; } try{ await settingsDoc().set({ overtimeThreshold: v }, {merge:true}); showToast('تم حفظ حد الوقت الإضافي', true); renderRecords(); renderSummary(); }catch(e){ showToast('تعذّر الحفظ', false); } }); document.getElementById('saveThresholds').addEventListener('click', async ()=>{ const raw = document.getElementById('warnThresholds').value.trim(); const arr = raw.split(/[,\s]+/).map(x=>parseInt(x)).filter(x=>!isNaN(x) && x>0); if(arr.length===0){ showToast('اكتب أرقامًا مفصولة بفاصلة، مثال: 3,5,7', false); return; } if(arr.length>4){ showToast('4 مستويات كحد أقصى', false); return; } try{ await settingsDoc().set({ warnThresholds: arr.sort((a,b)=>a-b) }, {merge:true}); showToast('تم حفظ عتبات الإنذار', true); loadSettingsForm(); }catch(e){ showToast('تعذّر الحفظ', false); } }); document.getElementById('saveSystemStart').addEventListener('click', async ()=>{ const v = document.getElementById('systemStart').value; try{ await settingsDoc().set({ systemStart: v || null }, {merge:true}); showToast(v? 'تم ضبط تاريخ بدء التشغيل' : 'تم إلغاء تاريخ البدء', true); renderSummary(); renderToday(); }catch(e){ showToast('تعذّر الحفظ', false); } }); document.getElementById('saveCutoff').addEventListener('click', async ()=>{ const v = parseInt(document.getElementById('dayCutoff').value); if(isNaN(v) || v<0 || v>12){ showToast('أدخل ساعة بين 0 و 12', false); return; } try{ await settingsDoc().set({ dayCutoffHour: v }, {merge:true}); showToast('تم حفظ بداية اليوم الجديد', true); }catch(e){ showToast('تعذّر الحفظ', false); } }); document.getElementById('saveWeekend').addEventListener('click', async ()=>{ const chosen=[...document.querySelectorAll('#weekendPicker button')] .filter(b=>!b.classList.contains('secondary')) .map(b=>parseInt(b.dataset.day)); try{ await settingsDoc().set({ weekend: chosen }, {merge:true}); showToast('تم حفظ أيام العطلة', true); renderSummary(); }catch(e){ showToast('تعذّر الحفظ', false); } }); function reflectAuthState(){ const el = document.getElementById('adminWho'); if(!el) return; const u = auth && auth.currentUser; el.textContent = u ? `مسجَّل الدخول باسم ${u.email}` : 'غير مسجَّل الدخول'; } function loadSettingsForm(){ refreshNotifState(); reflectAuthState(); document.getElementById('mgrPhone').value = settingsCache.mgrPhone || ''; document.getElementById('currency').value = currencyLabel(); document.getElementById('salaryDivisor').value = salaryDivisor(); document.getElementById('warnDeductEnabled').checked = warnDeductEnabled(); document.getElementById('warnDeductFrom').value = warnDeductFrom(); document.getElementById('warnDeductDays').value = warnDeductDays(); document.getElementById('deductCapPct').value = deductCapPct(); renderWarnDeductPreview(); document.getElementById('companyName').value = brandCache.companyName || ''; document.getElementById('tzSelect').value = tz(); document.getElementById('versionNote').textContent = `إصدار النظام ${APP_VERSION} · المنطقة الحالية ${tz()}`; document.getElementById('officeName').value=settingsCache.name||''; renderOfficeCoords(); document.getElementById('manualLat').value = settingsCache.lat!=null? settingsCache.lat.toFixed(6) : ''; document.getElementById('manualLng').value = settingsCache.lng!=null? settingsCache.lng.toFixed(6) : ''; document.getElementById('officeRadius').value=settingsCache.radius||150; document.getElementById('maxAccuracy').value=settingsCache.maxAccuracy||50; document.getElementById('startTime').value=settingsCache.startTime||'08:00'; const g=graceMinutes(); document.getElementById('graceMinutes').value=g; const eg=earlyGraceMinutes(); document.getElementById('earlyGrace').value=eg; document.getElementById('earlyGraceNote').textContent = eg>0 ? `الانصراف قبل نهاية الدوام بأقل من ${eg} دقيقة لا يُحتسب انصرافًا مبكرًا.` : 'أي انصراف قبل نهاية الدوام يُحتسب مبكرًا.'; const ot=overtimeThreshold(); document.getElementById('overtimeThreshold').value=ot; document.getElementById('overtimeNote').textContent = ot>0 ? `البقاء بعد نهاية الدوام بأقل من ${ot} دقيقة لا يُحتسب وقتًا إضافيًا.` : 'أي دقيقة بعد نهاية الدوام تُحتسب وقتًا إضافيًا.'; const th = warnThresholds(); document.getElementById('warnThresholds').value = th.join(','); document.getElementById('thresholdsNote').textContent = th.map((t,i)=>`${warnLabel(i+1)} عند ${t} مرات`).join(' · '); const sysStart = systemStartDate(); document.getElementById('systemStart').value = sysStart || ''; document.getElementById('systemStartNote').textContent = sysStart ? `الغياب يُحتسب من ${sysStart} فصاعدًا — وما قبله لا يُحسب على أحد.` : 'لم يُحدَّد — الغياب يُحتسب من أول كل شهر.'; const cut=dayCutoffHour(); document.getElementById('dayCutoff').value=cut; document.getElementById('cutoffNote').textContent = `تسجيل الانصراف قبل الساعة ${fmt12(pad(cut)+':00')} يُنسب ليوم العمل السابق — يخصّ الشفتات التي تمتد بعد منتصف الليل فقط.`; document.getElementById('graceNote').textContent = g>0 ? `الوصول حتى ${g} دقيقة بعد بدء الدوام لا يُحتسب تأخيرًا.` : 'فترة السماح معطّلة — أي دقيقة تأخير تُحتسب.'; document.getElementById('officeCoordsNote').textContent = settingsCache.lat!=null ? `الإحداثيات الحالية: ${settingsCache.lat.toFixed(5)}, ${settingsCache.lng.toFixed(5)}` : 'لم يتم تحديد إحداثيات المكتب بعد.'; } /* ===== معايرة موقع المكتب ===== قراءة واحدة قد تكون سيئة، فيصبح مركز المكتب مغلوطًا بعشرات الأمتار وكل حسابات النطاق بعدها خاطئة. هنا نجمع عدة قراءات ونأخذ متوسطًا مرجّحًا بدقة كل قراءة (الأدق تأخذ وزنًا أكبر). */ function calibrateOffice(seconds = 20){ return new Promise((resolve, reject)=>{ if(!navigator.geolocation){ reject(new Error('NO_GEO')); return; } const samples = []; let watchId = null, finished = false; const finish = ()=>{ if(finished) return; finished = true; clearTimeout(timer); clearInterval(ticker); if(watchId !== null) navigator.geolocation.clearWatch(watchId); if(!samples.length){ reject(new Error('NO_FIX')); return; } /* نتجاهل القراءات الأسوأ من ضعف أفضل دقة وصلت */ const bestAcc = Math.min(...samples.map(s=>s.acc)); const good = samples.filter(s=>s.acc <= Math.max(bestAcc*2, bestAcc+15)); let wSum=0, latSum=0, lngSum=0; good.forEach(s=>{ const w = 1/(s.acc*s.acc); wSum+=w; latSum+=s.lat*w; lngSum+=s.lng*w; }); resolve({ lat: latSum/wSum, lng: lngSum/wSum, acc: Math.round(bestAcc), used: good.length, total: samples.length }); }; const timer = setTimeout(finish, seconds*1000); const btn = document.getElementById('useMyLocation'); const started = Date.now(); const ticker = setInterval(()=>{ const left = Math.max(0, seconds - Math.round((Date.now()-started)/1000)); btn.textContent = `جارٍ المعايرة… ${left} ث (${samples.length} قراءة)`; }, 500); watchId = navigator.geolocation.watchPosition(pos=>{ const a = pos.coords.accuracy; if(typeof a !== 'number' || a > 200) return; // قراءة عديمة الفائدة samples.push({ lat:pos.coords.latitude, lng:pos.coords.longitude, acc:a }); if(samples.length >= 25) finish(); }, err=>{ if(err && (err.code===1 || err.code===2) && !samples.length) finish(); }, {enableHighAccuracy:true, maximumAge:0}); }); } /* ===== أدوات تشخيص الموقع ===== */ function renderOfficeCoords(){ const el = document.getElementById('officeCoords'); if(!el) return; if(settingsCache.lat == null){ el.textContent = 'لم يُحدَّد موقع المكتب بعد.'; return; } const acc = settingsCache.calibAcc; const n = settingsCache.calibSamples; el.innerHTML = `الموقع المحفوظ: ${settingsCache.lat.toFixed(6)}, ${settingsCache.lng.toFixed(6)}` + (acc? ` (معايرة بدقة ±${acc} م من ${n||'?'} قراءة)` : ''); } /* فتح الموقع المحفوظ في الخرائط — أسرع طريقة لكشف موقع خاطئ */ document.getElementById('openInMaps').addEventListener('click', ()=>{ if(settingsCache.lat == null){ showToast('لم يُحدَّد موقع المكتب بعد', false); return; } window.open(`https://www.google.com/maps/search/?api=1&query=${settingsCache.lat},${settingsCache.lng}`, '_blank'); }); /* قياس مباشر: أين أنا الآن بالنسبة للمكتب؟ */ document.getElementById('testLocation').addEventListener('click', async ()=>{ const out = document.getElementById('geoTest'); const btn = document.getElementById('testLocation'); if(settingsCache.lat == null){ showToast('حدّد موقع المكتب أولًا', false); return; } btn.disabled = true; out.textContent = 'جارٍ القياس…'; try{ const pos = await bestFix(); const acc = Math.round(pos.coords.accuracy); const d = Math.round(haversine(pos.coords.latitude, pos.coords.longitude, settingsCache.lat, settingsCache.lng)); const radius = settingsCache.radius || 150; const maxAcc = settingsCache.maxAccuracy || 50; const verdict = acc > maxAcc ? `سيُرفض: الدقة أضعف من الحد (${maxAcc} م)` : (d - acc) > radius? `سيُرفض: خارج النطاق يقينًا` : (d + acc) > radius? `سيُقبل مع علامة «حدّي»` : `سيُقبل`; out.innerHTML = `موقعك الآن: ${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}
` + `المسافة عن المكتب: ${d} م · دقة القراءة: ±${acc} م · النطاق: ${radius} م
` + `النتيجة: ${verdict}`; }catch(e){ out.innerHTML = 'تعذّر القياس — تأكد من تفعيل GPS والأذونات'; }finally{ btn.disabled = false; } }); /* إدخال الإحداثيات يدويًا — أدق من أي معايرة داخل مبنى */ document.getElementById('saveManualCoords').addEventListener('click', async ()=>{ if(!isAdminUnlocked()){ showToast('سجّل الدخول أولًا', false); return; } const lat = parseFloat(document.getElementById('manualLat').value.trim()); const lng = parseFloat(document.getElementById('manualLng').value.trim()); if(!isFinite(lat) || lat < -90 || lat > 90){ showToast('خط العرض غير صحيح (بين -90 و90)', false); return; } if(!isFinite(lng) || lng < -180 || lng > 180){ showToast('خط الطول غير صحيح (بين -180 و180)', false); return; } try{ await settingsDoc().set({ lat, lng, calibAcc:null, calibSamples:null }, {merge:true}); showToast('تم حفظ الإحداثيات — تحقق منها بزر «فتح في الخرائط»', true); }catch(e){ showToast(saveErrMsg(e), false); } }); document.getElementById('useMyLocation').addEventListener('click', async ()=>{ const btn = document.getElementById('useMyLocation'); const original = 'استخدام الموقع الحالي كموقع المكتب'; if(btn.disabled) return; if(!confirm('قف داخل المكتب في مكان مفتوح نسبيًا، ثم ابدأ.\n\nستستغرق المعايرة نحو 20 ثانية — لا تغلق الصفحة.')) return; btn.disabled = true; try{ const fix = await calibrateOffice(); await settingsDoc().set({ lat:fix.lat, lng:fix.lng, calibAcc:fix.acc, calibSamples:fix.used }, {merge:true}); showToast(`تمت المعايرة بدقة ±${fix.acc} م من ${fix.used} قراءة`, true); }catch(e){ showToast('تعذّرت المعايرة — تأكد من تفعيل GPS والأذونات', false); }finally{ btn.disabled = false; btn.textContent = original; } }); document.getElementById('saveSettings').addEventListener('click', async ()=>{ try{ await settingsDoc().set({ name: document.getElementById('officeName').value.trim(), radius: parseInt(document.getElementById('officeRadius').value)||150, maxAccuracy: parseInt(document.getElementById('maxAccuracy').value)||50 }, {merge:true}); showToast('تم حفظ الإعدادات', true); }catch(e){ showToast(saveErrMsg(e), false); console.error(e); } }); ['warnDeductEnabled','warnDeductFrom','warnDeductDays'].forEach(id=>{ document.getElementById(id).addEventListener('input', renderWarnDeductPreview); }); document.getElementById('saveSalarySettings').addEventListener('click', async ()=>{ if(!isAdminUnlocked()){ showToast('سجّل الدخول أولًا', false); return; } const div = parseInt(document.getElementById('salaryDivisor').value)||30; if(div<26 || div>31){ showToast('أساس القسمة بين 26 و31', false); return; } try{ const from = parseInt(document.getElementById('warnDeductFrom').value)||3; const days = parseFloat(document.getElementById('warnDeductDays').value)||1; const cap = parseFloat(document.getElementById('deductCapPct').value)||50; if(from<1 || from>6){ showToast('مستوى البدء بين 1 و6', false); return; } if(days<=0 || days>5){ showToast('أيام الخصم بين 0.5 و5', false); return; } if(cap<=0 || cap>100){ showToast('السقف بين 1 و100', false); return; } await settingsDoc().set({ currency: document.getElementById('currency').value, salaryDivisor: div, warnDeductEnabled: document.getElementById('warnDeductEnabled').checked, warnDeductFrom: from, warnDeductDays: days, deductCapPct: cap }, {merge:true}); showToast('تم حفظ إعدادات الرواتب', true); renderWarnDeductPreview(); renderSummary(); }catch(e){ showToast(saveErrMsg(e), false); } }); document.getElementById('saveStartTime').addEventListener('click', async ()=>{ await settingsDoc().set({ startTime: document.getElementById('startTime').value.trim()||'08:00' }, {merge:true}); showToast('تم حفظ وقت بدء الدوام', true); }); /* ===== تثبيت التطبيق (PWA) ===== العامل يعمل بمبدأ «الشبكة أولًا» لملف التطبيق، فلا يعلق أحد على نسخة قديمة بعد أي تحديث ترفعه. */ if('serviceWorker' in navigator){ window.addEventListener('load', ()=>{ navigator.serviceWorker.register('./sw.js').then(reg=>{ /* إن وصلت نسخة أحدث أثناء الاستخدام، نخبر المستخدم بدل تبديلها فجأة */ reg.addEventListener('updatefound', ()=>{ const sw = reg.installing; if(!sw) return; sw.addEventListener('statechange', ()=>{ if(sw.state === 'installed' && navigator.serviceWorker.controller){ showUpdateBar(reg); } }); }); }).catch(e=> console.warn('تعذّر تسجيل عامل الخدمة:', e)); let reloading = false; navigator.serviceWorker.addEventListener('controllerchange', ()=>{ if(reloading) return; reloading = true; location.reload(); }); }); } function showUpdateBar(reg){ if(document.getElementById('updateBar')) return; const bar = document.createElement('div'); bar.id = 'updateBar'; bar.innerHTML = 'يتوفّر تحديث للنظام' + ''; document.body.appendChild(bar); document.getElementById('updateNow').addEventListener('click', ()=>{ if(reg.waiting) reg.waiting.postMessage('SKIP_WAITING'); bar.remove(); }); } /* زر التثبيت يظهر للمتصفحات التي تدعمه */ let deferredInstall = null; window.addEventListener('beforeinstallprompt', ev=>{ ev.preventDefault(); deferredInstall = ev; const b = document.getElementById('installBtn'); if(b) b.style.display = 'inline-block'; }); window.addEventListener('appinstalled', ()=>{ deferredInstall = null; const b = document.getElementById('installBtn'); if(b) b.style.display = 'none'; showToast('تم تثبيت التطبيق على جهازك', true); }); /* إخفاء شاشة البداية بعد جاهزية الواجهة، وبسقف زمني حتى لا تعلق */ (function(){ const sp = document.getElementById('splash'); if(!sp) return; let hidden = false; const hide = ()=>{ if(hidden) return; hidden = true; sp.classList.add('gone'); setTimeout(()=> sp.remove(), 500); }; window.addEventListener('load', ()=> setTimeout(hide, 350)); setTimeout(hide, 3500); // سقف أمان window.__hideSplash = hide; })();
html> eSplash = hide; })();
html> tml> eSplash = hide; })();
html> tml>