stat2025 commited on
Commit
6835ab7
·
verified ·
1 Parent(s): 78e35ca

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.js +90 -4
  2. index.html +1 -1
  3. style.css +68 -0
app.js CHANGED
@@ -1,6 +1,7 @@
1
  "use strict";
2
 
3
  const PAGE_SIZE = 24;
 
4
  const STATE_KEY = "ics2ResearcherWorkspace";
5
  const DOCUMENTATION_DONE_KEY = "ics2DocumentationCompleted";
6
  const FORM_URL = "https://drive.google.com/file/d/1BEJ3qrqB3RMvhw4Z0i4dxZiwEreT9cJc/view?usp=sharing";
@@ -38,7 +39,7 @@ const elements = Object.fromEntries(
38
  "documentationUnavailable", "documentationForm", "fieldStatus", "otherStatusField", "otherStatus", "fieldStatement",
39
  "statementCount", "documentationPhotos", "photoPreviews", "documentationError",
40
  "uploadProgress", "uploadProgressBar", "cancelDocumentationButton", "saveDocumentationButton",
41
- "adminView", "adminLogoutButton", "adminUpdatedAt", "refreshAdminButton", "adminStats", "adminInsight",
42
  "adminProgressOverview", "adminTrendTotal", "adminTrendChart", "adminCityProgress",
43
  "adminControls", "adminSearch", "adminResearcherFilter", "adminCityFilter", "adminDateFilter",
44
  "adminStatusFilter", "clearAdminFilters", "activeResearchersCount",
@@ -63,6 +64,10 @@ let documentationFiles = [];
63
  let documentationExistingPhotos = [];
64
  let documentationRecords = [];
65
  let lastSavedRowId = "";
 
 
 
 
66
  let adminRecords = [];
67
  let directoryMode = "researcher";
68
  const mobileQuery = window.matchMedia("(max-width: 560px)");
@@ -271,6 +276,9 @@ function adminStat(value, label, note = "") {
271
  }
272
 
273
  function setAdminFilterOptions() {
 
 
 
274
  setSelectOptions(
275
  elements.adminResearcherFilter,
276
  uniqueSorted(payload.researchers.map((item) => item.name)),
@@ -286,6 +294,24 @@ function setAdminFilterOptions() {
286
  uniqueSorted(adminRecords.map((record) => record.fieldStatus)),
287
  "جميع الحالات",
288
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  }
290
 
291
  function renderAdminStats() {
@@ -478,6 +504,7 @@ function renderAdminRecords() {
478
  records.slice(0, 100).forEach((record) => {
479
  const item = document.createElement("article");
480
  item.className = "admin-record";
 
481
  const date = record.documentedAt
482
  ? new Intl.DateTimeFormat("ar-SA", {
483
  timeZone: "Asia/Riyadh",
@@ -553,11 +580,25 @@ function renderAdminRecords() {
553
  elements.adminRecords.replaceChildren(fragment);
554
  }
555
 
556
- async function loadAdminDashboard() {
 
 
557
  elements.refreshAdminButton.disabled = true;
 
558
  try {
559
  const result = await fetchDocumentation("admin", sessionAccessCode);
560
- adminRecords = hydrateRecordKeys(result.records || []);
 
 
 
 
 
 
 
 
 
 
 
561
  documentationRecords = adminRecords;
562
  setAdminFilterOptions();
563
  elements.adminUpdatedAt.textContent = `آخر مزامنة: ${new Intl.DateTimeFormat("ar-SA", {
@@ -569,11 +610,40 @@ async function loadAdminDashboard() {
569
  renderAdminOverview();
570
  renderProductivity();
571
  renderAdminRecords();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  } finally {
573
  elements.refreshAdminButton.disabled = false;
 
574
  }
575
  }
576
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  async function copyText(text, successMessage) {
578
  try {
579
  await navigator.clipboard.writeText(text);
@@ -1575,9 +1645,12 @@ elements.loginForm.addEventListener("submit", async (event) => {
1575
  sessionAccessCode = password;
1576
  elements.password.value = "";
1577
  if (supervisor) {
 
 
1578
  setAdminFilterOptions();
1579
  showOnly(elements.adminView);
1580
  await loadAdminDashboard();
 
1581
  return;
1582
  }
1583
  await syncResearcherDocumentation();
@@ -1621,14 +1694,27 @@ elements.logoutButton.addEventListener("click", () => {
1621
  elements.password.focus();
1622
  });
1623
  elements.adminLogoutButton.addEventListener("click", () => {
 
1624
  payload = null;
1625
  adminRecords = [];
1626
  documentationRecords = [];
 
 
1627
  sessionAccessCode = "";
1628
  showOnly(elements.loginView);
1629
  elements.password.focus();
1630
  });
1631
- elements.refreshAdminButton.addEventListener("click", loadAdminDashboard);
 
 
 
 
 
 
 
 
 
 
1632
  [elements.adminSearch, elements.adminResearcherFilter, elements.adminCityFilter, elements.adminDateFilter, elements.adminStatusFilter]
1633
  .forEach((control) => control.addEventListener(control.tagName === "INPUT" ? "input" : "change", renderAdminRecords));
1634
  elements.clearAdminFilters.addEventListener("click", () => {
 
1
  "use strict";
2
 
3
  const PAGE_SIZE = 24;
4
+ const ADMIN_REFRESH_INTERVAL = 60000;
5
  const STATE_KEY = "ics2ResearcherWorkspace";
6
  const DOCUMENTATION_DONE_KEY = "ics2DocumentationCompleted";
7
  const FORM_URL = "https://drive.google.com/file/d/1BEJ3qrqB3RMvhw4Z0i4dxZiwEreT9cJc/view?usp=sharing";
 
39
  "documentationUnavailable", "documentationForm", "fieldStatus", "otherStatusField", "otherStatus", "fieldStatement",
40
  "statementCount", "documentationPhotos", "photoPreviews", "documentationError",
41
  "uploadProgress", "uploadProgressBar", "cancelDocumentationButton", "saveDocumentationButton",
42
+ "adminView", "adminLogoutButton", "adminUpdatedAt", "adminSyncStatus", "refreshAdminButton", "adminStats", "adminInsight",
43
  "adminProgressOverview", "adminTrendTotal", "adminTrendChart", "adminCityProgress",
44
  "adminControls", "adminSearch", "adminResearcherFilter", "adminCityFilter", "adminDateFilter",
45
  "adminStatusFilter", "clearAdminFilters", "activeResearchersCount",
 
64
  let documentationExistingPhotos = [];
65
  let documentationRecords = [];
66
  let lastSavedRowId = "";
67
+ let adminRefreshTimer = null;
68
+ let adminRefreshInProgress = false;
69
+ let adminDashboardInitialized = false;
70
+ let newAdminRecordIds = new Set();
71
  let adminRecords = [];
72
  let directoryMode = "researcher";
73
  const mobileQuery = window.matchMedia("(max-width: 560px)");
 
276
  }
277
 
278
  function setAdminFilterOptions() {
279
+ const selectedResearcher = elements.adminResearcherFilter.value;
280
+ const selectedCity = elements.adminCityFilter.value;
281
+ const selectedStatus = elements.adminStatusFilter.value;
282
  setSelectOptions(
283
  elements.adminResearcherFilter,
284
  uniqueSorted(payload.researchers.map((item) => item.name)),
 
294
  uniqueSorted(adminRecords.map((record) => record.fieldStatus)),
295
  "جميع الحالات",
296
  );
297
+ if ([...elements.adminResearcherFilter.options].some((option) => option.value === selectedResearcher)) {
298
+ elements.adminResearcherFilter.value = selectedResearcher;
299
+ }
300
+ if ([...elements.adminCityFilter.options].some((option) => option.value === selectedCity)) {
301
+ elements.adminCityFilter.value = selectedCity;
302
+ }
303
+ if ([...elements.adminStatusFilter.options].some((option) => option.value === selectedStatus)) {
304
+ elements.adminStatusFilter.value = selectedStatus;
305
+ }
306
+ }
307
+
308
+ function adminRecordIdentity(record) {
309
+ return record.documentationId || record.sampleKey || `${record.commercialRecord}|${record.establishmentName}`;
310
+ }
311
+
312
+ function setAdminSyncState(state, text) {
313
+ elements.adminSyncStatus.className = `sync-status ${state}`;
314
+ elements.adminSyncStatus.querySelector("span").textContent = text;
315
  }
316
 
317
  function renderAdminStats() {
 
504
  records.slice(0, 100).forEach((record) => {
505
  const item = document.createElement("article");
506
  item.className = "admin-record";
507
+ if (newAdminRecordIds.has(adminRecordIdentity(record))) item.classList.add("new-record");
508
  const date = record.documentedAt
509
  ? new Intl.DateTimeFormat("ar-SA", {
510
  timeZone: "Asia/Riyadh",
 
580
  elements.adminRecords.replaceChildren(fragment);
581
  }
582
 
583
+ async function loadAdminDashboard({ automatic = false } = {}) {
584
+ if (adminRefreshInProgress || sessionAccessCode !== "1448") return;
585
+ adminRefreshInProgress = true;
586
  elements.refreshAdminButton.disabled = true;
587
+ setAdminSyncState("syncing", automatic ? "جارٍ التحديث التلقائي..." : "جارٍ تحديث البيانات...");
588
  try {
589
  const result = await fetchDocumentation("admin", sessionAccessCode);
590
+ const incomingRecords = hydrateRecordKeys(result.records || []);
591
+ if (adminDashboardInitialized) {
592
+ const previousIds = new Set(adminRecords.map(adminRecordIdentity));
593
+ newAdminRecordIds = new Set(
594
+ incomingRecords
595
+ .map(adminRecordIdentity)
596
+ .filter((identity) => identity && !previousIds.has(identity)),
597
+ );
598
+ } else {
599
+ newAdminRecordIds.clear();
600
+ }
601
+ adminRecords = incomingRecords;
602
  documentationRecords = adminRecords;
603
  setAdminFilterOptions();
604
  elements.adminUpdatedAt.textContent = `آخر مزامنة: ${new Intl.DateTimeFormat("ar-SA", {
 
610
  renderAdminOverview();
611
  renderProductivity();
612
  renderAdminRecords();
613
+ adminDashboardInitialized = true;
614
+ setAdminSyncState("connected", newAdminRecordIds.size
615
+ ? `${newAdminRecordIds.size} توثيق جديد`
616
+ : "محدّث تلقائيًا");
617
+ if (newAdminRecordIds.size) {
618
+ showToast(`وصل ${newAdminRecordIds.size} توثيق جديد وتم تحديث لوحة المشرف`);
619
+ setTimeout(() => {
620
+ newAdminRecordIds.clear();
621
+ elements.adminRecords.querySelectorAll(".new-record").forEach((item) => item.classList.remove("new-record"));
622
+ }, 6000);
623
+ }
624
+ } catch (error) {
625
+ console.warn(error.message);
626
+ setAdminSyncState("offline", "تعذر التحديث، ستتم إعادة المحاولة");
627
  } finally {
628
  elements.refreshAdminButton.disabled = false;
629
+ adminRefreshInProgress = false;
630
  }
631
  }
632
 
633
+ function stopAdminAutoRefresh() {
634
+ clearInterval(adminRefreshTimer);
635
+ adminRefreshTimer = null;
636
+ }
637
+
638
+ function startAdminAutoRefresh() {
639
+ stopAdminAutoRefresh();
640
+ if (sessionAccessCode !== "1448" || document.hidden) return;
641
+ adminRefreshTimer = setInterval(
642
+ () => loadAdminDashboard({ automatic: true }),
643
+ ADMIN_REFRESH_INTERVAL,
644
+ );
645
+ }
646
+
647
  async function copyText(text, successMessage) {
648
  try {
649
  await navigator.clipboard.writeText(text);
 
1645
  sessionAccessCode = password;
1646
  elements.password.value = "";
1647
  if (supervisor) {
1648
+ adminDashboardInitialized = false;
1649
+ newAdminRecordIds.clear();
1650
  setAdminFilterOptions();
1651
  showOnly(elements.adminView);
1652
  await loadAdminDashboard();
1653
+ startAdminAutoRefresh();
1654
  return;
1655
  }
1656
  await syncResearcherDocumentation();
 
1694
  elements.password.focus();
1695
  });
1696
  elements.adminLogoutButton.addEventListener("click", () => {
1697
+ stopAdminAutoRefresh();
1698
  payload = null;
1699
  adminRecords = [];
1700
  documentationRecords = [];
1701
+ adminDashboardInitialized = false;
1702
+ newAdminRecordIds.clear();
1703
  sessionAccessCode = "";
1704
  showOnly(elements.loginView);
1705
  elements.password.focus();
1706
  });
1707
+ elements.refreshAdminButton.addEventListener("click", () => loadAdminDashboard());
1708
+ document.addEventListener("visibilitychange", () => {
1709
+ if (sessionAccessCode !== "1448") return;
1710
+ if (document.hidden) {
1711
+ stopAdminAutoRefresh();
1712
+ setAdminSyncState("paused", "التحديث متوقف أثناء وجود الصفحة بالخلفية");
1713
+ return;
1714
+ }
1715
+ loadAdminDashboard({ automatic: true });
1716
+ startAdminAutoRefresh();
1717
+ });
1718
  [elements.adminSearch, elements.adminResearcherFilter, elements.adminCityFilter, elements.adminDateFilter, elements.adminStatusFilter]
1719
  .forEach((control) => control.addEventListener(control.tagName === "INPUT" ? "input" : "change", renderAdminRecords));
1720
  elements.clearAdminFilters.addEventListener("click", () => {
index.html CHANGED
@@ -203,7 +203,7 @@
203
  <p id="adminUpdatedAt">تتم مزامنة البيانات من سجل التوثيق في Google Drive.</p>
204
  </div>
205
  <div class="admin-heading-actions">
206
- <span class="sync-status"><i></i>متصل بسجل التوثيق</span>
207
  <button id="refreshAdminButton" class="secondary-button compact" type="button">
208
  <svg viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M6.1 9A7 7 0 0 1 18 6l2 5M17.9 15A7 7 0 0 1 6 18l-2-5" /></svg>
209
  تحديث
 
203
  <p id="adminUpdatedAt">تتم مزامنة البيانات من سجل التوثيق في Google Drive.</p>
204
  </div>
205
  <div class="admin-heading-actions">
206
+ <span id="adminSyncStatus" class="sync-status"><i></i><span>مزامنة تلقائية كل دقيقة</span></span>
207
  <button id="refreshAdminButton" class="secondary-button compact" type="button">
208
  <svg viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M6.1 9A7 7 0 0 1 18 6l2 5M17.9 15A7 7 0 0 1 6 18l-2-5" /></svg>
209
  تحديث
style.css CHANGED
@@ -2408,6 +2408,41 @@ body {
2408
  box-shadow: 0 0 0 4px rgba(0, 176, 80, 0.12);
2409
  }
2410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2411
  .admin-stats {
2412
  display: grid;
2413
  grid-template-columns: repeat(6, minmax(0, 1fr));
@@ -2417,6 +2452,12 @@ body {
2417
 
2418
  .admin-stat {
2419
  min-height: 98px;
 
 
 
 
 
 
2420
  }
2421
 
2422
  .admin-stat:nth-child(4) {
@@ -2920,6 +2961,24 @@ body {
2920
  border: 1px solid #e1e8ed;
2921
  border-radius: 5px;
2922
  background: #ffffff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2923
  }
2924
 
2925
  .admin-record-heading,
@@ -3033,6 +3092,15 @@ body {
3033
  }
3034
  }
3035
 
 
 
 
 
 
 
 
 
 
3036
  @media (max-width: 620px) {
3037
  .dashboard-shell,
3038
  .admin-shell {
 
2408
  box-shadow: 0 0 0 4px rgba(0, 176, 80, 0.12);
2409
  }
2410
 
2411
+ .sync-status.syncing i {
2412
+ background: #27ced7;
2413
+ box-shadow: 0 0 0 4px rgba(39, 206, 215, 0.14);
2414
+ animation: sync-pulse 1s ease-in-out infinite;
2415
+ }
2416
+
2417
+ .sync-status.offline {
2418
+ color: #a04b00;
2419
+ background: #fff7e8;
2420
+ border-color: #f4d38a;
2421
+ }
2422
+
2423
+ .sync-status.offline i {
2424
+ background: #ffbc00;
2425
+ box-shadow: 0 0 0 4px rgba(255, 188, 0, 0.14);
2426
+ }
2427
+
2428
+ .sync-status.paused {
2429
+ color: #526174;
2430
+ background: #f3f6f8;
2431
+ border-color: #dce5eb;
2432
+ }
2433
+
2434
+ .sync-status.paused i {
2435
+ background: #7a8797;
2436
+ box-shadow: none;
2437
+ }
2438
+
2439
+ @keyframes sync-pulse {
2440
+ 50% {
2441
+ opacity: 0.35;
2442
+ transform: scale(0.78);
2443
+ }
2444
+ }
2445
+
2446
  .admin-stats {
2447
  display: grid;
2448
  grid-template-columns: repeat(6, minmax(0, 1fr));
 
2452
 
2453
  .admin-stat {
2454
  min-height: 98px;
2455
+ transition: border-color 0.18s ease, box-shadow 0.18s ease;
2456
+ }
2457
+
2458
+ .admin-stat:hover {
2459
+ border-color: #b9c8d2;
2460
+ box-shadow: 0 5px 16px rgba(23, 32, 51, 0.07);
2461
  }
2462
 
2463
  .admin-stat:nth-child(4) {
 
2961
  border: 1px solid #e1e8ed;
2962
  border-radius: 5px;
2963
  background: #ffffff;
2964
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
2965
+ }
2966
+
2967
+ .admin-record.new-record {
2968
+ border-color: #00b050;
2969
+ box-shadow: 0 0 0 3px rgba(0, 176, 80, 0.1);
2970
+ animation: new-admin-record 0.7s ease both;
2971
+ }
2972
+
2973
+ @keyframes new-admin-record {
2974
+ from {
2975
+ opacity: 0.55;
2976
+ transform: translateY(-5px);
2977
+ }
2978
+ to {
2979
+ opacity: 1;
2980
+ transform: translateY(0);
2981
+ }
2982
  }
2983
 
2984
  .admin-record-heading,
 
3092
  }
3093
  }
3094
 
3095
+ @media (min-width: 981px) {
3096
+ .admin-controls {
3097
+ position: sticky;
3098
+ top: 8px;
3099
+ z-index: 30;
3100
+ box-shadow: 0 8px 22px rgba(23, 32, 51, 0.08);
3101
+ }
3102
+ }
3103
+
3104
  @media (max-width: 620px) {
3105
  .dashboard-shell,
3106
  .admin-shell {