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

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.js +257 -54
  2. index.html +16 -5
  3. style.css +222 -0
app.js CHANGED
@@ -1,7 +1,8 @@
1
  "use strict";
2
 
3
  const CONFIG = {
4
- password: "2030",
 
5
  managerValue: "__manager__",
6
  apiUrl: "https://script.google.com/macros/s/AKfycbwH2wJA6ASKfm9P5FL4X_AFcVpzIoGaCub2tZ2csdxYDhinwZhToKnxhkCQlKPJi4mXvg/exec",
7
  };
@@ -39,6 +40,19 @@ const DISPLAY_COLUMNS = [
39
  ["actions", "الإجراءات"],
40
  ];
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  const els = {
43
  loginView: document.getElementById("loginView"),
44
  appView: document.getElementById("appView"),
@@ -55,6 +69,8 @@ const els = {
55
  enteredCount: document.getElementById("enteredCount"),
56
  missingCount: document.getElementById("missingCount"),
57
  progressCount: document.getElementById("progressCount"),
 
 
58
  adminStats: document.getElementById("adminStats"),
59
  researcherStatsGrid: document.getElementById("researcherStatsGrid"),
60
  bestResearcher: document.getElementById("bestResearcher"),
@@ -67,9 +83,11 @@ const els = {
67
  regionFilter: document.getElementById("regionFilter"),
68
  districtFilter: document.getElementById("districtFilter"),
69
  statusFilter: document.getElementById("statusFilter"),
 
70
  resultsMeta: document.getElementById("resultsMeta"),
71
  tableHead: document.getElementById("tableHead"),
72
  tableBody: document.getElementById("tableBody"),
 
73
  emptyState: document.getElementById("emptyState"),
74
  refreshButton: document.getElementById("refreshButton"),
75
  logoutButton: document.getElementById("logoutButton"),
@@ -83,6 +101,7 @@ const state = {
83
  visibleRows: [],
84
  currentUser: "",
85
  isManager: false,
 
86
  toastTimer: null,
87
  };
88
 
@@ -148,6 +167,10 @@ function isEntered(row) {
148
  return EDITABLE_KEYS.every((key) => String(valueOf(row, key)).trim());
149
  }
150
 
 
 
 
 
151
  function rowsFromApiPayload(payload) {
152
  const headers = Array.isArray(payload.headers) ? payload.headers.map((header) => String(header ?? "").trim()) : [];
153
  const rows = Array.isArray(payload.rows) ? payload.rows : [];
@@ -200,6 +223,8 @@ function applyLoadedData(data) {
200
  state.headerMap = buildHeaderMap(state.headers);
201
  validateHeaders();
202
  state.rows = data.rows;
 
 
203
  populateSelects();
204
  }
205
 
@@ -232,26 +257,28 @@ function applyFilters() {
232
  const district = els.districtFilter.value;
233
  const status = els.statusFilter.value;
234
 
235
- state.visibleRows = scopedRows().filter((row) => {
236
- const searchText = normalize([
237
- valueOf(row, "commercial"),
238
- valueOf(row, "facility"),
239
- valueOf(row, "accountManager"),
240
- valueOf(row, "researcher"),
241
- valueOf(row, "region"),
242
- valueOf(row, "district"),
243
- valueOf(row, "name"),
244
- valueOf(row, "phone"),
245
- ].join(" "));
246
- const entered = isEntered(row);
247
- return (
248
- (!query || searchText.includes(query)) &&
249
- (!researcher || valueOf(row, "researcher") === researcher) &&
250
- (!region || valueOf(row, "region") === region) &&
251
- (!district || valueOf(row, "district") === district) &&
252
- (!status || (status === "entered" ? entered : !entered))
253
- );
254
- });
 
 
255
  render();
256
  }
257
 
@@ -276,6 +303,8 @@ function renderSummary() {
276
  els.enteredCount.textContent = stats.entered;
277
  els.missingCount.textContent = stats.missing;
278
  els.progressCount.textContent = `${stats.progress}%`;
 
 
279
  els.resultsMeta.textContent = `${state.visibleRows.length} من أصل ${stats.total} منشأة`;
280
  }
281
 
@@ -312,8 +341,10 @@ function createInput(row, key) {
312
  input.value = valueOf(row, key);
313
  input.dataset.row = row.rowNumber;
314
  input.dataset.key = key;
 
315
  if (key === "email") input.type = "email";
316
  if (key === "phone") input.inputMode = "tel";
 
317
  return input;
318
  }
319
 
@@ -324,37 +355,60 @@ function mapUrl(row) {
324
  return `https://www.google.com/maps?q=${encodeURIComponent(`${y},${x}`)}`;
325
  }
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  function renderTable() {
328
  els.tableBody.replaceChildren(...state.visibleRows.map((row) => {
329
  const tr = document.createElement("tr");
 
 
330
  DISPLAY_COLUMNS.forEach(([key]) => {
331
  const td = document.createElement("td");
332
  if (EDITABLE_KEYS.includes(key)) {
333
  td.append(createInput(row, key));
334
  } else if (key === "map") {
335
- const href = mapUrl(row);
336
- if (href) {
337
- const link = document.createElement("a");
338
- link.className = "map-link";
339
- link.href = href;
340
- link.target = "_blank";
341
- link.rel = "noopener";
342
- link.textContent = "الموقع";
343
- td.append(link);
344
- } else {
345
- td.textContent = "-";
346
- }
347
  } else if (key === "status") {
348
- const badge = document.createElement("span");
349
- badge.className = `status-badge ${isEntered(row) ? "status-entered" : "status-missing"}`;
350
- badge.textContent = isEntered(row) ? "مكتملة" : "غير مكتملة";
351
- td.append(badge);
352
  } else if (key === "actions") {
353
  const button = document.createElement("button");
354
  button.type = "button";
355
  button.className = "secondary-button save-button";
356
  button.textContent = "حفظ";
357
- button.addEventListener("click", () => saveRow(row, tr, button));
358
  td.append(button);
359
  } else {
360
  td.textContent = valueOf(row, key) || "-";
@@ -364,7 +418,87 @@ function renderTable() {
364
  });
365
  return tr;
366
  }));
367
- els.emptyState.hidden = state.visibleRows.length !== 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  }
369
 
370
  function render() {
@@ -372,21 +506,31 @@ function render() {
372
  renderAdminStats();
373
  renderTableHead();
374
  renderTable();
 
 
375
  }
376
 
377
- function collectRowInputs(row, tr) {
378
- const values = {};
379
- EDITABLE_KEYS.forEach((key) => {
380
- const input = tr.querySelector(`[data-row="${row.rowNumber}"][data-key="${key}"]`);
381
- values[key] = input ? input.value.trim() : valueOf(row, key);
382
- });
383
- return values;
384
  }
385
 
386
- async function saveRow(row, tr, button) {
387
- const values = collectRowInputs(row, tr);
388
- const updates = Object.fromEntries(EDITABLE_KEYS.map((key) => [state.headerMap[key], values[key]]));
 
 
 
 
 
 
 
 
 
 
 
 
389
  button.disabled = true;
 
390
  button.textContent = "جاري الحفظ...";
391
  try {
392
  await apiRequest({
@@ -397,14 +541,44 @@ async function saveRow(row, tr, button) {
397
  researcher: valueOf(row, "researcher"),
398
  updates,
399
  });
400
- EDITABLE_KEYS.forEach((key) => setValue(row, key, values[key]));
401
- showToast("تم تحديث نفس الصف في Google Sheet");
 
 
 
402
  applyFilters();
403
  } catch (error) {
404
  showToast(error.message || "تعذر الحفظ");
405
  } finally {
406
  button.disabled = false;
407
- button.textContent = "حفظ";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  }
409
  }
410
 
@@ -465,13 +639,22 @@ async function bootstrap() {
465
  }
466
  }
467
 
 
 
 
 
 
 
 
 
468
  function login() {
469
  const selected = els.researcherSelect.value;
470
  if (!selected) {
471
  els.loginError.textContent = "اختر اسم الباحث أو دخول المدير.";
472
  return;
473
  }
474
- if (els.password.value !== CONFIG.password) {
 
475
  els.loginError.textContent = "الرقم السري غير صحيح.";
476
  els.password.value = "";
477
  els.password.focus();
@@ -484,7 +667,7 @@ function login() {
484
  els.heroTitle.textContent = state.isManager ? "لوحة شاملة لجميع الباحثين والبيانات" : "العينات المسندة لك فقط";
485
  els.heroDescription.textContent = state.isManager
486
  ? "يمكنك مشاهدة جميع المنشآت والإحصائيات والتصدير والتعديل."
487
- : "يمكنك البحث والتصفية وتعديل بيانات التواصل للعينات المسندة لك فقط.";
488
  els.adminStats.hidden = !state.isManager;
489
  els.researcherFilterGroup.hidden = !state.isManager;
490
  els.exportSummaryButton.hidden = !state.isManager;
@@ -496,8 +679,11 @@ function login() {
496
  }
497
 
498
  function logout() {
 
499
  state.currentUser = "";
500
  state.isManager = false;
 
 
501
  els.password.value = "";
502
  els.searchInput.value = "";
503
  els.researcherFilter.value = "";
@@ -524,10 +710,21 @@ els.togglePassword.addEventListener("click", () => {
524
  control.addEventListener("change", applyFilters);
525
  });
526
 
 
 
 
 
 
 
 
 
 
527
  els.exportSummaryButton.addEventListener("click", exportSummary);
528
  els.exportFullButton.addEventListener("click", exportFull);
 
529
  els.logoutButton.addEventListener("click", logout);
530
  els.refreshButton.addEventListener("click", async () => {
 
531
  els.refreshButton.disabled = true;
532
  try {
533
  applyLoadedData(await loadData());
@@ -540,4 +737,10 @@ els.refreshButton.addEventListener("click", async () => {
540
  }
541
  });
542
 
 
 
 
 
 
 
543
  bootstrap();
 
1
  "use strict";
2
 
3
  const CONFIG = {
4
+ researcherPassword: "2030",
5
+ managerPassword: "20302030",
6
  managerValue: "__manager__",
7
  apiUrl: "https://script.google.com/macros/s/AKfycbwH2wJA6ASKfm9P5FL4X_AFcVpzIoGaCub2tZ2csdxYDhinwZhToKnxhkCQlKPJi4mXvg/exec",
8
  };
 
40
  ["actions", "الإجراءات"],
41
  ];
42
 
43
+ const LABELS = {
44
+ commercial: "السجل التجاري",
45
+ facility: "اسم المنشأة",
46
+ accountManager: "مدير الحساب",
47
+ name: "الاسم",
48
+ jobTitle: "المسمى الوظيفي",
49
+ email: "البريد الإلكتروني",
50
+ phone: "رقم التواصل",
51
+ region: "المنطقة",
52
+ district: "الحي",
53
+ researcher: "اسم الباحث",
54
+ };
55
+
56
  const els = {
57
  loginView: document.getElementById("loginView"),
58
  appView: document.getElementById("appView"),
 
69
  enteredCount: document.getElementById("enteredCount"),
70
  missingCount: document.getElementById("missingCount"),
71
  progressCount: document.getElementById("progressCount"),
72
+ progressLabel: document.getElementById("progressLabel"),
73
+ progressBar: document.getElementById("progressBar"),
74
  adminStats: document.getElementById("adminStats"),
75
  researcherStatsGrid: document.getElementById("researcherStatsGrid"),
76
  bestResearcher: document.getElementById("bestResearcher"),
 
83
  regionFilter: document.getElementById("regionFilter"),
84
  districtFilter: document.getElementById("districtFilter"),
85
  statusFilter: document.getElementById("statusFilter"),
86
+ saveAllButton: document.getElementById("saveAllButton"),
87
  resultsMeta: document.getElementById("resultsMeta"),
88
  tableHead: document.getElementById("tableHead"),
89
  tableBody: document.getElementById("tableBody"),
90
+ cardsGrid: document.getElementById("cardsGrid"),
91
  emptyState: document.getElementById("emptyState"),
92
  refreshButton: document.getElementById("refreshButton"),
93
  logoutButton: document.getElementById("logoutButton"),
 
101
  visibleRows: [],
102
  currentUser: "",
103
  isManager: false,
104
+ dirtyRows: new Map(),
105
  toastTimer: null,
106
  };
107
 
 
167
  return EDITABLE_KEYS.every((key) => String(valueOf(row, key)).trim());
168
  }
169
 
170
+ function rowByNumber(rowNumber) {
171
+ return state.rows.find((row) => Number(row.rowNumber) === Number(rowNumber));
172
+ }
173
+
174
  function rowsFromApiPayload(payload) {
175
  const headers = Array.isArray(payload.headers) ? payload.headers.map((header) => String(header ?? "").trim()) : [];
176
  const rows = Array.isArray(payload.rows) ? payload.rows : [];
 
223
  state.headerMap = buildHeaderMap(state.headers);
224
  validateHeaders();
225
  state.rows = data.rows;
226
+ state.dirtyRows.clear();
227
+ updateSaveAllState();
228
  populateSelects();
229
  }
230
 
 
257
  const district = els.districtFilter.value;
258
  const status = els.statusFilter.value;
259
 
260
+ state.visibleRows = scopedRows()
261
+ .filter((row) => {
262
+ const searchText = normalize([
263
+ valueOf(row, "commercial"),
264
+ valueOf(row, "facility"),
265
+ valueOf(row, "accountManager"),
266
+ valueOf(row, "researcher"),
267
+ valueOf(row, "region"),
268
+ valueOf(row, "district"),
269
+ valueOf(row, "name"),
270
+ valueOf(row, "phone"),
271
+ ].join(" "));
272
+ const entered = isEntered(row);
273
+ return (
274
+ (!query || searchText.includes(query)) &&
275
+ (!researcher || valueOf(row, "researcher") === researcher) &&
276
+ (!region || valueOf(row, "region") === region) &&
277
+ (!district || valueOf(row, "district") === district) &&
278
+ (!status || (status === "entered" ? entered : !entered))
279
+ );
280
+ })
281
+ .sort((a, b) => Number(isEntered(a)) - Number(isEntered(b)) || String(valueOf(a, "facility")).localeCompare(String(valueOf(b, "facility")), "ar"));
282
  render();
283
  }
284
 
 
303
  els.enteredCount.textContent = stats.entered;
304
  els.missingCount.textContent = stats.missing;
305
  els.progressCount.textContent = `${stats.progress}%`;
306
+ els.progressLabel.textContent = `نسبة الإنجاز ${stats.progress}%`;
307
+ els.progressBar.style.width = `${stats.progress}%`;
308
  els.resultsMeta.textContent = `${state.visibleRows.length} من أصل ${stats.total} منشأة`;
309
  }
310
 
 
341
  input.value = valueOf(row, key);
342
  input.dataset.row = row.rowNumber;
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
  }
350
 
 
355
  return `https://www.google.com/maps?q=${encodeURIComponent(`${y},${x}`)}`;
356
  }
357
 
358
+ function phoneDigits(value) {
359
+ const digits = String(value ?? "").replace(/\D/g, "");
360
+ if (!digits) return "";
361
+ if (digits.startsWith("966")) return digits;
362
+ if (digits.startsWith("05") && digits.length === 10) return `966${digits.slice(1)}`;
363
+ if (digits.startsWith("5") && digits.length === 9) return `966${digits}`;
364
+ return digits;
365
+ }
366
+
367
+ function handleFieldInput(row, key, value) {
368
+ const rowNumber = Number(row.rowNumber);
369
+ const current = state.dirtyRows.get(rowNumber) || Object.fromEntries(EDITABLE_KEYS.map((item) => [item, valueOf(row, item)]));
370
+ current[key] = value;
371
+ state.dirtyRows.set(rowNumber, current);
372
+ syncInputs(rowNumber, key, value);
373
+ markDirty(rowNumber, true);
374
+ updateSaveAllState();
375
+ }
376
+
377
+ function syncInputs(rowNumber, key, value) {
378
+ document.querySelectorAll(`[data-row="${rowNumber}"][data-key="${key}"]`).forEach((input) => {
379
+ if (input.value !== value) input.value = value;
380
+ });
381
+ }
382
+
383
+ 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");
396
+ tr.dataset.rowShell = row.rowNumber;
397
+ tr.classList.toggle("is-dirty", state.dirtyRows.has(row.rowNumber));
398
  DISPLAY_COLUMNS.forEach(([key]) => {
399
  const td = document.createElement("td");
400
  if (EDITABLE_KEYS.includes(key)) {
401
  td.append(createInput(row, key));
402
  } else if (key === "map") {
403
+ td.append(createMapLink(row));
 
 
 
 
 
 
 
 
 
 
 
404
  } else if (key === "status") {
405
+ td.append(createStatusBadge(row));
 
 
 
406
  } else if (key === "actions") {
407
  const button = document.createElement("button");
408
  button.type = "button";
409
  button.className = "secondary-button save-button";
410
  button.textContent = "حفظ";
411
+ button.addEventListener("click", () => saveRow(row, button));
412
  td.append(button);
413
  } else {
414
  td.textContent = valueOf(row, key) || "-";
 
418
  });
419
  return tr;
420
  }));
421
+ }
422
+
423
+ function createStatusBadge(row) {
424
+ const badge = document.createElement("span");
425
+ badge.className = `status-badge ${isEntered(row) ? "status-entered" : "status-missing"}`;
426
+ badge.textContent = isEntered(row) ? "مكتملة" : "غير مكتملة";
427
+ return badge;
428
+ }
429
+
430
+ function createMapLink(row) {
431
+ const href = mapUrl(row);
432
+ if (!href) return document.createTextNode("-");
433
+ const link = document.createElement("a");
434
+ link.className = "map-link";
435
+ link.href = href;
436
+ link.target = "_blank";
437
+ link.rel = "noopener";
438
+ link.textContent = "الموقع";
439
+ return link;
440
+ }
441
+
442
+ function renderCards() {
443
+ els.cardsGrid.replaceChildren(...state.visibleRows.map((row) => {
444
+ const card = document.createElement("article");
445
+ const entered = isEntered(row);
446
+ card.className = `facility-card ${entered ? "complete" : "incomplete"}`;
447
+ card.dataset.rowShell = row.rowNumber;
448
+ card.classList.toggle("is-dirty", state.dirtyRows.has(row.rowNumber));
449
+
450
+ const phone = valueOf(row, "phone");
451
+ const phoneNumber = phoneDigits(phone);
452
+ const actions = document.createElement("div");
453
+ actions.className = "card-actions";
454
+ actions.append(createMapLink(row));
455
+ if (phoneNumber) {
456
+ const call = document.createElement("a");
457
+ call.className = "secondary-button card-action-button";
458
+ call.href = `tel:${phoneNumber}`;
459
+ call.textContent = "اتصال";
460
+ const whatsapp = document.createElement("a");
461
+ whatsapp.className = "secondary-button card-action-button";
462
+ whatsapp.href = `https://wa.me/${phoneNumber}`;
463
+ whatsapp.target = "_blank";
464
+ whatsapp.rel = "noopener";
465
+ whatsapp.textContent = "واتساب";
466
+ actions.append(call, whatsapp);
467
+ }
468
+
469
+ const fields = document.createElement("div");
470
+ fields.className = "card-fields";
471
+ EDITABLE_KEYS.forEach((key) => {
472
+ const group = document.createElement("label");
473
+ group.textContent = LABELS[key];
474
+ group.append(createInput(row, key));
475
+ fields.append(group);
476
+ });
477
+
478
+ const save = document.createElement("button");
479
+ save.type = "button";
480
+ save.className = "primary-button card-save-button";
481
+ save.textContent = "حفظ";
482
+ save.addEventListener("click", () => saveRow(row, save));
483
+
484
+ card.innerHTML = `
485
+ <div class="facility-card-head">
486
+ <div>
487
+ <span class="facility-card-label">السجل التجاري: ${escapeHtml(valueOf(row, "commercial") || "-")}</span>
488
+ <h3>${escapeHtml(valueOf(row, "facility") || "منشأة دون اسم")}</h3>
489
+ </div>
490
+ </div>
491
+ <div class="card-info-grid">
492
+ <div><span>مدير الحساب</span><strong>${escapeHtml(valueOf(row, "accountManager") || "-")}</strong></div>
493
+ <div><span>المنطقة</span><strong>${escapeHtml(valueOf(row, "region") || "-")}</strong></div>
494
+ <div><span>الحي</span><strong>${escapeHtml(valueOf(row, "district") || "-")}</strong></div>
495
+ <div><span>الباحث</span><strong>${escapeHtml(valueOf(row, "researcher") || "-")}</strong></div>
496
+ </div>
497
+ `;
498
+ card.querySelector(".facility-card-head").append(createStatusBadge(row));
499
+ card.append(actions, fields, save);
500
+ return card;
501
+ }));
502
  }
503
 
504
  function render() {
 
506
  renderAdminStats();
507
  renderTableHead();
508
  renderTable();
509
+ renderCards();
510
+ els.emptyState.hidden = state.visibleRows.length !== 0;
511
  }
512
 
513
+ function valuesForRow(row) {
514
+ return state.dirtyRows.get(Number(row.rowNumber)) || Object.fromEntries(EDITABLE_KEYS.map((key) => [key, valueOf(row, key)]));
 
 
 
 
 
515
  }
516
 
517
+ 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({
 
541
  researcher: valueOf(row, "researcher"),
542
  updates,
543
  });
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
+
558
+ async function saveAllDirtyRows() {
559
+ const dirtyNumbers = [...state.dirtyRows.keys()];
560
+ if (!dirtyNumbers.length) return;
561
+ els.saveAllButton.disabled = true;
562
+ els.saveAllButton.textContent = "جاري حفظ الكل...";
563
+ try {
564
+ for (const rowNumber of dirtyNumbers) {
565
+ const row = rowByNumber(rowNumber);
566
+ if (!row) continue;
567
+ const values = valuesForRow(row);
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);
574
+ markDirty(rowNumber, false);
575
+ }
576
+ showToast("تم حفظ جميع التعديلات بنجاح");
577
+ applyFilters();
578
+ } catch (error) {
579
+ showToast(error.message || "تعذر حفظ جميع التعديلات");
580
+ } finally {
581
+ updateSaveAllState();
582
  }
583
  }
584
 
 
639
  }
640
  }
641
 
642
+ function hasUnsavedChanges() {
643
+ return state.dirtyRows.size > 0;
644
+ }
645
+
646
+ function confirmIfDirty() {
647
+ return !hasUnsavedChanges() || window.confirm("توجد بيانات معدلة لم يتم حفظها. هل تريد المتابعة بدون حفظ؟");
648
+ }
649
+
650
  function login() {
651
  const selected = els.researcherSelect.value;
652
  if (!selected) {
653
  els.loginError.textContent = "اختر اسم الباحث أو دخول المدير.";
654
  return;
655
  }
656
+ const expectedPassword = selected === CONFIG.managerValue ? CONFIG.managerPassword : CONFIG.researcherPassword;
657
+ if (els.password.value !== expectedPassword) {
658
  els.loginError.textContent = "الرقم السري غير صحيح.";
659
  els.password.value = "";
660
  els.password.focus();
 
667
  els.heroTitle.textContent = state.isManager ? "لوحة شاملة لجميع الباحثين والبيانات" : "العينات المسندة لك فقط";
668
  els.heroDescription.textContent = state.isManager
669
  ? "يمكنك مشاهدة جميع المنشآت والإحصائيات والتصدير والتعديل."
670
+ : "يمكنك فتح البطاقة، تعبئة بيانات التواصل، فتح الموقع، ثم الحفظ مباشرة.";
671
  els.adminStats.hidden = !state.isManager;
672
  els.researcherFilterGroup.hidden = !state.isManager;
673
  els.exportSummaryButton.hidden = !state.isManager;
 
679
  }
680
 
681
  function logout() {
682
+ if (!confirmIfDirty()) return;
683
  state.currentUser = "";
684
  state.isManager = false;
685
+ state.dirtyRows.clear();
686
+ updateSaveAllState();
687
  els.password.value = "";
688
  els.searchInput.value = "";
689
  els.researcherFilter.value = "";
 
710
  control.addEventListener("change", applyFilters);
711
  });
712
 
713
+ document.querySelectorAll(".quick-filter").forEach((button) => {
714
+ button.addEventListener("click", () => {
715
+ document.querySelectorAll(".quick-filter").forEach((item) => item.classList.remove("active"));
716
+ button.classList.add("active");
717
+ els.statusFilter.value = button.dataset.status || "";
718
+ applyFilters();
719
+ });
720
+ });
721
+
722
  els.exportSummaryButton.addEventListener("click", exportSummary);
723
  els.exportFullButton.addEventListener("click", exportFull);
724
+ els.saveAllButton.addEventListener("click", saveAllDirtyRows);
725
  els.logoutButton.addEventListener("click", logout);
726
  els.refreshButton.addEventListener("click", async () => {
727
+ if (!confirmIfDirty()) return;
728
  els.refreshButton.disabled = true;
729
  try {
730
  applyLoadedData(await loadData());
 
737
  }
738
  });
739
 
740
+ window.addEventListener("beforeunload", (event) => {
741
+ if (!hasUnsavedChanges()) return;
742
+ event.preventDefault();
743
+ event.returnValue = "";
744
+ });
745
+
746
  bootstrap();
index.html CHANGED
@@ -29,7 +29,7 @@
29
 
30
  <label for="password">الرقم السري</label>
31
  <div class="password-field">
32
- <input id="password" type="password" inputmode="numeric" autocomplete="new-password" placeholder="••••" required />
33
  <button id="togglePassword" type="button" class="icon-button" aria-label="إظهار الرقم السري">
34
  <svg viewBox="0 0 24 24"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" /><circle cx="12" cy="12" r="2.8" /></svg>
35
  </button>
@@ -74,10 +74,14 @@
74
  </div>
75
  <div class="quick-stats">
76
  <div><span>الإجمالي</span><strong id="totalCount">0</strong></div>
77
- <div><span>المدخلة</span><strong id="enteredCount">0</strong></div>
78
- <div><span>غير المدخلة</span><strong id="missingCount">0</strong></div>
79
  <div><span>الإنجاز</span><strong id="progressCount">0%</strong></div>
80
  </div>
 
 
 
 
81
  </section>
82
 
83
  <section id="adminStats" class="admin-stats" hidden>
@@ -139,13 +143,19 @@
139
  <div class="select-wrap">
140
  <select id="statusFilter">
141
  <option value="">كل الحالات</option>
142
- <option value="entered">مدخلة</option>
143
- <option value="missing">غير مدخلة</option>
144
  </select>
145
  <svg viewBox="0 0 24 24"><path d="m7 10 5 5 5-5" /></svg>
146
  </div>
147
  </div>
148
  </div>
 
 
 
 
 
 
149
  </section>
150
 
151
  <section class="table-section">
@@ -163,6 +173,7 @@
163
  <tbody id="tableBody"></tbody>
164
  </table>
165
  </div>
 
166
  <div id="emptyState" class="empty-state" hidden>
167
  <h3>لا توجد نتائج مطابقة</h3>
168
  <p>جرّب تغيير كلمات البحث أو التصفية.</p>
 
29
 
30
  <label for="password">الرقم السري</label>
31
  <div class="password-field">
32
+ <input id="password" type="password" inputmode="numeric" autocomplete="new-password" placeholder="****" required />
33
  <button id="togglePassword" type="button" class="icon-button" aria-label="إظهار الرقم السري">
34
  <svg viewBox="0 0 24 24"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" /><circle cx="12" cy="12" r="2.8" /></svg>
35
  </button>
 
74
  </div>
75
  <div class="quick-stats">
76
  <div><span>الإجمالي</span><strong id="totalCount">0</strong></div>
77
+ <div><span>المكتملة</span><strong id="enteredCount">0</strong></div>
78
+ <div><span>غير المكتملة</span><strong id="missingCount">0</strong></div>
79
  <div><span>الإنجاز</span><strong id="progressCount">0%</strong></div>
80
  </div>
81
+ <div class="progress-wrap">
82
+ <span id="progressLabel">نسبة الإنجاز 0%</span>
83
+ <div class="progress-track"><i id="progressBar"></i></div>
84
+ </div>
85
  </section>
86
 
87
  <section id="adminStats" class="admin-stats" hidden>
 
143
  <div class="select-wrap">
144
  <select id="statusFilter">
145
  <option value="">كل الحالات</option>
146
+ <option value="entered">مكتملة</option>
147
+ <option value="missing">غير مكتملة</option>
148
  </select>
149
  <svg viewBox="0 0 24 24"><path d="m7 10 5 5 5-5" /></svg>
150
  </div>
151
  </div>
152
  </div>
153
+ <div class="quick-filter-row">
154
+ <button class="quick-filter active" type="button" data-status="">الكل</button>
155
+ <button class="quick-filter" type="button" data-status="entered">مكتملة</button>
156
+ <button class="quick-filter" type="button" data-status="missing">غير مكتملة</button>
157
+ <button id="saveAllButton" class="primary-button save-all-button" type="button" disabled>حفظ الكل</button>
158
+ </div>
159
  </section>
160
 
161
  <section class="table-section">
 
173
  <tbody id="tableBody"></tbody>
174
  </table>
175
  </div>
176
+ <div id="cardsGrid" class="cards-grid"></div>
177
  <div id="emptyState" class="empty-state" hidden>
178
  <h3>لا توجد نتائج مطابقة</h3>
179
  <p>جرّب تغيير كلمات البحث أو التصفية.</p>
style.css CHANGED
@@ -252,6 +252,37 @@ input:focus, select:focus {
252
  line-height: 1;
253
  }
254
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  .admin-stats, .controls-panel, .table-section { margin-top: 14px; padding: 18px; }
256
 
257
  .section-heading {
@@ -325,6 +356,35 @@ input:focus, select:focus {
325
 
326
  .field-group label { margin-top: 0; }
327
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  .results-meta { margin: 0 0 4px; color: var(--accent); font-size: 14px; font-weight: 700; }
329
  .compact-heading { margin-bottom: 10px; }
330
 
@@ -360,6 +420,7 @@ th {
360
 
361
  td { color: #253b4c; font-size: 14px; line-height: 1.55; }
362
  tbody tr:hover { background: rgba(39, 206, 215, 0.06); }
 
363
 
364
  .ltr-value { direction: ltr; text-align: right; }
365
  .row-actions { display: flex; flex-wrap: wrap; gap: 7px; min-width: 170px; }
@@ -385,6 +446,133 @@ tbody tr:hover { background: rgba(39, 206, 215, 0.06); }
385
  }
386
  .save-button { min-height: 38px; padding: 0 12px; }
387
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  .empty-state {
389
  padding: 34px 20px;
390
  text-align: center;
@@ -427,6 +615,40 @@ tbody tr:hover { background: rgba(39, 206, 215, 0.06); }
427
  .hero-panel, .admin-stats, .controls-panel, .table-section { padding: 14px; }
428
  .quick-stats { grid-template-columns: 1fr 1fr; }
429
  .hero-panel h2 { font-size: 22px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  .toast { right: 12px; left: 12px; text-align: center; transform: translateY(18px); }
431
  .toast.show { transform: translateY(0); }
432
  }
 
 
 
 
 
 
 
 
252
  line-height: 1;
253
  }
254
 
255
+ .progress-wrap {
256
+ grid-column: 1 / -1;
257
+ padding: 12px 14px;
258
+ background: #fbfdfe;
259
+ border: 1px solid #dbe8ed;
260
+ border-radius: 8px;
261
+ }
262
+
263
+ .progress-wrap span {
264
+ display: block;
265
+ margin-bottom: 8px;
266
+ color: var(--muted);
267
+ font-size: 13px;
268
+ font-weight: 700;
269
+ }
270
+
271
+ .progress-track {
272
+ height: 10px;
273
+ overflow: hidden;
274
+ background: #edf3f6;
275
+ border-radius: 99px;
276
+ }
277
+
278
+ .progress-track i {
279
+ display: block;
280
+ width: 0;
281
+ height: 100%;
282
+ background: linear-gradient(90deg, var(--accent), var(--primary-alt));
283
+ transition: width 0.25s ease;
284
+ }
285
+
286
  .admin-stats, .controls-panel, .table-section { margin-top: 14px; padding: 18px; }
287
 
288
  .section-heading {
 
356
 
357
  .field-group label { margin-top: 0; }
358
 
359
+ .quick-filter-row {
360
+ display: flex;
361
+ flex-wrap: wrap;
362
+ gap: 8px;
363
+ margin-top: 14px;
364
+ }
365
+
366
+ .quick-filter {
367
+ min-height: 40px;
368
+ padding: 0 16px;
369
+ color: var(--primary-alt);
370
+ background: #fff;
371
+ border: 1px solid rgba(39, 206, 215, 0.34);
372
+ border-radius: 8px;
373
+ font-weight: 700;
374
+ }
375
+
376
+ .quick-filter.active {
377
+ color: #fff;
378
+ background: var(--primary);
379
+ border-color: var(--primary);
380
+ }
381
+
382
+ .save-all-button {
383
+ width: auto;
384
+ min-width: 150px;
385
+ margin-inline-start: auto;
386
+ }
387
+
388
  .results-meta { margin: 0 0 4px; color: var(--accent); font-size: 14px; font-weight: 700; }
389
  .compact-heading { margin-bottom: 10px; }
390
 
 
420
 
421
  td { color: #253b4c; font-size: 14px; line-height: 1.55; }
422
  tbody tr:hover { background: rgba(39, 206, 215, 0.06); }
423
+ tbody tr.is-dirty { background: rgba(255, 192, 0, 0.08); }
424
 
425
  .ltr-value { direction: ltr; text-align: right; }
426
  .row-actions { display: flex; flex-wrap: wrap; gap: 7px; min-width: 170px; }
 
446
  }
447
  .save-button { min-height: 38px; padding: 0 12px; }
448
 
449
+ .cards-grid {
450
+ display: none;
451
+ gap: 12px;
452
+ }
453
+
454
+ .facility-card {
455
+ padding: 14px;
456
+ background: #fff;
457
+ border: 1px solid var(--line);
458
+ border-radius: 8px;
459
+ box-shadow: var(--shadow);
460
+ }
461
+
462
+ .facility-card.incomplete {
463
+ background: #fffdf4;
464
+ border-color: rgba(255, 192, 0, 0.45);
465
+ }
466
+
467
+ .facility-card.complete {
468
+ background: #f4fff8;
469
+ border-color: rgba(0, 176, 80, 0.28);
470
+ }
471
+
472
+ .facility-card.is-dirty {
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;
479
+ justify-content: space-between;
480
+ gap: 10px;
481
+ margin-bottom: 12px;
482
+ }
483
+
484
+ .facility-card-label {
485
+ display: block;
486
+ margin-bottom: 5px;
487
+ color: var(--muted);
488
+ direction: rtl;
489
+ font-size: 12px;
490
+ font-weight: 700;
491
+ }
492
+
493
+ .facility-card h3 {
494
+ margin: 0;
495
+ color: var(--primary-alt);
496
+ font-size: 18px;
497
+ line-height: 1.55;
498
+ }
499
+
500
+ .card-info-grid {
501
+ display: grid;
502
+ grid-template-columns: 1fr 1fr;
503
+ gap: 8px;
504
+ margin-bottom: 12px;
505
+ }
506
+
507
+ .card-info-grid div {
508
+ min-width: 0;
509
+ padding: 9px 10px;
510
+ background: rgba(255, 255, 255, 0.72);
511
+ border: 1px solid rgba(215, 226, 238, 0.9);
512
+ border-radius: 8px;
513
+ }
514
+
515
+ .card-info-grid span,
516
+ .card-fields label {
517
+ display: block;
518
+ margin-bottom: 5px;
519
+ color: var(--muted);
520
+ font-size: 12px;
521
+ font-weight: 700;
522
+ }
523
+
524
+ .card-info-grid strong {
525
+ display: block;
526
+ color: var(--ink);
527
+ font-size: 14px;
528
+ line-height: 1.45;
529
+ }
530
+
531
+ .card-actions {
532
+ display: grid;
533
+ grid-template-columns: repeat(3, minmax(0, 1fr));
534
+ gap: 8px;
535
+ margin-bottom: 12px;
536
+ }
537
+
538
+ .facility-card .map-link,
539
+ .card-action-button {
540
+ min-height: 42px;
541
+ justify-content: center;
542
+ padding: 0 10px;
543
+ color: var(--primary-alt);
544
+ background: #fff;
545
+ border: 1px solid rgba(39, 206, 215, 0.34);
546
+ border-radius: 8px;
547
+ }
548
+
549
+ .facility-card .map-link:hover,
550
+ .card-action-button:hover {
551
+ color: #fff;
552
+ background: var(--primary-alt);
553
+ border-color: var(--primary-alt);
554
+ }
555
+
556
+ .card-fields {
557
+ display: grid;
558
+ grid-template-columns: 1fr;
559
+ gap: 10px;
560
+ }
561
+
562
+ .card-fields label {
563
+ margin: 0;
564
+ }
565
+
566
+ .card-fields .cell-input {
567
+ min-width: 0;
568
+ width: 100%;
569
+ min-height: 46px;
570
+ }
571
+
572
+ .card-save-button {
573
+ margin-top: 12px;
574
+ }
575
+
576
  .empty-state {
577
  padding: 34px 20px;
578
  text-align: center;
 
615
  .hero-panel, .admin-stats, .controls-panel, .table-section { padding: 14px; }
616
  .quick-stats { grid-template-columns: 1fr 1fr; }
617
  .hero-panel h2 { font-size: 22px; }
618
+ .quick-filter-row {
619
+ display: grid;
620
+ grid-template-columns: repeat(3, minmax(0, 1fr));
621
+ }
622
+ .save-all-button {
623
+ grid-column: 1 / -1;
624
+ width: 100%;
625
+ margin-inline-start: 0;
626
+ }
627
+ .table-wrap {
628
+ display: none;
629
+ }
630
+ .cards-grid {
631
+ display: grid;
632
+ }
633
+ table {
634
+ min-width: 0;
635
+ }
636
+ .card-actions {
637
+ grid-template-columns: 1fr;
638
+ }
639
+ .facility-card-head {
640
+ flex-direction: column;
641
+ }
642
+ .facility-card-head .status-badge {
643
+ align-self: flex-start;
644
+ }
645
  .toast { right: 12px; left: 12px; text-align: center; transform: translateY(18px); }
646
  .toast.show { transform: translateY(0); }
647
  }
648
+
649
+ @media (max-width: 420px) {
650
+ .card-info-grid,
651
+ .quick-stats {
652
+ grid-template-columns: 1fr;
653
+ }
654
+ }