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