stat2025 commited on
Commit
4d77499
·
verified ·
1 Parent(s): bde5c1c

Upload 4 files

Browse files
Files changed (4) hide show
  1. Code.gs +235 -0
  2. app.js +1 -6
  3. index.html +2 -2
  4. style.css +11 -0
Code.gs ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const CONFIG = Object.freeze({
2
+ folderId: "1QBsYtQtUp5-uhqjDRugvxHZinozIu6YY",
3
+ spreadsheetId: "1_LiYMMphsJaRRJje0pxKrNSiC1ahBS3GnHM01vPrrCk",
4
+ sheetName: "الورقة1",
5
+ timezone: "Asia/Riyadh",
6
+ maxPhotos: 3,
7
+ maxPhotoBytes: 3 * 1024 * 1024,
8
+ });
9
+
10
+ const HEADERS = [
11
+ "رقم التوثيق",
12
+ "تاريخ ووقت التسجيل",
13
+ "اسم الباحث/ة",
14
+ "اسم المنشأة",
15
+ "السجل التجاري",
16
+ "رقم العقد",
17
+ "المدينة الصناعية",
18
+ "حالة التوثيق",
19
+ "الإفادة الميدانية",
20
+ "روابط الصور",
21
+ "رابط مجلد المنشأة",
22
+ "عدد الصور",
23
+ "مفتاح العينة",
24
+ ];
25
+
26
+ function doGet(event) {
27
+ try {
28
+ const params = (event && event.parameter) || {};
29
+ const action = String(params.action || "health");
30
+ if (action === "health") {
31
+ return jsonResponse({ ok: true, service: "ICS2 Documentation", version: 2 });
32
+ }
33
+
34
+ if (action === "status") {
35
+ validateResearcherAccess(params.accessCode);
36
+ return jsonResponse({
37
+ ok: true,
38
+ generatedAt: new Date().toISOString(),
39
+ records: getDocumentationRecords(false),
40
+ });
41
+ }
42
+
43
+ if (action === "admin") {
44
+ validateSupervisorAccess(params.accessCode);
45
+ return jsonResponse({
46
+ ok: true,
47
+ generatedAt: new Date().toISOString(),
48
+ timezone: CONFIG.timezone,
49
+ records: getDocumentationRecords(true),
50
+ });
51
+ }
52
+
53
+ throw new Error("الطلب غير معروف.");
54
+ } catch (error) {
55
+ return jsonResponse({ ok: false, error: String(error.message || error) });
56
+ }
57
+ }
58
+
59
+ function authorizeService() {
60
+ DriveApp.getFolderById(CONFIG.folderId).getName();
61
+ SpreadsheetApp.openById(CONFIG.spreadsheetId).getName();
62
+ return "AUTHORIZED";
63
+ }
64
+
65
+ function doPost(event) {
66
+ const lock = LockService.getScriptLock();
67
+ try {
68
+ lock.waitLock(20000);
69
+ const request = JSON.parse(event.postData.contents || "{}");
70
+ validateResearcherAccess(request.accessCode);
71
+ validateRequest(request);
72
+
73
+ const sheet = getSheet();
74
+ ensureHeaders(sheet);
75
+ const existingIds = sheet
76
+ .getRange(1, 1, Math.max(sheet.getLastRow(), 1), 1)
77
+ .getDisplayValues()
78
+ .flat();
79
+ if (existingIds.includes(request.documentationId)) {
80
+ return jsonResponse({ ok: true, duplicate: true, documentationId: request.documentationId });
81
+ }
82
+
83
+ const folder = getEvidenceFolder(request);
84
+ const photoUrls = request.photos.map((photo, index) => savePhoto(folder, photo, request, index));
85
+ sheet.appendRow([
86
+ request.documentationId,
87
+ new Date(),
88
+ safeText(request.researcher),
89
+ safeText(request.establishmentName),
90
+ safeText(request.commercialRecord),
91
+ safeText(request.contractNumber) || "لا يوجد رقم عقد",
92
+ safeText(request.city),
93
+ safeText(request.fieldStatus),
94
+ safeText(request.statement),
95
+ photoUrls.join("\n"),
96
+ folder.getUrl(),
97
+ photoUrls.length,
98
+ safeText(request.sampleKey),
99
+ ]);
100
+
101
+ return jsonResponse({
102
+ ok: true,
103
+ documentationId: request.documentationId,
104
+ sampleKey: safeText(request.sampleKey),
105
+ documentedAt: new Date().toISOString(),
106
+ folderUrl: folder.getUrl(),
107
+ photoUrls,
108
+ });
109
+ } catch (error) {
110
+ return jsonResponse({ ok: false, error: String(error.message || error) });
111
+ } finally {
112
+ try {
113
+ lock.releaseLock();
114
+ } catch (_) {}
115
+ }
116
+ }
117
+
118
+ function validateResearcherAccess(accessCode) {
119
+ const expected = PropertiesService.getScriptProperties().getProperty("ACCESS_CODE") || "20302030";
120
+ if (String(accessCode || "") !== expected) throw new Error("غير مصرح بالوصول.");
121
+ }
122
+
123
+ function validateSupervisorAccess(accessCode) {
124
+ const expected = PropertiesService.getScriptProperties().getProperty("SUPERVISOR_CODE") || "1448";
125
+ if (String(accessCode || "") !== expected) throw new Error("غير مصرح بالوصول.");
126
+ }
127
+
128
+ function validateRequest(request) {
129
+ if (!request.documentationId) throw new Error("رقم التوثيق مفقود.");
130
+ if (!safeText(request.sampleKey)) throw new Error("مفتاح العينة مفقود.");
131
+ if (!safeText(request.researcher)) throw new Error("اسم الباحث مفقود.");
132
+ if (!safeText(request.establishmentName)) throw new Error("اسم المنشأة مفقود.");
133
+ if (!safeText(request.fieldStatus)) throw new Error("حالة التوثيق مطلوبة.");
134
+ if (!safeText(request.statement)) throw new Error("الإفادة الميدانية مطلوبة.");
135
+ if (!Array.isArray(request.photos)) request.photos = [];
136
+ if (request.photos.length > CONFIG.maxPhotos) throw new Error("الحد الأعلى 3 صور.");
137
+ }
138
+
139
+ function getDocumentationRecords(includeDetails) {
140
+ const sheet = getSheet();
141
+ ensureHeaders(sheet);
142
+ const lastRow = sheet.getLastRow();
143
+ if (lastRow < 2) return [];
144
+
145
+ const values = sheet.getRange(2, 1, lastRow - 1, HEADERS.length).getValues();
146
+ return values
147
+ .filter((row) => row.some((cell) => cell !== ""))
148
+ .map((row) => {
149
+ const date = row[1] instanceof Date ? row[1] : new Date(row[1]);
150
+ const validDate = !Number.isNaN(date.getTime());
151
+ const base = {
152
+ documentationId: safeText(row[0]),
153
+ documentedAt: validDate ? date.toISOString() : "",
154
+ documentedDate: validDate ? Utilities.formatDate(date, CONFIG.timezone, "yyyy-MM-dd") : "",
155
+ researcher: safeText(row[2]),
156
+ establishmentName: safeText(row[3]),
157
+ commercialRecord: safeText(row[4]),
158
+ contractNumber: safeText(row[5]),
159
+ city: safeText(row[6]),
160
+ fieldStatus: safeText(row[7]),
161
+ photoCount: Number(row[11]) || splitUrls(row[9]).length,
162
+ sampleKey: safeText(row[12]),
163
+ };
164
+ if (!includeDetails) return base;
165
+ return Object.assign(base, {
166
+ statement: safeText(row[8]),
167
+ photoUrls: splitUrls(row[9]),
168
+ folderUrl: safeText(row[10]),
169
+ });
170
+ })
171
+ .sort((a, b) => String(b.documentedAt).localeCompare(String(a.documentedAt)));
172
+ }
173
+
174
+ function splitUrls(value) {
175
+ return safeText(value).split(/\s+/).filter((item) => /^https?:\/\//.test(item));
176
+ }
177
+
178
+ function getSheet() {
179
+ const spreadsheet = SpreadsheetApp.openById(CONFIG.spreadsheetId);
180
+ return spreadsheet.getSheetByName(CONFIG.sheetName) || spreadsheet.insertSheet(CONFIG.sheetName);
181
+ }
182
+
183
+ function ensureHeaders(sheet) {
184
+ const current = sheet.getRange(1, 1, 1, HEADERS.length).getDisplayValues()[0];
185
+ if (current.join("") === "") {
186
+ sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
187
+ sheet.setFrozenRows(1);
188
+ sheet.getRange(1, 1, 1, HEADERS.length)
189
+ .setFontWeight("bold")
190
+ .setBackground("#4137A8")
191
+ .setFontColor("#FFFFFF");
192
+ sheet.autoResizeColumns(1, HEADERS.length);
193
+ return;
194
+ }
195
+ if (!current[12]) sheet.getRange(1, 13).setValue(HEADERS[12]);
196
+ }
197
+
198
+ function getEvidenceFolder(request) {
199
+ const root = DriveApp.getFolderById(CONFIG.folderId);
200
+ const researcherFolder = getOrCreateFolder(root, safeFileName(request.researcher));
201
+ const cityFolder = getOrCreateFolder(researcherFolder, safeFileName(request.city || "مدينة غير محددة"));
202
+ const entityName = `${safeText(request.commercialRecord) || "دون سجل"} - ${safeText(request.establishmentName)}`;
203
+ return getOrCreateFolder(cityFolder, safeFileName(entityName));
204
+ }
205
+
206
+ function getOrCreateFolder(parent, name) {
207
+ const folders = parent.getFoldersByName(name);
208
+ return folders.hasNext() ? folders.next() : parent.createFolder(name);
209
+ }
210
+
211
+ function savePhoto(folder, photo, request, index) {
212
+ const mimeType = String(photo.mimeType || "");
213
+ if (!/^image\/(jpeg|png|webp)$/.test(mimeType)) throw new Error("نوع الصورة غير مدعوم.");
214
+ const bytes = Utilities.base64Decode(String(photo.base64 || ""));
215
+ if (bytes.length > CONFIG.maxPhotoBytes) throw new Error("إحدى الصور أكبر من الحد المسموح.");
216
+ const extension = mimeType === "image/png" ? "png" : mimeType === "image/webp" ? "webp" : "jpg";
217
+ const timestamp = Utilities.formatDate(new Date(), CONFIG.timezone, "yyyyMMdd-HHmmss");
218
+ const name = safeFileName(
219
+ `${request.commercialRecord || "دون-سجل"}-${timestamp}-${index + 1}.${extension}`,
220
+ );
221
+ return folder.createFile(Utilities.newBlob(bytes, mimeType, name)).getUrl();
222
+ }
223
+
224
+ function safeText(value) {
225
+ return String(value == null ? "" : value).trim().slice(0, 2000);
226
+ }
227
+
228
+ function safeFileName(value) {
229
+ return safeText(value).replace(/[\\/:*?"<>|#%{}[\]]/g, "-").slice(0, 140) || "غير محدد";
230
+ }
231
+
232
+ function jsonResponse(payload) {
233
+ return ContentService.createTextOutput(JSON.stringify(payload))
234
+ .setMimeType(ContentService.MimeType.JSON);
235
+ }
app.js CHANGED
@@ -463,7 +463,7 @@ function renderAdminRecords() {
463
  const statusBadge = document.createElement("span");
464
  statusBadge.textContent = record.fieldStatus || "غير محدد";
465
  const photoBadge = document.createElement("span");
466
- photoBadge.textContent = `${record.photoCount || 0} صور`;
467
  badges.append(statusBadge, photoBadge);
468
  const details = document.createElement("div");
469
  details.className = "admin-record-details";
@@ -1602,11 +1602,6 @@ elements.documentationForm.addEventListener("submit", async (event) => {
1602
  elements.fieldStatement.focus();
1603
  return;
1604
  }
1605
- if (!documentationFiles.length) {
1606
- elements.documentationError.textContent = "أرفق صورة واحدة على الأقل.";
1607
- return;
1608
- }
1609
-
1610
  elements.saveDocumentationButton.disabled = true;
1611
  elements.saveDocumentationButton.textContent = "جارٍ الحفظ...";
1612
  elements.uploadProgress.hidden = false;
 
463
  const statusBadge = document.createElement("span");
464
  statusBadge.textContent = record.fieldStatus || "غير محدد";
465
  const photoBadge = document.createElement("span");
466
+ photoBadge.textContent = record.photoCount ? `${record.photoCount} صور` : "بدون صور";
467
  badges.append(statusBadge, photoBadge);
468
  const details = document.createElement("div");
469
  details.className = "admin-record-details";
 
1602
  elements.fieldStatement.focus();
1603
  return;
1604
  }
 
 
 
 
 
1605
  elements.saveDocumentationButton.disabled = true;
1606
  elements.saveDocumentationButton.textContent = "جارٍ الحفظ...";
1607
  elements.uploadProgress.hidden = false;
index.html CHANGED
@@ -455,11 +455,11 @@
455
  </label>
456
 
457
  <div class="dialog-field">
458
- <span>الصور المرفقة</span>
459
  <label class="photo-picker" for="documentationPhotos">
460
  <svg viewBox="0 0 24 24"><path d="M4 7h3l2-3h6l2 3h3v13H4V7Z M12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" /></svg>
461
  <strong>التقاط أو اختيار الصور</strong>
462
- <small>من صورة واحدة إلى 3 صور، وسيتم ضغطها قبل الرفع.</small>
463
  </label>
464
  <input id="documentationPhotos" class="visually-hidden" type="file" accept="image/*" multiple />
465
  <div id="photoPreviews" class="photo-previews"></div>
 
455
  </label>
456
 
457
  <div class="dialog-field">
458
+ <span>الصور المرفقة <small class="optional-label">اختياري</small></span>
459
  <label class="photo-picker" for="documentationPhotos">
460
  <svg viewBox="0 0 24 24"><path d="M4 7h3l2-3h6l2 3h3v13H4V7Z M12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" /></svg>
461
  <strong>التقاط أو اختيار الصور</strong>
462
+ <small>يمكن الحفظ دون صورة، أو إرفاق حتى 3 صور عند الحاجة.</small>
463
  </label>
464
  <input id="documentationPhotos" class="visually-hidden" type="file" accept="image/*" multiple />
465
  <div id="photoPreviews" class="photo-previews"></div>
style.css CHANGED
@@ -1286,6 +1286,17 @@ footer {
1286
  font-size: 12px;
1287
  }
1288
 
 
 
 
 
 
 
 
 
 
 
 
1289
  .phone-input-shell {
1290
  min-height: 52px;
1291
  display: flex;
 
1286
  font-size: 12px;
1287
  }
1288
 
1289
+ .optional-label {
1290
+ display: inline-flex;
1291
+ margin-right: 5px;
1292
+ padding: 2px 6px;
1293
+ color: #526174;
1294
+ background: #eef3f6;
1295
+ border-radius: 4px;
1296
+ font-size: 10px;
1297
+ font-weight: 800;
1298
+ }
1299
+
1300
  .phone-input-shell {
1301
  min-height: 52px;
1302
  display: flex;