| "use strict"; |
|
|
| const FIELD_LABELS = [ |
| "اسم المشرف", |
| "اسم الباحث", |
| "رقم السجل(العينة)", |
| "اسم العينة", |
| "حالة الاكتمال", |
| "حالة الاقرار", |
| "حالة الاستيفاء", |
| "نوع الاستيفاء", |
| "حالة الاعتماد", |
| "النشاط", |
| "الايميل", |
| "الموقع", |
| "رقم التواصل", |
| ]; |
|
|
| const FIELD_KEYS = [ |
| "supervisor", |
| "researcher", |
| "recordNumber", |
| "sampleName", |
| "completionStatus", |
| "declarationStatus", |
| "fulfillmentStatus", |
| "fulfillmentType", |
| "approvalStatus", |
| "activity", |
| "email", |
| "location", |
| "phone", |
| ]; |
|
|
| const DISPLAY_FIELDS = [ |
| ["recordNumber", "رقم السجل", false], |
| ["completionStatus", "حالة الاكتمال", false], |
| ["declarationStatus", "حالة الإقرار", false], |
| ["fulfillmentType", "نوع الاستيفاء", false], |
| ["approvalStatus", "حالة الاعتماد", false], |
| ["activity", "النشاط", true], |
| ["email", "البريد الإلكتروني", true], |
| ["location", "الموقع", true], |
| ["phone", "رقم التواصل", true], |
| ]; |
|
|
| const loginView = document.getElementById("loginView"); |
| const dashboardView = document.getElementById("dashboardView"); |
| const loginForm = document.getElementById("loginForm"); |
| const passwordInput = document.getElementById("password"); |
| const loginButton = document.getElementById("loginButton"); |
| const loginError = document.getElementById("loginError"); |
| const togglePassword = document.getElementById("togglePassword"); |
| const logoutButton = document.getElementById("logoutButton"); |
| const supervisorSelect = document.getElementById("supervisorSelect"); |
| const researcherSelect = document.getElementById("researcherSelect"); |
| const statusSelect = document.getElementById("statusSelect"); |
| const searchInput = document.getElementById("searchInput"); |
| const emptyState = document.getElementById("emptyState"); |
| const resultsSection = document.getElementById("resultsSection"); |
| const resultsMeta = document.getElementById("resultsMeta"); |
| const resultsTitle = document.getElementById("resultsTitle"); |
| const statusChips = document.getElementById("statusChips"); |
| const samplesGrid = document.getElementById("samplesGrid"); |
| const noResults = document.getElementById("noResults"); |
| const copyAllButton = document.getElementById("copyAllButton"); |
| const loadMoreWrap = document.getElementById("loadMoreWrap"); |
| const loadMoreButton = document.getElementById("loadMoreButton"); |
| const loadMoreMeta = document.getElementById("loadMoreMeta"); |
| const clearFiltersButton = document.getElementById("clearFiltersButton"); |
| const toast = document.getElementById("toast"); |
|
|
| let allRows = []; |
| let visibleRows = []; |
| let failedAttempts = 0; |
| let toastTimer; |
| let inactivityTimer; |
| let inactivityWarningTimer; |
| let renderedCount = 0; |
| const ALL_SUPERVISORS_VALUE = "__all_supervisors__"; |
| const PAGE_SIZE = 24; |
| const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000; |
| const INACTIVITY_WARNING_MS = 29 * 60 * 1000; |
| const ACTIVITY_EVENTS = ["click", "keydown", "pointerdown", "touchstart", "scroll"]; |
|
|
| function base64ToBytes(value) { |
| const binary = atob(value); |
| return Uint8Array.from(binary, (char) => char.charCodeAt(0)); |
| } |
|
|
| async function deriveKey(password, salt) { |
| const material = await crypto.subtle.importKey( |
| "raw", |
| new TextEncoder().encode(password), |
| "PBKDF2", |
| false, |
| ["deriveKey"], |
| ); |
|
|
| return crypto.subtle.deriveKey( |
| { |
| name: "PBKDF2", |
| salt, |
| iterations: ENCRYPTED_DATA.iterations, |
| hash: "SHA-256", |
| }, |
| material, |
| { name: "AES-GCM", length: 256 }, |
| false, |
| ["decrypt"], |
| ); |
| } |
|
|
| async function decryptData(password) { |
| const salt = base64ToBytes(ENCRYPTED_DATA.salt); |
| const iv = base64ToBytes(ENCRYPTED_DATA.iv); |
| const cipherText = base64ToBytes(ENCRYPTED_DATA.payload); |
| const key = await deriveKey(password, salt); |
| const plainBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipherText); |
| return JSON.parse(new TextDecoder().decode(plainBuffer)); |
| } |
|
|
| function normalize(value) { |
| return String(value ?? "") |
| .normalize("NFKD") |
| .replace(/[\u064B-\u065F\u0670]/g, "") |
| .replace(/[أإآ]/g, "ا") |
| .replace(/ى/g, "ي") |
| .replace(/ة/g, "ه") |
| .replace(/\s+/g, " ") |
| .trim() |
| .toLowerCase(); |
| } |
|
|
| function formatPhone(value) { |
| const raw = String(value ?? "").trim(); |
| if (!raw) return ""; |
| const digits = raw.replace(/\D/g, ""); |
| if (!digits) return raw; |
| if (digits.startsWith("0")) return digits; |
| if (digits.startsWith("966")) return `0${digits.slice(3)}`; |
| return `0${digits}`; |
| } |
|
|
| function rowToObject(row) { |
| const item = Object.fromEntries(FIELD_KEYS.map((key, index) => [key, row[index] ?? ""])); |
| item.phone = formatPhone(item.phone); |
| return item; |
| } |
|
|
| function uniqueSorted(values) { |
| return [...new Set(values.filter(Boolean))].sort((a, b) => |
| String(a).localeCompare(String(b), "ar", { sensitivity: "base" }), |
| ); |
| } |
|
|
| function setOptions(select, values, placeholder, counts = null) { |
| select.replaceChildren(new Option(placeholder, "")); |
| values.forEach((value) => { |
| const label = counts ? `${value} (${counts.get(value) || 0})` : value; |
| select.add(new Option(label, value)); |
| }); |
| } |
|
|
| function showToast(message) { |
| clearTimeout(toastTimer); |
| toast.textContent = message; |
| toast.classList.add("show"); |
| toastTimer = setTimeout(() => toast.classList.remove("show"), 2300); |
| } |
|
|
| async function copyText(text, successMessage) { |
| try { |
| await navigator.clipboard.writeText(text); |
| } catch { |
| const area = document.createElement("textarea"); |
| area.value = text; |
| area.style.position = "fixed"; |
| area.style.opacity = "0"; |
| document.body.append(area); |
| area.select(); |
| document.execCommand("copy"); |
| area.remove(); |
| } |
| showToast(successMessage); |
| } |
|
|
| function buildMessage(row, index = null) { |
| const title = index === null ? "بيانات العينة للتواصل" : `العينة رقم (${index})`; |
| const sampleLines = [ |
| title, |
| "────────────────────", |
| row.sampleName && `اسم العينة: ${row.sampleName}`, |
| row.recordNumber && `رقم السجل: ${row.recordNumber}`, |
| row.fulfillmentStatus && `حالة الاستيفاء: ${row.fulfillmentStatus}`, |
| row.activity && `النشاط: ${row.activity}`, |
| ].filter(Boolean); |
| const contactLines = [ |
| "بيانات التواصل", |
| "────────────────────", |
| row.phone && `رقم التواصل: ${formatPhone(row.phone)}`, |
| row.email && `البريد الإلكتروني: ${row.email}`, |
| row.location && `الموقع: ${row.location}`, |
| ].filter(Boolean); |
| const sections = []; |
|
|
| if (index === null) { |
| sections.push( |
| "السلام عليكم ورحمة الله وبركاته،", |
| "نأمل التكرم بالتواصل مع العينة التالية واستكمال الإجراء اللازم:", |
| ); |
| } |
|
|
| sections.push(sampleLines.join("\n")); |
| if (contactLines.length > 2) sections.push(contactLines.join("\n")); |
| if (index === null) sections.push("شاكرين لكم تعاونكم."); |
| return sections.join("\n\n"); |
| } |
|
|
| function createDetail(key, label, isContact, value) { |
| const wrapper = document.createElement("div"); |
| wrapper.className = `detail-item${isContact ? " contact-detail" : ""}`; |
|
|
| const labelElement = document.createElement("span"); |
| labelElement.className = "detail-label"; |
| labelElement.textContent = label; |
|
|
| const valueRow = document.createElement("div"); |
| valueRow.className = "detail-value-row"; |
| const valueElement = document.createElement("span"); |
| valueElement.className = "detail-value"; |
| valueElement.textContent = String(value); |
| if (key === "phone" || key === "recordNumber") { |
| valueElement.dir = "ltr"; |
| valueElement.style.textAlign = "right"; |
| } |
|
|
| valueRow.append(valueElement); |
| if (key === "phone" || key === "email") { |
| const copyFieldButton = document.createElement("button"); |
| copyFieldButton.type = "button"; |
| copyFieldButton.className = "copy-field-button"; |
| copyFieldButton.setAttribute("aria-label", key === "phone" ? "نسخ رقم التواصل" : "نسخ البريد الإلكتروني"); |
| copyFieldButton.title = key === "phone" ? "نسخ رقم التواصل" : "نسخ البريد الإلكتروني"; |
| copyFieldButton.innerHTML = ` |
| <svg viewBox="0 0 24 24" aria-hidden="true"> |
| <rect x="8" y="8" width="11" height="11" rx="2"></rect> |
| <path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"></path> |
| </svg> |
| `; |
| copyFieldButton.addEventListener("click", () => |
| copyText(String(value), key === "phone" ? "تم نسخ رقم التواصل" : "تم نسخ البريد الإلكتروني"), |
| ); |
| valueRow.append(copyFieldButton); |
| } |
|
|
| wrapper.append(labelElement, valueRow); |
| return wrapper; |
| } |
|
|
| function createSampleCard(row, index) { |
| const card = document.createElement("article"); |
| card.className = "sample-card"; |
|
|
| const header = document.createElement("div"); |
| header.className = "sample-card-header"; |
|
|
| const headingWrap = document.createElement("div"); |
| const number = document.createElement("span"); |
| number.className = "sample-index"; |
| number.textContent = `العينة ${index + 1}`; |
| const title = document.createElement("h3"); |
| title.textContent = row.sampleName || "عينة دون اسم"; |
| headingWrap.append(number, title); |
|
|
| const badge = document.createElement("span"); |
| badge.className = "status-badge"; |
| badge.textContent = row.fulfillmentStatus || "غير محدد"; |
| header.append(headingWrap, badge); |
|
|
| const details = document.createElement("div"); |
| details.className = "sample-details"; |
| DISPLAY_FIELDS.forEach(([key, label, isContact]) => { |
| if (String(row[key] ?? "").trim()) { |
| details.append(createDetail(key, label, isContact, row[key])); |
| } |
| }); |
|
|
| const footer = document.createElement("div"); |
| footer.className = "sample-card-footer"; |
| const copyButton = document.createElement("button"); |
| copyButton.className = "copy-sample"; |
| copyButton.type = "button"; |
| copyButton.textContent = "نسخ رسالة هذه العينة"; |
| copyButton.addEventListener("click", () => |
| copyText(buildMessage(row), "تم نسخ رسالة العينة"), |
| ); |
| footer.append(copyButton); |
|
|
| card.append(header, details, footer); |
| return card; |
| } |
|
|
| function renderVisibleRows(reset = false) { |
| if (reset) { |
| renderedCount = 0; |
| samplesGrid.replaceChildren(); |
| } |
|
|
| const nextRows = visibleRows.slice(renderedCount, renderedCount + PAGE_SIZE); |
| const fragment = document.createDocumentFragment(); |
| nextRows.forEach((row, offset) => { |
| fragment.append(createSampleCard(row, renderedCount + offset)); |
| }); |
| samplesGrid.append(fragment); |
| renderedCount += nextRows.length; |
|
|
| const remaining = visibleRows.length - renderedCount; |
| loadMoreWrap.hidden = remaining <= 0; |
| loadMoreButton.textContent = `عرض المزيد (${Math.min(PAGE_SIZE, remaining)})`; |
| loadMoreMeta.textContent = `تم عرض ${renderedCount} من أصل ${visibleRows.length} عينة`; |
| } |
|
|
| function renderStatusChips(rows) { |
| const counts = new Map(); |
| rows.forEach((row) => { |
| const status = row.fulfillmentStatus || "غير محدد"; |
| counts.set(status, (counts.get(status) || 0) + 1); |
| }); |
|
|
| const createChip = (label, value, count) => { |
| const chip = document.createElement("button"); |
| chip.type = "button"; |
| chip.className = `status-chip${statusSelect.value === value ? " active" : ""}`; |
| chip.setAttribute("aria-pressed", String(statusSelect.value === value)); |
| chip.append(document.createTextNode(label)); |
|
|
| const strong = document.createElement("strong"); |
| strong.textContent = count; |
| chip.append(strong); |
| chip.addEventListener("click", () => { |
| statusSelect.value = value; |
| applyFilters(); |
| }); |
| return chip; |
| }; |
|
|
| const chips = [createChip("الكل", "", rows.length)]; |
| [...counts.entries()].forEach(([status, count]) => { |
| chips.push(createChip(status, status, count)); |
| }); |
| statusChips.replaceChildren(...chips); |
| } |
|
|
| function applyFilters() { |
| const supervisor = supervisorSelect.value; |
| const researcher = researcherSelect.value; |
| const query = normalize(searchInput.value); |
|
|
| if (!supervisor && !query) { |
| resultsSection.hidden = true; |
| emptyState.hidden = false; |
| return; |
| } |
|
|
| const status = statusSelect.value; |
| const supervisorRows = |
| !supervisor || supervisor === ALL_SUPERVISORS_VALUE |
| ? allRows |
| : allRows.filter((row) => row.supervisor === supervisor); |
| const scopedRows = researcher |
| ? supervisorRows.filter((row) => row.researcher === researcher) |
| : supervisorRows; |
|
|
| visibleRows = scopedRows.filter((row) => { |
| const matchesStatus = !status || row.fulfillmentStatus === status; |
| const matchesSearch = |
| !query || |
| normalize(row.sampleName).includes(query) || |
| normalize(row.recordNumber).includes(query); |
| return matchesStatus && matchesSearch; |
| }); |
|
|
| emptyState.hidden = true; |
| resultsSection.hidden = false; |
| resultsTitle.textContent = |
| researcher || |
| (supervisor === ALL_SUPERVISORS_VALUE |
| ? "جميع العينات" |
| : supervisor |
| ? `عينات المشرف: ${supervisor}` |
| : "نتائج البحث"); |
| resultsMeta.textContent = `${visibleRows.length} من أصل ${scopedRows.length} عينة`; |
| copyAllButton.disabled = visibleRows.length === 0; |
| copyAllButton.textContent = researcher ? "نسخ جميع عينات الباحث" : "نسخ جميع النتائج المعروضة"; |
| renderStatusChips(scopedRows); |
|
|
| renderVisibleRows(true); |
| noResults.hidden = visibleRows.length !== 0; |
| } |
|
|
| function resetResearcherControls() { |
| setOptions(researcherSelect, [], "جميع الباحثين"); |
| setOptions(statusSelect, [], "جميع الحالات"); |
| researcherSelect.disabled = true; |
| statusSelect.disabled = true; |
| resultsSection.hidden = true; |
| emptyState.hidden = false; |
| emptyState.querySelector("h3").textContent = "ابحث أو اختر المشرف"; |
| emptyState.querySelector("p").textContent = |
| "يمكنك البحث مباشرة باسم العينة أو رقم السجل، أو اختيار المشرف لعرض كامل عيناته."; |
| } |
|
|
| function initializeDashboard(data) { |
| allRows = data.rows.map(rowToObject); |
| const supervisorCounts = new Map(); |
| allRows.forEach((row) => |
| supervisorCounts.set(row.supervisor, (supervisorCounts.get(row.supervisor) || 0) + 1), |
| ); |
| setOptions( |
| supervisorSelect, |
| uniqueSorted(allRows.map((row) => row.supervisor)), |
| "اختر المشرف", |
| supervisorCounts, |
| ); |
| supervisorSelect.add( |
| new Option(`إبراهيم - جميع العينات (${allRows.length})`, ALL_SUPERVISORS_VALUE), |
| 1, |
| ); |
| searchInput.value = ""; |
| searchInput.disabled = false; |
| resetResearcherControls(); |
| startInactivityTimer(); |
| } |
|
|
| function clearInactivityTimers() { |
| clearTimeout(inactivityTimer); |
| clearTimeout(inactivityWarningTimer); |
| } |
|
|
| function startInactivityTimer() { |
| clearInactivityTimers(); |
| if (dashboardView.hidden) return; |
|
|
| inactivityWarningTimer = setTimeout(() => { |
| showToast("سيتم تسجيل الخروج تلقائيًا بعد دقيقة لعدم وجود نشاط"); |
| }, INACTIVITY_WARNING_MS); |
|
|
| inactivityTimer = setTimeout(() => { |
| performLogout("تم تسجيل الخروج تلقائيًا بعد 30 دقيقة من عدم النشاط"); |
| }, INACTIVITY_TIMEOUT_MS); |
| } |
|
|
| function performLogout(message = "") { |
| clearInactivityTimers(); |
| allRows = []; |
| visibleRows = []; |
| renderedCount = 0; |
| supervisorSelect.value = ""; |
| searchInput.value = ""; |
| resetResearcherControls(); |
| dashboardView.hidden = true; |
| loginView.hidden = false; |
| passwordInput.focus(); |
| if (message) showToast(message); |
| } |
|
|
| function clearFilters() { |
| supervisorSelect.value = ""; |
| searchInput.value = ""; |
| resetResearcherControls(); |
| window.scrollTo({ top: 0, behavior: "smooth" }); |
| showToast("تم مسح جميع الفلاتر"); |
| } |
|
|
| function handleActivity() { |
| if (!dashboardView.hidden) startInactivityTimer(); |
| } |
|
|
| loginForm.addEventListener("submit", async (event) => { |
| event.preventDefault(); |
| const password = passwordInput.value.trim(); |
| if (!password) return; |
|
|
| loginButton.disabled = true; |
| loginButton.querySelector("span").textContent = "جارٍ التحقق..."; |
| loginError.textContent = ""; |
|
|
| if (failedAttempts >= 3) { |
| await new Promise((resolve) => setTimeout(resolve, Math.min(failedAttempts * 800, 5000))); |
| } |
|
|
| try { |
| const data = await decryptData(password); |
| initializeDashboard(data); |
| passwordInput.value = ""; |
| loginView.hidden = true; |
| dashboardView.hidden = false; |
| failedAttempts = 0; |
| } catch { |
| failedAttempts += 1; |
| loginError.textContent = "رمز الدخول غير صحيح. تحقق منه وحاول مرة أخرى."; |
| passwordInput.select(); |
| } finally { |
| loginButton.disabled = false; |
| loginButton.querySelector("span").textContent = "دخول آمن"; |
| } |
| }); |
|
|
| togglePassword.addEventListener("click", () => { |
| const showing = passwordInput.type === "text"; |
| passwordInput.type = showing ? "password" : "text"; |
| togglePassword.setAttribute("aria-label", showing ? "إظهار رمز الدخول" : "إخفاء رمز الدخول"); |
| }); |
|
|
| logoutButton.addEventListener("click", () => { |
| performLogout(); |
| }); |
|
|
| supervisorSelect.addEventListener("change", () => { |
| resetResearcherControls(); |
| searchInput.value = ""; |
| if (!supervisorSelect.value) { |
| applyFilters(); |
| return; |
| } |
|
|
| const researchers = uniqueSorted( |
| allRows |
| .filter( |
| (row) => |
| supervisorSelect.value === ALL_SUPERVISORS_VALUE || |
| row.supervisor === supervisorSelect.value, |
| ) |
| .map((row) => row.researcher), |
| ); |
| const scopeRows = |
| supervisorSelect.value === ALL_SUPERVISORS_VALUE |
| ? allRows |
| : allRows.filter((row) => row.supervisor === supervisorSelect.value); |
| const researcherCounts = new Map(); |
| scopeRows.forEach((row) => |
| researcherCounts.set(row.researcher, (researcherCounts.get(row.researcher) || 0) + 1), |
| ); |
| setOptions(researcherSelect, researchers, "جميع الباحثين", researcherCounts); |
| setOptions( |
| statusSelect, |
| uniqueSorted(scopeRows.map((row) => row.fulfillmentStatus)), |
| "جميع الحالات", |
| ); |
| researcherSelect.disabled = false; |
| statusSelect.disabled = false; |
| applyFilters(); |
| }); |
|
|
| researcherSelect.addEventListener("change", () => { |
| const rows = allRows.filter((row) => { |
| const matchesSupervisor = |
| supervisorSelect.value === ALL_SUPERVISORS_VALUE || |
| row.supervisor === supervisorSelect.value; |
| const matchesResearcher = |
| !researcherSelect.value || row.researcher === researcherSelect.value; |
| return matchesSupervisor && matchesResearcher; |
| }); |
| const statuses = uniqueSorted(rows.map((row) => row.fulfillmentStatus)); |
| setOptions(statusSelect, statuses, "جميع الحالات"); |
| statusSelect.disabled = false; |
| applyFilters(); |
| }); |
|
|
| statusSelect.addEventListener("change", applyFilters); |
| searchInput.addEventListener("input", applyFilters); |
| loadMoreButton.addEventListener("click", () => renderVisibleRows()); |
| clearFiltersButton.addEventListener("click", clearFilters); |
| ACTIVITY_EVENTS.forEach((eventName) => |
| document.addEventListener(eventName, handleActivity, { passive: true }), |
| ); |
|
|
| copyAllButton.addEventListener("click", () => { |
| if (!visibleRows.length) return; |
| const heading = researcherSelect.value |
| ? `بيانات العينات الخاصة بالباحث: ${researcherSelect.value}` |
| : `بيانات العينات المعروضة (${visibleRows.length} عينة)`; |
| const messages = visibleRows.map((row, index) => buildMessage(row, index + 1)); |
| copyText( |
| [heading, "════════════════════", ...messages].join("\n\n"), |
| "تم نسخ جميع العينات المعروضة", |
| ); |
| }); |
|
|
| if (!window.crypto?.subtle || typeof ENCRYPTED_DATA === "undefined") { |
| loginButton.disabled = true; |
| loginError.textContent = "تعذر تشغيل التشفير في هذا المتصفح. استخدم متصفحًا حديثًا."; |
| } |
|
|