Upload 3 files
Browse files- app.js +149 -33
- index.html +1 -0
- 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(
|
| 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 533 |
-
|
| 534 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 554 |
-
button
|
|
|
|
|
|
|
|
|
|
| 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
|
| 612 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 624 |
}
|
| 625 |
|
| 626 |
function exportFull() {
|
| 627 |
-
|
| 628 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|