| |
| const FIELD_ALIASES = { |
| commercialRegister: ["السجل التجاري", "رقم السجل التجاري", "رقم السجل", "السجل", "commercial register", "cr"], |
| facilityName: ["اسم المنشأة", "إسم المنشأة", "المنشأة", "اسم المنشاه", "facility", "establishment name"], |
| researcher: ["اسم الباحث", "اسم الباحث/ة", "الباحث", "researcher"], |
| collectionStatus: ["حالة الاستيفاء", "حالة جمع البيانات", "collection status"], |
| approvalStatus: ["حالة الاعتماد", "اعتماد", "الوصف", "الوصف.1", "approval"], |
| surveyName: ["اسم المسح", "المسح", "survey", "survey name"], |
| }; |
|
|
| const PASSCODE = "2030"; |
| const app = { rows: [], byRegister: new Map(), entityGroups: new Map(), researchers: new Map(), surveys: new Map(), batch: [] }; |
| const $ = (id) => document.getElementById(id); |
| const digits = { "٠":"0", "١":"1", "٢":"2", "٣":"3", "٤":"4", "٥":"5", "٦":"6", "٧":"7", "٨":"8", "٩":"9", "۰":"0", "۱":"1", "۲":"2", "۳":"3", "۴":"4", "۵":"5", "۶":"6", "۷":"7", "۸":"8", "۹":"9" }; |
|
|
| function plain(value) { return String(value ?? "").replace(/[\u200E\u200F\u202A-\u202E\u00A0]/g, " ").replace(/\s+/g, " ").trim(); } |
| function normText(value) { return plain(value).toLowerCase().replace(/[إأآا]/g, "ا").replace(/ى/g, "ي").replace(/ة/g, "ه").replace(/[\u064B-\u065F\u0670]/g, ""); } |
| function normalizeDigits(value) { return plain(value).replace(/[٠-٩۰-۹]/g, (c) => digits[c]); } |
|
|
| function expandScientific(value) { |
| const m = value.match(/^([+\-]?)(\d+)(?:\.(\d*))?[eE]([+\-]?\d+)$/); |
| if (!m) return value; |
| const sign = m[1] === "-" ? "-" : ""; |
| const whole = m[2]; |
| const fraction = m[3] || ""; |
| const power = Number(m[4]); |
| const all = whole + fraction; |
| const point = whole.length + power; |
| if (!Number.isInteger(power) || point < 0 || point > 80) return value; |
| if (point >= all.length) return sign + all + "0".repeat(point - all.length); |
| if (point <= 0) return sign + "0." + "0".repeat(-point) + all; |
| return sign + all.slice(0, point) + "." + all.slice(point); |
| } |
|
|
| function normalizeRegister(value) { |
| let v = expandScientific(normalizeDigits(value)); |
| if (!v || /^(nan|null|undefined)$/i.test(v)) return ""; |
| if (/^\d+\.0+$/.test(v)) v = v.replace(/\.0+$/, ""); |
| if (/^[\d\s,،._\-/]+$/.test(v)) return v.replace(/[\s,،._\-/]/g, ""); |
| return v; |
| } |
|
|
| function getField(raw, aliases) { |
| const entries = Object.entries(raw || {}); |
| for (const alias of aliases) { |
| const hit = entries.find(([key]) => normText(key) === normText(alias)); |
| if (hit && plain(hit[1])) return plain(hit[1]); |
| } |
| return ""; |
| } |
|
|
| function getStatusClass(value) { |
| const v = normText(value); |
| if (/اعطت كامل|استيفاء ذاتي|مكتمل|معتمد/.test(v) && !/غير/.test(v)) return "ok"; |
| if (/رفض|لم يعثر|مغلق|تعذر|غير مكتمل|ناقص|خارج نطاق/.test(v)) return "danger"; |
| if (/يطلب التواصل|بعض البيانات|جديد|اخرى|تحولت/.test(v)) return "warn"; |
| return ""; |
| } |
|
|
| function entityKey(row, id) { return row.commercialRegister ? `cr:${row.commercialRegister}` : `row:${id}`; } |
| function dataPath() { return (typeof SURVEY_DATA_CONFIG !== "undefined" && SURVEY_DATA_CONFIG.excelPath) || "./data/تقاطع.xlsx"; } |
| function rowHasData(raw) { return Object.entries(raw || {}).some(([key, value]) => !String(key).startsWith("__") && plain(value)); } |
|
|
| async function readWorkbookRows() { |
| const path = dataPath(); |
| if (typeof XLSX === "undefined") throw new Error("تعذر تحميل مكتبة قراءة Excel."); |
| const response = await fetch(path, { cache: "no-store" }); |
| if (!response.ok) throw new Error(`تعذر العثور على ملف بيانات المسوح. المسار: ${path}`); |
| const workbook = XLSX.read(await response.arrayBuffer(), { type: "array", cellText: true, cellDates: false }); |
| const rows = []; |
| workbook.SheetNames.forEach((sheetName) => { |
| const sheetRows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: "", raw: false, blankrows: false }); |
| sheetRows.filter(rowHasData).forEach((raw) => rows.push({ ...raw, "اسم المسح": sheetName, "__sheet": sheetName })); |
| }); |
| return { rows, path }; |
| } |
|
|
| async function loadData() { |
| const source = await readWorkbookRows(); |
| if (!source.rows.length) throw new Error(`تعذر تحميل بيانات المسوح من الملف: ${source.path}`); |
| app.rows = source.rows.map((raw, index) => { |
| const commercialRegister = normalizeRegister(getField(raw, FIELD_ALIASES.commercialRegister)); |
| return { |
| id: index, |
| raw, |
| commercialRegister, |
| sourceSheet: raw.__sheet || getField(raw, FIELD_ALIASES.surveyName), |
| facilityName: getField(raw, FIELD_ALIASES.facilityName), |
| researcher: getField(raw, FIELD_ALIASES.researcher), |
| collectionStatus: getField(raw, FIELD_ALIASES.collectionStatus), |
| approvalStatus: getField(raw, FIELD_ALIASES.approvalStatus), |
| surveyName: getField(raw, FIELD_ALIASES.surveyName) || raw.__sheet || "مسح غير معروف", |
| }; |
| }); |
|
|
| app.rows.forEach((row) => { |
| const key = entityKey(row, row.id); |
| if (!app.entityGroups.has(key)) app.entityGroups.set(key, { key, commercialRegister: row.commercialRegister, rows: [] }); |
| app.entityGroups.get(key).rows.push(row); |
|
|
| if (row.commercialRegister) { |
| if (!app.byRegister.has(row.commercialRegister)) app.byRegister.set(row.commercialRegister, []); |
| app.byRegister.get(row.commercialRegister).push(row); |
| } |
|
|
| const researcherKey = normText(row.researcher); |
| if (researcherKey) { |
| if (!app.researchers.has(researcherKey)) app.researchers.set(researcherKey, { key: researcherKey, name: row.researcher, rows: [] }); |
| app.researchers.get(researcherKey).rows.push(row); |
| } |
|
|
| if (!app.surveys.has(row.surveyName)) app.surveys.set(row.surveyName, []); |
| app.surveys.get(row.surveyName).push(row); |
| }); |
| } |
|
|
| function escapeHtml(v) { return String(v ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); } |
| function td(label, content) { return `<td data-label="${escapeHtml(label)}">${content}</td>`; } |
| function stackedList(items) { |
| const clean = items.map((item) => plain(item)).filter(Boolean); |
| return clean.length ? `<div class="stacked-list">${clean.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}</div>` : "-"; |
| } |
| function metric(value, label, target = "", description = "") { |
| const action = target ? ` role="button" tabindex="0" data-dashboard-detail="${target}"` : ""; |
| const note = description ? `<small>${escapeHtml(description)}</small>` : ""; |
| return `<div class="metric${target ? " metric-action" : ""}"${action}><b>${value}</b><span>${label}</span>${note}</div>`; |
| } |
|
|
| function countBy(rows, picker) { |
| return rows.reduce((map, row) => { |
| const value = picker(row) || "غير محدد"; |
| map.set(value, (map.get(value) || 0) + 1); |
| return map; |
| }, new Map()); |
| } |
|
|
| function sharedCount(rows) { |
| return new Set(rows.filter((row) => row.commercialRegister && new Set(app.byRegister.get(row.commercialRegister).map((z) => z.surveyName)).size > 1).map((row) => row.commercialRegister)).size; |
| } |
|
|
| function groupInfo(group) { |
| const rows = group.rows; |
| const names = [...new Set(rows.map((row) => row.facilityName).filter(Boolean))]; |
| const surveys = [...new Set(rows.map((row) => row.surveyName))]; |
| const researchers = [...new Set(rows.map((row) => row.researcher).filter(Boolean))]; |
| return { ...group, name: names[0] || "-", names, surveys, researchers, multiSurvey: surveys.length > 1 }; |
| } |
|
|
| function groupForRows(rows) { |
| const groups = new Map(); |
| rows.forEach((row) => { |
| const key = entityKey(row, row.id); |
| if (!groups.has(key)) groups.set(key, { key, commercialRegister: row.commercialRegister, rows: [] }); |
| groups.get(key).rows.push(row); |
| }); |
| return [...groups.values()].map(groupInfo); |
| } |
|
|
| function unlockApp() { |
| const gate = $("loginGate"); |
| if (!gate || gate.classList.contains("hidden")) return; |
| gate.classList.add("hidden"); |
| setTimeout(() => gate.remove(), 260); |
| setTimeout(() => document.querySelector("[data-view='search']")?.focus(), 300); |
| } |
|
|
| function setupLoginGate() { |
| const input = $("passcodeInput"); |
| if (!input) return; |
| input.focus(); |
| $("loginForm")?.addEventListener("submit", (event) => { |
| event.preventDefault(); |
| if (input.value === PASSCODE) unlockApp(); |
| }); |
| input.addEventListener("input", () => { |
| input.value = normalizeDigits(input.value).replace(/\D/g, "").slice(0, 4); |
| if (input.value === PASSCODE) unlockApp(); |
| }); |
| input.addEventListener("keydown", (event) => { |
| if (event.key === "Enter" && input.value === PASSCODE) unlockApp(); |
| }); |
| } |
|
|
| function showView(name) { |
| document.querySelectorAll(".view").forEach((view) => view.classList.toggle("active", view.id === name)); |
| document.querySelectorAll(".nav-link").forEach((button) => button.classList.toggle("active", button.dataset.view === name)); |
| window.scrollTo({ top: 0, behavior: "smooth" }); |
| } |
|
|
| function toast(message) { |
| const el = $("toast"); |
| el.textContent = message; |
| el.classList.add("show"); |
| clearTimeout(toast.timer); |
| toast.timer = setTimeout(() => el.classList.remove("show"), 3200); |
| } |
|
|
| function renderMetrics() { |
| const intersectionGroups = [...app.byRegister.entries()] |
| .map(([register, rows]) => groupInfo({ key: `cr:${register}`, commercialRegister: register, rows })) |
| .filter((group) => group.multiSurvey); |
| const shared = intersectionGroups.length; |
| const statusDifferenceGroups = intersectionGroups.filter((group) => new Set(group.rows.map((row) => plain(row.collectionStatus)).filter(Boolean)).size > 1); |
| $("metrics").innerHTML = [ |
| metric(shared.toLocaleString("ar-SA"), "عدد التقاطعات", "intersections", "سجلات ظهرت في أكثر من مسح"), |
| metric(statusDifferenceGroups.length.toLocaleString("ar-SA"), "اختلاف حالات الاستيفاء", "statuses", "اختلاف حالة المنشأة بين المسوح"), |
| metric(shared.toLocaleString("ar-SA"), "المنشآت المتقاطعة", "establishments", "منشآت مشتركة قابلة للمراجعة"), |
| ].join(""); |
| } |
|
|
| function renderEntity(group) { |
| const g = groupInfo(group); |
| const title = g.names.length > 1 ? `${escapeHtml(g.name)} <span class="badge warn">أسماء متعددة</span>` : escapeHtml(g.name); |
| const note = g.multiSurvey ? `<span class="badge warn">موجودة في أكثر من مسح</span>` : `<span class="badge ok">موجودة في مسح واحد</span>`; |
| const occurrences = [...g.rows].sort((a, b) => a.surveyName.localeCompare(b.surveyName, "ar")).map((row) => `<div class="occurrence"> |
| <div><span>المسح</span>${escapeHtml(row.surveyName)}</div> |
| <div><span>الباحث</span>${escapeHtml(row.researcher || "-")}</div> |
| <div><span>حالة الاستيفاء</span><i class="badge ${getStatusClass(row.collectionStatus)}">${escapeHtml(row.collectionStatus || "-")}</i></div> |
| </div>`).join(""); |
|
|
| return `<article class="entity-card"> |
| <div class="entity-top"> |
| <div><h3>${title}</h3><p>السجل التجاري: <b>${escapeHtml(g.commercialRegister || "غير متاح في المصدر")}</b></p></div> |
| <div class="badges"><span class="badge">${g.surveys.length} مسح</span><span class="badge">${g.rows.length} ظهور</span><span class="badge">${g.researchers.length} باحث</span>${note}</div> |
| </div> |
| <div class="occurrences"> |
| <div class="occurrence occurrence-head"><div>المسح</div><div>الباحث</div><div>حالة الاستيفاء</div></div> |
| ${occurrences} |
| </div> |
| </article>`; |
| } |
|
|
| function intersectionGroups() { |
| return [...app.byRegister.entries()] |
| .map(([register, rows]) => groupInfo({ key: `cr:${register}`, commercialRegister: register, rows })) |
| .filter((group) => group.multiSurvey) |
| .sort((a, b) => b.surveys.length - a.surveys.length || a.name.localeCompare(b.name, "ar")); |
| } |
|
|
| function dashboardDetailHeader(title, subtitle) { |
| return `<div class="dashboard-detail-head"><div><h2>${escapeHtml(title)}</h2><p>${escapeHtml(subtitle)}</p></div><button class="btn secondary dashboard-close" type="button">إخفاء</button></div>`; |
| } |
|
|
| function renderDashboardDetail(type) { |
| const panel = $("dashboardDetails"); |
| if (!panel) return; |
| const groups = intersectionGroups(); |
| const sharedRows = groups.flatMap((group) => group.rows); |
| panel.className = "dashboard-details card"; |
|
|
| if (type === "statuses") { |
| const statusDiffs = groups.filter((group) => new Set(group.rows.map((row) => plain(row.collectionStatus)).filter(Boolean)).size > 1); |
| panel.innerHTML = `${dashboardDetailHeader("اختلاف حالات الاستيفاء بين المسوح", "منشآت متقاطعة تختلف حالة الاستيفاء لها من مسح إلى آخر.")} |
| <div class="table-wrap"><table><thead><tr><th>السجل التجاري</th><th>اسم المنشأة</th><th>الحالات حسب المسح</th><th>عدد الحالات</th></tr></thead><tbody>${statusDiffs.slice(0, 50).map((group) => { |
| const statuses = group.rows |
| .sort((a, b) => a.surveyName.localeCompare(b.surveyName, "ar")) |
| .map((row) => `${row.surveyName}: ${row.collectionStatus || "-"}`); |
| const count = new Set(group.rows.map((row) => plain(row.collectionStatus)).filter(Boolean)).size; |
| return `<tr>${td("السجل التجاري", `<button class="row-button" data-entity="${escapeHtml(group.key)}">${escapeHtml(group.commercialRegister)}</button>`)}${td("اسم المنشأة", escapeHtml(group.name))}${td("الحالات حسب المسح", stackedList(statuses))}${td("عدد الحالات", count)}</tr>`; |
| }).join("")}</tbody></table></div>`; |
| } else if (type === "researchers") { |
| const researchers = [...countBy(sharedRows, (row) => row.researcher).entries()].sort((a, b) => b[1] - a[1]).slice(0, 20); |
| panel.innerHTML = `${dashboardDetailHeader("الباحثون الذين لديهم تقاطعات", "أعلى الباحثين ظهورًا ضمن المنشآت المتقاطعة بين المسوح.")} |
| <div class="table-wrap compact-table"><table><thead><tr><th>الباحث</th><th>عدد الظهور في التقاطعات</th></tr></thead><tbody>${researchers.map(([name, count]) => `<tr>${td("الباحث", escapeHtml(name))}${td("عدد الظهور في التقاطعات", count.toLocaleString("ar-SA"))}</tr>`).join("")}</tbody></table></div>`; |
| } else { |
| const filtered = groups; |
| const title = type === "establishments" ? "المنشآت المتقاطعة" : "تفاصيل التقاطعات"; |
| const subtitle = "السجلات التجارية التي ظهرت في أكثر من مسح."; |
| panel.innerHTML = `${dashboardDetailHeader(title, subtitle)} |
| <div class="table-wrap"><table><thead><tr><th>السجل التجاري</th><th>اسم المنشأة</th><th>عدد المسوح</th><th>المسوح</th><th>الباحثون</th></tr></thead><tbody>${filtered.slice(0, 50).map((group) => `<tr>${td("السجل التجاري", `<button class="row-button" data-entity="${escapeHtml(group.key)}">${escapeHtml(group.commercialRegister)}</button>`)}${td("اسم المنشأة", escapeHtml(group.name))}${td("عدد المسوح", group.surveys.length)}${td("المسوح", stackedList(group.surveys))}${td("الباحثون", stackedList(group.researchers))}</tr>`).join("")}</tbody></table></div>`; |
| } |
|
|
| panel.scrollIntoView({ behavior: "smooth", block: "start" }); |
| } |
|
|
| function search() { |
| const input = $("q").value; |
| const q = normText(input); |
| const reg = normalizeRegister(input); |
| if (!q) { |
| $("searchResults").className = "results empty-state"; |
| $("searchResults").textContent = "ابدأ بالكتابة لعرض النتائج مباشرة."; |
| $("searchMeta").textContent = "البحث يعمل تلقائيًا أثناء الكتابة."; |
| return; |
| } |
|
|
| let rows = []; |
| let mode = ""; |
| if (reg && app.byRegister.has(reg)) { |
| rows = app.byRegister.get(reg); |
| mode = "سجل تجاري"; |
| } else { |
| rows = app.rows.filter((row) => normText(row.facilityName).includes(q) || normText(row.researcher).includes(q)); |
| mode = "اسم منشأة أو باحث"; |
| } |
|
|
| const groups = groupForRows(rows); |
| $("searchMeta").textContent = groups.length ? `تم العثور على ${groups.length} نتيجة حسب ${mode}.` : "لم يتم العثور على نتائج مطابقة."; |
| $("searchResults").className = "results"; |
| $("searchResults").innerHTML = groups.length ? groups.slice(0, 100).map(renderEntity).join("") : "<div class='empty-state'>لم يتم العثور على نتائج مطابقة.</div>"; |
| if (groups.length > 100) toast("تم عرض أول 100 نتيجة حفاظًا على سرعة الصفحة."); |
| } |
|
|
| function parseBatch(text) { |
| const rawTokens = String(text || "").split(/[\s,،;؛\t\n\r]+/).map(plain).filter(Boolean); |
| const values = []; |
| const seen = new Set(); |
| let invalid = 0; |
| let duplicates = 0; |
|
|
| rawTokens.forEach((token) => { |
| const register = normalizeRegister(token); |
| if (!/^\d+$/.test(register)) { |
| invalid += 1; |
| return; |
| } |
| if (seen.has(register)) { |
| duplicates += 1; |
| return; |
| } |
| seen.add(register); |
| values.push(register); |
| }); |
|
|
| return { values, duplicates, invalid, entered: rawTokens.length }; |
| } |
|
|
| function updateBatchHint() { |
| const parsed = parseBatch($("batchInput").value); |
| if (!parsed.entered) { |
| $("batchHint").textContent = "لم تُدخل أي سجلات بعد."; |
| return; |
| } |
| const ignored = []; |
| if (parsed.duplicates) ignored.push(`${parsed.duplicates} مكرر`); |
| if (parsed.invalid) ignored.push(`${parsed.invalid} يحتوي أحرفًا أو رموزًا`); |
| $("batchHint").textContent = `تم التعرف على ${parsed.values.length} سجل رقمي${ignored.length ? `، وتم تجاهل ${ignored.join(" و")}.` : "."}`; |
| } |
|
|
| function batchCheck() { |
| const parsed = parseBatch($("batchInput").value); |
| if (!parsed.values.length) { |
| toast("أدخل أرقام سجلات فقط، بدون أحرف أو رموز."); |
| return; |
| } |
|
|
| app.batch = parsed.values.map((register, order) => { |
| const rows = app.byRegister.get(register) || []; |
| const group = rows.length ? groupInfo({ key: `cr:${register}`, commercialRegister: register, rows }) : null; |
| return { order, register, group }; |
| }); |
|
|
| const found = app.batch.filter((item) => item.group); |
| const multi = found.filter((item) => item.group.surveys.length > 1); |
| $("batchSummary").innerHTML = [ |
| metric(app.batch.length, "إجمالي السجلات"), |
| metric(found.length, "الموجودة"), |
| metric(app.batch.length - found.length, "غير الموجودة"), |
| metric(found.length - multi.length, "في مسح واحد"), |
| metric(multi.length, "في عدة مسوح"), |
| ].join(""); |
| $("batchMeta").textContent = `تم فحص ${app.batch.length} سجل رقمي بالترتيب الذي أُدخلت به.`; |
| $("batchResults").className = "results"; |
| $("batchResults").innerHTML = renderBatchTable(app.batch); |
| $("btnExport").disabled = false; |
| } |
|
|
| function renderBatchTable(items) { |
| return `<div class="table-wrap"><table><thead><tr><th>رقم السجل التجاري</th><th>اسم المنشأة</th><th>مرات الظهور</th><th>الباحثون</th><th>حالة المطابقة</th></tr></thead><tbody>${items.map((item) => { |
| const g = item.group; |
| if (!g) return `<tr>${td("رقم السجل التجاري", escapeHtml(item.register))}${td("اسم المنشأة", "-")}${td("مرات الظهور", "0")}${td("الباحثون", "-")}${td("حالة المطابقة", `<span class="badge danger">غير موجود</span>`)}</tr>`; |
| return `<tr>${td("رقم السجل التجاري", `<button class="row-button" data-entity="${escapeHtml(g.key)}">${escapeHtml(item.register)}</button>`)}${td("اسم المنشأة", escapeHtml(g.name))}${td("مرات الظهور", g.rows.length)}${td("الباحثون", g.researchers.length)}${td("حالة المطابقة", `<span class="badge ${g.multiSurvey ? "warn" : "ok"}">${g.multiSurvey ? "موجود في عدة مسوح" : "موجود"}</span>`)}</tr>`; |
| }).join("")}</tbody></table></div>`; |
| } |
|
|
| function renderIntersections() { |
| const groups = [...app.byRegister.entries()].map(([register, rows]) => groupInfo({ key: `cr:${register}`, commercialRegister: register, rows })).filter((group) => group.multiSurvey).sort((a, b) => b.surveys.length - a.surveys.length); |
| const two = groups.filter((group) => group.surveys.length === 2).length; |
| const three = groups.filter((group) => group.surveys.length >= 3).length; |
| $("intersectionMetrics").innerHTML = [metric(groups.length, "منشآت مشتركة"), metric(two, "في مسحين"), metric(three, "في 3 مسوح أو أكثر"), metric(app.byRegister.size, "سجلات قابلة للمطابقة"), metric(app.surveys.size, "إجمالي المسوح")].join(""); |
| $("intersectionResults").className = "results"; |
| $("intersectionResults").innerHTML = groups.length ? `<div class="table-wrap"><table><thead><tr><th>السجل التجاري</th><th>اسم المنشأة</th><th>عدد المسوح</th><th>المسوح</th><th>الباحثون</th></tr></thead><tbody>${groups.slice(0, 200).map((group) => `<tr>${td("السجل التجاري", `<button class="row-button" data-entity="${escapeHtml(group.key)}">${escapeHtml(group.commercialRegister)}</button>`)}${td("اسم المنشأة", escapeHtml(group.name))}${td("عدد المسوح", group.surveys.length)}${td("المسوح", stackedList(group.surveys))}${td("الباحثون", stackedList(group.researchers))}</tr>`).join("")}</tbody></table></div>` : "<div class='empty-state'>لا توجد تقاطعات قابلة للحساب.</div>"; |
| } |
|
|
| function renderCompactEntities(groups) { |
| return `<div class="table-wrap compact-table"><table><thead><tr><th>السجل التجاري</th><th>اسم المنشأة</th><th>المسوح</th><th>الباحثون</th></tr></thead><tbody>${groups.map((group) => `<tr>${td("السجل التجاري", `<button class="row-button" data-entity="${escapeHtml(group.key)}">${escapeHtml(group.commercialRegister || "-")}</button>`)}${td("اسم المنشأة", escapeHtml(group.name))}${td("المسوح", stackedList(group.surveys))}${td("الباحثون", stackedList(group.researchers))}</tr>`).join("")}</tbody></table></div>`; |
| } |
|
|
| function renderResearchers() { |
| const q = normText($("researcherFilter").value); |
| const entries = [...app.researchers.values()].filter((item) => normText(item.name).includes(q)).sort((a, b) => b.rows.length - a.rows.length).slice(0, 80); |
| $("researcherResults").innerHTML = entries.length ? entries.map((researcher) => { |
| const surveys = countBy(researcher.rows, (row) => row.surveyName); |
| const statuses = countBy(researcher.rows, (row) => row.collectionStatus); |
| const sampleGroups = groupForRows(researcher.rows).slice(0, 10); |
| return `<details class="card detail-card researcher-card"> |
| <summary><span><strong>${escapeHtml(researcher.name)}</strong><small>${researcher.rows.length} منشأة/سجل · ${surveys.size} مسح · ${sharedCount(researcher.rows)} منشأة متقاطعة</small></span><i>عرض التفاصيل</i></summary> |
| <div class="detail-body"> |
| <div class="mini-grid">${[...surveys.entries()].map(([name, count]) => metric(count, name)).join("")}</div> |
| <h4>حالات الاستيفاء</h4> |
| <div class="badges status-cloud">${[...statuses.entries()].map(([status, count]) => `<span class="badge ${getStatusClass(status)}">${escapeHtml(status)} · ${count}</span>`).join("")}</div> |
| <h4>نماذج من السجلات</h4> |
| ${renderCompactEntities(sampleGroups)} |
| </div> |
| </details>`; |
| }).join("") : "<div class='empty-state'>لا يوجد باحث مطابق.</div>"; |
| } |
|
|
| function renderSurveys() { |
| $("surveyResults").innerHTML = [...app.surveys.entries()].map(([name, rows]) => { |
| const researchers = countBy(rows, (row) => row.researcher); |
| const statuses = countBy(rows, (row) => row.collectionStatus); |
| const shared = sharedCount(rows); |
| const sampleGroups = groupForRows(rows.filter((row) => row.commercialRegister && new Set(app.byRegister.get(row.commercialRegister).map((z) => z.surveyName)).size > 1)).slice(0, 8); |
| return `<details class="card detail-card survey-card"> |
| <summary><span><strong>${escapeHtml(name)}</strong><small>${rows.length.toLocaleString("ar-SA")} سجل · ${researchers.size} باحث · ${shared} منشأة متقاطعة</small></span><i>عرض التفاصيل</i></summary> |
| <div class="detail-body"> |
| <div class="mini-grid">${metric(rows.length.toLocaleString("ar-SA"), "إجمالي السجلات")}${metric(researchers.size, "عدد الباحثين")}${metric(shared, "منشآت متقاطعة")}</div> |
| <h4>حالات الاستيفاء في المسح</h4> |
| <div class="badges status-cloud">${[...statuses.entries()].map(([status, count]) => `<span class="badge ${getStatusClass(status)}">${escapeHtml(status)} · ${count}</span>`).join("")}</div> |
| <h4>أعلى الباحثين حسب عدد السجلات</h4> |
| <div class="badges status-cloud">${[...researchers.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([researcher, count]) => `<span class="badge">${escapeHtml(researcher)} · ${count}</span>`).join("")}</div> |
| <h4>نماذج من التقاطعات</h4> |
| ${sampleGroups.length ? renderCompactEntities(sampleGroups) : "<div class='empty-state small'>لا توجد تقاطعات في هذا المسح.</div>"} |
| </div> |
| </details>`; |
| }).join(""); |
| } |
|
|
| function escapeXml(v) { return String(v ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); } |
| function xmlCell(value, style = "") { return `<Cell${style ? ` ss:StyleID="${style}"` : ""}><Data ss:Type="String">${escapeXml(value)}</Data></Cell>`; } |
|
|
| function exportBatch() { |
| if (!app.batch.length) { |
| toast("لا توجد نتائج لتصديرها."); |
| return; |
| } |
| const max = Math.max(0, ...app.batch.map((item) => item.group?.rows.length || 0)); |
| const headers = ["رقم السجل التجاري", "اسم المنشأة", "عدد مرات الظهور", "حالة المطابقة"]; |
| for (let i = 1; i <= max; i += 1) headers.push(`الباحث ${i}`, `المسح ${i}`, `حالة الاستيفاء ${i}`); |
| const data = app.batch.map((item) => { |
| const g = item.group; |
| const values = [item.register, g?.name || "", g?.rows.length || 0, g ? (g.multiSurvey ? "موجود في عدة مسوح" : "موجود") : "غير موجود"]; |
| const occurrences = g ? [...g.rows].sort((a, b) => a.surveyName.localeCompare(b.surveyName, "ar")) : []; |
| for (let i = 0; i < max; i += 1) { |
| const row = occurrences[i]; |
| values.push(row?.researcher || "", row?.surveyName || "", row?.collectionStatus || ""); |
| } |
| return values; |
| }); |
| const xml = `<?xml version="1.0" encoding="UTF-8"?><?mso-application progid="Excel.Sheet"?><Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet" xmlns:x="urn:schemas-microsoft-com:office:excel"><Styles><Style ss:ID="h"><Font ss:Bold="1" ss:Color="#FFFFFF"/><Interior ss:Color="#226D68" ss:Pattern="Solid"/><Alignment ss:Horizontal="Center" ss:Vertical="Center"/></Style></Styles><Worksheet ss:Name="نتائج الفحص" ss:RightToLeft="1"><Table><Row>${headers.map((h) => xmlCell(h, "h")).join("")}</Row>${data.map((row) => `<Row>${row.map((v) => xmlCell(v)).join("")}</Row>`).join("")}</Table><AutoFilter x:Range="R1C1:R${data.length + 1}C${headers.length}" xmlns="urn:schemas-microsoft-com:office:excel"/><WorksheetOptions xmlns="urn:schemas-microsoft-com:office:excel"><FreezePanes/><FrozenNoSplit/><SplitHorizontal>1</SplitHorizontal><TopRowBottomPane>1</TopRowBottomPane></WorksheetOptions></Worksheet></Workbook>`; |
| const blob = new Blob(["\uFEFF" + xml], { type: "application/vnd.ms-excel;charset=utf-8" }); |
| const a = document.createElement("a"); |
| a.href = URL.createObjectURL(blob); |
| a.download = "نتائج_فحص_السجلات.xls"; |
| a.click(); |
| URL.revokeObjectURL(a.href); |
| toast("تم إنشاء ملف Excel بنجاح."); |
| } |
|
|
| function openEntity(key) { |
| const group = app.entityGroups.get(key); |
| if (!group) return; |
| showView("search"); |
| $("q").value = group.commercialRegister || group.rows[0]?.facilityName || ""; |
| $("searchResults").className = "results"; |
| $("searchResults").innerHTML = renderEntity(group); |
| $("searchMeta").textContent = "تفاصيل السجل المحدد."; |
| } |
|
|
| function debounce(fn, wait = 180) { |
| let timer; |
| return (...args) => { |
| clearTimeout(timer); |
| timer = setTimeout(() => fn(...args), wait); |
| }; |
| } |
|
|
| async function init() { |
| setupLoginGate(); |
| try { |
| await loadData(); |
| renderMetrics(); |
| renderIntersections(); |
| renderResearchers(); |
| renderSurveys(); |
| } catch (err) { |
| console.error(err); |
| document.querySelector("main").innerHTML = "<div class='empty-state'>تعذر العثور على ملف بيانات المسوح.</div>"; |
| return; |
| } |
|
|
| const debouncedSearch = debounce(search); |
| document.querySelectorAll(".nav-link").forEach((button) => button.addEventListener("click", () => showView(button.dataset.view))); |
| $("q").addEventListener("input", debouncedSearch); |
| $("q").addEventListener("keydown", (event) => { if (event.key === "Enter") search(); }); |
| $("btnClear").addEventListener("click", () => { |
| $("q").value = ""; |
| search(); |
| $("q").focus(); |
| }); |
| $("batchInput").addEventListener("input", updateBatchHint); |
| $("btnBatch").addEventListener("click", batchCheck); |
| $("btnBatchClear").addEventListener("click", () => { |
| $("batchInput").value = ""; |
| app.batch = []; |
| updateBatchHint(); |
| $("batchSummary").innerHTML = ""; |
| $("batchResults").className = "results empty-state"; |
| $("batchResults").textContent = "الصق قائمة السجلات ثم اختر «فحص السجلات»."; |
| $("btnExport").disabled = true; |
| }); |
| $("btnExport").addEventListener("click", exportBatch); |
| $("researcherFilter").addEventListener("input", renderResearchers); |
| document.addEventListener("click", (event) => { |
| const button = event.target.closest("[data-entity]"); |
| if (button) { |
| openEntity(button.dataset.entity); |
| return; |
| } |
| const closeDashboard = event.target.closest(".dashboard-close"); |
| if (closeDashboard) { |
| const panel = $("dashboardDetails"); |
| panel.className = "dashboard-details empty-state"; |
| panel.textContent = "اختر إحدى البطاقات لعرض التفاصيل هنا."; |
| return; |
| } |
| const dashboardCard = event.target.closest("[data-dashboard-detail]"); |
| if (dashboardCard) renderDashboardDetail(dashboardCard.dataset.dashboardDetail); |
| }); |
| document.addEventListener("keydown", (event) => { |
| if (event.key !== "Enter" && event.key !== " ") return; |
| const dashboardCard = event.target.closest("[data-dashboard-detail]"); |
| if (!dashboardCard) return; |
| event.preventDefault(); |
| renderDashboardDetail(dashboardCard.dataset.dashboardDetail); |
| }); |
| } |
|
|
| init(); |
|
|