| "use strict"; |
|
|
| const PAGE_SIZE = 24; |
| const ADMIN_REFRESH_INTERVAL = 60000; |
| const STATE_KEY = "ics2ResearcherWorkspace"; |
| const DOCUMENTATION_DONE_KEY = "ics2DocumentationCompleted"; |
| const FORM_URL = "https://drive.google.com/file/d/1BEJ3qrqB3RMvhw4Z0i4dxZiwEreT9cJc/view?usp=sharing"; |
| const MADON_GROUP_URL = "https://chat.whatsapp.com/CeSJId4TjanKJk7xAMWOAg?mode=gi_t"; |
|
|
| const REPRESENTATIVES = [ |
| { match: ["الصناعية الأولى", "الصناعية الاولى"], city: "المدينة الصناعية الأولى بالدمام", name: "يوسف العباد", phone: "0501444214" }, |
| { match: ["الصناعية الثانية"], city: "المدينة الصناعية الثانية بالدمام", name: "سالم بوسوده", phone: "0560503707" }, |
| { match: ["الصناعية الثالثة"], city: "المدينة الصناعية الثالثة بالدمام", name: "إبراهيم الغامدي", phone: "0555309388" }, |
| { match: ["الأحساء", "الاحساء", "العيون", "واحة مدن"], city: "المدينة الصناعية الأولى بالأحساء", name: "أحمد الخليفة", phone: "0506448053" }, |
| { match: ["حفر الباطن"], city: "المدينة الصناعية بحفر الباطن", name: "براك المطيري", phone: "0540055303", role: "مدير المدينة الصناعية بحفر الباطن" }, |
| ]; |
|
|
| const ICONS = { |
| copy: "M8 8h11v11H8z M5 16H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1", |
| map: "M12 21s7-4.6 7-11a7 7 0 1 0-14 0c0 6.4 7 11 7 11Z M12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z", |
| whatsapp: "M20.5 11.5a8.5 8.5 0 0 1-12.6 7.4L3 20l1.2-4.7A8.5 8.5 0 1 1 20.5 11.5Z M8.6 8.7c.2 3.3 2.7 5.8 6 6.2", |
| }; |
|
|
| const elements = Object.fromEntries( |
| [ |
| "loginView", "loginForm", "password", "togglePassword", "loginButton", "loginError", |
| "researcherView", "researcherSearch", "researcherGrid", "entitySearchResults", |
| "researcherModeButton", "entityModeButton", "dashboardView", "researcherName", |
| "switchResearcherButton", "logoutButton", "summaryStats", "researcherMapLink", "formLink", |
| "shareFormButton", "representativesDirectory", "toggleContactsButton", "cityButtons", "searchInput", "statusSelect", "locationSelect", |
| "clearFiltersButton", "resultsMeta", "resultsTitle", "samplesGrid", |
| "noResults", "loadMoreWrap", "loadMoreButton", "loadMoreMeta", "toast", |
| "selectionBar", "selectionCount", "clearSelectionButton", "sendSelectionButton", |
| "shareDialog", "closeShareDialog", "shareForm", "recipientPhone", "recipientPhoneError", |
| "messagePreview", "copyShareMessage", "sendShareButton", |
| "madonDialog", "closeMadonDialog", "madonForm", "madonRepresentativeOptions", |
| "madonMessagePreview", "copyMadonMessage", "openMadonGroup", "sendMadonMessage", |
| "documentationDialog", "documentationDialogTitle", "closeDocumentationDialog", "documentationEntity", |
| "documentationUnavailable", "documentationForm", "fieldStatus", "otherStatusField", "otherStatus", "fieldStatement", |
| "statementCount", "documentationPhotos", "photoPreviews", "documentationError", |
| "uploadProgress", "uploadProgressBar", "cancelDocumentationButton", "saveDocumentationButton", |
| "adminView", "adminLogoutButton", "adminUpdatedAt", "adminSyncStatus", "refreshAdminButton", "adminStats", "adminInsight", |
| "adminProgressOverview", "adminTrendTotal", "adminTrendChart", "adminCityProgress", |
| "adminControls", "adminSearch", "adminResearcherFilter", "adminCityFilter", "adminDateFilter", |
| "adminStatusFilter", "clearAdminFilters", "activeResearchersCount", |
| "researcherProductivity", "adminRecordsCount", "adminRecords", "adminEmpty", |
| ].map((id) => [id, document.getElementById(id)]), |
| ); |
|
|
| let payload = null; |
| let activeResearcher = null; |
| let researcherRows = []; |
| let visibleRows = []; |
| let renderedCount = 0; |
| let toastTimer; |
| let shareRow = null; |
| let madonRow = null; |
| let selectedRepresentative = null; |
| const selectedRows = new Set(); |
| let isBulkMadonMessage = false; |
| let sessionAccessCode = ""; |
| let documentationRow = null; |
| let documentationFiles = []; |
| let documentationExistingPhotos = []; |
| let documentationRecords = []; |
| let lastSavedRowId = ""; |
| let adminRefreshTimer = null; |
| let adminRefreshInProgress = false; |
| let adminDashboardInitialized = false; |
| let newAdminRecordIds = new Set(); |
| let adminRecords = []; |
| let directoryMode = "researcher"; |
| const mobileQuery = window.matchMedia("(max-width: 560px)"); |
|
|
| 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, encryptedSource = ENCRYPTED_DATA) { |
| const salt = base64ToBytes(encryptedSource.salt); |
| const iv = base64ToBytes(encryptedSource.iv); |
| const combined = base64ToBytes(encryptedSource.payload); |
| const cipherText = combined.slice(0, -16); |
| const authTag = combined.slice(-16); |
| const encrypted = new Uint8Array(cipherText.length + authTag.length); |
| encrypted.set(cipherText); |
| encrypted.set(authTag, cipherText.length); |
| const keyMaterial = await crypto.subtle.importKey( |
| "raw", |
| new TextEncoder().encode(password), |
| "PBKDF2", |
| false, |
| ["deriveKey"], |
| ); |
| const key = await crypto.subtle.deriveKey( |
| { name: "PBKDF2", salt, iterations: encryptedSource.iterations, hash: "SHA-256" }, |
| keyMaterial, |
| { name: "AES-GCM", length: 256 }, |
| false, |
| ["decrypt"], |
| ); |
| const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, encrypted); |
| return JSON.parse(new TextDecoder().decode(plain)); |
| } |
|
|
| 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 uniqueSorted(values) { |
| return [...new Set(values.filter(Boolean))].sort((a, b) => |
| String(a).localeCompare(String(b), "ar", { sensitivity: "base" }), |
| ); |
| } |
|
|
| function createIcon(path) { |
| const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); |
| svg.setAttribute("viewBox", "0 0 24 24"); |
| svg.setAttribute("aria-hidden", "true"); |
| const element = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| element.setAttribute("d", path); |
| svg.append(element); |
| return svg; |
| } |
|
|
| function showToast(message) { |
| clearTimeout(toastTimer); |
| elements.toast.textContent = message; |
| elements.toast.classList.add("show"); |
| toastTimer = setTimeout(() => elements.toast.classList.remove("show"), 4000); |
| } |
|
|
| function documentationReady() { |
| return Boolean( |
| typeof DOCUMENTATION_CONFIG === "object" && |
| /^https:\/\/script\.google\.com\/macros\/s\/.+\/exec$/.test(DOCUMENTATION_CONFIG.endpoint), |
| ); |
| } |
|
|
| async function fetchDocumentation(action, accessCode) { |
| if (!documentationReady()) throw new Error("خدمة التوثيق غير متاحة."); |
| const url = new URL(DOCUMENTATION_CONFIG.endpoint); |
| url.searchParams.set("action", action); |
| url.searchParams.set("accessCode", accessCode); |
| const response = await fetch(url); |
| const result = await response.json(); |
| if (!result.ok) throw new Error(result.error || "تعذر مزامنة بيانات التوثيق."); |
| return result; |
| } |
|
|
| function latestRecordsBySample(records) { |
| const map = new Map(); |
| records.forEach((record) => { |
| if (!record.sampleKey || map.has(record.sampleKey)) return; |
| map.set(record.sampleKey, record); |
| }); |
| return map; |
| } |
|
|
| function latestRecordsByCommercialRecord(records) { |
| const map = new Map(); |
| records.forEach((record) => { |
| if (!record.commercialRecord || map.has(record.commercialRecord)) return; |
| map.set(record.commercialRecord, record); |
| }); |
| return map; |
| } |
|
|
| function documentationRecord(row) { |
| return ( |
| latestRecordsBySample(documentationRecords).get(row.sampleKey) || |
| latestRecordsByCommercialRecord(documentationRecords).get(row.commercialRecord) |
| ); |
| } |
|
|
| function hydrateRecordKeys(records) { |
| const currentKeys = new Set(payload?.rows.map((row) => row.sampleKey) || []); |
| return records.map((record) => { |
| if (record.sampleKey && currentKeys.has(record.sampleKey)) return record; |
| const match = payload?.rows.find((row) => { |
| if (record.commercialRecord && row.commercialRecord === record.commercialRecord) { |
| return true; |
| } |
| return ( |
| normalize(row.establishmentName) === normalize(record.establishmentName) && |
| (!record.contractNumber || row.contractNumber === record.contractNumber) |
| ); |
| }); |
| return { |
| ...record, |
| sampleKey: match?.sampleKey || record.sampleKey || "", |
| researcher: match?.researcher || record.researcher, |
| }; |
| }); |
| } |
|
|
| function preserveDocumentedRows(records) { |
| if (!payload?.rows?.length || !Array.isArray(records) || !records.length) return; |
| const existingKeys = new Set(payload.rows.map((row) => row.sampleKey)); |
| const existingCommercialRecords = new Set(payload.rows.map((row) => row.commercialRecord).filter(Boolean)); |
| const extraRows = []; |
|
|
| records.forEach((record) => { |
| if (!record.sampleKey || existingKeys.has(record.sampleKey) || !record.establishmentName) return; |
| if (record.commercialRecord && existingCommercialRecords.has(record.commercialRecord)) return; |
| existingKeys.add(record.sampleKey); |
| if (record.commercialRecord) existingCommercialRecords.add(record.commercialRecord); |
| extraRows.push({ |
| researcher: record.researcher || "غير محدد", |
| establishmentName: record.establishmentName, |
| contractNumber: record.contractNumber || "لا يوجد رقم عقد", |
| city: record.city || "غير محدد", |
| sourceCity: record.city || "غير محدد", |
| representativeCity: record.city || "غير محدد", |
| status: record.fieldStatus || "توثيق سابق", |
| madonStatement: "", |
| madonNoteText: "", |
| coordinates: "", |
| locationType: "none", |
| commercialRecord: record.commercialRecord || "", |
| unifiedNumber: "", |
| activityCode: "", |
| activity: "", |
| sampleKey: record.sampleKey, |
| preservedDocumentation: true, |
| }); |
| }); |
|
|
| if (!extraRows.length) return; |
| payload.rows = [...payload.rows, ...extraRows]; |
| const counts = new Map(); |
| payload.rows.forEach((row) => counts.set(row.researcher, (counts.get(row.researcher) || 0) + 1)); |
| const researchers = new Map(payload.researchers.map((researcher) => [researcher.name, researcher])); |
| counts.forEach((count, name) => { |
| const existing = researchers.get(name); |
| researchers.set(name, { |
| name, |
| count, |
| mapUrl: existing?.mapUrl || "", |
| }); |
| }); |
| payload.researchers = [...researchers.values()].sort((a, b) => |
| a.name.localeCompare(b.name, "ar", { sensitivity: "base" }), |
| ); |
| } |
|
|
| function riyadhToday() { |
| return new Intl.DateTimeFormat("en-CA", { |
| timeZone: "Asia/Riyadh", |
| year: "numeric", |
| month: "2-digit", |
| day: "2-digit", |
| }).format(new Date()); |
| } |
|
|
| function dateOffset(dateValue, offset) { |
| const [year, month, day] = dateValue.split("-").map(Number); |
| const date = new Date(Date.UTC(year, month - 1, day + offset)); |
| return [ |
| date.getUTCFullYear(), |
| String(date.getUTCMonth() + 1).padStart(2, "0"), |
| String(date.getUTCDate()).padStart(2, "0"), |
| ].join("-"); |
| } |
|
|
| function adminSnapshot() { |
| const records = [...latestRecordsBySample(adminRecords).values()]; |
| const recordMap = new Map(records.map((record) => [record.sampleKey, record])); |
| const commercialRecordMap = latestRecordsByCommercialRecord(records); |
| const today = riyadhToday(); |
| const yesterday = dateOffset(today, -1); |
| const documented = payload.rows.filter((row) => |
| recordMap.has(row.sampleKey) || |
| Boolean(row.commercialRecord && commercialRecordMap.has(row.commercialRecord)), |
| ); |
| const todayRecords = records.filter((record) => record.documentedDate === today); |
| const yesterdayRecords = records.filter((record) => record.documentedDate === yesterday); |
| const activeToday = new Set(todayRecords.map((record) => record.researcher).filter(Boolean)); |
| return { |
| records, |
| recordMap, |
| commercialRecordMap, |
| today, |
| yesterday, |
| documented, |
| todayRecords, |
| yesterdayRecords, |
| activeToday, |
| percentage: payload.rows.length ? (documented.length / payload.rows.length) * 100 : 0, |
| }; |
| } |
|
|
| async function syncResearcherDocumentation() { |
| try { |
| const result = await fetchDocumentation("status", sessionAccessCode); |
| documentationRecords = hydrateRecordKeys(result.records || []); |
| preserveDocumentedRows(documentationRecords); |
| } catch (error) { |
| console.warn(error.message); |
| documentationRecords = []; |
| } |
| } |
|
|
| function adminStat(value, label, note = "") { |
| const item = document.createElement("article"); |
| item.className = "admin-stat"; |
| const strong = document.createElement("strong"); |
| strong.textContent = value; |
| const span = document.createElement("span"); |
| span.textContent = label; |
| item.append(strong, span); |
| if (note) { |
| const small = document.createElement("small"); |
| small.textContent = note; |
| item.append(small); |
| } |
| return item; |
| } |
|
|
| function setAdminFilterOptions() { |
| const selectedResearcher = elements.adminResearcherFilter.value; |
| const selectedCity = elements.adminCityFilter.value; |
| const selectedStatus = elements.adminStatusFilter.value; |
| setSelectOptions( |
| elements.adminResearcherFilter, |
| uniqueSorted(payload.researchers.map((item) => item.name)), |
| "جميع الباحثين", |
| ); |
| setSelectOptions( |
| elements.adminCityFilter, |
| uniqueSorted(payload.rows.map((row) => row.city)), |
| "جميع المدن", |
| ); |
| setSelectOptions( |
| elements.adminStatusFilter, |
| uniqueSorted(adminRecords.map((record) => record.fieldStatus)), |
| "جميع الحالات", |
| ); |
| if ([...elements.adminResearcherFilter.options].some((option) => option.value === selectedResearcher)) { |
| elements.adminResearcherFilter.value = selectedResearcher; |
| } |
| if ([...elements.adminCityFilter.options].some((option) => option.value === selectedCity)) { |
| elements.adminCityFilter.value = selectedCity; |
| } |
| if ([...elements.adminStatusFilter.options].some((option) => option.value === selectedStatus)) { |
| elements.adminStatusFilter.value = selectedStatus; |
| } |
| } |
|
|
| function adminRecordIdentity(record) { |
| return record.documentationId || record.sampleKey || `${record.commercialRecord}|${record.establishmentName}`; |
| } |
|
|
| function setAdminSyncState(state, text) { |
| elements.adminSyncStatus.className = `sync-status ${state}`; |
| elements.adminSyncStatus.querySelector("span").textContent = text; |
| } |
|
|
| function renderAdminStats() { |
| const snapshot = adminSnapshot(); |
| const inactiveToday = Math.max(payload.researchers.length - snapshot.activeToday.size, 0); |
| elements.adminStats.replaceChildren( |
| adminStat(payload.rows.length, "إجمالي العينات"), |
| adminStat(snapshot.documented.length, "تم التوثيق", `${snapshot.percentage.toFixed(1)}% من الإجمالي`), |
| adminStat(payload.rows.length - snapshot.documented.length, "المتبقي"), |
| adminStat(snapshot.todayRecords.length, "إنجاز اليوم", snapshot.today), |
| adminStat(snapshot.yesterdayRecords.length, "إنجاز أمس"), |
| adminStat(snapshot.activeToday.size, "باحثون أنجزوا اليوم", `${inactiveToday} دون إنجاز اليوم`), |
| ); |
| renderAdminInsight(snapshot, inactiveToday); |
| } |
|
|
| function renderAdminInsight(snapshot, inactiveToday) { |
| const todayByResearcher = new Map(); |
| snapshot.todayRecords.forEach((record) => { |
| if (!record.researcher) return; |
| todayByResearcher.set(record.researcher, (todayByResearcher.get(record.researcher) || 0) + 1); |
| }); |
| const leader = [...todayByResearcher.entries()].sort((a, b) => b[1] - a[1])[0]; |
| const difference = snapshot.todayRecords.length - snapshot.yesterdayRecords.length; |
| const trendText = difference > 0 |
| ? `إنجاز اليوم أعلى من أمس بـ ${difference}` |
| : difference < 0 |
| ? `إنجاز اليوم أقل من أمس بـ ${Math.abs(difference)}` |
| : "إنجاز اليوم مماثل لأمس"; |
| const leaderText = leader |
| ? `الأعلى اليوم: ${leader[0]} بعدد ${leader[1]}` |
| : "لم يسجل أي إنجاز اليوم حتى الآن"; |
|
|
| elements.adminInsight.className = `admin-insight ${difference < 0 ? "needs-attention" : "positive"}`; |
| elements.adminInsight.innerHTML = ` |
| <span class="admin-insight-icon">${difference < 0 ? "!" : "✓"}</span> |
| <div> |
| <strong>${trendText}</strong> |
| <p>${leaderText} · ${inactiveToday} باحث دون إنجاز اليوم.</p> |
| </div>`; |
| } |
|
|
| function renderAdminOverview() { |
| const snapshot = adminSnapshot(); |
| const remaining = Math.max(payload.rows.length - snapshot.documented.length, 0); |
| const degrees = Math.min(snapshot.percentage, 100) * 3.6; |
| elements.adminProgressOverview.innerHTML = ` |
| <div class="completion-ring" style="--progress:${degrees}deg"> |
| <div><strong>${snapshot.percentage.toFixed(1)}%</strong><span>مكتمل</span></div> |
| </div> |
| <div class="completion-legend"> |
| <p><i class="documented-dot"></i><span>موثق</span><strong>${snapshot.documented.length}</strong></p> |
| <p><i class="remaining-dot"></i><span>متبقٍ</span><strong>${remaining}</strong></p> |
| <small>تُحتسب النسبة من التوثيق الفعلي المسجل في Google Drive.</small> |
| </div>`; |
|
|
| const days = Array.from({ length: 7 }, (_, index) => dateOffset(snapshot.today, index - 6)); |
| const dayCounts = days.map((day) => |
| snapshot.records.filter((record) => record.documentedDate === day).length |
| ); |
| const maxCount = Math.max(...dayCounts, 1); |
| const weekTotal = dayCounts.reduce((sum, count) => sum + count, 0); |
| const dayFormatter = new Intl.DateTimeFormat("ar-SA", { |
| weekday: "short", |
| timeZone: "UTC", |
| }); |
| elements.adminTrendTotal.textContent = `${weekTotal} توثيق`; |
| elements.adminTrendChart.replaceChildren( |
| ...days.map((day, index) => { |
| const item = document.createElement("div"); |
| item.className = "trend-day"; |
| const date = new Date(`${day}T12:00:00Z`); |
| const height = dayCounts[index] ? Math.max((dayCounts[index] / maxCount) * 100, 12) : 3; |
| item.innerHTML = ` |
| <strong>${dayCounts[index]}</strong> |
| <span class="trend-bar"><i style="height:${height}%"></i></span> |
| <small>${dayFormatter.format(date)}</small>`; |
| if (day === snapshot.today) item.classList.add("today"); |
| return item; |
| }), |
| ); |
|
|
| const totalsByCity = new Map(); |
| payload.rows.forEach((row) => totalsByCity.set(row.city, (totalsByCity.get(row.city) || 0) + 1)); |
| const doneByCity = new Map(); |
| snapshot.documented.forEach((row) => doneByCity.set(row.city, (doneByCity.get(row.city) || 0) + 1)); |
| const cities = [...totalsByCity].map(([city, total]) => { |
| const done = doneByCity.get(city) || 0; |
| return { city, total, done, percent: total ? (done / total) * 100 : 0 }; |
| }).sort((a, b) => b.done - a.done || b.percent - a.percent || a.city.localeCompare(b.city, "ar")); |
| elements.adminCityProgress.replaceChildren( |
| ...cities.slice(0, 6).map((item) => { |
| const button = document.createElement("button"); |
| button.type = "button"; |
| button.className = "city-progress-row"; |
| button.innerHTML = ` |
| <span class="city-progress-heading"><strong>${item.city || "غير محدد"}</strong><b>${item.done} / ${item.total}</b></span> |
| <span class="city-progress-track"><i style="width:${item.percent}%"></i></span> |
| <small>${item.percent.toFixed(1)}% مكتمل</small>`; |
| button.addEventListener("click", () => { |
| elements.adminCityFilter.value = item.city; |
| renderAdminRecords(); |
| elements.adminControls?.scrollIntoView?.({ behavior: "smooth", block: "start" }); |
| }); |
| return button; |
| }), |
| ); |
| } |
|
|
| function filteredAdminRecords() { |
| const query = normalize(elements.adminSearch.value); |
| const researcher = elements.adminResearcherFilter.value; |
| const city = elements.adminCityFilter.value; |
| const date = elements.adminDateFilter.value; |
| const status = elements.adminStatusFilter.value; |
| return adminRecords.filter((record) => { |
| if (researcher && record.researcher !== researcher) return false; |
| if (city && record.city !== city) return false; |
| if (date && record.documentedDate !== date) return false; |
| if (status && record.fieldStatus !== status) return false; |
| if (!query) return true; |
| return normalize( |
| `${record.establishmentName} ${record.commercialRecord} ${record.researcher} ${record.city}`, |
| ).includes(query); |
| }).sort((a, b) => { |
| const aTime = Date.parse(a.updatedAt || a.documentedAt || "") || 0; |
| const bTime = Date.parse(b.updatedAt || b.documentedAt || "") || 0; |
| return bTime - aTime; |
| }); |
| } |
|
|
| function renderProductivity() { |
| const snapshot = adminSnapshot(); |
| const totals = new Map(payload.researchers.map((item) => [item.name, item.count])); |
| const documented = new Map(); |
| const today = new Map(); |
| snapshot.records.forEach((record) => { |
| documented.set(record.researcher, (documented.get(record.researcher) || 0) + 1); |
| if (record.documentedDate === snapshot.today) { |
| today.set(record.researcher, (today.get(record.researcher) || 0) + 1); |
| } |
| }); |
| const fragment = document.createDocumentFragment(); |
| const researchers = payload.researchers |
| .map((item) => ({ |
| name: item.name, |
| total: totals.get(item.name) || 0, |
| done: documented.get(item.name) || 0, |
| today: today.get(item.name) || 0, |
| })) |
| .sort((a, b) => b.done - a.done || b.today - a.today || a.name.localeCompare(b.name, "ar")); |
| elements.activeResearchersCount.textContent = `${snapshot.activeToday.size} نشط اليوم`; |
| researchers.forEach((item, index) => { |
| const row = document.createElement("button"); |
| row.type = "button"; |
| row.className = "productivity-row"; |
| const percent = item.total ? (item.done / item.total) * 100 : 0; |
| row.innerHTML = ` |
| <span class="productivity-rank">${index + 1}</span> |
| <span class="productivity-identity"> |
| <strong class="productivity-name">${item.name}</strong> |
| <small>${item.today ? `${item.today} اليوم` : "دون إنجاز اليوم"} · متبقي ${item.total - item.done}</small> |
| </span> |
| <span class="productivity-numbers"><strong>${item.done}</strong><small>من ${item.total}</small></span> |
| <span class="productivity-track"><i style="width:${percent}%"></i></span> |
| <span class="productivity-percent">${percent.toFixed(1)}%</span>`; |
| row.addEventListener("click", () => { |
| elements.adminResearcherFilter.value = item.name; |
| renderAdminRecords(); |
| }); |
| fragment.append(row); |
| }); |
| elements.researcherProductivity.replaceChildren(fragment); |
| } |
|
|
| function renderAdminRecords() { |
| const records = filteredAdminRecords(); |
| const hasFilters = Boolean( |
| elements.adminSearch.value || |
| elements.adminResearcherFilter.value || |
| elements.adminCityFilter.value || |
| elements.adminDateFilter.value || |
| elements.adminStatusFilter.value |
| ); |
| elements.adminRecordsCount.textContent = hasFilters |
| ? `${records.length} من ${adminRecords.length}` |
| : `${records.length} عملية`; |
| elements.adminEmpty.hidden = records.length > 0; |
| const fragment = document.createDocumentFragment(); |
| records.slice(0, 100).forEach((record) => { |
| const item = document.createElement("article"); |
| item.className = "admin-record"; |
| if (newAdminRecordIds.has(adminRecordIdentity(record))) item.classList.add("new-record"); |
| const date = record.documentedAt |
| ? new Intl.DateTimeFormat("ar-SA", { |
| timeZone: "Asia/Riyadh", |
| dateStyle: "medium", |
| timeStyle: "short", |
| }).format(new Date(record.documentedAt)) |
| : "-"; |
| const heading = document.createElement("div"); |
| heading.className = "admin-record-heading"; |
| const title = document.createElement("div"); |
| const name = document.createElement("strong"); |
| name.textContent = record.establishmentName || "منشأة غير محددة"; |
| const meta = document.createElement("small"); |
| meta.textContent = `${record.researcher || "-"} · ${record.city || "-"}`; |
| title.append(name, meta); |
| const time = document.createElement("time"); |
| const updatedDate = record.updatedAt |
| ? new Intl.DateTimeFormat("ar-SA", { |
| timeZone: "Asia/Riyadh", |
| dateStyle: "medium", |
| timeStyle: "short", |
| }).format(new Date(record.updatedAt)) |
| : ""; |
| time.textContent = updatedDate ? `آخر تعديل: ${updatedDate}` : date; |
| heading.append(title, time); |
| const badges = document.createElement("div"); |
| badges.className = "admin-record-badges"; |
| const documentedBadge = document.createElement("span"); |
| documentedBadge.className = "admin-badge-documented"; |
| documentedBadge.textContent = record.documentedDate === riyadhToday() ? "موثق اليوم" : "موثق"; |
| const statusBadge = document.createElement("span"); |
| statusBadge.textContent = record.fieldStatus || "غير محدد"; |
| const photoBadge = document.createElement("span"); |
| photoBadge.textContent = record.photoCount ? `${record.photoCount} صور` : "بدون صور"; |
| badges.append(documentedBadge, statusBadge, photoBadge); |
| const documentedTime = Date.parse(record.documentedAt || "") || 0; |
| const updatedTime = Date.parse(record.updatedAt || "") || 0; |
| if (updatedTime && documentedTime && updatedTime - documentedTime > 60000) { |
| const editedBadge = document.createElement("span"); |
| editedBadge.className = "admin-badge-edited"; |
| editedBadge.textContent = "تم التعديل"; |
| badges.append(editedBadge); |
| } |
| const details = document.createElement("div"); |
| details.className = "admin-record-details"; |
| details.append( |
| detailItem("السجل التجاري", record.commercialRecord || "-"), |
| detailItem("الحالة الميدانية", record.fieldStatus || "-"), |
| detailItem("عدد الصور", record.photoCount || 0), |
| ); |
| if (record.statement) details.append(detailItem("الإفادة", record.statement, "wide")); |
| const actions = document.createElement("div"); |
| actions.className = "admin-record-actions"; |
| if (record.folderUrl) { |
| const folder = document.createElement("a"); |
| folder.href = record.folderUrl; |
| folder.target = "_blank"; |
| folder.rel = "noopener"; |
| folder.textContent = "فتح المجلد"; |
| actions.append(folder); |
| } |
| (record.photoUrls || []).slice(0, 3).forEach((url, index) => { |
| const photo = document.createElement("a"); |
| photo.href = url; |
| photo.target = "_blank"; |
| photo.rel = "noopener"; |
| photo.textContent = `الصورة ${index + 1}`; |
| actions.append(photo); |
| }); |
| item.append(heading, badges, details, actions); |
| fragment.append(item); |
| }); |
| elements.adminRecords.replaceChildren(fragment); |
| } |
|
|
| async function loadAdminDashboard({ automatic = false } = {}) { |
| if (adminRefreshInProgress || sessionAccessCode !== "1448") return; |
| adminRefreshInProgress = true; |
| elements.refreshAdminButton.disabled = true; |
| setAdminSyncState("syncing", automatic ? "جارٍ التحديث التلقائي..." : "جارٍ تحديث البيانات..."); |
| try { |
| const result = await fetchDocumentation("admin", sessionAccessCode); |
| const incomingRecords = hydrateRecordKeys(result.records || []); |
| preserveDocumentedRows(incomingRecords); |
| if (adminDashboardInitialized) { |
| const previousIds = new Set(adminRecords.map(adminRecordIdentity)); |
| newAdminRecordIds = new Set( |
| incomingRecords |
| .map(adminRecordIdentity) |
| .filter((identity) => identity && !previousIds.has(identity)), |
| ); |
| } else { |
| newAdminRecordIds.clear(); |
| } |
| adminRecords = incomingRecords; |
| documentationRecords = adminRecords; |
| setAdminFilterOptions(); |
| elements.adminUpdatedAt.textContent = `آخر مزامنة: ${new Intl.DateTimeFormat("ar-SA", { |
| timeZone: "Asia/Riyadh", |
| dateStyle: "medium", |
| timeStyle: "short", |
| }).format(new Date(result.generatedAt))}`; |
| renderAdminStats(); |
| renderAdminOverview(); |
| renderProductivity(); |
| renderAdminRecords(); |
| adminDashboardInitialized = true; |
| setAdminSyncState("connected", newAdminRecordIds.size |
| ? `${newAdminRecordIds.size} توثيق جديد` |
| : "محدّث تلقائيًا"); |
| if (newAdminRecordIds.size) { |
| showToast(`وصل ${newAdminRecordIds.size} توثيق جديد وتم تحديث لوحة المشرف`); |
| setTimeout(() => { |
| newAdminRecordIds.clear(); |
| elements.adminRecords.querySelectorAll(".new-record").forEach((item) => item.classList.remove("new-record")); |
| }, 6000); |
| } |
| } catch (error) { |
| console.warn(error.message); |
| setAdminSyncState("offline", "تعذر التحديث، ستتم إعادة المحاولة"); |
| } finally { |
| elements.refreshAdminButton.disabled = false; |
| adminRefreshInProgress = false; |
| } |
| } |
|
|
| function stopAdminAutoRefresh() { |
| clearInterval(adminRefreshTimer); |
| adminRefreshTimer = null; |
| } |
|
|
| function startAdminAutoRefresh() { |
| stopAdminAutoRefresh(); |
| if (sessionAccessCode !== "1448" || document.hidden) return; |
| adminRefreshTimer = setInterval( |
| () => loadAdminDashboard({ automatic: true }), |
| ADMIN_REFRESH_INTERVAL, |
| ); |
| } |
|
|
| async function copyText(text, successMessage) { |
| try { |
| await navigator.clipboard.writeText(text); |
| } catch { |
| const area = document.createElement("textarea"); |
| area.value = text; |
| area.style.cssText = "position:fixed;opacity:0"; |
| document.body.append(area); |
| area.select(); |
| document.execCommand("copy"); |
| area.remove(); |
| } |
| showToast(successMessage); |
| } |
|
|
| function localPhone(value) { |
| const digits = String(value ?? "").replace(/\D/g, ""); |
| if (digits.startsWith("966")) return `0${digits.slice(3)}`; |
| return digits; |
| } |
|
|
| function internationalPhone(value) { |
| const digits = localPhone(value); |
| return digits.startsWith("0") ? `966${digits.slice(1)}` : digits; |
| } |
|
|
| function toEnglishDigits(value) { |
| return String(value ?? "") |
| .replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit))) |
| .replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit))); |
| } |
|
|
| function recipientPhone(value) { |
| let digits = toEnglishDigits(value).replace(/\D/g, ""); |
| if (digits.startsWith("00966")) digits = digits.slice(2); |
| if (digits.startsWith("966")) digits = digits.slice(3); |
| if (digits.startsWith("0")) digits = digits.slice(1); |
| return /^5\d{8}$/.test(digits) ? `966${digits}` : ""; |
| } |
|
|
| function locationRank(row) { |
| return { madon: 4, base: 3, statement: 2, none: 1 }[row.locationType] || 0; |
| } |
|
|
| function saveState() { |
| if (!activeResearcher) return; |
| localStorage.setItem( |
| STATE_KEY, |
| JSON.stringify({ |
| researcher: activeResearcher.name, |
| city: elements.cityButtons.querySelector(".city-choice.active")?.dataset.city || "", |
| search: elements.searchInput.value, |
| status: elements.statusSelect.value, |
| location: elements.locationSelect.value, |
| }), |
| ); |
| } |
|
|
| function savedState() { |
| try { |
| return JSON.parse(localStorage.getItem(STATE_KEY) || "{}"); |
| } catch { |
| return {}; |
| } |
| } |
|
|
| function showOnly(view) { |
| [elements.loginView, elements.researcherView, elements.dashboardView, elements.adminView].forEach((item) => { |
| item.hidden = item !== view; |
| }); |
| } |
|
|
| function renderResearchers() { |
| const query = normalize(elements.researcherSearch.value); |
| const fragment = document.createDocumentFragment(); |
| payload.researchers |
| .filter((researcher) => !query || normalize(researcher.name).includes(query)) |
| .forEach((researcher) => { |
| const button = document.createElement("button"); |
| button.type = "button"; |
| button.className = "researcher-choice"; |
| const content = document.createElement("span"); |
| const name = document.createElement("strong"); |
| name.textContent = researcher.name; |
| const meta = document.createElement("small"); |
| meta.textContent = `${researcher.count} عينة`; |
| content.append(name, meta); |
| const arrow = createIcon("m15 18-6-6 6-6"); |
| button.append(content, arrow); |
| button.addEventListener("click", () => openResearcher(researcher)); |
| fragment.append(button); |
| }); |
| elements.researcherGrid.replaceChildren(fragment); |
| } |
|
|
| function setDirectoryMode(mode) { |
| directoryMode = mode; |
| const entityMode = mode === "entity"; |
| elements.researcherModeButton.classList.toggle("active", !entityMode); |
| elements.entityModeButton.classList.toggle("active", entityMode); |
| elements.researcherGrid.hidden = entityMode; |
| elements.entitySearchResults.hidden = !entityMode; |
| elements.researcherSearch.placeholder = entityMode |
| ? "ابحث باسم المنشأة أو السجل التجاري" |
| : "ابحث باسم الباحث"; |
| elements.researcherSearch.value = ""; |
| if (entityMode) renderEntitySearch(); |
| else renderResearchers(); |
| } |
|
|
| function renderEntitySearch() { |
| const query = normalize(elements.researcherSearch.value); |
| if (query.length < 2) { |
| elements.entitySearchResults.innerHTML = |
| '<div class="directory-empty">اكتب حرفين على الأقل للبحث في جميع المنشآت.</div>'; |
| return; |
| } |
| const matches = payload.rows |
| .filter((row) => |
| normalize(`${row.establishmentName} ${row.commercialRecord} ${row.unifiedNumber} ${row.researcher}`) |
| .includes(query)) |
| .slice(0, 40); |
| if (!matches.length) { |
| elements.entitySearchResults.innerHTML = |
| '<div class="directory-empty">لا توجد منشأة مطابقة لبحثك.</div>'; |
| return; |
| } |
| const fragment = document.createDocumentFragment(); |
| matches.forEach((row) => { |
| const button = document.createElement("button"); |
| button.type = "button"; |
| button.className = "entity-result"; |
| const text = document.createElement("span"); |
| const name = document.createElement("strong"); |
| name.textContent = row.establishmentName; |
| const meta = document.createElement("small"); |
| meta.textContent = `${row.researcher} · ${row.city}`; |
| text.append(name, meta); |
| const record = document.createElement("span"); |
| record.className = "entity-result-meta"; |
| record.textContent = row.commercialRecord || "دون سجل"; |
| button.append(text, record); |
| button.addEventListener("click", () => { |
| const researcher = payload.researchers.find((item) => item.name === row.researcher); |
| openResearcher(researcher, row.sampleKey); |
| }); |
| fragment.append(button); |
| }); |
| elements.entitySearchResults.replaceChildren(fragment); |
| } |
|
|
| function createStat(value, label, className) { |
| const item = document.createElement("div"); |
| item.className = `stat-item ${className}`; |
| const strong = document.createElement("strong"); |
| strong.textContent = value; |
| const span = document.createElement("span"); |
| span.textContent = label; |
| item.append(strong, span); |
| return item; |
| } |
|
|
| function renderSummary() { |
| const records = latestRecordsBySample(documentationRecords); |
| const commercialRecords = latestRecordsByCommercialRecord(documentationRecords); |
| const documented = researcherRows.filter((row) => |
| records.has(row.sampleKey) || |
| Boolean(row.commercialRecord && commercialRecords.has(row.commercialRecord)), |
| ).length; |
| const total = researcherRows.length; |
| const remaining = Math.max(total - documented, 0); |
| const percentage = total ? Math.round((documented / total) * 100) : 0; |
| const today = new Intl.DateTimeFormat("en-CA", { |
| timeZone: "Asia/Riyadh", |
| year: "numeric", |
| month: "2-digit", |
| day: "2-digit", |
| }).format(new Date()); |
| const documentedToday = researcherRows.filter( |
| (row) => (records.get(row.sampleKey) || commercialRecords.get(row.commercialRecord))?.documentedDate === today, |
| ).length; |
| const progress = document.createElement("div"); |
| progress.className = "researcher-progress"; |
| progress.innerHTML = ` |
| <div class="researcher-progress-heading"> |
| <span>${remaining === 0 && total ? "أحسنت، اكتملت جميع العينات" : motivationalMessage(percentage)}</span> |
| <strong>${percentage}%</strong> |
| </div> |
| <div class="researcher-progress-track" role="progressbar" aria-label="نسبة إنجاز الباحث" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${percentage}"> |
| <span style="width: ${percentage}%"></span> |
| </div> |
| `; |
| elements.summaryStats.replaceChildren( |
| createStat(total, "إجمالي العينات", "total"), |
| createStat(documented, "تم التوثيق", "documented"), |
| createStat(remaining, "المتبقي", "remaining"), |
| createStat(documentedToday, "إنجاز اليوم", "today"), |
| progress, |
| ); |
| } |
|
|
| function motivationalMessage(percentage) { |
| if (percentage >= 90) return "بقي القليل، شكرًا لجهودك"; |
| if (percentage >= 50) return "تقدم ممتاز، واصل الإنجاز"; |
| if (percentage > 0) return "بداية موفقة، كل توثيق يصنع فرقًا"; |
| return "جاهز للبدء، نتمنى لك يومًا موفقًا"; |
| } |
|
|
| function activeCity() { |
| return elements.cityButtons.querySelector(".city-choice.active")?.dataset.city || ""; |
| } |
|
|
| function setActiveCity(city) { |
| elements.cityButtons.querySelectorAll(".city-choice").forEach((button) => { |
| const active = button.dataset.city === city; |
| button.classList.toggle("active", active); |
| button.setAttribute("aria-checked", String(active)); |
| }); |
| updateRepresentativeHighlight(city); |
| applyFilters(); |
| } |
|
|
| function renderCities(preferredCity = "") { |
| const counts = new Map(); |
| researcherRows.forEach((row) => counts.set(row.city, (counts.get(row.city) || 0) + 1)); |
| const cities = uniqueSorted([...counts.keys()]); |
| const allButton = document.createElement("button"); |
| allButton.type = "button"; |
| allButton.className = "city-choice"; |
| allButton.dataset.city = ""; |
| allButton.innerHTML = `<span>جميع المدن</span><strong>${researcherRows.length}</strong>`; |
| allButton.addEventListener("click", () => setActiveCity("")); |
| const buttons = [allButton]; |
| cities.forEach((city) => { |
| const button = document.createElement("button"); |
| button.type = "button"; |
| button.className = "city-choice"; |
| button.dataset.city = city; |
| const label = document.createElement("span"); |
| label.textContent = city; |
| const count = document.createElement("strong"); |
| count.textContent = counts.get(city); |
| button.append(label, count); |
| button.addEventListener("click", () => setActiveCity(city)); |
| buttons.push(button); |
| }); |
| elements.cityButtons.replaceChildren(...buttons); |
| const initialCity = cities.includes(preferredCity) ? preferredCity : ""; |
| setActiveCity(initialCity); |
| } |
|
|
| function findRepresentative(city) { |
| const normalizedCity = normalize(city); |
| if (!normalizedCity) return null; |
| return REPRESENTATIVES.find((item) => item.match.some((term) => normalizedCity.includes(normalize(term)))); |
| } |
|
|
| function actionButton(label, icon, onClick) { |
| const button = document.createElement("button"); |
| button.type = "button"; |
| button.className = "mini-action"; |
| button.append(createIcon(icon), document.createTextNode(label)); |
| button.addEventListener("click", onClick); |
| return button; |
| } |
|
|
| function actionLink(label, href, icon) { |
| const link = document.createElement("a"); |
| link.className = "mini-action"; |
| link.href = href; |
| link.target = "_blank"; |
| link.rel = "noopener"; |
| link.append(createIcon(icon), document.createTextNode(label)); |
| return link; |
| } |
|
|
| function representativeMessage(representative, row = null) { |
| const lines = [ |
| "السلام عليكم ورحمة الله وبركاته،", |
| "", |
| `الأستاذ/ ${representative.name} المحترم،`, |
| "", |
| "نأمل التكرم بإفادتنا عن المنشأة الموضحة أدناه، وتزويدنا بموقعها عند توفره، وذلك لاستكمال بيانات الزيارة الميدانية.", |
| ]; |
| if (row) { |
| lines.push( |
| "", |
| "*بيانات المنشأة:*", |
| `• المنشأة: ${row.establishmentName || "-"}`, |
| `• السجل التجاري: ${row.commercialRecord || "-"}`, |
| `• رقم العقد: ${row.contractNumber || "لا يوجد رقم عقد"}`, |
| `• المدينة الصناعية: ${row.city || "-"}`, |
| `• حالة العينة: ${row.status || "-"}`, |
| ); |
| if (row.madonStatement) lines.push(`• إفادة مدن المسجلة: ${row.madonStatement}`); |
| } |
| lines.push("", "شاكرين لكم تعاونكم وتجاوبكم."); |
| return lines.join("\n"); |
| } |
|
|
| function bulkRepresentativeMessage(representative, rows) { |
| const lines = [ |
| "السلام عليكم ورحمة الله وبركاته،", |
| "", |
| `الأستاذ/ ${representative.name} المحترم،`, |
| "", |
| `نأمل التكرم بإفادتنا عن المنشآت التالية وعددها (${rows.length})، وتزويدنا بمواقعها عند توفرها، وذلك لاستكمال بيانات الزيارات الميدانية.`, |
| ]; |
| rows.forEach((row, index) => { |
| lines.push( |
| "", |
| `*${index + 1}. ${row.establishmentName || "منشأة دون اسم"}*`, |
| `• السجل التجاري: ${row.commercialRecord || "-"}`, |
| `• رقم العقد: ${row.contractNumber || "لا يوجد رقم عقد"}`, |
| `• المدينة الصناعية: ${row.city || "-"}`, |
| `• حالة العينة: ${row.status || "-"}`, |
| ); |
| if (row.madonStatement) lines.push(`• إفادة مدن المسجلة: ${row.madonStatement}`); |
| if (row.madonNoteText) lines.push(`• ملاحظات مدن: ${row.madonNoteText}`); |
| }); |
| lines.push("", "شاكرين لكم تعاونكم وتجاوبكم."); |
| return lines.join("\n"); |
| } |
|
|
| function renderRepresentativesDirectory() { |
| const fragment = document.createDocumentFragment(); |
| REPRESENTATIVES.forEach((representative) => { |
| const card = document.createElement("article"); |
| card.className = "representative-card"; |
| card.dataset.city = representative.city; |
|
|
| const header = document.createElement("div"); |
| header.className = "representative-card-header"; |
| const icon = document.createElement("span"); |
| icon.className = "representative-icon"; |
| icon.append(createIcon("M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8M19 8v6M22 11h-6")); |
| const text = document.createElement("div"); |
| const city = document.createElement("small"); |
| city.textContent = representative.role || representative.city; |
| const name = document.createElement("strong"); |
| name.textContent = representative.name; |
| const phone = document.createElement("span"); |
| phone.textContent = representative.phone; |
| text.append(city, name, phone); |
| header.append(icon, text); |
|
|
| const actions = document.createElement("div"); |
| actions.className = "representative-card-actions"; |
| actions.append( |
| actionButton("نسخ الرقم", ICONS.copy, () => copyText(representative.phone, "تم نسخ رقم الممثل")), |
| actionLink( |
| "واتساب", |
| `https://wa.me/${internationalPhone(representative.phone)}?text=${encodeURIComponent(representativeMessage(representative))}`, |
| ICONS.whatsapp, |
| ), |
| ); |
| card.append(header, actions); |
| fragment.append(card); |
| }); |
| elements.representativesDirectory.replaceChildren(fragment); |
| } |
|
|
| function setContactsExpanded(expanded) { |
| elements.representativesDirectory.classList.toggle("collapsed", !expanded); |
| elements.toggleContactsButton.setAttribute("aria-expanded", String(expanded)); |
| elements.toggleContactsButton.querySelector("span").textContent = expanded ? "إخفاء الأرقام" : "عرض أرقام الممثلين"; |
| } |
|
|
| function updateRepresentativeHighlight(city) { |
| const cityRow = researcherRows.find((row) => row.city === city); |
| const representative = findRepresentative(cityRow?.representativeCity || city); |
| elements.representativesDirectory.querySelectorAll(".representative-card").forEach((card) => { |
| card.classList.toggle("recommended", Boolean(representative && card.dataset.city === representative.city)); |
| }); |
| } |
|
|
| function setSelectOptions(select, values, placeholder) { |
| select.replaceChildren(new Option(placeholder, "")); |
| values.forEach((value) => select.add(new Option(value, value))); |
| } |
|
|
| function openResearcher(researcher, focusSampleKey = "") { |
| clearSelection(); |
| activeResearcher = researcher; |
| researcherRows = payload.rows |
| .filter((row) => row.researcher === researcher.name) |
| .sort((a, b) => locationRank(b) - locationRank(a)) |
| .map((row) => ({ ...row, rowId: row.sampleKey })); |
| elements.researcherName.textContent = researcher.name; |
| elements.researcherMapLink.href = researcher.mapUrl || "#"; |
| elements.researcherMapLink.hidden = !researcher.mapUrl; |
| elements.formLink.href = FORM_URL; |
| elements.searchInput.value = ""; |
| elements.locationSelect.value = ""; |
| setSelectOptions(elements.statusSelect, uniqueSorted(researcherRows.map((row) => row.status)), "جميع الحالات"); |
| renderSummary(); |
| renderRepresentativesDirectory(); |
| setContactsExpanded(!mobileQuery.matches); |
| const state = savedState(); |
| if (state.researcher === researcher.name) { |
| elements.searchInput.value = state.search || ""; |
| elements.statusSelect.value = state.status || ""; |
| elements.locationSelect.value = state.location || ""; |
| } |
| renderCities(state.researcher === researcher.name ? state.city : ""); |
| showOnly(elements.dashboardView); |
| window.scrollTo({ top: 0 }); |
| saveState(); |
| if (focusSampleKey) { |
| elements.searchInput.value = |
| researcherRows.find((row) => row.sampleKey === focusSampleKey)?.establishmentName || ""; |
| applyFilters(); |
| } |
| } |
|
|
| function detailItem(label, value, extraClass = "") { |
| const item = document.createElement("div"); |
| item.className = `detail-item ${extraClass}`.trim(); |
| const name = document.createElement("span"); |
| name.className = "detail-label"; |
| name.textContent = label; |
| const content = document.createElement("span"); |
| content.className = "detail-value"; |
| content.textContent = String(value); |
| item.append(name, content); |
| return item; |
| } |
|
|
| function createLocationBlock(row) { |
| if (!row.coordinates) return null; |
| const block = document.createElement("div"); |
| block.className = `location-block location-${row.locationType}`; |
|
|
| if (row.coordinates) { |
| const heading = document.createElement("div"); |
| heading.className = "location-heading"; |
| const label = document.createElement("span"); |
| label.textContent = row.locationType === "madon" ? "إحداثية مدن" : "الإحداثية الأساسية"; |
| const source = document.createElement("small"); |
| source.textContent = row.locationType === "madon" ? "الموقع المحدّث" : "حسب بيانات X وY"; |
| heading.append(label, source); |
| block.append(heading); |
| block.append( |
| actionLink( |
| "فتح الموقع في خرائط Google", |
| `https://www.google.com/maps?q=${encodeURIComponent(row.coordinates)}`, |
| ICONS.map, |
| ), |
| ); |
| } |
|
|
| return block; |
| } |
|
|
| function createMadonInfo(row) { |
| if (!row.madonStatement && !row.madonNoteText) return null; |
| const section = document.createElement("div"); |
| section.className = "madon-info"; |
|
|
| if (row.madonStatement) { |
| const statement = document.createElement("div"); |
| statement.className = "madon-info-item madon-report"; |
| const title = document.createElement("span"); |
| title.textContent = "إفادة مدن"; |
| const text = document.createElement("p"); |
| text.textContent = row.madonStatement; |
| statement.append(title, text); |
| section.append(statement); |
| } |
|
|
| if (row.madonNoteText) { |
| const note = document.createElement("div"); |
| note.className = "madon-info-item madon-note"; |
| const title = document.createElement("span"); |
| title.textContent = "ملاحظات مدن"; |
| const text = document.createElement("p"); |
| text.textContent = row.madonNoteText; |
| note.append(title, text); |
| section.append(note); |
| } |
| return section; |
| } |
|
|
| function formMessage(row = null) { |
| const lines = [ |
| "السلام عليكم ورحمة الله وبركاته،", |
| "", |
| row ? "السادة/ إدارة المنشأة المحترمين،" : "السادة الكرام،", |
| "نأمل التكرم باستكمال استمارة المسح عبر الرابط المرفق، وذلك لاستكمال بيانات الزيارة الميدانية.", |
| ]; |
| if (row) { |
| lines.push( |
| "", |
| "*بيانات المنشأة:*", |
| `• المنشأة: ${row.establishmentName || "-"}`, |
| `• السجل التجاري: ${row.commercialRecord || "-"}`, |
| `• رقم العقد: ${row.contractNumber || "لا يوجد رقم عقد"}`, |
| `• المدينة الصناعية: ${row.city || "-"}`, |
| ); |
| } |
| lines.push( |
| "", |
| "*رابط الاستمارة:*", |
| FORM_URL, |
| "", |
| "نأمل إشعارنا بعد استكمال الاستمارة.", |
| "", |
| "شاكرين لكم حسن تعاونكم وتجاوبكم.", |
| ); |
| return lines.join("\n"); |
| } |
|
|
| function openShareDialog(row = null) { |
| shareRow = row; |
| elements.recipientPhone.value = localStorage.getItem("ics2LastRecipientPhone") || ""; |
| elements.recipientPhoneError.textContent = ""; |
| elements.messagePreview.value = formMessage(row); |
| updateShareButton(); |
| elements.shareDialog.hidden = false; |
| document.body.classList.add("dialog-open"); |
| requestAnimationFrame(() => elements.recipientPhone.focus()); |
| } |
|
|
| function updateShareButton() { |
| elements.sendShareButton.disabled = !recipientPhone(elements.recipientPhone.value); |
| } |
|
|
| function closeShareDialog() { |
| elements.shareDialog.hidden = true; |
| document.body.classList.remove("dialog-open"); |
| shareRow = null; |
| } |
|
|
| function renderMadonOptions(recommended) { |
| const fragment = document.createDocumentFragment(); |
| REPRESENTATIVES.forEach((representative, index) => { |
| const label = document.createElement("label"); |
| label.className = "representative-option"; |
| const input = document.createElement("input"); |
| input.type = "radio"; |
| input.name = "madonRepresentative"; |
| input.value = String(index); |
| input.checked = representative === recommended; |
| const content = document.createElement("span"); |
| const name = document.createElement("strong"); |
| name.textContent = representative.name; |
| const meta = document.createElement("small"); |
| meta.textContent = `${representative.role || representative.city} • ${representative.phone}`; |
| content.append(name, meta); |
| const check = createIcon("m5 12 4 4L19 6"); |
| label.append(input, content, check); |
| input.addEventListener("change", () => { |
| selectedRepresentative = representative; |
| elements.madonMessagePreview.value = isBulkMadonMessage |
| ? bulkRepresentativeMessage(representative, selectedRowItems()) |
| : representativeMessage(representative, madonRow); |
| }); |
| fragment.append(label); |
| }); |
| elements.madonRepresentativeOptions.replaceChildren(fragment); |
| } |
|
|
| function openMadonDialog(row) { |
| isBulkMadonMessage = false; |
| madonRow = row; |
| selectedRepresentative = |
| findRepresentative(row.representativeCity || row.city) || REPRESENTATIVES[0]; |
| renderMadonOptions(selectedRepresentative); |
| elements.madonMessagePreview.value = representativeMessage(selectedRepresentative, row); |
| elements.madonDialog.hidden = false; |
| document.body.classList.add("dialog-open"); |
| } |
|
|
| function selectedRowItems() { |
| return researcherRows.filter((row) => selectedRows.has(row.rowId)); |
| } |
|
|
| function recommendedRepresentativeForRows(rows) { |
| const representatives = rows |
| .map((row) => findRepresentative(row.representativeCity || row.city)) |
| .filter(Boolean); |
| if (!representatives.length) return REPRESENTATIVES[0]; |
| const counts = new Map(); |
| representatives.forEach((item) => counts.set(item, (counts.get(item) || 0) + 1)); |
| return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0]; |
| } |
|
|
| function openBulkMadonDialog() { |
| const rows = selectedRowItems(); |
| if (!rows.length) return; |
| isBulkMadonMessage = true; |
| madonRow = null; |
| selectedRepresentative = recommendedRepresentativeForRows(rows); |
| renderMadonOptions(selectedRepresentative); |
| elements.madonMessagePreview.value = bulkRepresentativeMessage(selectedRepresentative, rows); |
| elements.madonDialog.hidden = false; |
| document.body.classList.add("dialog-open"); |
| } |
|
|
| function closeMadonDialog() { |
| elements.madonDialog.hidden = true; |
| document.body.classList.remove("dialog-open"); |
| madonRow = null; |
| selectedRepresentative = null; |
| isBulkMadonMessage = false; |
| } |
|
|
| function renderPhotoPreviews() { |
| const fragment = document.createDocumentFragment(); |
| documentationExistingPhotos.forEach((photo, index) => { |
| const item = document.createElement("div"); |
| item.className = "photo-preview existing-photo"; |
| const link = document.createElement("a"); |
| link.href = photo.url; |
| link.target = "_blank"; |
| link.rel = "noopener"; |
| link.append( |
| createIcon(ICONS.map), |
| document.createTextNode(`صورة محفوظة ${index + 1}`), |
| ); |
| const remove = document.createElement("button"); |
| remove.type = "button"; |
| remove.setAttribute("aria-label", `حذف الصورة المحفوظة ${index + 1}`); |
| remove.title = "حذف الصورة من التوثيق"; |
| remove.append(createIcon("M18 6 6 18M6 6l12 12")); |
| remove.addEventListener("click", () => { |
| documentationExistingPhotos.splice(index, 1); |
| renderPhotoPreviews(); |
| }); |
| item.append(link, remove); |
| fragment.append(item); |
| }); |
| documentationFiles.forEach((file, index) => { |
| const item = document.createElement("div"); |
| item.className = "photo-preview"; |
| const image = document.createElement("img"); |
| image.src = URL.createObjectURL(file); |
| image.alt = `الصورة ${index + 1}`; |
| image.addEventListener("load", () => URL.revokeObjectURL(image.src), { once: true }); |
| const remove = document.createElement("button"); |
| remove.type = "button"; |
| remove.setAttribute("aria-label", `حذف الصورة ${index + 1}`); |
| remove.title = "حذف الصورة"; |
| remove.append(createIcon("M18 6 6 18M6 6l12 12")); |
| remove.addEventListener("click", () => { |
| documentationFiles.splice(index, 1); |
| renderPhotoPreviews(); |
| }); |
| item.append(image, remove); |
| fragment.append(item); |
| }); |
| elements.photoPreviews.replaceChildren(fragment); |
| } |
|
|
| function openDocumentationDialog(row) { |
| documentationRow = row; |
| documentationFiles = []; |
| const record = documentationRecord(row); |
| documentationExistingPhotos = (record?.photoUrls || []).map((url) => ({ url })); |
| elements.documentationEntity.replaceChildren( |
| detailItem("المنشأة", row.establishmentName || "-", "wide"), |
| detailItem("السجل التجاري", row.commercialRecord || "-", "ltr-value"), |
| detailItem("رقم العقد", row.contractNumber || "لا يوجد رقم عقد", "ltr-value"), |
| ); |
| const storedStatus = record?.fieldStatus || ""; |
| const statusOptions = [...elements.fieldStatus.options].map((option) => option.value); |
| const knownStatus = statusOptions.includes(storedStatus); |
| const customStatus = storedStatus.startsWith("أخرى:") |
| ? storedStatus.replace(/^أخرى:\s*/, "") |
| : storedStatus && !knownStatus |
| ? storedStatus |
| : ""; |
| const otherMode = storedStatus === "أخرى" || Boolean(customStatus); |
| elements.fieldStatus.value = otherMode ? "أخرى" : knownStatus ? storedStatus : ""; |
| elements.otherStatus.value = ""; |
| if (customStatus) elements.otherStatus.value = customStatus; |
| elements.otherStatusField.hidden = !otherMode; |
| elements.otherStatus.required = otherMode; |
| elements.fieldStatement.value = record?.statement || ""; |
| elements.statementCount.textContent = String(elements.fieldStatement.value.length); |
| elements.documentationPhotos.value = ""; |
| elements.documentationError.textContent = ""; |
| elements.uploadProgress.hidden = true; |
| elements.uploadProgressBar.style.width = "0"; |
| elements.documentationUnavailable.hidden = documentationReady(); |
| elements.saveDocumentationButton.disabled = !documentationReady(); |
| elements.documentationDialogTitle.textContent = record ? "تعديل التوثيق الميداني" : "إضافة إفادة ميدانية"; |
| renderPhotoPreviews(); |
| elements.documentationDialog.hidden = false; |
| document.body.classList.add("dialog-open"); |
| } |
|
|
| function closeDocumentationDialog() { |
| elements.documentationDialog.hidden = true; |
| document.body.classList.remove("dialog-open"); |
| documentationRow = null; |
| documentationFiles = []; |
| documentationExistingPhotos = []; |
| } |
|
|
| async function compressImage(file) { |
| let source; |
| let widthSource; |
| let heightSource; |
| if ("createImageBitmap" in window) { |
| source = await createImageBitmap(file); |
| widthSource = source.width; |
| heightSource = source.height; |
| } else { |
| source = await new Promise((resolve, reject) => { |
| const image = new Image(); |
| const url = URL.createObjectURL(file); |
| image.onload = () => { |
| URL.revokeObjectURL(url); |
| resolve(image); |
| }; |
| image.onerror = () => { |
| URL.revokeObjectURL(url); |
| reject(new Error("تعذر فتح إحدى الصور.")); |
| }; |
| image.src = url; |
| }); |
| widthSource = source.naturalWidth; |
| heightSource = source.naturalHeight; |
| } |
| const maxDimension = DOCUMENTATION_CONFIG.maxImageDimension || 1600; |
| const scale = Math.min(1, maxDimension / Math.max(widthSource, heightSource)); |
| const width = Math.max(1, Math.round(widthSource * scale)); |
| const height = Math.max(1, Math.round(heightSource * scale)); |
| const canvas = document.createElement("canvas"); |
| canvas.width = width; |
| canvas.height = height; |
| canvas.getContext("2d", { alpha: false }).drawImage(source, 0, 0, width, height); |
| if (typeof source.close === "function") source.close(); |
| const blob = await new Promise((resolve) => |
| canvas.toBlob(resolve, "image/jpeg", DOCUMENTATION_CONFIG.jpegQuality || 0.78), |
| ); |
| if (!blob) throw new Error("تعذر تجهيز إحدى الصور."); |
| return blob; |
| } |
|
|
| function blobToBase64(blob) { |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader(); |
| reader.onload = () => resolve(String(reader.result).split(",")[1] || ""); |
| reader.onerror = () => reject(new Error("تعذر قراءة الصورة.")); |
| reader.readAsDataURL(blob); |
| }); |
| } |
|
|
| async function buildDocumentationPhotos() { |
| const output = []; |
| for (let index = 0; index < documentationFiles.length; index += 1) { |
| elements.uploadProgressBar.style.width = `${15 + Math.round((index / documentationFiles.length) * 45)}%`; |
| const compressed = await compressImage(documentationFiles[index]); |
| output.push({ |
| mimeType: "image/jpeg", |
| base64: await blobToBase64(compressed), |
| }); |
| } |
| return output; |
| } |
|
|
| function updateSelectionBar() { |
| const count = selectedRows.size; |
| elements.selectionBar.hidden = count === 0; |
| elements.selectionCount.textContent = `${count} ${count === 1 ? "منشأة محددة" : "منشآت محددة"}`; |
| document.body.classList.toggle("has-selection", count > 0); |
| elements.samplesGrid.querySelectorAll(".sample-card").forEach((card) => { |
| const selected = selectedRows.has(card.dataset.rowId); |
| card.classList.toggle("selected", selected); |
| const input = card.querySelector(".sample-selector input"); |
| if (input) input.checked = selected; |
| }); |
| } |
|
|
| function clearSelection() { |
| selectedRows.clear(); |
| updateSelectionBar(); |
| } |
|
|
| function completedDocumentationMap() { |
| try { |
| return JSON.parse(localStorage.getItem(DOCUMENTATION_DONE_KEY) || "{}"); |
| } catch { |
| return {}; |
| } |
| } |
|
|
| function isDocumentationCompleted(rowId) { |
| const row = researcherRows.find((item) => item.rowId === rowId || item.sampleKey === rowId); |
| return Boolean( |
| latestRecordsBySample(documentationRecords).has(rowId) || |
| (row?.commercialRecord && latestRecordsByCommercialRecord(documentationRecords).has(row.commercialRecord)) || |
| completedDocumentationMap()[rowId], |
| ); |
| } |
|
|
| function documentedRowsFirst(rows) { |
| const records = latestRecordsBySample(documentationRecords); |
| const commercialRecords = latestRecordsByCommercialRecord(documentationRecords); |
| const locallyCompleted = completedDocumentationMap(); |
|
|
| return [...rows].sort((a, b) => { |
| const aRecord = records.get(a.sampleKey) || commercialRecords.get(a.commercialRecord); |
| const bRecord = records.get(b.sampleKey) || commercialRecords.get(b.commercialRecord); |
| const aCompleted = Boolean(aRecord || locallyCompleted[a.rowId]); |
| const bCompleted = Boolean(bRecord || locallyCompleted[b.rowId]); |
|
|
| if (aCompleted !== bCompleted) return bCompleted - aCompleted; |
| if (!aCompleted) return 0; |
|
|
| const aTime = Date.parse(aRecord?.documentedAt || "") || 0; |
| const bTime = Date.parse(bRecord?.documentedAt || "") || 0; |
| return bTime - aTime; |
| }); |
| } |
|
|
| function markDocumentationCompleted(rowId, record = {}) { |
| if (!rowId) return; |
|
|
| const completed = completedDocumentationMap(); |
| completed[rowId] = true; |
| localStorage.setItem(DOCUMENTATION_DONE_KEY, JSON.stringify(completed)); |
| documentationRecords = [ |
| { |
| sampleKey: rowId, |
| documentedAt: record.documentedAt || new Date().toISOString(), |
| documentedDate: record.documentedDate || new Intl.DateTimeFormat("en-CA", { |
| timeZone: "Asia/Riyadh", |
| year: "numeric", |
| month: "2-digit", |
| day: "2-digit", |
| }).format(new Date()), |
| ...record, |
| }, |
| ...documentationRecords.filter((item) => item.sampleKey !== rowId), |
| ]; |
|
|
| const card = elements.samplesGrid.querySelector(`[data-row-id="${rowId}"]`); |
| if (card) card.classList.add("completed"); |
| renderSummary(); |
| } |
|
|
| function createCard(row, index) { |
| const card = document.createElement("article"); |
| card.className = "sample-card"; |
| card.dataset.rowId = row.rowId; |
| if (row.rowId === lastSavedRowId) card.classList.add("recently-documented"); |
| if (isDocumentationCompleted(row.rowId)) { |
| card.classList.add("completed"); |
| const record = documentationRecord(row); |
| if (record?.documentedAt) { |
| card.dataset.documentedAt = new Intl.DateTimeFormat("ar-SA", { |
| timeZone: "Asia/Riyadh", |
| dateStyle: "medium", |
| }).format(new Date(record.documentedAt)); |
| } |
| } |
| const header = document.createElement("div"); |
| header.className = "card-header"; |
| const titleWrap = document.createElement("div"); |
| const selector = document.createElement("label"); |
| selector.className = "sample-selector"; |
| const selectorInput = document.createElement("input"); |
| selectorInput.type = "checkbox"; |
| selectorInput.checked = selectedRows.has(row.rowId); |
| const selectorMark = document.createElement("span"); |
| selectorMark.append(createIcon("m5 12 4 4L19 6")); |
| const selectorText = document.createElement("span"); |
| selectorText.textContent = "تحديد"; |
| selector.append(selectorInput, selectorMark, selectorText); |
| selectorInput.addEventListener("change", () => { |
| if (selectorInput.checked) selectedRows.add(row.rowId); |
| else selectedRows.delete(row.rowId); |
| updateSelectionBar(); |
| }); |
| const indexLabel = document.createElement("span"); |
| indexLabel.className = "sample-index"; |
| indexLabel.textContent = `عينة ${index + 1}`; |
| const title = document.createElement("h3"); |
| title.textContent = row.establishmentName || "منشأة دون اسم"; |
| titleWrap.append(selector, indexLabel, title); |
| const badges = document.createElement("div"); |
| badges.className = "card-badges"; |
| if (isDocumentationCompleted(row.rowId)) { |
| const documentedBadge = document.createElement("span"); |
| documentedBadge.className = "documented-badge"; |
| documentedBadge.textContent = "تم التوثيق بنجاح"; |
| badges.append(documentedBadge); |
| } |
| const badge = document.createElement("span"); |
| badge.className = `status-badge status-${normalize(row.status).includes("مغلق") ? "closed" : "default"}`; |
| badge.textContent = row.status || "غير محدد"; |
| const locationBadge = document.createElement("span"); |
| locationBadge.className = `location-badge location-badge-${row.locationType}`; |
| locationBadge.textContent = { |
| madon: "موقع مدن", |
| base: "موقع أساسي", |
| statement: "إفادة مدن", |
| none: "دون موقع", |
| }[row.locationType] || "دون موقع"; |
| badges.append(badge, locationBadge); |
| header.append(titleWrap, badges); |
|
|
| const details = document.createElement("div"); |
| details.className = "card-details"; |
| [ |
| ["السجل التجاري", row.commercialRecord, "ltr-value"], |
| ["رقم العقد", row.contractNumber || "لا يوجد رقم عقد", "ltr-value"], |
| ["الرقم الموحد", row.unifiedNumber, "ltr-value"], |
| ["المدينة الصناعية", row.city, "wide"], |
| ["النشاط", row.activity, "wide"], |
| ].forEach(([label, value, className]) => { |
| if (String(value ?? "").trim()) details.append(detailItem(label, value, className)); |
| }); |
| const location = createLocationBlock(row); |
| if (location) details.append(location); |
| const madonInfo = createMadonInfo(row); |
| if (madonInfo) details.append(madonInfo); |
|
|
| const footer = document.createElement("div"); |
| footer.className = "card-actions"; |
| const documented = isDocumentationCompleted(row.rowId); |
| footer.append( |
| actionButton("رسالة لمسؤول مدن", ICONS.whatsapp, () => openMadonDialog(row)), |
| actionButton("مشاركة الاستمارة", ICONS.whatsapp, () => openShareDialog(row)), |
| actionButton( |
| documented ? "تعديل التوثيق" : "توثيق ميداني", |
| documented ? "M4 20h4l11-11-4-4L4 16v4Z M13.5 6.5l4 4" : "M5 3h14v18H5z M8 7h8M8 11h8M8 15h5", |
| () => openDocumentationDialog(row), |
| ), |
| ); |
| card.append(header, details, footer); |
| return card; |
| } |
|
|
| function renderRows(reset = false) { |
| if (reset) { |
| renderedCount = 0; |
| elements.samplesGrid.replaceChildren(); |
| } |
| const fragment = document.createDocumentFragment(); |
| visibleRows.slice(renderedCount, renderedCount + PAGE_SIZE).forEach((row, offset) => { |
| fragment.append(createCard(row, renderedCount + offset)); |
| }); |
| elements.samplesGrid.append(fragment); |
| updateSelectionBar(); |
| renderedCount += Math.min(PAGE_SIZE, visibleRows.length - renderedCount); |
| const remaining = visibleRows.length - renderedCount; |
| elements.loadMoreWrap.hidden = remaining <= 0; |
| elements.loadMoreMeta.textContent = remaining > 0 ? `متبقي ${remaining} عينة` : ""; |
| } |
|
|
| function applyFilters() { |
| if (!activeResearcher) return; |
| const city = activeCity(); |
| const query = normalize(elements.searchInput.value); |
| const status = elements.statusSelect.value; |
| const location = elements.locationSelect.value; |
| visibleRows = documentedRowsFirst(researcherRows.filter((row) => { |
| if (city && row.city !== city) return false; |
| if (status && row.status !== status) return false; |
| if (location === "statement" && !row.madonStatement && !row.madonNoteText) return false; |
| if (location && location !== "statement" && row.locationType !== location) return false; |
| if (query) { |
| const haystack = normalize( |
| [row.establishmentName, row.commercialRecord, row.unifiedNumber, row.activity].join(" "), |
| ); |
| if (!haystack.includes(query)) return false; |
| } |
| return true; |
| })); |
| elements.resultsTitle.textContent = city || "جميع العينات"; |
| elements.resultsMeta.textContent = `${visibleRows.length} من أصل ${researcherRows.length} عينة`; |
| elements.noResults.hidden = visibleRows.length > 0; |
| renderRows(true); |
| saveState(); |
| } |
|
|
| function clearFilters() { |
| elements.searchInput.value = ""; |
| elements.statusSelect.value = ""; |
| elements.locationSelect.value = ""; |
| setActiveCity(""); |
| } |
|
|
| function shareForm() { |
| openShareDialog(); |
| } |
|
|
| elements.loginForm.addEventListener("submit", async (event) => { |
| event.preventDefault(); |
| elements.loginError.textContent = ""; |
| elements.loginButton.disabled = true; |
| elements.loginButton.querySelector("span").textContent = "جارٍ الدخول..."; |
| try { |
| const password = elements.password.value; |
| const supervisor = password === "1448"; |
| payload = await decryptData( |
| password, |
| supervisor && typeof SUPERVISOR_ENCRYPTED_DATA === "object" |
| ? SUPERVISOR_ENCRYPTED_DATA |
| : ENCRYPTED_DATA, |
| ); |
| sessionAccessCode = password; |
| elements.password.value = ""; |
| if (supervisor) { |
| adminDashboardInitialized = false; |
| newAdminRecordIds.clear(); |
| setAdminFilterOptions(); |
| showOnly(elements.adminView); |
| await loadAdminDashboard(); |
| startAdminAutoRefresh(); |
| return; |
| } |
| await syncResearcherDocumentation(); |
| elements.researcherSearch.value = ""; |
| setDirectoryMode("researcher"); |
| renderResearchers(); |
| showOnly(elements.researcherView); |
| } catch { |
| elements.loginError.textContent = "رمز الدخول غير صحيح."; |
| } finally { |
| elements.loginButton.disabled = false; |
| elements.loginButton.querySelector("span").textContent = "دخول"; |
| } |
| }); |
|
|
| elements.togglePassword.addEventListener("click", () => { |
| const visible = elements.password.type === "text"; |
| elements.password.type = visible ? "password" : "text"; |
| elements.togglePassword.setAttribute("aria-label", visible ? "إظهار رمز الدخول" : "إخفاء رمز الدخول"); |
| }); |
| elements.researcherSearch.addEventListener("input", () => { |
| if (directoryMode === "entity") renderEntitySearch(); |
| else renderResearchers(); |
| }); |
| elements.researcherModeButton.addEventListener("click", () => setDirectoryMode("researcher")); |
| elements.entityModeButton.addEventListener("click", () => setDirectoryMode("entity")); |
| elements.switchResearcherButton.addEventListener("click", () => { |
| activeResearcher = null; |
| setDirectoryMode("researcher"); |
| renderResearchers(); |
| showOnly(elements.researcherView); |
| window.scrollTo({ top: 0 }); |
| }); |
| elements.logoutButton.addEventListener("click", () => { |
| payload = null; |
| activeResearcher = null; |
| researcherRows = []; |
| visibleRows = []; |
| sessionAccessCode = ""; |
| showOnly(elements.loginView); |
| elements.password.focus(); |
| }); |
| elements.adminLogoutButton.addEventListener("click", () => { |
| stopAdminAutoRefresh(); |
| payload = null; |
| adminRecords = []; |
| documentationRecords = []; |
| adminDashboardInitialized = false; |
| newAdminRecordIds.clear(); |
| sessionAccessCode = ""; |
| showOnly(elements.loginView); |
| elements.password.focus(); |
| }); |
| elements.refreshAdminButton.addEventListener("click", () => loadAdminDashboard()); |
| document.addEventListener("visibilitychange", () => { |
| if (sessionAccessCode !== "1448") return; |
| if (document.hidden) { |
| stopAdminAutoRefresh(); |
| setAdminSyncState("paused", "التحديث متوقف أثناء وجود الصفحة بالخلفية"); |
| return; |
| } |
| loadAdminDashboard({ automatic: true }); |
| startAdminAutoRefresh(); |
| }); |
| [elements.adminSearch, elements.adminResearcherFilter, elements.adminCityFilter, elements.adminDateFilter, elements.adminStatusFilter] |
| .forEach((control) => control.addEventListener(control.tagName === "INPUT" ? "input" : "change", renderAdminRecords)); |
| elements.clearAdminFilters.addEventListener("click", () => { |
| elements.adminSearch.value = ""; |
| elements.adminResearcherFilter.value = ""; |
| elements.adminCityFilter.value = ""; |
| elements.adminDateFilter.value = ""; |
| elements.adminStatusFilter.value = ""; |
| renderAdminRecords(); |
| }); |
| elements.searchInput.addEventListener("input", applyFilters); |
| elements.statusSelect.addEventListener("change", applyFilters); |
| elements.locationSelect.addEventListener("change", applyFilters); |
| elements.clearFiltersButton.addEventListener("click", clearFilters); |
| elements.loadMoreButton.addEventListener("click", () => renderRows(false)); |
| elements.shareFormButton.addEventListener("click", shareForm); |
| elements.toggleContactsButton.addEventListener("click", () => { |
| setContactsExpanded(elements.toggleContactsButton.getAttribute("aria-expanded") !== "true"); |
| }); |
| elements.closeShareDialog.addEventListener("click", closeShareDialog); |
| elements.shareDialog.addEventListener("click", (event) => { |
| if (event.target === elements.shareDialog) closeShareDialog(); |
| }); |
| elements.recipientPhone.addEventListener("input", () => { |
| elements.recipientPhoneError.textContent = ""; |
| updateShareButton(); |
| }); |
| elements.copyShareMessage.addEventListener("click", () => |
| copyText(elements.messagePreview.value, "تم نسخ رسالة الاستمارة"), |
| ); |
| elements.shareForm.addEventListener("submit", (event) => { |
| event.preventDefault(); |
| const phone = recipientPhone(elements.recipientPhone.value); |
| if (!phone) { |
| elements.recipientPhoneError.textContent = "يرجى إدخال رقم جوال سعودي صحيح، مثال: 0501234567."; |
| elements.recipientPhone.focus(); |
| return; |
| } |
| localStorage.setItem("ics2LastRecipientPhone", localPhone(phone)); |
| const message = formMessage(shareRow); |
| window.open(`https://wa.me/${phone}?text=${encodeURIComponent(message)}`, "_blank", "noopener"); |
| closeShareDialog(); |
| }); |
| elements.closeMadonDialog.addEventListener("click", closeMadonDialog); |
| elements.madonDialog.addEventListener("click", (event) => { |
| if (event.target === elements.madonDialog) closeMadonDialog(); |
| }); |
| elements.copyMadonMessage.addEventListener("click", () => |
| copyText(elements.madonMessagePreview.value, "تم نسخ رسالة مسؤول مدن"), |
| ); |
| elements.openMadonGroup.addEventListener("click", async () => { |
| await copyText(elements.madonMessagePreview.value, "تم نسخ الرسالة، ألصقها الآن في مجموعة مدن"); |
| window.open(MADON_GROUP_URL, "_blank", "noopener"); |
| }); |
| elements.madonForm.addEventListener("submit", (event) => { |
| event.preventDefault(); |
| if (!selectedRepresentative) return; |
| const message = isBulkMadonMessage |
| ? bulkRepresentativeMessage(selectedRepresentative, selectedRowItems()) |
| : representativeMessage(selectedRepresentative, madonRow); |
| window.open( |
| `https://wa.me/${internationalPhone(selectedRepresentative.phone)}?text=${encodeURIComponent(message)}`, |
| "_blank", |
| "noopener", |
| ); |
| closeMadonDialog(); |
| }); |
| elements.closeDocumentationDialog.addEventListener("click", closeDocumentationDialog); |
| elements.cancelDocumentationButton.addEventListener("click", closeDocumentationDialog); |
| elements.documentationDialog.addEventListener("click", (event) => { |
| if (event.target === elements.documentationDialog) closeDocumentationDialog(); |
| }); |
| elements.fieldStatus.addEventListener("change", () => { |
| const isOther = elements.fieldStatus.value === "أخرى"; |
| elements.otherStatusField.hidden = !isOther; |
| elements.otherStatus.required = isOther; |
| if (!isOther) elements.otherStatus.value = ""; |
| elements.documentationError.textContent = ""; |
| if (isOther) elements.otherStatus.focus(); |
| }); |
| elements.otherStatus.addEventListener("input", () => { |
| elements.documentationError.textContent = ""; |
| }); |
| elements.fieldStatement.addEventListener("input", () => { |
| elements.statementCount.textContent = String(elements.fieldStatement.value.length); |
| elements.documentationError.textContent = ""; |
| }); |
| elements.documentationPhotos.addEventListener("change", () => { |
| const incoming = [...elements.documentationPhotos.files].filter((file) => file.type.startsWith("image/")); |
| const maxPhotos = DOCUMENTATION_CONFIG.maxPhotos || 3; |
| const availableSlots = Math.max(maxPhotos - documentationExistingPhotos.length, 0); |
| const combined = [...documentationFiles, ...incoming]; |
| documentationFiles = combined.slice(0, availableSlots); |
| elements.documentationPhotos.value = ""; |
| elements.documentationError.textContent = |
| combined.length > availableSlots ? `الحد الأعلى ${maxPhotos} صور مع الصور المحفوظة.` : ""; |
| renderPhotoPreviews(); |
| }); |
| elements.documentationForm.addEventListener("submit", async (event) => { |
| event.preventDefault(); |
| elements.documentationError.textContent = ""; |
| if (!documentationReady()) { |
| elements.documentationError.textContent = "خدمة التوثيق غير مفعلة بعد."; |
| return; |
| } |
| if (!elements.fieldStatus.value) { |
| elements.documentationError.textContent = "اختر الحالة الميدانية."; |
| elements.fieldStatus.focus(); |
| return; |
| } |
| const customStatus = elements.otherStatus.value.trim(); |
| if (elements.fieldStatus.value === "أخرى" && customStatus.length < 3) { |
| elements.documentationError.textContent = "اكتب توضيح الحالة الأخرى."; |
| elements.otherStatus.focus(); |
| return; |
| } |
| if (!elements.fieldStatement.value.trim()) { |
| elements.documentationError.textContent = "اكتب الإفادة الميدانية."; |
| elements.fieldStatement.focus(); |
| return; |
| } |
| elements.saveDocumentationButton.disabled = true; |
| elements.saveDocumentationButton.textContent = "جارٍ الحفظ..."; |
| elements.uploadProgress.hidden = false; |
| elements.uploadProgressBar.style.width = "10%"; |
| try { |
| const photos = await buildDocumentationPhotos(); |
| elements.uploadProgressBar.style.width = "65%"; |
| const existingRecord = documentationRecord(documentationRow); |
| const documentationId = existingRecord?.documentationId || crypto.randomUUID(); |
| const isEditing = Boolean(existingRecord); |
| const response = await fetch(DOCUMENTATION_CONFIG.endpoint, { |
| method: "POST", |
| headers: { "Content-Type": "text/plain;charset=utf-8" }, |
| body: JSON.stringify({ |
| action: isEditing ? "updateEvidence" : "createEvidence", |
| accessCode: sessionAccessCode, |
| documentationId, |
| sampleKey: documentationRow.sampleKey, |
| researcher: activeResearcher.name, |
| establishmentName: documentationRow.establishmentName, |
| commercialRecord: documentationRow.commercialRecord, |
| contractNumber: documentationRow.contractNumber, |
| city: documentationRow.city, |
| fieldStatus: elements.fieldStatus.value === "أخرى" ? `أخرى: ${customStatus}` : elements.fieldStatus.value, |
| statement: elements.fieldStatement.value.trim(), |
| keptPhotoUrls: documentationExistingPhotos.map((photo) => photo.url), |
| photos, |
| }), |
| }); |
| elements.uploadProgressBar.style.width = "90%"; |
| const result = await response.json(); |
| if (!result.ok) throw new Error(result.error || "تعذر حفظ التوثيق."); |
| elements.uploadProgressBar.style.width = "100%"; |
| markDocumentationCompleted(documentationRow.rowId, result); |
| lastSavedRowId = documentationRow.rowId; |
| closeDocumentationDialog(); |
| applyFilters(); |
| const records = latestRecordsBySample(documentationRecords); |
| const commercialRecords = latestRecordsByCommercialRecord(documentationRecords); |
| const completedCount = researcherRows.filter((row) => |
| records.has(row.sampleKey) || |
| Boolean(row.commercialRecord && commercialRecords.has(row.commercialRecord)), |
| ).length; |
| const remainingCount = Math.max(researcherRows.length - completedCount, 0); |
| showToast( |
| remainingCount === 0 |
| ? "أحسنت، تم استكمال جميع العينات المسندة إليك" |
| : `شكرًا لك، تم ${isEditing ? "تحديث" : "توثيق"} العينة وإضافتها إلى إنجاز اليوم. المتبقي ${remainingCount}`, |
| ); |
| setTimeout(() => { |
| lastSavedRowId = ""; |
| elements.samplesGrid.querySelector(".recently-documented")?.classList.remove("recently-documented"); |
| }, 4200); |
| } catch (error) { |
| elements.documentationError.textContent = |
| error.message || "تعذر رفع التوثيق. تحقق من الاتصال وحاول مرة أخرى."; |
| elements.uploadProgress.hidden = true; |
| } finally { |
| elements.saveDocumentationButton.disabled = !documentationReady(); |
| elements.saveDocumentationButton.replaceChildren( |
| createIcon("M5 3h12l2 2v16H5V3Z M8 3v6h8V3M8 21v-8h8v8"), |
| document.createTextNode("حفظ التوثيق"), |
| ); |
| } |
| }); |
| elements.clearSelectionButton.addEventListener("click", clearSelection); |
| elements.sendSelectionButton.addEventListener("click", openBulkMadonDialog); |
| document.addEventListener("keydown", (event) => { |
| if (event.key !== "Escape") return; |
| if (!elements.shareDialog.hidden) closeShareDialog(); |
| if (!elements.madonDialog.hidden) closeMadonDialog(); |
| if (!elements.documentationDialog.hidden) closeDocumentationDialog(); |
| }); |
|
|