@7365admin1/layer-common 3.1.3 → 3.1.4-staging.46

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.
@@ -34,3 +34,14 @@ jobs:
34
34
 
35
35
  - name: Publish to npm with staging tag
36
36
  run: npm publish --tag staging --registry https://registry.npmjs.org
37
+
38
+ - name: Notify Discord on failure
39
+ if: failure()
40
+ env:
41
+ DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
42
+ run: |
43
+ MESSAGE="@everyone :rotating_light: **Publish Staging** failed in \`${{ github.repository }}\` (\`${{ github.workflow }}\`)
44
+ ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
45
+ curl -s -X POST -H "Content-Type: application/json" \
46
+ -d "$(jq -n --arg content "$MESSAGE" '{content: $content}')" \
47
+ "$DISCORD_WEBHOOK_URL"
@@ -48,7 +48,7 @@
48
48
  <v-list-item class="cursor-pointer" @click="openMediaViewer(item._id, item.mimeType)">
49
49
  <template #prepend>
50
50
  <v-avatar size="36" rounded="sm" class="mr-3">
51
- <v-img :src="getFileUrl(item._id)" cover>
51
+ <v-img :src="imageCover(item._id)" cover>
52
52
  <template #error>
53
53
  <v-icon color="green" size="20">mdi-image</v-icon>
54
54
  </template>
@@ -97,7 +97,7 @@
97
97
  </v-row>
98
98
 
99
99
  <!-- Media Carousel (images + videos) -->
100
- <ImageCarousel v-model="showCarousel" :active-file-id="activeFileId" :files="carouselFiles" />
100
+ <ImageCarousel v-model="showCarousel" :active-file-id="activeFileId" :files="carouselFiles" :anpr="anpr" :site="site" />
101
101
 
102
102
  <!-- Document Viewer -->
103
103
  <DocumentViewer v-model="showDocumentViewer" :file-id="activeFileId" :file-name="activeFileName"
@@ -123,10 +123,18 @@ const props = defineProps({
123
123
  showFileTypeTitle: {
124
124
  type: Boolean,
125
125
  default: false,
126
+ },
127
+ anpr: {
128
+ type: Boolean,
129
+ default: false
130
+ },
131
+ site: {
132
+ type: String,
133
+ default: ""
126
134
  }
127
135
  });
128
136
 
129
- const { getFileUrl, urlToFile, getFileById } = useFile();
137
+ const { getFileUrl, urlToFile, getFileById, getFileUrlAnpr } = useFile();
130
138
 
131
139
  const rawFileArray = ref<{ file: File, _id: string, preview: boolean, mimeType: string }[]>([]);
132
140
  const loading = ref(true);
@@ -159,6 +167,12 @@ function openDocumentViewer(id: string, name: string, mimeType: string) {
159
167
  showDocumentViewer.value = true;
160
168
  }
161
169
 
170
+ const imageCover = (id: string) => {
171
+ if(props.anpr && props.site){
172
+ return getFileUrlAnpr(`${props.site}/${id}`)
173
+ } else return getFileUrl(id)
174
+ }
175
+
162
176
  function getFileIcon(mimeType: string): string {
163
177
  if (mimeType.startsWith('image/')) return 'mdi-image';
164
178
  if (mimeType.startsWith('video/')) return 'mdi-video';
@@ -206,7 +220,11 @@ watchEffect(async () => {
206
220
 
207
221
  try {
208
222
  // Try to get the actual file with proper MIME type detection from blob
209
- const fileUrl = getFileUrl(item.id);
223
+ // const fileUrl = getFileUrl(item.id);
224
+ let fileUrl = ""
225
+ if(props.anpr && props.site){
226
+ fileUrl = getFileUrlAnpr(`${props.site}/${item.id}`)
227
+ } else fileUrl = getFileUrl(item.id);
210
228
  file = await urlToFile(fileUrl, item.name);
211
229
  mimeType = file.type || 'application/octet-stream';
212
230
 
@@ -984,60 +984,44 @@
984
984
  <div
985
985
  v-for="item in formattedActivePatrolItems"
986
986
  :key="item.id || item._id"
987
- class="d-flex align-center justify-space-between"
988
- style="
989
- padding: 16px 20px;
990
- border: 1px solid #e2e8f0;
991
- border-radius: 12px;
992
- margin-bottom: 12px;
993
- background-color: #ffffff;
994
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
995
- "
987
+ class="active-patrol-row"
996
988
  >
997
- <div style="flex: 1; min-width: 140px">
998
- <strong
999
- style="
1000
- display: block;
1001
- font-size: 15px;
1002
- font-weight: 700;
1003
- color: #0b2f47;
1004
- margin-bottom: 4px;
1005
- "
1006
- >{{ item.title || item.name }}</strong
1007
- >
1008
- <span style="font-size: 13px; color: #94a3b8">{{
1009
- item.subtitle || item.location || ""
1010
- }}</span>
989
+ <div class="active-patrol-main">
990
+ <strong>{{ item.title || item.name }}</strong>
991
+ <span>{{ item.subtitle || item.location || "" }}</span>
1011
992
  </div>
1012
993
 
1013
- <div
1014
- style="flex: 1; display: flex; align-items: center; gap: 12px"
1015
- >
1016
- <AvatarMain
1017
- :name="item.person || item.assignedTo"
1018
- :size="36"
1019
- />
1020
- <span style="font-size: 14px; color: #64748b">{{
1021
- item.person || item.assignedTo
1022
- }}</span>
994
+ <div class="active-patrol-assignees">
995
+ <div class="active-patrol-assignee">
996
+ <AvatarMain :name="item.primaryAssignee" :size="24" />
997
+ <span>{{ item.primaryAssignee }}</span>
998
+ </div>
999
+ <span
1000
+ v-if="item.extraAssigneeCount > 0"
1001
+ class="active-patrol-more-pill active-patrol-tooltip"
1002
+ :data-tooltip="item.extraAssignees.join(', ')"
1003
+ >
1004
+ +{{ item.extraAssigneeCount }}
1005
+ </span>
1023
1006
  </div>
1024
1007
 
1025
- <div style="flex: 1; display: flex; justify-content: center">
1008
+ <div class="active-patrol-statuses">
1026
1009
  <div
1027
- :style="getPatrolStatusStyle(item.status)"
1028
- style="
1029
- padding: 6px 16px;
1030
- border-radius: 20px;
1031
- font-size: 13px;
1032
- font-weight: 500;
1033
- text-transform: capitalize;
1034
- "
1010
+ class="active-patrol-status-pill"
1011
+ :style="getPatrolStatusStyle(item.primaryStatus)"
1035
1012
  >
1036
- {{ item.status }}
1013
+ {{ item.primaryStatus }}
1037
1014
  </div>
1015
+ <span
1016
+ v-if="item.extraStatusCount > 0"
1017
+ class="active-patrol-more-pill active-patrol-tooltip"
1018
+ :data-tooltip="item.extraStatuses.join(', ')"
1019
+ >
1020
+ +{{ item.extraStatusCount }}
1021
+ </span>
1038
1022
  </div>
1039
1023
 
1040
- <div style="flex: 0 0 auto; text-align: right">
1024
+ <div class="active-patrol-action">
1041
1025
  <v-btn
1042
1026
  variant="outlined"
1043
1027
  class="text-none font-weight-medium"
@@ -1545,30 +1529,16 @@ const { userAppRole } = useLocalSetup();
1545
1529
  const { downloadPDF } = usePDFDownload();
1546
1530
  const route = useRoute();
1547
1531
 
1548
- const dashboardWidgetPermissions: Record<string, string> = {
1549
- activity: "dashboard:view-activity",
1550
- recentActivity: "dashboard:view-activity",
1551
- taskSchedule: "dashboard:view-task-schedule",
1552
- staffStatus: "dashboard:view-attendance-widget",
1553
- attendance: "dashboard:view-attendance-widget",
1554
- feedbacks: "dashboard:view-feedbacks-widget",
1555
- workOrderStatus: "dashboard:view-work-order-status",
1556
- activePatrol: "dashboard:view-active-patrol",
1557
- upcomingEvents: "dashboard:view-upcoming-events",
1558
- todayReminders: "dashboard:view-today-reminders",
1559
- todayAttentions: "dashboard:view-today-attentions",
1560
- };
1561
-
1562
1532
  const propertyWidgetPermissions: Record<string, string[]> = {
1563
1533
  workOrders: [
1564
- "workOrder:see-all-work-orders",
1534
+ "work_orders:see-all-work-orders",
1565
1535
  "work-order:see-all-work-orders",
1566
1536
  ],
1567
1537
  incidents: ["incident-reports:see-incident-reports"],
1568
1538
  visitors: ["visitorManagement:see-all-visitor"],
1569
1539
  facilityBookings: ["facility-booking-mgmt:see-all-facility-booking"],
1570
1540
  activity: [
1571
- "workOrder:see-all-work-orders",
1541
+ "work_orders:see-all-work-orders",
1572
1542
  "work-order:see-all-work-orders",
1573
1543
  ],
1574
1544
  upcomingEvents: ["event-mgmt:see-all-event"],
@@ -1578,7 +1548,7 @@ const propertyWidgetPermissions: Record<string, string[]> = {
1578
1548
  "facility-booking-mgmt:see-all-facility-booking",
1579
1549
  ],
1580
1550
  workOrderStatus: [
1581
- "workOrder:see-all-work-orders",
1551
+ "work_orders:see-all-work-orders",
1582
1552
  "work-order:see-all-work-orders",
1583
1553
  ],
1584
1554
  feedbacks: ["feedbacks:see-all-feedback"],
@@ -1603,14 +1573,14 @@ const moduleWidgetPermissions: Record<string, string[]> = {
1603
1573
  "work_order:see-all-work-orders",
1604
1574
  "work-order:see-all-work-orders",
1605
1575
  "work-orders:see-all-work-orders",
1576
+ "work_orders:see-all-work-orders",
1606
1577
  ],
1607
- taskSchedule: [
1608
- "cleaning-schedule-mgmt:see-all-schedules",
1609
- "schedule-task-mgmt:see-all-schedule-tasks",
1610
- ],
1611
- taskCompleted: [
1612
- "cleaning-schedule-mgmt:see-all-schedules",
1613
- "schedule-task-mgmt:see-all-schedule-tasks",
1578
+ recentActivity: [
1579
+ "work_orders:see-all-work-orders",
1580
+ "work_order:see-all-work-orders",
1581
+ "work-order:see-all-work-orders",
1582
+ "work-orders:see-all-work-orders",
1583
+ "work_orders:see-all-work-orders",
1614
1584
  ],
1615
1585
  staffStatus: ["attendance-mgmt:see-all-attendance"],
1616
1586
  attendance: ["attendance-mgmt:see-all-attendance"],
@@ -1633,9 +1603,14 @@ const moduleWidgetPermissions: Record<string, string[]> = {
1633
1603
  activePatrol: ["virtual-patrol:see-all-virtual-patrol-logs"],
1634
1604
  };
1635
1605
 
1606
+ function hasSchedulePermission(permissions: string[], action: string) {
1607
+ return permissions.some((permission) =>
1608
+ permission.endsWith(`-schedule-mgmt:${action}`)
1609
+ );
1610
+ }
1611
+
1636
1612
  function canViewWidget(key: string) {
1637
1613
  const permissions = userAppRole.value?.permissions;
1638
- const dashboardPermission = dashboardWidgetPermissions[key];
1639
1614
  const modulePermissions = isPropertyMode.value
1640
1615
  ? propertyWidgetPermissions[key]
1641
1616
  : moduleWidgetPermissions[key];
@@ -1643,20 +1618,22 @@ function canViewWidget(key: string) {
1643
1618
  if (!permissions) return true;
1644
1619
  if (permissions.includes("*")) return true;
1645
1620
 
1621
+ if (
1622
+ !isPropertyMode.value &&
1623
+ ["taskSchedule", "taskCompleted"].includes(key)
1624
+ ) {
1625
+ return hasSchedulePermission(permissions, "see-all-schedules");
1626
+ }
1627
+
1646
1628
  if (isPropertyMode.value && modulePermissions) {
1647
1629
  return modulePermissions.some((permission) =>
1648
1630
  permissions.includes(permission)
1649
1631
  );
1650
1632
  }
1651
1633
 
1652
- if (dashboardPermission || modulePermissions) {
1653
- return (
1654
- (dashboardPermission && permissions.includes(dashboardPermission)) ||
1655
- Boolean(
1656
- modulePermissions?.some((permission) =>
1657
- permissions.includes(permission)
1658
- )
1659
- )
1634
+ if (modulePermissions) {
1635
+ return modulePermissions.some((permission) =>
1636
+ permissions.includes(permission)
1660
1637
  );
1661
1638
  }
1662
1639
 
@@ -2774,18 +2751,23 @@ const activePatrolItems = computed<any[]>(
2774
2751
 
2775
2752
  const formattedActivePatrolItems = computed<any[]>(() => {
2776
2753
  return activePatrolItems.value.map((item) => {
2777
- let status = item.status;
2778
- if (Array.isArray(status)) {
2779
- status = status.join(", ");
2780
- }
2781
- let person = item.person || item.assignedTo || "Unassigned";
2782
- if (Array.isArray(person)) {
2783
- person = person.join(", ");
2784
- }
2754
+ const statuses = normalizeListValue(item.status || "Pending");
2755
+ const assignees = normalizeListValue(
2756
+ item.person || item.assignedTo || "Unassigned"
2757
+ );
2758
+
2785
2759
  return {
2786
2760
  ...item,
2787
- status,
2788
- person,
2761
+ status: statuses.join(", "),
2762
+ statuses,
2763
+ primaryStatus: statuses[0] || "Pending",
2764
+ extraStatuses: statuses.slice(1),
2765
+ extraStatusCount: Math.max(0, statuses.length - 1),
2766
+ person: assignees.join(", "),
2767
+ assignees,
2768
+ primaryAssignee: assignees[0] || "Unassigned",
2769
+ extraAssignees: assignees.slice(1),
2770
+ extraAssigneeCount: Math.max(0, assignees.length - 1),
2789
2771
  };
2790
2772
  });
2791
2773
  });
@@ -3955,6 +3937,20 @@ function normalizeStatus(value: any) {
3955
3937
  return text.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
3956
3938
  }
3957
3939
 
3940
+ function normalizeListValue(value: any) {
3941
+ if (Array.isArray(value)) {
3942
+ return value
3943
+ .flatMap((entry) => normalizeListValue(entry))
3944
+ .map((entry) => String(entry).trim())
3945
+ .filter(Boolean);
3946
+ }
3947
+
3948
+ return String(value || "")
3949
+ .split(",")
3950
+ .map((entry) => entry.trim())
3951
+ .filter(Boolean);
3952
+ }
3953
+
3958
3954
  function getStatusTone(status: any) {
3959
3955
  const s = String(status || "").toLowerCase();
3960
3956
  if (
@@ -4422,6 +4418,151 @@ function formatDashboardTime(value?: string) {
4422
4418
  border: 2px solid #f9c97c;
4423
4419
  }
4424
4420
 
4421
+ .active-patrol-row {
4422
+ align-items: center;
4423
+ background-color: #ffffff;
4424
+ border: 1px solid #e2e8f0;
4425
+ border-radius: 8px;
4426
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
4427
+ display: grid;
4428
+ gap: 10px;
4429
+ grid-template-columns: 150px minmax(0, 1fr) max-content max-content;
4430
+ margin-bottom: 12px;
4431
+ min-height: 64px;
4432
+ overflow: visible;
4433
+ padding: 12px 14px;
4434
+ }
4435
+
4436
+ .active-patrol-main {
4437
+ min-width: 0;
4438
+ }
4439
+
4440
+ .active-patrol-main strong {
4441
+ color: #0b2f47;
4442
+ display: block;
4443
+ font-size: 13px;
4444
+ font-weight: 700;
4445
+ line-height: 1.25;
4446
+ margin-bottom: 3px;
4447
+ overflow-wrap: anywhere;
4448
+ }
4449
+
4450
+ .active-patrol-main span {
4451
+ color: #94a3b8;
4452
+ display: block;
4453
+ font-size: 11px;
4454
+ line-height: 1.25;
4455
+ overflow-wrap: anywhere;
4456
+ }
4457
+
4458
+ .active-patrol-assignees {
4459
+ align-items: center;
4460
+ display: flex;
4461
+ gap: 6px;
4462
+ min-width: 0;
4463
+ }
4464
+
4465
+ .active-patrol-assignee {
4466
+ align-items: center;
4467
+ background: #f8fafc;
4468
+ border: 1px solid #e7edf3;
4469
+ border-radius: 999px;
4470
+ display: inline-flex;
4471
+ flex: 1 1 auto;
4472
+ gap: 6px;
4473
+ max-width: 100%;
4474
+ min-width: 0;
4475
+ padding: 2px 8px 2px 3px;
4476
+ }
4477
+
4478
+ .active-patrol-assignee span {
4479
+ color: #475569;
4480
+ font-size: 11px;
4481
+ font-weight: 600;
4482
+ line-height: 1.2;
4483
+ min-width: 0;
4484
+ overflow: hidden;
4485
+ text-overflow: ellipsis;
4486
+ white-space: nowrap;
4487
+ }
4488
+
4489
+ .active-patrol-statuses {
4490
+ align-items: center;
4491
+ display: flex;
4492
+ gap: 6px;
4493
+ justify-content: flex-start;
4494
+ min-width: 0;
4495
+ }
4496
+
4497
+ .active-patrol-status-pill {
4498
+ border-radius: 20px;
4499
+ font-size: 11px;
4500
+ font-weight: 600;
4501
+ line-height: 1.2;
4502
+ padding: 5px 10px;
4503
+ text-transform: capitalize;
4504
+ white-space: nowrap;
4505
+ }
4506
+
4507
+ .active-patrol-more-pill {
4508
+ align-items: center;
4509
+ background: #eef3f8;
4510
+ border: 1px solid #dbe4ed;
4511
+ border-radius: 999px;
4512
+ color: #607386;
4513
+ display: inline-flex;
4514
+ flex: 0 0 auto;
4515
+ font-size: 11px;
4516
+ font-weight: 800;
4517
+ height: 24px;
4518
+ justify-content: center;
4519
+ line-height: 1;
4520
+ min-width: 30px;
4521
+ padding: 0 8px;
4522
+ position: relative;
4523
+ white-space: nowrap;
4524
+ }
4525
+
4526
+ .active-patrol-action {
4527
+ justify-self: end;
4528
+ text-align: right;
4529
+ }
4530
+
4531
+ .active-patrol-tooltip::after {
4532
+ background: #0f2638;
4533
+ border-radius: 6px;
4534
+ bottom: calc(100% + 8px);
4535
+ box-shadow: 0 8px 24px rgba(15, 38, 56, 0.18);
4536
+ color: #ffffff;
4537
+ content: attr(data-tooltip);
4538
+ font-size: 11px;
4539
+ font-weight: 600;
4540
+ left: 50%;
4541
+ line-height: 1.35;
4542
+ max-width: 260px;
4543
+ opacity: 0;
4544
+ padding: 7px 9px;
4545
+ pointer-events: none;
4546
+ position: absolute;
4547
+ text-align: left;
4548
+ transform: translate(-50%, 4px);
4549
+ transition: opacity 0.14s ease, transform 0.14s ease;
4550
+ white-space: normal;
4551
+ width: max-content;
4552
+ z-index: 5;
4553
+ }
4554
+
4555
+ .active-patrol-tooltip:hover::after {
4556
+ opacity: 1;
4557
+ transform: translate(-50%, 0);
4558
+ }
4559
+
4560
+ @media (max-width: 960px) {
4561
+ .active-patrol-row {
4562
+ grid-template-columns: 120px minmax(0, 1fr) max-content max-content;
4563
+ }
4564
+ }
4565
+
4425
4566
  .figma-icon-badge {
4426
4567
  align-items: center;
4427
4568
  border-radius: 50%;
@@ -7,7 +7,7 @@
7
7
  <template v-if="fileTypes?.[x] === 'image'">
8
8
  <v-carousel-item height="100%" width="100%" rounded="lg">
9
9
  <v-row no-gutters class="w-100 h-100" align="center">
10
- <v-img :lazy-src="getFileUrl(x)" :src="getFileUrl(x)" width="70%" height="70%"
10
+ <v-img :lazy-src="getFinalUrl(x)" :src="getFinalUrl(x)" width="70%" height="70%"
11
11
  :alt="'Image Viewer Card -' + index"></v-img>
12
12
  </v-row>
13
13
  <template v-slot:placeholder>
@@ -21,7 +21,7 @@
21
21
  <v-carousel-item>
22
22
  <v-row no-gutters class="h-100 w-100" align="center" justify="center">
23
23
  <video width="80%" height="80%" controls>
24
- <source :src="getFileUrl(x)" />
24
+ <source :src="getFinalUrl(x)" />
25
25
  </video>
26
26
  </v-row>
27
27
  <template v-slot:placeholder>
@@ -78,11 +78,19 @@ const props = defineProps({
78
78
  files: {
79
79
  type: Array as PropType<string[]>,
80
80
  default: []
81
+ },
82
+ site: {
83
+ type: String,
84
+ default: ""
85
+ },
86
+ anpr: {
87
+ type: Boolean,
88
+ default: false
81
89
  }
82
90
  });
83
91
 
84
92
 
85
- const { getFileUrl, urlToFile } = useFile()
93
+ const { getFileUrl, urlToFile, getFileUrlAnpr } = useFile()
86
94
  const overlay = defineModel({ required: true, default: false });
87
95
  const activeIndex = ref(0);
88
96
  const fileTypes = ref<Record<string, "image" | "video" | "other">>({});
@@ -107,10 +115,13 @@ async function resolveFileTypes() {
107
115
 
108
116
  for (const x of props.files) {
109
117
  try {
110
- const url = getFileUrl(x);
118
+ let url = ""
119
+ if (props.anpr && props.site) {
120
+ url = getFileUrlAnpr(`${props.site}/${x}`)
121
+ } else url = getFileUrl(x);
111
122
  const file = await urlToFile(url, x);
112
123
  const type = file?.type;
113
-
124
+
114
125
 
115
126
  if (type?.startsWith("video")) fileTypes.value[x] = "video";
116
127
  else if (type?.startsWith("image")) fileTypes.value[x] = "image";
@@ -129,6 +140,13 @@ watch(
129
140
  );
130
141
 
131
142
 
143
+ const getFinalUrl = (id: string) => {
144
+ if (props.anpr && props.site) {
145
+ return getFileUrlAnpr(`${props.site}/${id}`)
146
+ } else return getFileUrl(id)
147
+ }
148
+
149
+
132
150
  </script>
133
151
 
134
152
  <style scoped>
@@ -304,31 +304,38 @@
304
304
 
305
305
  <v-divider />
306
306
 
307
- <v-card-actions class="pa-0">
308
- <v-btn
309
- color="grey"
310
- variant="flat"
311
- rounded="0"
312
- class="flex-grow-1 text-none"
313
- height="48"
314
- :disabled="rejectModal.submitting"
315
- @click="closeRejectDialog"
316
- >
317
- Cancel
318
- </v-btn>
319
- <v-btn
320
- color="error"
321
- variant="flat"
322
- rounded="0"
323
- class="flex-grow-1 text-none"
324
- height="48"
325
- :loading="rejectModal.submitting"
326
- :disabled="!isRejectModalValid || rejectModal.submitting"
327
- @click="submitRejectDialog"
328
- >
329
- Confirm
330
- </v-btn>
331
- </v-card-actions>
307
+ <v-toolbar class="pa-0" density="compact">
308
+ <v-row no-gutters>
309
+ <v-col cols="6" class="pa-0">
310
+ <v-btn
311
+ block
312
+ variant="text"
313
+ class="text-none"
314
+ size="large"
315
+ height="48"
316
+ :disabled="rejectModal.submitting"
317
+ @click="closeRejectDialog"
318
+ >
319
+ Cancel
320
+ </v-btn>
321
+ </v-col>
322
+
323
+ <v-col cols="6" class="pa-0">
324
+ <v-btn
325
+ block
326
+ variant="flat"
327
+ color="black"
328
+ class="text-none font-weight-bold rounded-0"
329
+ height="48"
330
+ :loading="rejectModal.submitting"
331
+ :disabled="!isRejectModalValid || rejectModal.submitting"
332
+ @click="submitRejectDialog"
333
+ >
334
+ Confirm
335
+ </v-btn>
336
+ </v-col>
337
+ </v-row>
338
+ </v-toolbar>
332
339
  </v-card>
333
340
  </v-dialog>
334
341
 
@@ -149,21 +149,23 @@
149
149
  </template>
150
150
 
151
151
  <template #footer>
152
- <v-btn
153
- variant="text"
154
- @click="confirmDialog = false"
155
- :disabled="updateLoading"
156
- >
157
- Close
158
- </v-btn>
159
- <v-btn
160
- color="primary"
161
- variant="flat"
162
- @click="handleUpdateMemberStatus"
163
- :loading="updateLoading"
164
- >
165
- {{ updateActionText }} Member </v-btn
166
- >r
152
+ <div class="d-flex justify-center ga-3 w-100">
153
+ <v-btn
154
+ variant="text"
155
+ @click="confirmDialog = false"
156
+ :disabled="updateLoading"
157
+ >
158
+ Close
159
+ </v-btn>
160
+ <v-btn
161
+ color="primary"
162
+ variant="flat"
163
+ @click="handleUpdateMemberStatus"
164
+ :loading="updateLoading"
165
+ >
166
+ {{ updateActionText }} Member
167
+ </v-btn>
168
+ </div>
167
169
  </template>
168
170
  </ConfirmDialog>
169
171
 
@@ -59,6 +59,21 @@
59
59
  </v-col>
60
60
  </v-row>
61
61
 
62
+ <v-dialog v-model="showPhotoPreview" max-width="900">
63
+ <v-card>
64
+ <v-card-actions class="pa-2 justify-end">
65
+ <v-btn
66
+ icon="mdi-close"
67
+ variant="text"
68
+ @click="showPhotoPreview = false"
69
+ />
70
+ </v-card-actions>
71
+ <v-card-text class="pa-2 pt-0">
72
+ <v-img :src="selectedPhoto" contain max-height="80vh" />
73
+ </v-card-text>
74
+ </v-card>
75
+ </v-dialog>
76
+
62
77
  <v-dialog v-model="photoOptionsDialog" max-width="420">
63
78
  <v-card>
64
79
  <v-card-title class="d-flex align-center pa-4">
@@ -196,6 +211,8 @@ const photos = ref<string[]>([...props.modelValue]);
196
211
  const photosIds = ref<string[]>([...props.photoIds]);
197
212
  const photoOptionsDialog = ref(false);
198
213
  const showCameraDialog = ref(false);
214
+ const showPhotoPreview = ref(false);
215
+ const selectedPhoto = ref("");
199
216
  const videoRef = ref<HTMLVideoElement | null>(null);
200
217
  const canvasRef = ref<HTMLCanvasElement | null>(null);
201
218
  const fileInputRef = ref<HTMLInputElement | null>(null);
@@ -205,10 +222,8 @@ const uploading = ref(false);
205
222
  const { addFile } = useFile();
206
223
 
207
224
  function viewPhoto(photoData: string) {
208
- const link = document.createElement("a");
209
- link.href = photoData;
210
- link.target = "_blank";
211
- link.click();
225
+ selectedPhoto.value = photoData;
226
+ showPhotoPreview.value = true;
212
227
  }
213
228
 
214
229
  watch(
@@ -19,7 +19,7 @@
19
19
  :error-messages="errorMessages"
20
20
  :readonly="!edit"
21
21
  :hint="
22
- edit ? 'Select permissions' : 'Permissions are not editable'
22
+ edit && 'Select permissions'
23
23
  "
24
24
  persistent-hint
25
25
  />
@@ -734,7 +734,7 @@ const {
734
734
  updateStatus: updateServiceProviderStatus,
735
735
  invite,
736
736
  } = useServiceProvider();
737
- const { getAllByUserId } = useMember();
737
+ const { getAll: getAllMembers, getAllByUserId } = useMember();
738
738
 
739
739
  const { getVerifications, cancelUserInvitation, getVerificationByEmail } =
740
740
  useVerification();
@@ -757,6 +757,9 @@ const createInviteDialog = ref(false);
757
757
  // const loadingInviteRoles = ref(false);
758
758
  // const serviceProviderInviteRoles = ref<Array<Record<string, any>>>([]);
759
759
 
760
+ const { getByUserType } = useMember();
761
+ const { getOrgs } = useOrg();
762
+ const { getByEmail } = useServiceProvider();
760
763
 
761
764
  async function checkExistingAccount(email: string) {
762
765
  existingAccount.value = null;
@@ -767,40 +770,54 @@ async function checkExistingAccount(email: string) {
767
770
  checkingExistingAccount.value = true;
768
771
 
769
772
  try {
773
+ // Check if user exists
770
774
  const user = await getUserByEmail(email);
771
775
 
772
776
  if (!user?._id) return;
773
777
 
774
- const members = await getAllByUserId(user._id);
778
+ // Check if this service provider already exists in current org/site
779
+ const serviceProviders = await getByEmail({
780
+ email,
781
+ orgId: props.orgId,
782
+ siteId: props.siteId,
783
+ });
775
784
 
776
- const appMember = (members.items ?? []).find(
777
- (member: any) => member.type === serviceProviderInvite.value.app
778
- );
785
+ console.log("serviceProviders", serviceProviders);
779
786
 
780
- if (!appMember) {
781
- existingAccount.value = null;
782
- return;
783
- }
787
+ if (serviceProviders?.length) {
788
+ messageServiceProvider.value =
789
+ "This service provider already exists.";
790
+ return;
791
+ }
784
792
 
785
- if (
786
- appMember.org === props.orgId &&
787
- (appMember.siteId || "") === (props.siteId || "")
788
- ) {
789
- existingAccount.value = null;
790
- messageServiceProvider.value =
791
- "This user is already a member of this service provider.";
792
- return;
793
- }
793
+ // Check existing memberships
794
+ const members = await getAllByUserId(user._id);
794
795
 
795
- existingAccount.value = {
796
- _id: appMember.org,
797
- text: appMember.orgName,
798
- org: appMember.org,
799
- orgName: appMember.orgName,
800
- siteId: appMember.siteId,
801
- siteName: appMember.siteName,
802
- };
796
+ const appMember = (members.items ?? []).find(
797
+ (member: any) => member.type === serviceProviderInvite.value.app
798
+ );
799
+
800
+ if (!appMember) return;
803
801
 
802
+ // Already belongs to the same org/site
803
+ if (
804
+ appMember.org === props.orgId &&
805
+ (appMember.siteId || "") === (props.siteId || "")
806
+ ) {
807
+ messageServiceProvider.value =
808
+ "This user is already a member of this service provider.";
809
+ return;
810
+ }
811
+
812
+ // Existing account in another org/site
813
+ existingAccount.value = {
814
+ _id: appMember.org,
815
+ text: appMember.orgName,
816
+ org: appMember.org,
817
+ orgName: appMember.orgName,
818
+ siteId: appMember.siteId,
819
+ siteName: appMember.siteName,
820
+ };
804
821
  } catch (error) {
805
822
  existingAccount.value = null;
806
823
  } finally {
@@ -36,10 +36,17 @@
36
36
  </template>
37
37
 
38
38
  <v-list v-if="nricSearchResults.length" class="pa-1 phone-search-results">
39
- <v-list-item v-for="(item, index) in nricSearchResults" :key="item._id || index"
40
- @click="selectNricSearchResult(item)" class="cursor-pointer nric-search-item">
41
- <VisitorSearchResultSummary :item="item" />
42
- </v-list-item>
39
+ <div class="search-results-scroll">
40
+ <v-list-item v-for="(item, index) in nricSearchResults" :key="item._id || index"
41
+ @click="selectNricSearchResult(item)" class="cursor-pointer nric-search-item">
42
+ <VisitorSearchResultSummary :item="item" />
43
+ </v-list-item>
44
+ </div>
45
+ <div class="search-results-close pa-1">
46
+ <v-btn block variant="flat" color="red" @click.stop="closeAutofillSearchResults('nric')">
47
+ Close
48
+ </v-btn>
49
+ </div>
43
50
  </v-list>
44
51
  </v-menu>
45
52
  </v-col>
@@ -58,10 +65,17 @@
58
65
  </template>
59
66
 
60
67
  <v-list v-if="phoneSearchResults.length" class="pa-1 phone-search-results">
61
- <v-list-item v-for="(item, index) in phoneSearchResults" :key="item._id || index"
62
- @click="selectPhoneNumberSearchResult(item)" class="cursor-pointer nric-search-item">
63
- <VisitorSearchResultSummary :item="item" />
64
- </v-list-item>
68
+ <div class="search-results-scroll">
69
+ <v-list-item v-for="(item, index) in phoneSearchResults" :key="item._id || index"
70
+ @click="selectPhoneNumberSearchResult(item)" class="cursor-pointer nric-search-item">
71
+ <VisitorSearchResultSummary :item="item" />
72
+ </v-list-item>
73
+ </div>
74
+ <div class="search-results-close pa-1">
75
+ <v-btn block variant="flat" color="red" @click.stop="closeAutofillSearchResults('contact')">
76
+ Close
77
+ </v-btn>
78
+ </div>
65
79
  </v-list>
66
80
  </v-menu>
67
81
  </v-col>
@@ -77,15 +91,23 @@
77
91
  <v-menu v-model="vehicleSearchMenu" location="bottom" offset-y :close-on-content-click="false">
78
92
  <template #activator="{ props }">
79
93
  <InputVehicleNumber v-bind="props" v-model="visitor.plateNumber" density="comfortable"
80
- @update:model-value="handleUpdateVehicleNumber" @focus="isPlateNumberFieldFocused = true"
94
+ @update:model-value="handleUpdateVehicleNumber" @focus="handleFocusPlateNumber"
81
95
  @blur="isPlateNumberFieldFocused = false" />
82
96
  </template>
83
97
 
84
98
  <v-list v-if="vehicleSearchResults.length" class="pa-1 phone-search-results">
85
- <v-list-item v-for="(item, index) in vehicleSearchResults" :key="item._id || index"
86
- @click="selectPlateNumberSearchResult(item)" class="cursor-pointer nric-search-item">
87
- <VisitorSearchResultSummary :item="item" />
88
- </v-list-item>
99
+ <div class="search-results-scroll">
100
+ <v-list-item v-for="(item, index) in vehicleSearchResults" :key="item._id || index"
101
+ @click="selectPlateNumberSearchResult(item)" class="cursor-pointer nric-search-item">
102
+ <VisitorSearchResultSummary :item="item" />
103
+ </v-list-item>
104
+ </div>
105
+ <div class="search-results-close pa-1">
106
+ <v-btn block variant="flat" color="red"
107
+ @click.stop="closeAutofillSearchResults('vehicleNumber')">
108
+ Close
109
+ </v-btn>
110
+ </div>
89
111
  </v-list>
90
112
  </v-menu>
91
113
  </v-col>
@@ -308,10 +330,10 @@
308
330
 
309
331
  <div class="nric-selected-person d-flex align-center justify-space-between ga-3 mb-5">
310
332
  <span>{{ selectedNricSearchResult.name || "Unknown" }}</span>
311
- <span class="text-no-wrap">NRIC: {{ selectedNricSearchResult.nric || "N/A" }}</span>
333
+ <span v-if="!visitor.nric || currentAutofillSource === 'nric'" class="text-no-wrap">NRIC: {{ selectedNricSearchResult.nric || "N/A" }}</span>
312
334
  </div>
313
335
 
314
- <template v-if="!visitor.contact && selectedNricContactOptions.length >= 1">
336
+ <template v-if="(!visitor.contact || currentAutofillSource === 'contact') && selectedNricContactOptions.length >= 1">
315
337
  <h3 class="nric-options-section-title font-weight-bold mb-2">Contact</h3>
316
338
  <v-radio-group v-model="selectedNricContact" hide-details color="success" class="nric-option-group">
317
339
  <v-radio v-for="contact in selectedNricContactOptions" :key="contact" :label="contact" :value="contact"
@@ -319,7 +341,7 @@
319
341
  </v-radio-group>
320
342
  </template>
321
343
 
322
- <template v-if="!visitor.plateNumber && selectedNricPlateOptions.length >= 1">
344
+ <template v-if="(!visitor.plateNumber || currentAutofillSource === 'vehicleNumber') && selectedNricPlateOptions.length >= 1">
323
345
  <h3 class="nric-options-section-title font-weight-bold mb-2 mt-5">Vehicle</h3>
324
346
  <v-radio-group v-model="selectedNricPlate" hide-details color="success" class="nric-option-group">
325
347
  <v-radio v-for="plate in selectedNricPlateOptions" :key="plate" :label="plate" :value="plate"
@@ -1044,8 +1066,8 @@ function selectAutofillSearchResult(item: Partial<TPeople>, source: Exclude<Auto
1044
1066
  const selectedPlate =
1045
1067
  findMatchingPlateOption(plateOptions, visitor.plateNumber) || plateOptions[0] || "";
1046
1068
 
1047
- const needsContactChoice = !visitor.contact?.trim() && contactOptions.length > 1;
1048
- const needsPlateChoice = !visitor.plateNumber?.trim() && plateOptions.length > 1;
1069
+ const needsContactChoice = (!visitor.contact?.trim() || source === 'contact') && contactOptions.length > 1;
1070
+ const needsPlateChoice = (!visitor.plateNumber?.trim() || source === 'vehicleNumber') && plateOptions.length > 1;
1049
1071
 
1050
1072
  if (needsContactChoice || needsPlateChoice) {
1051
1073
  nricSearchMenu.value = false;
@@ -1066,6 +1088,23 @@ function selectAutofillSearchResult(item: Partial<TPeople>, source: Exclude<Auto
1066
1088
  });
1067
1089
  }
1068
1090
 
1091
+ function closeAutofillSearchResults(source: Exclude<AutofillSource, null>) {
1092
+ if (source === "nric") {
1093
+ nricSearchMenu.value = false;
1094
+ nricSearchResults.value = [];
1095
+ }
1096
+
1097
+ if (source === "contact") {
1098
+ phoneSearchMenu.value = false;
1099
+ phoneSearchResults.value = [];
1100
+ }
1101
+
1102
+ if (source === "vehicleNumber") {
1103
+ vehicleSearchMenu.value = false;
1104
+ vehicleSearchResults.value = [];
1105
+ }
1106
+ }
1107
+
1069
1108
  function closeNricAutofillOptions() {
1070
1109
  dialog.nricAutofillOptions = false;
1071
1110
  selectedNricSearchResult.value = null;
@@ -1503,6 +1542,15 @@ function handleUpdateVehicleNumber() {
1503
1542
  debounceFetchPlateNumber();
1504
1543
  }
1505
1544
 
1545
+ const handleFocusPlateNumber = () => {
1546
+ isPlateNumberFieldFocused.value = true;
1547
+ if (prop.mode === 'register') {
1548
+
1549
+ handleUpdateVehicleNumber();
1550
+ }
1551
+ };
1552
+
1553
+
1506
1554
  function backToSelection() {
1507
1555
  emit("back");
1508
1556
  message.value = "";
@@ -2108,10 +2156,22 @@ onMounted(() => {
2108
2156
  }
2109
2157
 
2110
2158
  .phone-search-results {
2159
+ display: flex;
2160
+ flex-direction: column;
2111
2161
  max-height: 400px;
2162
+ overflow: hidden;
2163
+ }
2164
+
2165
+ .search-results-scroll {
2166
+ flex: 1 1 auto;
2167
+ min-height: 0;
2112
2168
  overflow-y: auto;
2113
2169
  }
2114
2170
 
2171
+ .search-results-close {
2172
+ flex: 0 0 auto;
2173
+ }
2174
+
2115
2175
  .nric-options-card {
2116
2176
  border-radius: 16px !important;
2117
2177
  }
@@ -675,6 +675,24 @@
675
675
  />
676
676
  </div>
677
677
  </span>
678
+ <span
679
+ v-else-if="
680
+ key === 'snapshotEntryImage' || key === 'snapshotExitImage'
681
+ "
682
+ class="d-flex flex-column"
683
+ >
684
+ <strong class="w-full">{{ label }}:</strong>
685
+ <div class="d-flex flex-wrap ga-2">
686
+ <AttachmentsViewer
687
+ :files="
688
+ mappedSnapshots([selectedVisitorObject.snapshotEntryImage])
689
+ "
690
+ anpr
691
+ :site="siteId"
692
+ title="Snapshot"
693
+ />
694
+ </div>
695
+ </span>
678
696
 
679
697
  <span
680
698
  v-else-if="selectedVisitorObject[key]"
@@ -825,10 +843,8 @@
825
843
  item-title="label"
826
844
  item-value="value"
827
845
  v-model="pass.status"
828
- :disabled="
829
- selectedVisitorObject.checkOut || pass.status == 'Removed'
830
- "
831
- ></v-select>
846
+ :disabled="pass.status == 'Removed'"
847
+ />
832
848
  <v-textarea
833
849
  v-if="pass.status === 'Lost' || pass.status === 'Damaged'"
834
850
  no-resize
@@ -836,8 +852,7 @@
836
852
  class="w-100"
837
853
  density="compact"
838
854
  v-model="pass.remarks"
839
- :disabled="selectedVisitorObject.checkOut"
840
- ></v-textarea>
855
+ />
841
856
  </v-row>
842
857
  </div>
843
858
  <div v-if="keyReturnStatuses.length > 0" class="mb-2">
@@ -864,10 +879,8 @@
864
879
  item-title="label"
865
880
  item-value="value"
866
881
  v-model="key.status"
867
- :disabled="
868
- selectedVisitorObject.checkOut || key.status == 'Removed'
869
- "
870
- ></v-select>
882
+ :disabled="key.status == 'Removed'"
883
+ />
871
884
  <v-textarea
872
885
  v-if="key.status === 'Lost' || key.status === 'Damaged'"
873
886
  no-resize
@@ -875,8 +888,7 @@
875
888
  class="w-100"
876
889
  density="compact"
877
890
  v-model="key.remarks"
878
- :disabled="selectedVisitorObject.checkOut"
879
- ></v-textarea>
891
+ />
880
892
  </v-row>
881
893
  </div>
882
894
 
@@ -886,11 +898,11 @@
886
898
  color="blue"
887
899
  density="comfortable"
888
900
  class="text-capitalize"
889
- :disabled="selectedVisitorObject.checkOut"
890
901
  :loading="loading.updatingPassKeys"
891
902
  @click.stop="handleUpdatePassKeys"
892
- >Update Pass/Keys</v-btn
893
903
  >
904
+ Update Pass/Keys
905
+ </v-btn>
894
906
  </v-row>
895
907
  </v-card-text>
896
908
  <v-toolbar class="pa-0" density="compact">
@@ -900,8 +912,9 @@
900
912
  variant="text"
901
913
  block
902
914
  @click="dialog.returnPassesKeys = false"
903
- >Close</v-btn
904
915
  >
916
+ Close
917
+ </v-btn>
905
918
  </v-col>
906
919
  <v-col cols="6">
907
920
  <v-btn
@@ -915,8 +928,9 @@
915
928
  !canConfirmCheckout || selectedVisitorObject.checkOut
916
929
  "
917
930
  @click="proceedCheckout"
918
- >Confirm Checkout</v-btn
919
931
  >
932
+ Confirm Checkout
933
+ </v-btn>
920
934
  </v-col>
921
935
  </v-row>
922
936
  </v-toolbar>
@@ -1442,6 +1456,15 @@ function mappedAttachments(attachments: string[]) {
1442
1456
  });
1443
1457
  }
1444
1458
 
1459
+ function mappedSnapshots(attachments: string[]) {
1460
+ return attachments.map((x, index) => {
1461
+ return {
1462
+ id: x,
1463
+ name: `Snapshot - ${x}`,
1464
+ };
1465
+ });
1466
+ }
1467
+
1445
1468
  function normalizeDateOnly(value: string) {
1446
1469
  if (!value) return "";
1447
1470
  if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
@@ -1,58 +1,23 @@
1
1
  export function useCleaningSchedulePermission() {
2
- const { hasPermission } = usePermission();
3
- const { permissions } = useCleaningPermission();
4
-
5
2
  const { userAppRole } = useLocalSetup();
6
3
 
7
- const canViewSchedules = computed(() => {
8
- if (!userAppRole.value) return false;
9
- if (userAppRole.value.permissions.includes("*")) return true;
10
- return hasPermission(userAppRole.value, permissions, "cleaning-schedule-mgmt", "see-all-schedules");
11
- });
12
-
13
- const canViewScheduleDetails = computed(() => {
14
- if (!userAppRole.value) return false;
15
- if (userAppRole.value.permissions.includes("*")) return true;
16
- return hasPermission(userAppRole.value, permissions, "cleaning-schedule-mgmt", "see-schedule-details");
17
- });
18
-
19
- const canDownloadSchedule = computed(() => {
20
- if (!userAppRole.value) return false;
21
- if (userAppRole.value.permissions.includes("*")) return true;
22
- return hasPermission(userAppRole.value, permissions, "cleaning-schedule-mgmt", "download-schedule");
23
- });
24
-
25
- const canManageScheduleTasks = computed(() => {
26
- if (!userAppRole.value) return false;
27
- if (userAppRole.value.permissions.includes("*")) return true;
28
- return hasPermission(userAppRole.value, permissions, "cleaning-schedule-mgmt", "manage-schedule-tasks");
29
- });
30
-
31
- const canGenerateChecklist = computed(() => {
32
- if (!userAppRole.value) return false;
33
- if (userAppRole.value.permissions.includes("*")) return true;
34
- return hasPermission(userAppRole.value, permissions, "cleaning-schedule-mgmt", "generate-checklist");
35
- });
36
-
37
- const canViewHistory = computed(() => {
38
- if (!userAppRole.value) return false;
39
- if (userAppRole.value.permissions.includes("*")) return true;
40
- return hasPermission(userAppRole.value, permissions, "cleaning-schedule-mgmt", "view-history");
41
- });
42
-
43
- const canAddRemarks = computed(() => {
44
- if (!userAppRole.value) return false;
45
- if (userAppRole.value.permissions.includes("*")) return true;
46
- return hasPermission(userAppRole.value, permissions, "cleaning-schedule-mgmt", "add-remarks");
47
- });
4
+ const can = (action: string) =>
5
+ computed(() => {
6
+ const permissions = userAppRole.value?.permissions;
7
+ if (!permissions) return false;
8
+ if (permissions.includes("*")) return true;
9
+ return permissions.some((permission) =>
10
+ permission.endsWith(`-schedule-mgmt:${action}`),
11
+ );
12
+ });
48
13
 
49
14
  return {
50
- canViewSchedules,
51
- canViewScheduleDetails,
52
- canDownloadSchedule,
53
- canManageScheduleTasks,
54
- canGenerateChecklist,
55
- canViewHistory,
56
- canAddRemarks,
15
+ canViewSchedules: can("see-all-schedules"),
16
+ canViewScheduleDetails: can("see-schedule-details"),
17
+ canDownloadSchedule: can("download-schedule"),
18
+ canManageScheduleTasks: can("manage-schedule-tasks"),
19
+ canGenerateChecklist: can("generate-checklist"),
20
+ canViewHistory: can("view-history"),
21
+ canAddRemarks: can("add-remarks"),
57
22
  };
58
23
  }
@@ -236,6 +236,26 @@ export default function useServiceProvider() {
236
236
  );
237
237
  }
238
238
 
239
+ async function getByEmail({
240
+ email = "",
241
+ orgId = "",
242
+ siteId = "",
243
+ serviceProviderOrgId = "",
244
+ } = {}) {
245
+ return useNuxtApp().$api<Record<string, any>>(
246
+ "/api/service-providers/email",
247
+ {
248
+ method: "GET",
249
+ query: {
250
+ email,
251
+ ...(orgId ? { orgId } : {}),
252
+ ...(siteId ? { siteId } : {}),
253
+ ...(serviceProviderOrgId ? { serviceProviderOrgId } : {}),
254
+ },
255
+ }
256
+ );
257
+ }
258
+
239
259
  return {
240
260
  serviceProviders,
241
261
  serviceProvider,
@@ -254,5 +274,6 @@ export default function useServiceProvider() {
254
274
  add,
255
275
  addViaInvite,
256
276
  updateStatus,
277
+ getByEmail,
257
278
  };
258
279
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.1.3",
5
+ "version": "3.1.4-staging.46",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {