stat2025 commited on
Commit
e9af8e8
·
verified ·
1 Parent(s): 2ab9ea2

Upload 6 files

Browse files
Files changed (6) hide show
  1. README.md +16 -4
  2. app.js +408 -0
  3. data.js +0 -0
  4. generate-data.mjs +60 -0
  5. index.html +201 -17
  6. style.css +788 -18
README.md CHANGED
@@ -1,12 +1,24 @@
1
  ---
2
  title: DE
3
- emoji: 📊
4
- colorFrom: green
5
  colorTo: green
6
  sdk: static
7
  pinned: false
8
  license: mit
9
- short_description: DE
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: DE
3
+ emoji: "\U0001F5C2"
4
+ colorFrom: blue
5
  colorTo: green
6
  sdk: static
7
  pinned: false
8
  license: mit
9
+ short_description: نظام متابعة العينات
10
  ---
11
 
12
+ # نظام متابعة العينات
13
+
14
+ واجهة عربية للاستعلام عن العينات وبيانات التواصل حسب المشرف والباحث.
15
+
16
+ ## تحديث البيانات
17
+
18
+ يشفر الأمر التالي بيانات ملف Excel وينشئ ملف `data.js` مستقلًا:
19
+
20
+ ```powershell
21
+ node generate-data.mjs "path\to\input.xlsx" "data.js" "PASSWORD"
22
+ ```
23
+
24
+ لا ترفع ملف Excel الأصلي إلى المستودع.
app.js ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+
3
+ const FIELD_LABELS = [
4
+ "اسم المشرف",
5
+ "اسم الباحث",
6
+ "رقم السجل(العينة)",
7
+ "اسم العينة",
8
+ "حالة الاكتمال",
9
+ "حالة الاقرار",
10
+ "حالة الاستيفاء",
11
+ "نوع الاستيفاء",
12
+ "حالة الاعتماد",
13
+ "النشاط",
14
+ "الايميل",
15
+ "الموقع",
16
+ "رقم التواصل",
17
+ ];
18
+
19
+ const FIELD_KEYS = [
20
+ "supervisor",
21
+ "researcher",
22
+ "recordNumber",
23
+ "sampleName",
24
+ "completionStatus",
25
+ "declarationStatus",
26
+ "fulfillmentStatus",
27
+ "fulfillmentType",
28
+ "approvalStatus",
29
+ "activity",
30
+ "email",
31
+ "location",
32
+ "phone",
33
+ ];
34
+
35
+ const DISPLAY_FIELDS = [
36
+ ["recordNumber", "رقم السجل", false],
37
+ ["completionStatus", "حالة الاكتمال", false],
38
+ ["declarationStatus", "حالة الإقرار", false],
39
+ ["fulfillmentType", "نوع الاستيفاء", false],
40
+ ["approvalStatus", "حالة الاعتماد", false],
41
+ ["activity", "النشاط", true],
42
+ ["email", "البريد الإلكتروني", true],
43
+ ["location", "الموقع", true],
44
+ ["phone", "رقم التواصل", true],
45
+ ];
46
+
47
+ const loginView = document.getElementById("loginView");
48
+ const dashboardView = document.getElementById("dashboardView");
49
+ const loginForm = document.getElementById("loginForm");
50
+ const passwordInput = document.getElementById("password");
51
+ const loginButton = document.getElementById("loginButton");
52
+ const loginError = document.getElementById("loginError");
53
+ const togglePassword = document.getElementById("togglePassword");
54
+ const logoutButton = document.getElementById("logoutButton");
55
+ const supervisorSelect = document.getElementById("supervisorSelect");
56
+ const researcherSelect = document.getElementById("researcherSelect");
57
+ const statusSelect = document.getElementById("statusSelect");
58
+ const searchInput = document.getElementById("searchInput");
59
+ const emptyState = document.getElementById("emptyState");
60
+ const resultsSection = document.getElementById("resultsSection");
61
+ const resultsMeta = document.getElementById("resultsMeta");
62
+ const resultsTitle = document.getElementById("resultsTitle");
63
+ const statusChips = document.getElementById("statusChips");
64
+ const samplesGrid = document.getElementById("samplesGrid");
65
+ const noResults = document.getElementById("noResults");
66
+ const copyAllButton = document.getElementById("copyAllButton");
67
+ const toast = document.getElementById("toast");
68
+
69
+ let allRows = [];
70
+ let visibleRows = [];
71
+ let failedAttempts = 0;
72
+ let toastTimer;
73
+
74
+ function base64ToBytes(value) {
75
+ const binary = atob(value);
76
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
77
+ }
78
+
79
+ async function deriveKey(password, salt) {
80
+ const material = await crypto.subtle.importKey(
81
+ "raw",
82
+ new TextEncoder().encode(password),
83
+ "PBKDF2",
84
+ false,
85
+ ["deriveKey"],
86
+ );
87
+
88
+ return crypto.subtle.deriveKey(
89
+ {
90
+ name: "PBKDF2",
91
+ salt,
92
+ iterations: ENCRYPTED_DATA.iterations,
93
+ hash: "SHA-256",
94
+ },
95
+ material,
96
+ { name: "AES-GCM", length: 256 },
97
+ false,
98
+ ["decrypt"],
99
+ );
100
+ }
101
+
102
+ async function decryptData(password) {
103
+ const salt = base64ToBytes(ENCRYPTED_DATA.salt);
104
+ const iv = base64ToBytes(ENCRYPTED_DATA.iv);
105
+ const cipherText = base64ToBytes(ENCRYPTED_DATA.payload);
106
+ const key = await deriveKey(password, salt);
107
+ const plainBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipherText);
108
+ return JSON.parse(new TextDecoder().decode(plainBuffer));
109
+ }
110
+
111
+ function normalize(value) {
112
+ return String(value ?? "")
113
+ .normalize("NFKD")
114
+ .replace(/[\u064B-\u065F\u0670]/g, "")
115
+ .replace(/[أإآ]/g, "ا")
116
+ .replace(/ى/g, "ي")
117
+ .replace(/ة/g, "ه")
118
+ .replace(/\s+/g, " ")
119
+ .trim()
120
+ .toLowerCase();
121
+ }
122
+
123
+ function rowToObject(row) {
124
+ return Object.fromEntries(FIELD_KEYS.map((key, index) => [key, row[index] ?? ""]));
125
+ }
126
+
127
+ function uniqueSorted(values) {
128
+ return [...new Set(values.filter(Boolean))].sort((a, b) =>
129
+ String(a).localeCompare(String(b), "ar", { sensitivity: "base" }),
130
+ );
131
+ }
132
+
133
+ function setOptions(select, values, placeholder) {
134
+ select.replaceChildren(new Option(placeholder, ""));
135
+ values.forEach((value) => select.add(new Option(value, value)));
136
+ }
137
+
138
+ function showToast(message) {
139
+ clearTimeout(toastTimer);
140
+ toast.textContent = message;
141
+ toast.classList.add("show");
142
+ toastTimer = setTimeout(() => toast.classList.remove("show"), 2300);
143
+ }
144
+
145
+ async function copyText(text, successMessage) {
146
+ try {
147
+ await navigator.clipboard.writeText(text);
148
+ } catch {
149
+ const area = document.createElement("textarea");
150
+ area.value = text;
151
+ area.style.position = "fixed";
152
+ area.style.opacity = "0";
153
+ document.body.append(area);
154
+ area.select();
155
+ document.execCommand("copy");
156
+ area.remove();
157
+ }
158
+ showToast(successMessage);
159
+ }
160
+
161
+ function buildMessage(row, index = null) {
162
+ const title = index === null ? "بيانات العينة المطلوبة للتواصل:" : `العينة رقم ${index}:`;
163
+ const lines = [
164
+ title,
165
+ row.sampleName && `اسم العينة: ${row.sampleName}`,
166
+ row.recordNumber && `رقم السجل: ${row.recordNumber}`,
167
+ row.fulfillmentStatus && `حالة الاستيفاء: ${row.fulfillmentStatus}`,
168
+ row.activity && `النشاط: ${row.activity}`,
169
+ row.phone && `رقم التواصل: ${row.phone}`,
170
+ row.email && `البريد الإلكتروني: ${row.email}`,
171
+ row.location && `الموقع: ${row.location}`,
172
+ ].filter(Boolean);
173
+ return lines.join("\n");
174
+ }
175
+
176
+ function createDetail(key, label, isContact, value) {
177
+ const wrapper = document.createElement("div");
178
+ wrapper.className = `detail-item${isContact ? " contact-detail" : ""}`;
179
+
180
+ const labelElement = document.createElement("span");
181
+ labelElement.className = "detail-label";
182
+ labelElement.textContent = label;
183
+
184
+ const valueElement = document.createElement("span");
185
+ valueElement.className = "detail-value";
186
+ valueElement.textContent = String(value);
187
+ if (key === "phone" || key === "recordNumber") {
188
+ valueElement.dir = "ltr";
189
+ valueElement.style.textAlign = "right";
190
+ }
191
+
192
+ wrapper.append(labelElement, valueElement);
193
+ return wrapper;
194
+ }
195
+
196
+ function createSampleCard(row, index) {
197
+ const card = document.createElement("article");
198
+ card.className = "sample-card";
199
+
200
+ const header = document.createElement("div");
201
+ header.className = "sample-card-header";
202
+
203
+ const headingWrap = document.createElement("div");
204
+ const number = document.createElement("span");
205
+ number.className = "sample-index";
206
+ number.textContent = `العينة ${index + 1}`;
207
+ const title = document.createElement("h3");
208
+ title.textContent = row.sampleName || "عينة دون اسم";
209
+ headingWrap.append(number, title);
210
+
211
+ const badge = document.createElement("span");
212
+ badge.className = "status-badge";
213
+ badge.textContent = row.fulfillmentStatus || "غير محدد";
214
+ header.append(headingWrap, badge);
215
+
216
+ const details = document.createElement("div");
217
+ details.className = "sample-details";
218
+ DISPLAY_FIELDS.forEach(([key, label, isContact]) => {
219
+ if (String(row[key] ?? "").trim()) {
220
+ details.append(createDetail(key, label, isContact, row[key]));
221
+ }
222
+ });
223
+
224
+ const footer = document.createElement("div");
225
+ footer.className = "sample-card-footer";
226
+ const copyButton = document.createElement("button");
227
+ copyButton.className = "copy-sample";
228
+ copyButton.type = "button";
229
+ copyButton.textContent = "نسخ رسالة هذه العينة";
230
+ copyButton.addEventListener("click", () =>
231
+ copyText(buildMessage(row), "تم نسخ رسالة العينة"),
232
+ );
233
+ footer.append(copyButton);
234
+
235
+ card.append(header, details, footer);
236
+ return card;
237
+ }
238
+
239
+ function renderStatusChips(rows) {
240
+ const counts = new Map();
241
+ rows.forEach((row) => {
242
+ const status = row.fulfillmentStatus || "غير محدد";
243
+ counts.set(status, (counts.get(status) || 0) + 1);
244
+ });
245
+
246
+ statusChips.replaceChildren(
247
+ ...[...counts.entries()].map(([status, count]) => {
248
+ const chip = document.createElement("span");
249
+ chip.className = "status-chip";
250
+ chip.append(document.createTextNode(status));
251
+ const strong = document.createElement("strong");
252
+ strong.textContent = count;
253
+ chip.append(strong);
254
+ return chip;
255
+ }),
256
+ );
257
+ }
258
+
259
+ function applyFilters() {
260
+ const researcher = researcherSelect.value;
261
+ if (!researcher) {
262
+ resultsSection.hidden = true;
263
+ emptyState.hidden = false;
264
+ return;
265
+ }
266
+
267
+ const status = statusSelect.value;
268
+ const query = normalize(searchInput.value);
269
+ const researcherRows = allRows.filter(
270
+ (row) => row.supervisor === supervisorSelect.value && row.researcher === researcher,
271
+ );
272
+
273
+ visibleRows = researcherRows.filter((row) => {
274
+ const matchesStatus = !status || row.fulfillmentStatus === status;
275
+ const matchesSearch =
276
+ !query ||
277
+ normalize(row.sampleName).includes(query) ||
278
+ normalize(row.recordNumber).includes(query);
279
+ return matchesStatus && matchesSearch;
280
+ });
281
+
282
+ emptyState.hidden = true;
283
+ resultsSection.hidden = false;
284
+ resultsTitle.textContent = researcher;
285
+ resultsMeta.textContent = `${visibleRows.length} من أصل ${researcherRows.length} عينة`;
286
+ copyAllButton.disabled = visibleRows.length === 0;
287
+ renderStatusChips(researcherRows);
288
+
289
+ samplesGrid.replaceChildren(
290
+ ...visibleRows.map((row, index) => createSampleCard(row, index)),
291
+ );
292
+ noResults.hidden = visibleRows.length !== 0;
293
+ }
294
+
295
+ function resetResearcherControls() {
296
+ setOptions(researcherSelect, [], "اختر الباحث");
297
+ setOptions(statusSelect, [], "جميع الحالات");
298
+ researcherSelect.disabled = true;
299
+ statusSelect.disabled = true;
300
+ searchInput.disabled = true;
301
+ searchInput.value = "";
302
+ resultsSection.hidden = true;
303
+ emptyState.hidden = false;
304
+ emptyState.querySelector("h3").textContent = supervisorSelect.value
305
+ ? "اختر الباحث"
306
+ : "ابدأ باختيار المشرف";
307
+ emptyState.querySelector("p").textContent = supervisorSelect.value
308
+ ? "اختر اسم الباحث لعرض العينات المسندة إليه."
309
+ : "لن تظهر أي بيانات للعينات حتى يتم اختيار المشرف والباحث.";
310
+ }
311
+
312
+ function initializeDashboard(data) {
313
+ allRows = data.rows.map(rowToObject);
314
+ setOptions(
315
+ supervisorSelect,
316
+ uniqueSorted(allRows.map((row) => row.supervisor)),
317
+ "اختر المشرف",
318
+ );
319
+ resetResearcherControls();
320
+ }
321
+
322
+ loginForm.addEventListener("submit", async (event) => {
323
+ event.preventDefault();
324
+ const password = passwordInput.value.trim();
325
+ if (!password) return;
326
+
327
+ loginButton.disabled = true;
328
+ loginButton.querySelector("span").textContent = "جارٍ التحقق...";
329
+ loginError.textContent = "";
330
+
331
+ if (failedAttempts >= 3) {
332
+ await new Promise((resolve) => setTimeout(resolve, Math.min(failedAttempts * 800, 5000)));
333
+ }
334
+
335
+ try {
336
+ const data = await decryptData(password);
337
+ initializeDashboard(data);
338
+ passwordInput.value = "";
339
+ loginView.hidden = true;
340
+ dashboardView.hidden = false;
341
+ failedAttempts = 0;
342
+ } catch {
343
+ failedAttempts += 1;
344
+ loginError.textContent = "رمز الدخول غير صحيح. تحقق منه وحاول مرة أخرى.";
345
+ passwordInput.select();
346
+ } finally {
347
+ loginButton.disabled = false;
348
+ loginButton.querySelector("span").textContent = "دخول آمن";
349
+ }
350
+ });
351
+
352
+ togglePassword.addEventListener("click", () => {
353
+ const showing = passwordInput.type === "text";
354
+ passwordInput.type = showing ? "password" : "text";
355
+ togglePassword.setAttribute("aria-label", showing ? "إظهار رمز الدخول" : "إخفاء رمز الدخول");
356
+ });
357
+
358
+ logoutButton.addEventListener("click", () => {
359
+ allRows = [];
360
+ visibleRows = [];
361
+ supervisorSelect.value = "";
362
+ resetResearcherControls();
363
+ dashboardView.hidden = true;
364
+ loginView.hidden = false;
365
+ passwordInput.focus();
366
+ });
367
+
368
+ supervisorSelect.addEventListener("change", () => {
369
+ resetResearcherControls();
370
+ if (!supervisorSelect.value) return;
371
+
372
+ const researchers = uniqueSorted(
373
+ allRows
374
+ .filter((row) => row.supervisor === supervisorSelect.value)
375
+ .map((row) => row.researcher),
376
+ );
377
+ setOptions(researcherSelect, researchers, "اختر الباحث");
378
+ researcherSelect.disabled = false;
379
+ });
380
+
381
+ researcherSelect.addEventListener("change", () => {
382
+ const rows = allRows.filter(
383
+ (row) =>
384
+ row.supervisor === supervisorSelect.value &&
385
+ row.researcher === researcherSelect.value,
386
+ );
387
+ const statuses = uniqueSorted(rows.map((row) => row.fulfillmentStatus));
388
+ setOptions(statusSelect, statuses, "جميع الحالات");
389
+ statusSelect.disabled = !researcherSelect.value;
390
+ searchInput.disabled = !researcherSelect.value;
391
+ searchInput.value = "";
392
+ applyFilters();
393
+ });
394
+
395
+ statusSelect.addEventListener("change", applyFilters);
396
+ searchInput.addEventListener("input", applyFilters);
397
+
398
+ copyAllButton.addEventListener("click", () => {
399
+ if (!visibleRows.length) return;
400
+ const heading = `بيانات العينات الخاصة بالباحث: ${researcherSelect.value}`;
401
+ const messages = visibleRows.map((row, index) => buildMessage(row, index + 1));
402
+ copyText([heading, ...messages].join("\n\n--------------------\n\n"), "تم نسخ جميع العينات المعروضة");
403
+ });
404
+
405
+ if (!window.crypto?.subtle || typeof ENCRYPTED_DATA === "undefined") {
406
+ loginButton.disabled = true;
407
+ loginError.textContent = "تعذر تشغيل التشفير في هذا المتصفح. استخدم متصفحًا حديثًا.";
408
+ }
data.js ADDED
The diff for this file is too large to render. See raw diff
 
generate-data.mjs ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from "node:fs/promises";
2
+ import crypto from "node:crypto";
3
+ import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
4
+
5
+ const [inputPath, outputPath, password] = process.argv.slice(2);
6
+
7
+ if (!inputPath || !outputPath || !password) {
8
+ console.error("Usage: node generate-data.mjs <input.xlsx> <output.js> <password>");
9
+ process.exitCode = 1;
10
+ } else {
11
+ const blob = await FileBlob.load(inputPath);
12
+ const workbook = await SpreadsheetFile.importXlsx(blob);
13
+ const sheet = workbook.worksheets.getItemAt(0);
14
+ const values = sheet.getUsedRange(true).values;
15
+ const headers = values[0].map((value) => String(value ?? "").trim());
16
+ const rows = values.slice(1).map((row) =>
17
+ headers.map((_, index) => {
18
+ const value = row[index];
19
+ if (value === null || value === undefined) return "";
20
+ return typeof value === "string" ? value.trim() : String(value);
21
+ }),
22
+ );
23
+
24
+ const payload = JSON.stringify({
25
+ version: 1,
26
+ generatedAt: new Date().toISOString(),
27
+ headers,
28
+ rows,
29
+ });
30
+
31
+ const iterations = 310000;
32
+ const salt = crypto.randomBytes(16);
33
+ const iv = crypto.randomBytes(12);
34
+ const key = crypto.pbkdf2Sync(password, salt, iterations, 32, "sha256");
35
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
36
+ const encrypted = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
37
+ const authTag = cipher.getAuthTag();
38
+ const combined = Buffer.concat([encrypted, authTag]);
39
+
40
+ const output = [
41
+ "/* Generated encrypted data. Replace this file using generate-data.mjs when the workbook changes. */",
42
+ "const ENCRYPTED_DATA = Object.freeze({",
43
+ ` iterations: ${iterations},`,
44
+ ` salt: "${salt.toString("base64")}",`,
45
+ ` iv: "${iv.toString("base64")}",`,
46
+ ` payload: "${combined.toString("base64")}",`,
47
+ "});",
48
+ "",
49
+ ].join("\n");
50
+
51
+ await fs.writeFile(outputPath, output, "utf8");
52
+ console.log(
53
+ JSON.stringify({
54
+ rows: rows.length,
55
+ columns: headers.length,
56
+ encryptedBytes: combined.length,
57
+ outputPath,
58
+ }),
59
+ );
60
+ }
index.html CHANGED
@@ -1,19 +1,203 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="ar" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="theme-color" content="#123c69" />
7
+ <meta name="robots" content="noindex, nofollow" />
8
+ <title>نظام متابعة العينات</title>
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
+ <link
12
+ href="https://fonts.googleapis.com/css2?family=Tajawal:wght@400;500;600;700;800&display=swap"
13
+ rel="stylesheet"
14
+ />
15
+ <link rel="stylesheet" href="style.css" />
16
+ </head>
17
+ <body>
18
+ <div class="background-shape shape-one"></div>
19
+ <div class="background-shape shape-two"></div>
20
+
21
+ <main>
22
+ <section id="loginView" class="login-view">
23
+ <div class="login-card">
24
+ <div class="brand-mark" aria-hidden="true">
25
+ <svg viewBox="0 0 48 48" role="img">
26
+ <path d="M24 4 42 14v20L24 44 6 34V14L24 4Z" />
27
+ <path d="M16 25.5 21.5 31 33 18.5" />
28
+ </svg>
29
+ </div>
30
+ <p class="eyebrow">بوابة العمل الميداني</p>
31
+ <h1>نظام متابعة العينات</h1>
32
+ <p class="login-description">
33
+ أدخل رمز الدخول للوصول إلى بيانات التواصل ومتابعة حالات العينات.
34
+ </p>
35
+
36
+ <form id="loginForm" class="login-form" autocomplete="off">
37
+ <label for="password">رمز الدخول</label>
38
+ <div class="password-field">
39
+ <input
40
+ id="password"
41
+ name="password"
42
+ type="password"
43
+ inputmode="numeric"
44
+ placeholder="أدخل رمز الدخول"
45
+ required
46
+ autofocus
47
+ />
48
+ <button id="togglePassword" type="button" class="icon-button" aria-label="إظهار رمز الدخول">
49
+ <svg viewBox="0 0 24 24" aria-hidden="true">
50
+ <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" />
51
+ <circle cx="12" cy="12" r="2.8" />
52
+ </svg>
53
+ </button>
54
+ </div>
55
+ <p id="loginError" class="form-error" role="alert"></p>
56
+ <button id="loginButton" class="primary-button" type="submit">
57
+ <span>دخول آمن</span>
58
+ <svg viewBox="0 0 24 24" aria-hidden="true">
59
+ <path d="m9 18 6-6-6-6" />
60
+ </svg>
61
+ </button>
62
+ </form>
63
+
64
+ <div class="security-note">
65
+ <svg viewBox="0 0 24 24" aria-hidden="true">
66
+ <rect x="5" y="10" width="14" height="10" rx="2" />
67
+ <path d="M8 10V7a4 4 0 0 1 8 0v3" />
68
+ </svg>
69
+ <span>البيانات مشفرة ولا تُعرض قبل تسجيل الدخول.</span>
70
+ </div>
71
+ </div>
72
+ </section>
73
+
74
+ <section id="dashboardView" class="dashboard-view" hidden>
75
+ <header class="topbar">
76
+ <div class="topbar-brand">
77
+ <div class="brand-mark small" aria-hidden="true">
78
+ <svg viewBox="0 0 48 48">
79
+ <path d="M24 4 42 14v20L24 44 6 34V14L24 4Z" />
80
+ <path d="M16 25.5 21.5 31 33 18.5" />
81
+ </svg>
82
+ </div>
83
+ <div>
84
+ <p>بوابة العمل الميداني</p>
85
+ <h1>نظام متابعة العينات</h1>
86
+ </div>
87
+ </div>
88
+ <button id="logoutButton" class="secondary-button compact" type="button">
89
+ <svg viewBox="0 0 24 24" aria-hidden="true">
90
+ <path d="M10 17l5-5-5-5M15 12H3M15 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4" />
91
+ </svg>
92
+ خروج
93
+ </button>
94
+ </header>
95
+
96
+ <div class="dashboard-shell">
97
+ <section class="welcome-panel">
98
+ <div>
99
+ <p class="eyebrow">لوحة الاستعلام</p>
100
+ <h2>الوصول إلى بيانات العينة بسهولة</h2>
101
+ <p>اختر المشرف والباحث، ثم استخدم البحث والفلاتر للوصول إلى العينة المطلوبة.</p>
102
+ </div>
103
+ <div class="privacy-pill">
104
+ <span class="status-dot"></span>
105
+ جلسة خاصة
106
+ </div>
107
+ </section>
108
+
109
+ <section class="controls-card">
110
+ <div class="filters-grid">
111
+ <div class="field-group">
112
+ <label for="supervisorSelect">اسم المشرف</label>
113
+ <div class="select-wrap">
114
+ <select id="supervisorSelect">
115
+ <option value="">اختر المشرف</option>
116
+ </select>
117
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg>
118
+ </div>
119
+ </div>
120
+
121
+ <div class="field-group">
122
+ <label for="researcherSelect">اسم الباحث</label>
123
+ <div class="select-wrap">
124
+ <select id="researcherSelect" disabled>
125
+ <option value="">اختر الباحث</option>
126
+ </select>
127
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg>
128
+ </div>
129
+ </div>
130
+
131
+ <div class="field-group">
132
+ <label for="statusSelect">حالة الاستيفاء</label>
133
+ <div class="select-wrap">
134
+ <select id="statusSelect" disabled>
135
+ <option value="">جميع الحالات</option>
136
+ </select>
137
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 10 5 5 5-5" /></svg>
138
+ </div>
139
+ </div>
140
+
141
+ <div class="field-group search-group">
142
+ <label for="searchInput">البحث الذكي</label>
143
+ <div class="search-wrap">
144
+ <svg viewBox="0 0 24 24" aria-hidden="true">
145
+ <circle cx="11" cy="11" r="7" />
146
+ <path d="m20 20-4-4" />
147
+ </svg>
148
+ <input
149
+ id="searchInput"
150
+ type="search"
151
+ placeholder="اسم العينة أو رقم السجل"
152
+ disabled
153
+ />
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </section>
158
+
159
+ <section id="emptyState" class="empty-state">
160
+ <div class="empty-icon">
161
+ <svg viewBox="0 0 48 48" aria-hidden="true">
162
+ <path d="M8 13h32v27H8z" />
163
+ <path d="M15 8h18v10H15zM15 26h18M15 33h11" />
164
+ </svg>
165
+ </div>
166
+ <h3>ابدأ باختيار المشرف</h3>
167
+ <p>لن تظهر أي بيانات للعينات حتى يتم اختيار المشرف والباحث.</p>
168
+ </section>
169
+
170
+ <section id="resultsSection" class="results-section" hidden>
171
+ <div class="results-header">
172
+ <div>
173
+ <p id="resultsMeta" class="results-meta"></p>
174
+ <h2 id="resultsTitle">العينات</h2>
175
+ </div>
176
+ <button id="copyAllButton" class="primary-button copy-all" type="button">
177
+ <svg viewBox="0 0 24 24" aria-hidden="true">
178
+ <rect x="8" y="8" width="11" height="11" rx="2" />
179
+ <path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
180
+ </svg>
181
+ نسخ جميع عينات الباحث
182
+ </button>
183
+ </div>
184
+
185
+ <div id="statusChips" class="status-chips" aria-label="ملخص الحالات"></div>
186
+ <div id="samplesGrid" class="samples-grid"></div>
187
+
188
+ <div id="noResults" class="no-results" hidden>
189
+ <h3>لا توجد نتائج مطابقة</h3>
190
+ <p>جرّب تغيير حالة الاستيفاء أو عبارة البحث.</p>
191
+ </div>
192
+ </section>
193
+ </div>
194
+ </section>
195
+ </main>
196
+
197
+ <footer id="footer">إعداد نوف الناصر</footer>
198
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
199
+
200
+ <script src="data.js"></script>
201
+ <script src="app.js"></script>
202
+ </body>
203
  </html>
style.css CHANGED
@@ -1,28 +1,798 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
 
 
 
 
 
 
9
  }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
16
  }
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
 
 
28
  }
 
1
+ :root {
2
+ --navy: #123c69;
3
+ --navy-dark: #0a2c50;
4
+ --blue: #1d659b;
5
+ --sky: #e9f4fb;
6
+ --teal: #12877d;
7
+ --teal-soft: #e8f6f3;
8
+ --ink: #172b3a;
9
+ --muted: #657786;
10
+ --line: #dce6ec;
11
+ --surface: #ffffff;
12
+ --page: #f4f8fa;
13
+ --danger: #b43d4f;
14
+ --shadow: 0 18px 50px rgba(15, 54, 82, 0.11);
15
+ }
16
+
17
+ * {
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ html {
22
+ min-height: 100%;
23
+ background: var(--page);
24
+ }
25
+
26
  body {
27
+ min-height: 100vh;
28
+ margin: 0;
29
+ color: var(--ink);
30
+ font-family: "Tajawal", Arial, sans-serif;
31
+ background:
32
+ radial-gradient(circle at 10% 10%, rgba(41, 151, 173, 0.12), transparent 30%),
33
+ linear-gradient(145deg, #f9fbfc 0%, #eff6f8 100%);
34
+ }
35
+
36
+ button,
37
+ input,
38
+ select {
39
+ font: inherit;
40
+ }
41
+
42
+ button {
43
+ cursor: pointer;
44
+ }
45
+
46
+ [hidden] {
47
+ display: none !important;
48
+ }
49
+
50
+ svg {
51
+ fill: none;
52
+ stroke: currentColor;
53
+ stroke-linecap: round;
54
+ stroke-linejoin: round;
55
+ stroke-width: 1.8;
56
+ }
57
+
58
+ .background-shape {
59
+ position: fixed;
60
+ z-index: -1;
61
+ border-radius: 999px;
62
+ filter: blur(2px);
63
+ pointer-events: none;
64
+ }
65
+
66
+ .shape-one {
67
+ width: 340px;
68
+ height: 340px;
69
+ top: -170px;
70
+ right: -100px;
71
+ background: rgba(29, 101, 155, 0.1);
72
+ }
73
+
74
+ .shape-two {
75
+ width: 280px;
76
+ height: 280px;
77
+ bottom: -150px;
78
+ left: -80px;
79
+ background: rgba(18, 135, 125, 0.1);
80
+ }
81
+
82
+ .login-view {
83
+ min-height: calc(100vh - 56px);
84
+ display: grid;
85
+ place-items: center;
86
+ padding: 32px 20px;
87
+ }
88
+
89
+ .login-card {
90
+ width: min(100%, 450px);
91
+ padding: 42px;
92
+ text-align: center;
93
+ background: rgba(255, 255, 255, 0.95);
94
+ border: 1px solid rgba(220, 230, 236, 0.9);
95
+ border-radius: 28px;
96
+ box-shadow: var(--shadow);
97
+ backdrop-filter: blur(18px);
98
+ }
99
+
100
+ .brand-mark {
101
+ width: 72px;
102
+ height: 72px;
103
+ display: grid;
104
+ place-items: center;
105
+ margin: 0 auto 22px;
106
+ color: white;
107
+ background: linear-gradient(145deg, var(--navy), var(--blue));
108
+ border-radius: 22px;
109
+ box-shadow: 0 14px 30px rgba(18, 60, 105, 0.25);
110
+ }
111
+
112
+ .brand-mark svg {
113
+ width: 43px;
114
+ height: 43px;
115
+ }
116
+
117
+ .brand-mark svg path:first-child {
118
+ fill: rgba(255, 255, 255, 0.08);
119
+ }
120
+
121
+ .brand-mark svg path:last-child {
122
+ stroke-width: 3;
123
+ }
124
+
125
+ .brand-mark.small {
126
+ width: 48px;
127
+ height: 48px;
128
+ margin: 0;
129
+ border-radius: 15px;
130
+ box-shadow: none;
131
+ }
132
+
133
+ .brand-mark.small svg {
134
+ width: 29px;
135
+ height: 29px;
136
+ }
137
+
138
+ .eyebrow {
139
+ margin: 0 0 8px;
140
+ color: var(--teal);
141
+ font-size: 13px;
142
+ font-weight: 800;
143
+ letter-spacing: 0.04em;
144
+ }
145
+
146
+ .login-card h1 {
147
+ margin: 0;
148
+ color: var(--navy-dark);
149
+ font-size: clamp(27px, 5vw, 34px);
150
+ line-height: 1.35;
151
+ }
152
+
153
+ .login-description {
154
+ max-width: 340px;
155
+ margin: 12px auto 28px;
156
+ color: var(--muted);
157
+ font-size: 15px;
158
+ line-height: 1.8;
159
+ }
160
+
161
+ .login-form {
162
+ text-align: right;
163
+ }
164
+
165
+ .login-form label,
166
+ .field-group label {
167
+ display: block;
168
+ margin-bottom: 8px;
169
+ color: #314b5e;
170
+ font-size: 14px;
171
+ font-weight: 700;
172
+ }
173
+
174
+ .password-field,
175
+ .search-wrap,
176
+ .select-wrap {
177
+ position: relative;
178
+ }
179
+
180
+ .password-field input,
181
+ .search-wrap input,
182
+ .select-wrap select {
183
+ width: 100%;
184
+ height: 52px;
185
+ color: var(--ink);
186
+ background: #fbfdfe;
187
+ border: 1px solid var(--line);
188
+ border-radius: 14px;
189
+ outline: none;
190
+ transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
191
+ }
192
+
193
+ .password-field input {
194
+ padding: 0 16px 0 50px;
195
+ }
196
+
197
+ .password-field input:focus,
198
+ .search-wrap input:focus,
199
+ .select-wrap select:focus {
200
+ background: white;
201
+ border-color: var(--blue);
202
+ box-shadow: 0 0 0 4px rgba(29, 101, 155, 0.1);
203
+ }
204
+
205
+ .icon-button {
206
+ position: absolute;
207
+ top: 50%;
208
+ left: 8px;
209
+ width: 38px;
210
+ height: 38px;
211
+ display: grid;
212
+ place-items: center;
213
+ padding: 0;
214
+ color: var(--muted);
215
+ background: transparent;
216
+ border: 0;
217
+ border-radius: 10px;
218
+ transform: translateY(-50%);
219
+ }
220
+
221
+ .icon-button:hover {
222
+ color: var(--navy);
223
+ background: var(--sky);
224
+ }
225
+
226
+ .icon-button svg {
227
+ width: 22px;
228
+ height: 22px;
229
+ }
230
+
231
+ .form-error {
232
+ min-height: 22px;
233
+ margin: 7px 2px 5px;
234
+ color: var(--danger);
235
+ font-size: 13px;
236
+ font-weight: 600;
237
+ }
238
+
239
+ .primary-button,
240
+ .secondary-button {
241
+ display: inline-flex;
242
+ align-items: center;
243
+ justify-content: center;
244
+ gap: 9px;
245
+ min-height: 48px;
246
+ padding: 0 20px;
247
+ font-weight: 700;
248
+ border-radius: 13px;
249
+ transition: transform 0.18s, box-shadow 0.18s, background 0.18s;
250
+ }
251
+
252
+ .primary-button {
253
+ color: white;
254
+ background: linear-gradient(135deg, var(--navy), var(--blue));
255
+ border: 0;
256
+ box-shadow: 0 9px 22px rgba(18, 60, 105, 0.2);
257
+ }
258
+
259
+ .login-form .primary-button {
260
+ width: 100%;
261
+ }
262
+
263
+ .primary-button:hover {
264
+ transform: translateY(-1px);
265
+ box-shadow: 0 12px 25px rgba(18, 60, 105, 0.26);
266
+ }
267
+
268
+ .primary-button:disabled {
269
+ cursor: wait;
270
+ opacity: 0.65;
271
+ transform: none;
272
+ }
273
+
274
+ .primary-button svg,
275
+ .secondary-button svg {
276
+ width: 20px;
277
+ height: 20px;
278
+ }
279
+
280
+ .security-note {
281
+ display: flex;
282
+ align-items: center;
283
+ justify-content: center;
284
+ gap: 8px;
285
+ margin-top: 22px;
286
+ color: var(--muted);
287
+ font-size: 12px;
288
+ }
289
+
290
+ .security-note svg {
291
+ width: 17px;
292
+ height: 17px;
293
+ color: var(--teal);
294
+ }
295
+
296
+ .topbar {
297
+ min-height: 78px;
298
+ display: flex;
299
+ align-items: center;
300
+ justify-content: space-between;
301
+ padding: 13px max(20px, calc((100% - 1240px) / 2));
302
+ background: rgba(255, 255, 255, 0.92);
303
+ border-bottom: 1px solid var(--line);
304
+ box-shadow: 0 5px 22px rgba(27, 64, 90, 0.05);
305
+ backdrop-filter: blur(12px);
306
+ }
307
+
308
+ .topbar-brand {
309
+ display: flex;
310
+ align-items: center;
311
+ gap: 13px;
312
+ }
313
+
314
+ .topbar-brand p {
315
+ margin: 0 0 2px;
316
+ color: var(--teal);
317
+ font-size: 11px;
318
+ font-weight: 700;
319
+ }
320
+
321
+ .topbar-brand h1 {
322
+ margin: 0;
323
+ color: var(--navy-dark);
324
+ font-size: 20px;
325
+ }
326
+
327
+ .secondary-button {
328
+ color: var(--navy);
329
+ background: white;
330
+ border: 1px solid var(--line);
331
+ }
332
+
333
+ .secondary-button:hover {
334
+ background: var(--sky);
335
+ border-color: #b9d4e4;
336
+ }
337
+
338
+ .secondary-button.compact {
339
+ min-height: 42px;
340
+ padding: 0 15px;
341
+ }
342
+
343
+ .dashboard-shell {
344
+ width: min(1240px, calc(100% - 40px));
345
+ min-height: calc(100vh - 134px);
346
+ margin: 28px auto 44px;
347
+ }
348
+
349
+ .welcome-panel {
350
+ display: flex;
351
+ align-items: center;
352
+ justify-content: space-between;
353
+ gap: 24px;
354
+ padding: 24px 28px;
355
+ color: white;
356
+ background:
357
+ linear-gradient(115deg, rgba(10, 44, 80, 0.98), rgba(29, 101, 155, 0.92)),
358
+ var(--navy);
359
+ border-radius: 22px;
360
+ box-shadow: 0 14px 32px rgba(18, 60, 105, 0.17);
361
+ }
362
+
363
+ .welcome-panel .eyebrow {
364
+ color: #91dfd6;
365
+ }
366
+
367
+ .welcome-panel h2 {
368
+ margin: 0 0 5px;
369
+ font-size: clamp(21px, 3vw, 28px);
370
+ }
371
+
372
+ .welcome-panel > div > p:last-child {
373
+ margin: 0;
374
+ color: rgba(255, 255, 255, 0.77);
375
+ font-size: 14px;
376
  }
377
 
378
+ .privacy-pill {
379
+ flex: 0 0 auto;
380
+ display: flex;
381
+ align-items: center;
382
+ gap: 8px;
383
+ padding: 10px 14px;
384
+ color: #d9fff9;
385
+ font-size: 13px;
386
+ font-weight: 700;
387
+ background: rgba(255, 255, 255, 0.11);
388
+ border: 1px solid rgba(255, 255, 255, 0.18);
389
+ border-radius: 999px;
390
  }
391
 
392
+ .status-dot {
393
+ width: 8px;
394
+ height: 8px;
395
+ background: #68ebce;
396
+ border-radius: 50%;
397
+ box-shadow: 0 0 0 5px rgba(104, 235, 206, 0.14);
398
  }
399
 
400
+ .controls-card {
401
+ margin-top: 18px;
402
+ padding: 22px;
403
+ background: var(--surface);
404
+ border: 1px solid var(--line);
405
+ border-radius: 20px;
406
+ box-shadow: 0 10px 26px rgba(24, 66, 91, 0.06);
407
+ }
408
+
409
+ .filters-grid {
410
+ display: grid;
411
+ grid-template-columns: repeat(3, minmax(180px, 1fr)) minmax(220px, 1.2fr);
412
+ gap: 16px;
413
+ }
414
+
415
+ .select-wrap select {
416
+ padding: 0 15px 0 42px;
417
+ appearance: none;
418
+ }
419
+
420
+ .select-wrap > svg {
421
+ position: absolute;
422
+ top: 50%;
423
+ left: 15px;
424
+ width: 19px;
425
+ color: var(--muted);
426
+ transform: translateY(-50%);
427
+ pointer-events: none;
428
+ }
429
+
430
+ .search-wrap input {
431
+ padding: 0 44px 0 14px;
432
+ }
433
+
434
+ .search-wrap > svg {
435
+ position: absolute;
436
+ top: 50%;
437
+ right: 15px;
438
+ width: 20px;
439
+ color: var(--muted);
440
+ transform: translateY(-50%);
441
+ }
442
+
443
+ select:disabled,
444
+ input:disabled {
445
+ cursor: not-allowed;
446
+ color: #9aa8b2;
447
+ background: #f3f6f7;
448
+ }
449
+
450
+ .empty-state,
451
+ .no-results {
452
+ margin-top: 18px;
453
+ padding: 64px 24px;
454
+ text-align: center;
455
+ background: rgba(255, 255, 255, 0.7);
456
+ border: 1px dashed #bfd0da;
457
+ border-radius: 20px;
458
+ }
459
+
460
+ .empty-icon {
461
+ width: 72px;
462
+ height: 72px;
463
+ display: grid;
464
+ place-items: center;
465
+ margin: 0 auto 16px;
466
+ color: var(--blue);
467
+ background: var(--sky);
468
+ border-radius: 22px;
469
+ }
470
+
471
+ .empty-icon svg {
472
+ width: 39px;
473
+ height: 39px;
474
+ }
475
+
476
+ .empty-state h3,
477
+ .no-results h3 {
478
+ margin: 0 0 7px;
479
+ color: var(--navy-dark);
480
+ font-size: 20px;
481
+ }
482
+
483
+ .empty-state p,
484
+ .no-results p {
485
+ margin: 0;
486
+ color: var(--muted);
487
+ }
488
+
489
+ .results-section {
490
+ margin-top: 24px;
491
+ }
492
+
493
+ .results-header {
494
+ display: flex;
495
+ align-items: center;
496
+ justify-content: space-between;
497
+ gap: 18px;
498
+ margin-bottom: 14px;
499
+ }
500
+
501
+ .results-meta {
502
+ margin: 0 0 4px;
503
+ color: var(--teal);
504
+ font-size: 13px;
505
+ font-weight: 800;
506
+ }
507
+
508
+ .results-header h2 {
509
+ margin: 0;
510
+ color: var(--navy-dark);
511
+ font-size: 24px;
512
+ }
513
+
514
+ .copy-all {
515
+ min-height: 44px;
516
+ background: var(--teal);
517
+ box-shadow: 0 8px 20px rgba(18, 135, 125, 0.18);
518
+ }
519
+
520
+ .status-chips {
521
+ display: flex;
522
+ flex-wrap: wrap;
523
+ gap: 8px;
524
+ margin-bottom: 17px;
525
+ }
526
+
527
+ .status-chip {
528
+ padding: 7px 11px;
529
+ color: #466071;
530
+ font-size: 12px;
531
+ font-weight: 700;
532
+ background: white;
533
+ border: 1px solid var(--line);
534
+ border-radius: 999px;
535
+ }
536
+
537
+ .status-chip strong {
538
+ margin-right: 5px;
539
+ color: var(--navy);
540
+ }
541
+
542
+ .samples-grid {
543
+ display: grid;
544
+ grid-template-columns: repeat(2, minmax(0, 1fr));
545
+ gap: 16px;
546
+ }
547
+
548
+ .sample-card {
549
+ overflow: hidden;
550
+ background: white;
551
+ border: 1px solid var(--line);
552
+ border-radius: 19px;
553
+ box-shadow: 0 8px 24px rgba(26, 63, 87, 0.055);
554
+ transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
555
+ }
556
+
557
+ .sample-card:hover {
558
+ border-color: #b9d4e4;
559
+ box-shadow: 0 13px 30px rgba(26, 63, 87, 0.1);
560
+ transform: translateY(-2px);
561
+ }
562
+
563
+ .sample-card-header {
564
+ display: flex;
565
+ align-items: flex-start;
566
+ justify-content: space-between;
567
+ gap: 14px;
568
+ padding: 18px 20px 15px;
569
+ background: linear-gradient(180deg, #fbfdfe, white);
570
+ border-bottom: 1px solid #edf2f5;
571
+ }
572
+
573
+ .sample-index {
574
+ display: inline-block;
575
+ margin-bottom: 5px;
576
+ color: var(--teal);
577
+ font-size: 11px;
578
+ font-weight: 800;
579
+ }
580
+
581
+ .sample-card h3 {
582
+ margin: 0;
583
+ color: var(--navy-dark);
584
+ font-size: 17px;
585
+ line-height: 1.65;
586
+ }
587
+
588
+ .status-badge {
589
+ flex: 0 0 auto;
590
+ max-width: 155px;
591
+ padding: 7px 10px;
592
+ color: #8f4f16;
593
+ font-size: 11px;
594
+ font-weight: 800;
595
+ line-height: 1.4;
596
+ text-align: center;
597
+ background: #fff4e7;
598
+ border: 1px solid #f4d3aa;
599
+ border-radius: 999px;
600
+ }
601
+
602
+ .sample-details {
603
+ display: grid;
604
+ grid-template-columns: repeat(2, minmax(0, 1fr));
605
+ gap: 0;
606
+ padding: 4px 20px;
607
+ }
608
+
609
+ .detail-item {
610
+ min-width: 0;
611
+ padding: 13px 0;
612
+ border-bottom: 1px solid #edf2f5;
613
+ }
614
+
615
+ .detail-item:nth-child(odd) {
616
+ padding-left: 14px;
617
+ border-left: 1px solid #edf2f5;
618
+ }
619
+
620
+ .detail-item:nth-child(even) {
621
+ padding-right: 14px;
622
+ }
623
+
624
+ .detail-label {
625
+ display: block;
626
+ margin-bottom: 5px;
627
+ color: var(--muted);
628
+ font-size: 11px;
629
+ font-weight: 700;
630
+ }
631
+
632
+ .detail-value {
633
+ display: block;
634
+ overflow-wrap: anywhere;
635
+ color: #233d4f;
636
+ font-size: 13px;
637
+ font-weight: 600;
638
+ line-height: 1.6;
639
+ }
640
+
641
+ .contact-detail .detail-value {
642
+ color: var(--blue);
643
+ }
644
+
645
+ .sample-card-footer {
646
+ padding: 14px 20px 18px;
647
+ }
648
+
649
+ .copy-sample {
650
+ width: 100%;
651
+ min-height: 42px;
652
+ color: var(--navy);
653
+ font-weight: 800;
654
+ background: var(--sky);
655
+ border: 1px solid #cde2ee;
656
+ border-radius: 12px;
657
+ }
658
+
659
+ .copy-sample:hover {
660
+ color: white;
661
+ background: var(--navy);
662
+ border-color: var(--navy);
663
+ }
664
+
665
+ .no-results {
666
+ margin-top: 0;
667
+ }
668
+
669
+ footer {
670
+ min-height: 56px;
671
+ display: flex;
672
+ align-items: center;
673
+ justify-content: center;
674
+ color: #70828f;
675
+ font-size: 13px;
676
+ font-weight: 600;
677
+ }
678
+
679
+ .toast {
680
+ position: fixed;
681
+ z-index: 30;
682
+ right: 50%;
683
+ bottom: 24px;
684
+ padding: 12px 18px;
685
+ color: white;
686
+ font-size: 14px;
687
+ font-weight: 700;
688
+ background: #173b52;
689
+ border-radius: 12px;
690
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
691
+ opacity: 0;
692
+ transform: translate(50%, 20px);
693
+ transition: opacity 0.25s, transform 0.25s;
694
+ pointer-events: none;
695
+ }
696
+
697
+ .toast.show {
698
+ opacity: 1;
699
+ transform: translate(50%, 0);
700
+ }
701
+
702
+ @media (max-width: 980px) {
703
+ .filters-grid {
704
+ grid-template-columns: repeat(2, minmax(0, 1fr));
705
+ }
706
+ }
707
+
708
+ @media (max-width: 760px) {
709
+ .dashboard-shell {
710
+ width: min(100% - 24px, 1240px);
711
+ margin-top: 14px;
712
+ }
713
+
714
+ .welcome-panel,
715
+ .results-header {
716
+ align-items: stretch;
717
+ flex-direction: column;
718
+ }
719
+
720
+ .privacy-pill {
721
+ align-self: flex-start;
722
+ }
723
+
724
+ .samples-grid {
725
+ grid-template-columns: 1fr;
726
+ }
727
+
728
+ .copy-all {
729
+ width: 100%;
730
+ }
731
+ }
732
+
733
+ @media (max-width: 560px) {
734
+ .login-card {
735
+ padding: 32px 22px;
736
+ border-radius: 22px;
737
+ }
738
+
739
+ .topbar {
740
+ padding: 11px 14px;
741
+ }
742
+
743
+ .topbar-brand p {
744
+ display: none;
745
+ }
746
+
747
+ .topbar-brand h1 {
748
+ font-size: 16px;
749
+ }
750
+
751
+ .brand-mark.small {
752
+ width: 42px;
753
+ height: 42px;
754
+ }
755
+
756
+ .secondary-button.compact {
757
+ padding: 0 12px;
758
+ font-size: 13px;
759
+ }
760
+
761
+ .welcome-panel,
762
+ .controls-card {
763
+ padding: 19px;
764
+ border-radius: 17px;
765
+ }
766
+
767
+ .filters-grid {
768
+ grid-template-columns: 1fr;
769
+ }
770
+
771
+ .sample-card-header {
772
+ flex-direction: column;
773
+ }
774
+
775
+ .status-badge {
776
+ max-width: 100%;
777
+ }
778
+
779
+ .sample-details {
780
+ grid-template-columns: 1fr;
781
+ }
782
+
783
+ .detail-item:nth-child(odd),
784
+ .detail-item:nth-child(even) {
785
+ padding-right: 0;
786
+ padding-left: 0;
787
+ border-left: 0;
788
+ }
789
  }
790
 
791
+ @media (prefers-reduced-motion: reduce) {
792
+ *,
793
+ *::before,
794
+ *::after {
795
+ scroll-behavior: auto !important;
796
+ transition: none !important;
797
+ }
798
  }