@7365admin1/layer-common 3.0.6 → 3.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,39 @@
14
14
  </v-row>
15
15
  </v-toolbar>
16
16
 
17
+ <!-- Submission metadata bar (readonly only) -->
18
+ <div
19
+ v-if="readonly && (submissionStatus)"
20
+ class="px-6 py-2 d-flex align-center ga-4"
21
+ style="background: #f5f5f5; border-bottom: 1px solid #e0e0e0; flex-wrap: wrap"
22
+ >
23
+ <v-chip
24
+ v-if="submissionStatus"
25
+ :color="submissionStatusColor"
26
+ variant="flat"
27
+ size="x-small"
28
+ class="text-capitalize"
29
+ >{{ submissionStatus }}</v-chip>
30
+ <template v-if="requiresPayment">
31
+ <v-chip
32
+ v-if="initialManagementValues?.paymentStatus"
33
+ color="green"
34
+ variant="flat"
35
+ size="x-small"
36
+ >Payment {{ initialManagementValues.paymentStatus }}</v-chip>
37
+ <v-chip
38
+ v-else
39
+ color="orange"
40
+ variant="flat"
41
+ size="x-small"
42
+ >Payment Not Paid</v-chip>
43
+ <span
44
+ v-if="initialManagementValues?.paymentOrderId"
45
+ class="text-caption text-medium-emphasis"
46
+ >Order: {{ initialManagementValues.paymentOrderId }}</span>
47
+ </template>
48
+ </div>
49
+
17
50
  <!-- Scrollable body -->
18
51
  <v-card-text
19
52
  style="max-height: 76vh; overflow-y: auto"
@@ -31,12 +64,12 @@
31
64
  <v-row no-gutters>
32
65
  <v-col cols="5">
33
66
  <span class="text-body-2">
34
- <strong>Date&nbsp;:&nbsp;</strong>{{ todayDate }}
67
+ <strong>Date&nbsp;:&nbsp;</strong>{{ (initialValues as any).formDate || submissionDate || todayDate }}
35
68
  </span>
36
69
  </v-col>
37
70
  <v-col cols="7">
38
71
  <span class="text-body-2">
39
- <strong>To&nbsp;&nbsp;&nbsp;:&nbsp;</strong>{{ resolveText(block.to) }}
72
+ <strong>To&nbsp;&nbsp;&nbsp;:&nbsp;</strong>{{ (initialValues as any).formTo || resolveText(block.to) }}
40
73
  </span>
41
74
  </v-col>
42
75
  </v-row>
@@ -220,12 +253,28 @@
220
253
  </div>
221
254
  <div
222
255
  v-else-if="block.type === 'signature-row' && readonly"
223
- class="mt-4 mb-2"
256
+ class="mt-3 mb-5"
224
257
  >
258
+ <span class="text-caption text-medium-emphasis d-block mb-1">{{ block.label }}</span>
225
259
  <div
260
+ v-if="initialValues[block.key]"
261
+ style="
262
+ display: inline-block;
263
+ border-radius: 8px;
264
+ background: #111111;
265
+ padding: 8px;
266
+ max-width: 100%;
267
+ "
268
+ >
269
+ <img
270
+ :src="initialValues[block.key]"
271
+ style="display: block; max-width: 100%; max-height: 130px; object-fit: contain;"
272
+ />
273
+ </div>
274
+ <div
275
+ v-else
226
276
  style="border-bottom: 1px dashed #bbb; height: 56px; background: #fafafa; border-radius: 4px"
227
277
  />
228
- <span class="text-caption text-medium-emphasis">{{ block.label }}</span>
229
278
  </div>
230
279
 
231
280
  <!-- ACKNOWLEDGEMENT -->
@@ -237,27 +286,38 @@
237
286
  <span class="text-body-2 font-weight-medium">{{ resolveText(block.text) }}</span>
238
287
  </div>
239
288
 
240
- <!-- MANAGEMENT USE (read-only, shown at bottom) -->
289
+ <!-- MANAGEMENT USE -->
241
290
  <div
242
291
  v-else-if="block.type === 'management-use'"
243
292
  class="pa-4 mt-2"
244
- style="background: #f0f0f0; border-radius: 6px; opacity: 0.75"
293
+ :style="approvalMode && selectedAction === 'approved'
294
+ ? 'background: #e8f5e9; border-radius: 6px; border: 1px solid #a5d6a7'
295
+ : 'background: #f0f0f0; border-radius: 6px; opacity: 0.75'"
245
296
  >
246
297
  <v-row no-gutters class="mb-2">
247
298
  <span class="text-caption font-weight-bold text-medium-emphasis">
248
299
  FOR MANAGEMENT USE ONLY
249
300
  </span>
250
301
  </v-row>
251
- <v-row no-gutters style="gap: 16px">
302
+ <v-row no-gutters style="gap: 16px; flex-wrap: wrap">
252
303
  <div
253
304
  v-for="(item, idx) in block.items"
254
305
  :key="idx"
255
- style="min-width: 160px"
306
+ style="min-width: 160px; flex: 1"
256
307
  >
257
308
  <div class="text-caption text-medium-emphasis mb-1">{{ item }}</div>
258
- <div
259
- style="border-bottom: 1px solid #aaa; min-width: 120px; height: 22px"
309
+ <v-text-field
310
+ v-if="approvalMode && selectedAction === 'approved'"
311
+ v-model="managementValues[item]"
312
+ density="compact"
313
+ variant="underlined"
314
+ :error="!(managementValues[item] ?? '').trim()"
315
+ :error-messages="!(managementValues[item] ?? '').trim() ? 'Required' : ''"
260
316
  />
317
+ <div
318
+ v-else
319
+ style="border-bottom: 1px solid #aaa; min-width: 120px; height: 22px; font-size: 13px; padding-top: 2px"
320
+ >{{ managementValues[item] || '' }}</div>
261
321
  </div>
262
322
  </v-row>
263
323
  </div>
@@ -271,53 +331,70 @@
271
331
  </v-form>
272
332
  </v-card-text>
273
333
 
274
- <!-- Action bar -->
275
- <v-toolbar density="compact">
334
+ <!-- Approval action panel -->
335
+ <div
336
+ v-if="readonly && approvalMode"
337
+ style="border-top: 1px solid #e0e0e0; padding: 12px 16px; background: #fafafa; display: flex; flex-direction: column; gap: 10px"
338
+ >
339
+ <v-text-field
340
+ v-model="adminRemarks"
341
+ label="Remarks"
342
+ density="compact"
343
+ variant="outlined"
344
+ :hide-details="adminRemarks.trim().length > 0"
345
+ :error="remarksRequired && adminRemarks.trim().length === 0"
346
+ :error-messages="remarksRequired && adminRemarks.trim().length === 0 ? 'Remarks are required' : ''"
347
+ />
348
+ <div class="d-flex align-center ga-2 justify-end">
349
+ <v-btn variant="text" class="text-none" size="small" @click="emit('cancel')">Close</v-btn>
350
+ <v-btn
351
+ :variant="selectedAction === 'resubmission' ? 'flat' : 'tonal'"
352
+ color="orange-darken-1"
353
+ class="text-none"
354
+ size="small"
355
+ @click="selectAction('resubmission')"
356
+ >Resubmit</v-btn>
357
+ <v-btn
358
+ :variant="selectedAction === 'rejected' ? 'flat' : 'tonal'"
359
+ color="red"
360
+ class="text-none"
361
+ size="small"
362
+ @click="selectAction('rejected')"
363
+ >Reject</v-btn>
364
+ <v-btn
365
+ :variant="selectedAction === 'approved' ? 'flat' : 'tonal'"
366
+ color="green"
367
+ class="text-none"
368
+ size="small"
369
+ @click="selectAction('approved')"
370
+ >Approve</v-btn>
371
+ <v-btn
372
+ v-if="selectedAction"
373
+ variant="flat"
374
+ color="grey-darken-3"
375
+ class="text-none"
376
+ size="small"
377
+ :disabled="!canConfirm"
378
+ :loading="updatingStatus"
379
+ @click="confirmStatusUpdate"
380
+ >Confirm</v-btn>
381
+ </div>
382
+ </div>
383
+
384
+ <!-- Normal action bar -->
385
+ <v-toolbar v-else density="compact">
276
386
  <v-row no-gutters>
277
- <!-- Readonly: single Close button -->
278
387
  <template v-if="readonly">
279
388
  <v-col cols="12">
280
- <v-btn
281
- tile
282
- block
283
- variant="text"
284
- class="text-none"
285
- size="48"
286
- @click="emit('cancel')"
287
- >
288
- Close
289
- </v-btn>
389
+ <v-btn tile block variant="text" class="text-none" size="48" @click="emit('cancel')">Close</v-btn>
290
390
  </v-col>
291
391
  </template>
292
- <!-- Interactive: Cancel + Submit -->
293
392
  <template v-else>
294
393
  <v-col cols="6">
295
- <v-btn
296
- tile
297
- block
298
- variant="text"
299
- class="text-none"
300
- size="48"
301
- :disabled="submitting"
302
- @click="emit('cancel')"
303
- >
304
- Cancel
305
- </v-btn>
394
+ <v-btn tile block variant="text" class="text-none" size="48" :disabled="submitting" @click="emit('cancel')">Cancel</v-btn>
306
395
  </v-col>
307
396
  <v-col cols="6">
308
- <v-btn
309
- tile
310
- block
311
- variant="flat"
312
- color="black"
313
- class="text-none"
314
- size="48"
315
- :disabled="!validForm || submitting"
316
- :loading="submitting"
317
- @click="submit"
318
- >
319
- Submit
320
- </v-btn>
397
+ <v-btn tile block variant="flat" color="black" class="text-none" size="48" :disabled="!validForm || submitting" :loading="submitting" @click="submit">Submit</v-btn>
321
398
  </v-col>
322
399
  </template>
323
400
  </v-row>
@@ -339,6 +416,13 @@ const props = defineProps({
339
416
  orgId: { type: String, required: true },
340
417
  siteName: { type: String, default: "" },
341
418
  readonly: { type: Boolean, default: false },
419
+ initialValues: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
420
+ submissionDate: { type: String, default: "" },
421
+ submissionStatus: { type: String, default: "" },
422
+ submissionUnit: { type: String, default: "" },
423
+ approvalMode: { type: Boolean, default: false },
424
+ initialManagementValues: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
425
+ formType: { type: String, default: "" },
342
426
  /** Optional custom submit handler. Receives the collected form values.
343
427
  * If omitted the component submits to /api/online-forms automatically. */
344
428
  submitFn: {
@@ -347,7 +431,7 @@ const props = defineProps({
347
431
  },
348
432
  });
349
433
 
350
- const emit = defineEmits(["cancel", "success"]);
434
+ const emit = defineEmits(["cancel", "success", "update-status"]);
351
435
 
352
436
  function resolveText(text: string): string {
353
437
  return text.replace(/\{siteName\}/g, props.siteName || "the Management");
@@ -361,8 +445,100 @@ const submitting = ref(false);
361
445
  const message = ref("");
362
446
  const values = ref<Record<string, any>>({});
363
447
 
448
+ const selectedAction = ref("");
449
+ const adminRemarks = ref("");
450
+ const updatingStatus = ref(false);
451
+ const managementValues = ref<Record<string, string>>({});
452
+
453
+ const managementUseItems = computed<string[]>(() => {
454
+ const result: string[] = [];
455
+ for (const block of props.definition.blocks) {
456
+ if (block.type === "management-use") result.push(...block.items);
457
+ }
458
+ return result;
459
+ });
460
+
461
+ // Load previously saved management values (saved keys have colons stripped; remap to match item labels)
462
+ watch(() => props.initialManagementValues, (saved) => {
463
+ if (!saved || !Object.keys(saved).length) return;
464
+ const loaded: Record<string, string> = {};
465
+ for (const item of managementUseItems.value) {
466
+ const stripped = item.replace(/:+\s*$/, "").trim();
467
+ const value = saved[stripped] ?? saved[item] ?? "";
468
+ if (value) loaded[item] = value;
469
+ }
470
+ managementValues.value = { ...loaded, ...managementValues.value };
471
+ }, { immediate: true });
472
+
473
+ const remarksRequired = computed(() => !!selectedAction.value);
474
+
475
+ const managementComplete = computed(() => {
476
+ if (selectedAction.value !== "approved") return true;
477
+ return managementUseItems.value.every(item => (managementValues.value[item] ?? "").trim().length > 0);
478
+ });
479
+
480
+ const canConfirm = computed(() =>
481
+ !!selectedAction.value &&
482
+ adminRemarks.value.trim().length > 0 &&
483
+ managementComplete.value
484
+ );
485
+
486
+ function prePopulateManagement() {
487
+ const today = new Date().toLocaleDateString("en-SG", { day: "2-digit", month: "short", year: "numeric" });
488
+ const userName = currentUser.value?.name ?? "";
489
+ for (const item of managementUseItems.value) {
490
+ if (managementValues.value[item]) continue;
491
+ const lower = item.toLowerCase();
492
+ if (lower.includes("by:") || lower.endsWith(" by")) {
493
+ managementValues.value[item] = userName;
494
+ } else if (lower.includes("date")) {
495
+ managementValues.value[item] = today;
496
+ }
497
+ }
498
+ }
499
+
500
+ function selectAction(action: string) {
501
+ selectedAction.value = selectedAction.value === action ? "" : action;
502
+ if (selectedAction.value === "approved") prePopulateManagement();
503
+ }
504
+
505
+ function confirmStatusUpdate() {
506
+ if (!canConfirm.value) return;
507
+ updatingStatus.value = true;
508
+ emit("update-status", {
509
+ status: selectedAction.value,
510
+ remarks: adminRemarks.value,
511
+ managementValues: managementValues.value,
512
+ });
513
+ }
514
+
364
515
  const requiredRule = (v: any) => (v !== null && v !== undefined && v !== "") || "This field is required";
365
516
 
517
+ const submissionStatusColor = computed(() => {
518
+ switch (props.submissionStatus?.toLowerCase()) {
519
+ case "approved": return "green";
520
+ case "pending": return "orange";
521
+ case "resubmission": return "orange";
522
+ case "rejected": return "red";
523
+ default: return "grey";
524
+ }
525
+ });
526
+
527
+ const PAYMENT_REQUIRED_FORM_TYPES = new Set([
528
+ "Application for Electronic Access Card",
529
+ "Application for Booking of BBQ Pit",
530
+ "Application for Moving In/Out & Delivery",
531
+ "Application for Main Entrance RFID Transponder",
532
+ ]);
533
+
534
+ const requiresPayment = computed(() => {
535
+ if (!PAYMENT_REQUIRED_FORM_TYPES.has(props.formType)) return false;
536
+ if (props.formType === "Application for Main Entrance RFID Transponder") {
537
+ return !!(props.initialValues.reasonLost || props.initialValues.reasonDamaged);
538
+ }
539
+ return true;
540
+ });
541
+
366
542
  // Today's date formatted DD MMM YYYY
367
543
  const todayDate = computed(() => {
368
544
  const d = new Date();
@@ -390,7 +566,7 @@ function initValues(def: FormDefinition) {
390
566
  return init;
391
567
  }
392
568
 
393
- values.value = initValues(props.definition);
569
+ values.value = { ...initValues(props.definition), ...props.initialValues };
394
570
 
395
571
  const NAME_LABELS = new Set([
396
572
  "name",
@@ -421,7 +597,7 @@ function prePopulate(u: any) {
421
597
  }
422
598
  }
423
599
 
424
- watch(currentUser, (u) => { if (u) prePopulate(u); }, { immediate: true });
600
+ watch(currentUser, (u) => { if (u && !props.readonly) prePopulate(u); }, { immediate: true });
425
601
 
426
602
  async function submit() {
427
603
  submitting.value = true;
@@ -27,6 +27,8 @@
27
27
  persistent-hint
28
28
  small-chips
29
29
  :clearable="props.clearable"
30
+ @click="fetchPasses"
31
+ :loading="isPassListLoading"
30
32
  >
31
33
  <template v-slot:chip="{ props, item }">
32
34
  <v-chip
@@ -56,6 +58,8 @@
56
58
  persistent-hint
57
59
  small-chips
58
60
  :clearable="props.clearable"
61
+ @click="fetchKeys"
62
+ :loading="isKeyListLoading"
59
63
  >
60
64
  <template v-slot:chip="{ props, item }">
61
65
  <v-chip
@@ -83,6 +87,7 @@
83
87
  </v-card-text>
84
88
  </v-card>
85
89
  </v-row>
90
+ <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
86
91
  </template>
87
92
 
88
93
  <script setup lang="ts">
@@ -90,6 +95,7 @@ import type { PropType } from "vue";
90
95
  import type { ValidationRule } from "vuetify/lib/types.mjs";
91
96
  import usePassKey from "../composables/usePassKey";
92
97
  import useKey from "../composables/useKey";
98
+ import { string } from "zod/v4";
93
99
 
94
100
  const props = defineProps({
95
101
  passRules: {
@@ -112,6 +118,10 @@ const props = defineProps({
112
118
  type: String,
113
119
  default: "",
114
120
  },
121
+ passKeys: {
122
+ type: Array,
123
+ default: [],
124
+ },
115
125
  hideKeys: {
116
126
  type: Boolean,
117
127
  default: false,
@@ -158,43 +168,72 @@ const passTypesComputed = computed(() => {
158
168
  }
159
169
  });
160
170
 
161
- const {
162
- data: passesData,
163
- refresh: refreshPassesData,
164
- pending: fetchPassesPending,
165
- } = await useLazyAsyncData("get-pass-keys", () => {
166
- return getPassKeysByPageSearch({
167
- search: passInput.value,
168
- page: 1,
169
- limit: 500,
170
- passTypes: passTypesComputed.value,
171
- sites: [props.site],
172
- statuses: ["Available"],
173
- });
174
- });
171
+ const message = ref("");
172
+ const messageColor = ref("");
173
+ const messageSnackbar = ref(false);
175
174
 
176
- watch(passesData, (data: any) => {
177
- passItems.value = data?.items || [];
178
- });
175
+ function showMessage(msg: string, color: string) {
176
+ message.value = msg;
177
+ messageColor.value = color;
178
+ messageSnackbar.value = true;
179
+ }
179
180
 
180
- const {
181
- data: keysData,
182
- refresh: refreshKeysData,
183
- pending: fetchKeysPending,
184
- } = await useLazyAsyncData("get-keys", () => {
185
- return getPassKeysByPageSearch({
186
- search: keyInput.value,
187
- statuses: ["Available"],
188
- passTypes: ["pass-key"],
189
- page: 1,
190
- limit: 500,
191
- sites: [props.site],
192
- });
193
- });
181
+ const isPassListLoading = ref(false);
194
182
 
195
- watch(keysData, (data: any) => {
196
- keyItems.value = data?.items || [];
197
- });
183
+ async function fetchPasses() {
184
+ // load pass list
185
+ passItems.value = [];
186
+ isPassListLoading.value = true;
187
+ try {
188
+ const result = await getPassKeysByPageSearch({
189
+ page: 1,
190
+ sites: [props.site],
191
+ limit: 10000,
192
+ passTypes: passTypesComputed.value,
193
+ statuses: ["Available"],
194
+ sortBy: { column: "prefixAndName", order: "1" },
195
+ });
196
+ passItems.value = result?.items;
197
+ } catch (error) {
198
+ showMessage(errorConverter(error as string), "error");
199
+ } finally {
200
+ isPassListLoading.value = false;
201
+ }
202
+ }
203
+
204
+ const isKeyListLoading = ref(false);
205
+
206
+ async function fetchKeys() {
207
+ keyItems.value = [];
208
+ isKeyListLoading.value = true;
209
+ try {
210
+ const keys: any = await getPassKeysByPageSearch({
211
+ page: 1,
212
+ sites: [props.site],
213
+ limit: 10000,
214
+ passTypes: ["pass-key"],
215
+ statuses: ["Available"],
216
+ sortBy: { column: "prefixAndName", order: "1" },
217
+ });
218
+ const selectedKeys = keyItems.value.filter((item: any) => {
219
+ if (Array.isArray(props?.passKeys) && props.passKeys.length) {
220
+ return props.passKeys.includes(item._id);
221
+ }
222
+ return false;
223
+ });
224
+ const filteredArray = keys.items.filter((item: any) => {
225
+ if (Array.isArray(props?.passKeys) && props.passKeys.length) {
226
+ return !props.passKeys.includes(item._id);
227
+ }
228
+ return true;
229
+ });
230
+ keyItems.value = [...selectedKeys, ...filteredArray];
231
+ } catch (error) {
232
+ showMessage(errorConverter(error as string), "error");
233
+ } finally {
234
+ isKeyListLoading.value = false;
235
+ }
236
+ }
198
237
 
199
238
  watch(selectedPass, (newVal) => {
200
239
  pass.value = [{ keyId: newVal }];