stat2025 commited on
Commit
917da94
·
verified ·
1 Parent(s): 28a46a9

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.js +149 -33
  2. index.html +1 -0
  3. style.css +15 -0
app.js CHANGED
@@ -102,6 +102,7 @@ const state = {
102
  currentUser: "",
103
  isManager: false,
104
  dirtyRows: new Map(),
 
105
  toastTimer: null,
106
  };
107
 
@@ -158,13 +159,18 @@ function valueOf(row, key) {
158
  return header ? row.raw[header] ?? "" : "";
159
  }
160
 
 
 
 
 
 
161
  function setValue(row, key, value) {
162
  const header = state.headerMap[key];
163
  if (header) row.raw[header] = value;
164
  }
165
 
166
  function isEntered(row) {
167
- return EDITABLE_KEYS.every((key) => String(valueOf(row, key)).trim());
168
  }
169
 
170
  function rowByNumber(rowNumber) {
@@ -223,6 +229,8 @@ function applyLoadedData(data) {
223
  state.headerMap = buildHeaderMap(state.headers);
224
  validateHeaders();
225
  state.rows = data.rows;
 
 
226
  state.dirtyRows.clear();
227
  updateSaveAllState();
228
  populateSelects();
@@ -343,7 +351,11 @@ function createInput(row, key) {
343
  input.dataset.key = key;
344
  input.placeholder = LABELS[key];
345
  if (key === "email") input.type = "email";
346
- if (key === "phone") input.inputMode = "tel";
 
 
 
 
347
  input.addEventListener("input", () => handleFieldInput(row, key, input.value));
348
  return input;
349
  }
@@ -372,6 +384,8 @@ function handleFieldInput(row, key, value) {
372
  syncInputs(rowNumber, key, value);
373
  markDirty(rowNumber, true);
374
  updateSaveAllState();
 
 
375
  }
376
 
377
  function syncInputs(rowNumber, key, value) {
@@ -384,12 +398,39 @@ function markDirty(rowNumber, isDirty) {
384
  document.querySelectorAll(`[data-row-shell="${rowNumber}"]`).forEach((node) => node.classList.toggle("is-dirty", isDirty));
385
  }
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  function updateSaveAllState() {
388
  const count = state.dirtyRows.size;
389
  els.saveAllButton.disabled = count === 0;
390
  els.saveAllButton.textContent = count ? `حفظ الكل (${count})` : "حفظ الكل";
391
  }
392
 
 
 
 
 
 
 
 
 
 
 
393
  function renderTable() {
394
  els.tableBody.replaceChildren(...state.visibleRows.map((row) => {
395
  const tr = document.createElement("tr");
@@ -518,20 +559,29 @@ function validateSaveValues(values) {
518
  if (EDITABLE_KEYS.every((key) => !String(values[key] || "").trim())) {
519
  return "أدخل بيانات التواصل قبل الحفظ.";
520
  }
 
 
 
 
521
  return "";
522
  }
523
 
524
- async function saveRow(row, button) {
525
  const values = valuesForRow(row);
526
  const validation = validateSaveValues(values);
527
  if (validation) {
528
- showToast(validation);
529
  return;
530
  }
 
531
  const updates = Object.fromEntries(EDITABLE_KEYS.map((key) => [state.headerMap[key], values[key] || ""]));
532
- button.disabled = true;
533
- const originalText = button.textContent;
534
- button.textContent = "جاري الحفظ...";
 
 
 
 
535
  try {
536
  await apiRequest({
537
  action: "update",
@@ -544,14 +594,18 @@ async function saveRow(row, button) {
544
  EDITABLE_KEYS.forEach((key) => setValue(row, key, values[key] || ""));
545
  state.dirtyRows.delete(Number(row.rowNumber));
546
  markDirty(row.rowNumber, false);
 
547
  updateSaveAllState();
548
- showToast("تم الحفظ بنجاح");
549
  applyFilters();
550
  } catch (error) {
551
  showToast(error.message || "تعذر الحفظ");
552
  } finally {
553
- button.disabled = false;
554
- button.textContent = originalText;
 
 
 
555
  }
556
  }
557
 
@@ -568,6 +622,7 @@ async function saveAllDirtyRows() {
568
  const validation = validateSaveValues(values);
569
  if (validation) throw new Error(`الصف ${rowNumber}: ${validation}`);
570
  const updates = Object.fromEntries(EDITABLE_KEYS.map((key) => [state.headerMap[key], values[key] || ""]));
 
571
  await apiRequest({ action: "update", rowNumber, updates });
572
  EDITABLE_KEYS.forEach((key) => setValue(row, key, values[key] || ""));
573
  state.dirtyRows.delete(rowNumber);
@@ -582,21 +637,6 @@ async function saveAllDirtyRows() {
582
  }
583
  }
584
 
585
- function csvEscape(value) {
586
- return `"${String(value ?? "").replace(/"/g, '""')}"`;
587
- }
588
-
589
- function downloadCsv(filename, headers, rows) {
590
- const csv = [headers.map(csvEscape).join(",")].concat(rows.map((row) => row.map(csvEscape).join(","))).join("\r\n");
591
- const blob = new Blob([`\ufeff${csv}`], { type: "text/csv;charset=utf-8" });
592
- const url = URL.createObjectURL(blob);
593
- const link = document.createElement("a");
594
- link.href = url;
595
- link.download = filename;
596
- link.click();
597
- URL.revokeObjectURL(url);
598
- }
599
-
600
  function aggregateCount(rows, key) {
601
  const map = new Map();
602
  rows.forEach((row) => {
@@ -606,26 +646,100 @@ function aggregateCount(rows, key) {
606
  return [...map.entries()].map(([label, count]) => [label, count]);
607
  }
608
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  function exportSummary() {
 
610
  const total = computeStats(state.rows);
611
- const rows = [
612
- ["الإجمالي", total.total, total.entered, total.missing, `${total.progress}%`],
 
 
613
  [],
 
 
 
 
614
  ["الباحث", "عدد العينات", "مكتملة", "غير مكتملة", "نسبة الإنجاز"],
615
  ...researcherStats().map((item) => [item.name, item.total, item.entered, item.missing, `${item.progress}%`]),
616
- [],
 
617
  ["المنطقة", "عدد العينات"],
618
  ...aggregateCount(state.rows, "region"),
619
- [],
 
620
  ["الحي", "عدد العينات"],
621
  ...aggregateCount(state.rows, "district"),
622
- ];
623
- downloadCsv("تقرير-إحصائي.csv", ["البند", "القيمة 1", "القيمة 2", "القيمة 3", "القيمة 4"], rows);
624
  }
625
 
626
  function exportFull() {
627
- const rows = state.rows.map((row) => state.headers.map((header) => row.raw[header] ?? ""));
628
- downloadCsv("تقرير-تفصيلي-كامل.csv", state.headers, rows);
 
 
 
 
 
 
629
  }
630
 
631
  async function bootstrap() {
@@ -682,6 +796,8 @@ function logout() {
682
  if (!confirmIfDirty()) return;
683
  state.currentUser = "";
684
  state.isManager = false;
 
 
685
  state.dirtyRows.clear();
686
  updateSaveAllState();
687
  els.password.value = "";
 
102
  currentUser: "",
103
  isManager: false,
104
  dirtyRows: new Map(),
105
+ autosaveTimers: new Map(),
106
  toastTimer: null,
107
  };
108
 
 
159
  return header ? row.raw[header] ?? "" : "";
160
  }
161
 
162
+ function effectiveValue(row, key) {
163
+ const dirty = state.dirtyRows.get(Number(row.rowNumber));
164
+ return dirty && key in dirty ? dirty[key] : valueOf(row, key);
165
+ }
166
+
167
  function setValue(row, key, value) {
168
  const header = state.headerMap[key];
169
  if (header) row.raw[header] = value;
170
  }
171
 
172
  function isEntered(row) {
173
+ return EDITABLE_KEYS.every((key) => String(effectiveValue(row, key)).trim());
174
  }
175
 
176
  function rowByNumber(rowNumber) {
 
229
  state.headerMap = buildHeaderMap(state.headers);
230
  validateHeaders();
231
  state.rows = data.rows;
232
+ state.autosaveTimers.forEach((timer) => clearTimeout(timer));
233
+ state.autosaveTimers.clear();
234
  state.dirtyRows.clear();
235
  updateSaveAllState();
236
  populateSelects();
 
351
  input.dataset.key = key;
352
  input.placeholder = LABELS[key];
353
  if (key === "email") input.type = "email";
354
+ if (key === "phone") {
355
+ input.inputMode = "numeric";
356
+ input.maxLength = 10;
357
+ input.pattern = "05[0-9]{8}";
358
+ }
359
  input.addEventListener("input", () => handleFieldInput(row, key, input.value));
360
  return input;
361
  }
 
384
  syncInputs(rowNumber, key, value);
385
  markDirty(rowNumber, true);
386
  updateSaveAllState();
387
+ updateRowShellStatus(row);
388
+ scheduleAutosave(row);
389
  }
390
 
391
  function syncInputs(rowNumber, key, value) {
 
398
  document.querySelectorAll(`[data-row-shell="${rowNumber}"]`).forEach((node) => node.classList.toggle("is-dirty", isDirty));
399
  }
400
 
401
+ function markSaving(rowNumber, isSaving) {
402
+ document.querySelectorAll(`[data-row-shell="${rowNumber}"]`).forEach((node) => node.classList.toggle("is-saving", isSaving));
403
+ }
404
+
405
+ function updateRowShellStatus(row) {
406
+ const complete = isEntered(row);
407
+ document.querySelectorAll(`[data-row-shell="${row.rowNumber}"]`).forEach((node) => {
408
+ node.classList.toggle("complete", complete);
409
+ node.classList.toggle("incomplete", !complete);
410
+ const badge = node.querySelector(".status-badge");
411
+ if (badge) {
412
+ badge.className = `status-badge ${complete ? "status-entered" : "status-missing"}`;
413
+ badge.textContent = complete ? "مكتملة" : "غير مكتملة";
414
+ }
415
+ });
416
+ }
417
+
418
  function updateSaveAllState() {
419
  const count = state.dirtyRows.size;
420
  els.saveAllButton.disabled = count === 0;
421
  els.saveAllButton.textContent = count ? `حفظ الكل (${count})` : "حفظ الكل";
422
  }
423
 
424
+ function scheduleAutosave(row) {
425
+ const rowNumber = Number(row.rowNumber);
426
+ clearTimeout(state.autosaveTimers.get(rowNumber));
427
+ const timer = setTimeout(() => {
428
+ if (!state.dirtyRows.has(rowNumber)) return;
429
+ saveRow(row, null, { silent: true, autosave: true });
430
+ }, 1200);
431
+ state.autosaveTimers.set(rowNumber, timer);
432
+ }
433
+
434
  function renderTable() {
435
  els.tableBody.replaceChildren(...state.visibleRows.map((row) => {
436
  const tr = document.createElement("tr");
 
559
  if (EDITABLE_KEYS.every((key) => !String(values[key] || "").trim())) {
560
  return "أدخل بيانات التواصل قبل الحفظ.";
561
  }
562
+ const phone = String(values.phone || "").trim();
563
+ if (phone && !/^05\d{8}$/.test(phone)) {
564
+ return "رقم الجوال يجب أن يكون 10 أرقام ويبدأ بـ 05.";
565
+ }
566
  return "";
567
  }
568
 
569
+ async function saveRow(row, button, options = {}) {
570
  const values = valuesForRow(row);
571
  const validation = validateSaveValues(values);
572
  if (validation) {
573
+ if (!options.silent) showToast(validation);
574
  return;
575
  }
576
+ clearTimeout(state.autosaveTimers.get(Number(row.rowNumber)));
577
  const updates = Object.fromEntries(EDITABLE_KEYS.map((key) => [state.headerMap[key], values[key] || ""]));
578
+ let originalText = "";
579
+ if (button) {
580
+ button.disabled = true;
581
+ originalText = button.textContent;
582
+ button.textContent = "جاري الحفظ...";
583
+ }
584
+ markSaving(row.rowNumber, true);
585
  try {
586
  await apiRequest({
587
  action: "update",
 
594
  EDITABLE_KEYS.forEach((key) => setValue(row, key, values[key] || ""));
595
  state.dirtyRows.delete(Number(row.rowNumber));
596
  markDirty(row.rowNumber, false);
597
+ updateRowShellStatus(row);
598
  updateSaveAllState();
599
+ showToast(options.autosave ? "تم الحفظ تلقائيًا" : "تم الحفظ بنجاح");
600
  applyFilters();
601
  } catch (error) {
602
  showToast(error.message || "تعذر الحفظ");
603
  } finally {
604
+ markSaving(row.rowNumber, false);
605
+ if (button) {
606
+ button.disabled = false;
607
+ button.textContent = originalText;
608
+ }
609
  }
610
  }
611
 
 
622
  const validation = validateSaveValues(values);
623
  if (validation) throw new Error(`الصف ${rowNumber}: ${validation}`);
624
  const updates = Object.fromEntries(EDITABLE_KEYS.map((key) => [state.headerMap[key], values[key] || ""]));
625
+ clearTimeout(state.autosaveTimers.get(rowNumber));
626
  await apiRequest({ action: "update", rowNumber, updates });
627
  EDITABLE_KEYS.forEach((key) => setValue(row, key, values[key] || ""));
628
  state.dirtyRows.delete(rowNumber);
 
637
  }
638
  }
639
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  function aggregateCount(rows, key) {
641
  const map = new Map();
642
  rows.forEach((row) => {
 
646
  return [...map.entries()].map(([label, count]) => [label, count]);
647
  }
648
 
649
+ function ensureExcelLibrary() {
650
+ if (!window.XLSX) {
651
+ showToast("تعذر تحميل مكتبة Excel. تحقق من اتصال الإنترنت ثم حاول مرة أخرى.");
652
+ return false;
653
+ }
654
+ return true;
655
+ }
656
+
657
+ function makeWorksheet(rows, columnWidths = []) {
658
+ const sheet = XLSX.utils.aoa_to_sheet(rows);
659
+ sheet["!cols"] = columnWidths.map((width) => ({ wch: width }));
660
+ sheet["!autofilter"] = { ref: XLSX.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: Math.max(rows.length - 1, 0), c: Math.max((rows[0] || []).length - 1, 0) } }) };
661
+ return sheet;
662
+ }
663
+
664
+ function appendSheet(workbook, name, rows, widths) {
665
+ const sheet = makeWorksheet(rows, widths);
666
+ XLSX.utils.book_append_sheet(workbook, sheet, name.slice(0, 31));
667
+ }
668
+
669
+ function writeWorkbook(filename, workbook) {
670
+ workbook.Workbook = { Views: [{ RTL: true }] };
671
+ XLSX.writeFile(workbook, filename, { bookType: "xlsx", compression: true });
672
+ }
673
+
674
+ function fullDataRows() {
675
+ const exportHeaders = [
676
+ "السجل التجاري",
677
+ "اسم المنشأة",
678
+ "مدير الحساب",
679
+ "الاسم",
680
+ "المسمى الوظيفي",
681
+ "البريد الإلكتروني",
682
+ "رقم التواصل",
683
+ "X",
684
+ "Y",
685
+ "المنطقة",
686
+ "الحي",
687
+ "اسم الباحث",
688
+ "حالة الإدخال",
689
+ ];
690
+ const rows = state.rows.map((row) => [
691
+ valueOf(row, "commercial"),
692
+ valueOf(row, "facility"),
693
+ valueOf(row, "accountManager"),
694
+ valueOf(row, "name"),
695
+ valueOf(row, "jobTitle"),
696
+ valueOf(row, "email"),
697
+ valueOf(row, "phone"),
698
+ valueOf(row, "x"),
699
+ valueOf(row, "y"),
700
+ valueOf(row, "region"),
701
+ valueOf(row, "district"),
702
+ valueOf(row, "researcher"),
703
+ isEntered(row) ? "مكتملة" : "غير مكتملة",
704
+ ]);
705
+ return [exportHeaders, ...rows];
706
+ }
707
+
708
  function exportSummary() {
709
+ if (!ensureExcelLibrary()) return;
710
  const total = computeStats(state.rows);
711
+ const workbook = XLSX.utils.book_new();
712
+ appendSheet(workbook, "الملخص", [
713
+ ["تقرير إحصائي لمشروع رصد بيانات المنشآت"],
714
+ ["تاريخ التصدير", new Date().toLocaleString("ar-SA")],
715
  [],
716
+ ["إجمالي العينات", "المكتملة", "غير المكتملة", "نسبة الإنجاز"],
717
+ [total.total, total.entered, total.missing, `${total.progress}%`],
718
+ ], [26, 16, 16, 16]);
719
+ appendSheet(workbook, "حسب الباحث", [
720
  ["الباحث", "عدد العينات", "مكتملة", "غير مكتملة", "نسبة الإنجاز"],
721
  ...researcherStats().map((item) => [item.name, item.total, item.entered, item.missing, `${item.progress}%`]),
722
+ ], [28, 16, 16, 16, 16]);
723
+ appendSheet(workbook, "حسب المنطقة", [
724
  ["المنطقة", "عدد العينات"],
725
  ...aggregateCount(state.rows, "region"),
726
+ ], [30, 16]);
727
+ appendSheet(workbook, "حسب الحي", [
728
  ["الحي", "عدد العينات"],
729
  ...aggregateCount(state.rows, "district"),
730
+ ], [34, 16]);
731
+ writeWorkbook("تقرير-إحصائي-رصد-المنشآت.xlsx", workbook);
732
  }
733
 
734
  function exportFull() {
735
+ if (!ensureExcelLibrary()) return;
736
+ const workbook = XLSX.utils.book_new();
737
+ appendSheet(workbook, "جميع البيانات", fullDataRows(), [15, 46, 22, 20, 22, 28, 16, 16, 16, 22, 28, 22, 16]);
738
+ appendSheet(workbook, "إحصائيات الباحثين", [
739
+ ["الباحث", "عدد العينات", "مكتملة", "غير مكتملة", "نسبة الإنجاز"],
740
+ ...researcherStats().map((item) => [item.name, item.total, item.entered, item.missing, `${item.progress}%`]),
741
+ ], [28, 16, 16, 16, 16]);
742
+ writeWorkbook("تقرير-تفصيلي-كامل-رصد-المنشآت.xlsx", workbook);
743
  }
744
 
745
  async function bootstrap() {
 
796
  if (!confirmIfDirty()) return;
797
  state.currentUser = "";
798
  state.isManager = false;
799
+ state.autosaveTimers.forEach((timer) => clearTimeout(timer));
800
+ state.autosaveTimers.clear();
801
  state.dirtyRows.clear();
802
  updateSaveAllState();
803
  els.password.value = "";
index.html CHANGED
@@ -183,6 +183,7 @@
183
  </section>
184
  </main>
185
  <div id="toast" class="toast" role="status" aria-live="polite"></div>
 
186
  <script src="app.js"></script>
187
  </body>
188
  </html>
 
183
  </section>
184
  </main>
185
  <div id="toast" class="toast" role="status" aria-live="polite"></div>
186
+ <script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
187
  <script src="app.js"></script>
188
  </body>
189
  </html>
style.css CHANGED
@@ -473,6 +473,21 @@ tbody tr.is-dirty { background: rgba(255, 192, 0, 0.08); }
473
  box-shadow: 0 0 0 3px rgba(255, 192, 0, 0.22), var(--shadow);
474
  }
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  .facility-card-head {
477
  display: flex;
478
  align-items: flex-start;
 
473
  box-shadow: 0 0 0 3px rgba(255, 192, 0, 0.22), var(--shadow);
474
  }
475
 
476
+ .facility-card.is-saving,
477
+ tbody tr.is-saving {
478
+ opacity: 0.78;
479
+ }
480
+
481
+ .facility-card.is-saving::after {
482
+ content: "جاري الحفظ...";
483
+ display: block;
484
+ margin-top: 10px;
485
+ color: var(--primary-alt);
486
+ font-size: 12px;
487
+ font-weight: 700;
488
+ text-align: center;
489
+ }
490
+
491
  .facility-card-head {
492
  display: flex;
493
  align-items: flex-start;