@7365admin1/layer-common 4.35.1-staging.385 → 4.36.0

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +10 -4
  2. package/assets/css/primitives.css +0 -43
  3. package/assets/css/robot-hero.css +2 -2
  4. package/components/AccessCardAddForm.vue +4 -5
  5. package/components/AccessCardAssignToUnitForm.vue +2 -3
  6. package/components/AccessCardPreviewDialog.vue +3 -4
  7. package/components/AppSelect.vue +3 -77
  8. package/components/CameraForm.vue +6 -20
  9. package/components/DashboardMain.vue +60 -66
  10. package/components/Dialog/UpdateMoreAction.vue +1 -11
  11. package/components/DocumentForm.vue +112 -407
  12. package/components/DocumentManagement.vue +12 -213
  13. package/components/EntryPass/QrTemplatePreview.vue +25 -21
  14. package/components/EntryPassInformation.vue +24 -119
  15. package/components/Facility/BookingSetup.vue +0 -63
  16. package/components/HidAccessLogDashboard.vue +17 -74
  17. package/components/HidIntercomManagement.vue +14 -180
  18. package/components/HidQrCodeConfiguration.vue +195 -17
  19. package/components/HidReaderManagement.vue +0 -398
  20. package/components/HidReaderUserRoster.vue +3 -44
  21. package/components/HidUserEnrollment.vue +155 -845
  22. package/components/InvitationClientForm.vue +1 -19
  23. package/components/Layout/NavigationDrawer.vue +1 -4
  24. package/components/NotificationBell.vue +7 -59
  25. package/components/Robot/BuildingMap.vue +9 -16
  26. package/components/Robot/LiftQueue.vue +4 -9
  27. package/components/Robot/MapEditor.vue +8 -1
  28. package/components/RolePermissionFormCreate.vue +3 -4
  29. package/components/RolePermissionFormPreviewUpdate.vue +5 -12
  30. package/components/RolePermissionMain.vue +0 -1
  31. package/components/ServiceProviderInvitationPrompt.vue +155 -0
  32. package/components/ServiceProviderMain.vue +30 -178
  33. package/components/VisitorForm.vue +0 -12
  34. package/components/VisitorManagement.vue +10 -285
  35. package/components/VisitorSocketPopUp.vue +1 -2
  36. package/composables/useANPRSettings.ts +15 -45
  37. package/composables/useAccessManagement.ts +0 -5
  38. package/composables/useCleaningPermission.ts +0 -2
  39. package/composables/useCommonPermission.ts +0 -32
  40. package/composables/useDocument.ts +0 -7
  41. package/composables/useFacility.ts +0 -11
  42. package/composables/useHidAmico.ts +0 -159
  43. package/composables/useHidNavigation.ts +1 -25
  44. package/composables/usePeople.ts +0 -19
  45. package/composables/usePropertyPermission.ts +0 -19
  46. package/composables/useRobotBuilding.ts +3 -22
  47. package/composables/useRobots.ts +0 -13
  48. package/composables/useSecurityPermission.ts +0 -19
  49. package/composables/useServiceProvider.ts +0 -3
  50. package/composables/useServiceProviderInvitation.ts +3 -101
  51. package/composables/useSiteEntryPassSettings.ts +0 -26
  52. package/composables/useWebUsb.ts +11 -14
  53. package/package.json +2 -2
  54. package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
  55. package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
  56. package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
  57. package/plugins/API.ts +0 -11
  58. package/types/document.d.ts +0 -14
  59. package/types/facility.d.ts +0 -4
  60. package/types/people.d.ts +1 -28
  61. package/utils/module-routes.ts +1 -8
  62. package/utils/nav-sections.ts +2 -8
  63. package/utils/notification-target.ts +8 -277
  64. package/utils/permission-labels.ts +2 -20
  65. package/components/ClientInvitations.vue +0 -291
  66. package/components/HidAccessPermissions.vue +0 -424
  67. package/components/HidProfileQrCode.vue +0 -332
  68. package/composables/useHidReaderSelection.ts +0 -50
  69. package/composables/useSiteCategory.ts +0 -45
  70. package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
  71. package/utils/dashboard-widget-prefs.ts +0 -54
  72. package/utils/hid-permission-assignments.ts +0 -190
  73. package/utils/hid-reader-selection.ts +0 -52
  74. package/utils/occupancy-role.ts +0 -109
  75. package/utils/robot-fleet-core.ts +0 -57
  76. package/utils/sp-inviter-row.ts +0 -97
@@ -388,27 +388,9 @@ watch(
388
388
  const roles = ref<any[]>([])
389
389
  const { getRoles } = useRole()
390
390
 
391
- /*
392
- * `type` is the ROLE type, not the service the client subscribes to, and the
393
- * only caller (`ClientMain.vue` "Invite Client") passes no `app` prop -- so
394
- * this sent `type: ""` and the endpoint applied no type filter at all. The
395
- * dropdown therefore listed every org-less role of every type, which includes
396
- * the platform's OWN staff roles (`type: "admin"` -- the two rows the console's
397
- * `/super-admin/role-permissions` screen edits). Offering a Seven365 console
398
- * staff role on a CLIENT invitation is the defect.
399
- *
400
- * A client admin's role is `type: "organization"`, which is what the client
401
- * roles screen (`org/[organization]/roles-permissions.vue`) already lists. This
402
- * narrows to exactly that. It is a narrowing of this one picker only: no role
403
- * changes, no permission changes, and the org-less `organization` roles that
404
- * were already being picked are all still offered.
405
- *
406
- * Keying off the selected service instead would empty the dropdown -- no
407
- * org-less role carries a service type -- so `props.app` is not the filter.
408
- */
409
391
  const { data: roleData, refresh: refreshRoles } = await useLazyAsyncData(
410
392
  "roles",
411
- () => getRoles({ org: org.value?._id, type: "organization", limit: 50 }),
393
+ () => getRoles({ org: org.value?._id, type: props.app, limit: 50 }),
412
394
  { watch: [() => props.app] }
413
395
  )
414
396
 
@@ -129,9 +129,6 @@ import { withoutDeniedRoutes } from "../../utils/module-routes";
129
129
  const props = defineProps({
130
130
  navigationItems: { type: Array, required: true },
131
131
  title: { type: String, default: "" },
132
- // An app's own name for a section heading (`withSections`), e.g.
133
- // `{ "SECURITY OPERATIONS": "ESTATE SECURITY" }` in Property Management.
134
- sectionLabels: { type: Object, default: () => ({}) },
135
132
  });
136
133
 
137
134
  const emit = defineEmits(["navigate"]);
@@ -199,7 +196,7 @@ const navigationItems = computed(() => {
199
196
  return withoutDeniedRoutes(items, userAppModulesDenied.value);
200
197
  });
201
198
 
202
- const sectionedItems = computed(() => withSections(navigationItems.value, props.sectionLabels as Record<string, string>));
199
+ const sectionedItems = computed(() => withSections(navigationItems.value));
203
200
 
204
201
  /**
205
202
  * The design's brand tile carries the product's own mark. One icon for eleven
@@ -70,10 +70,7 @@
70
70
  :key="item._id"
71
71
  type="button"
72
72
  class="notif-card"
73
- :class="{
74
- 'notif-card--unread': !item.hasRead,
75
- 'notif-card--plain': !targetFor(item),
76
- }"
73
+ :class="{ 'notif-card--unread': !item.hasRead }"
77
74
  @click="openNotification(item)"
78
75
  >
79
76
  <div class="notif-card__top">
@@ -137,7 +134,7 @@ import {
137
134
  timeAgo,
138
135
  type TInboxItem,
139
136
  } from "../utils/notification-inbox";
140
- import { chooseNavigation, notificationTarget } from "../utils/notification-target";
137
+ import { notificationTarget } from "../utils/notification-target";
141
138
 
142
139
  /**
143
140
  * The top-bar notification feed for the web, in every signed-in app.
@@ -171,7 +168,6 @@ const { getAll, getUnreadCount, markAsRead, markAllAsRead } = useNotification();
171
168
  const { currentUser } = useLocalAuth();
172
169
  const route = useRoute();
173
170
  const router = useRouter();
174
- const appConfig = useRuntimeConfig();
175
171
 
176
172
  const open = ref(false);
177
173
  const loading = ref(false);
@@ -307,59 +303,16 @@ function markRead(item: Record<string, any>) {
307
303
  * layer: the ten apps that have no order queue simply do not match it, and the
308
304
  * click there stays what it always was.
309
305
  */
310
- function targetFor(item: Record<string, any>) {
311
- return notificationTarget(item as any, {
312
- // The org app nests under `organization`; the service apps under `org`.
313
- // Staff pages and the org app have neither, so the person's own default
314
- // organisation builds a service-app link from there.
315
- organization: String(
316
- route.params?.organization ?? route.params?.org ?? currentUser.value?.defaultOrg ?? "",
317
- ),
318
- site: String(route.params?.site ?? ""),
319
- });
320
- }
321
-
322
- /** A path this app really has a page for (every app has a 404 fallback, so check the match). */
323
- function hasLocalPage(path: string) {
324
- try {
325
- const resolved = router.resolve(path);
326
- return resolved.matched.length > 0 && !resolved.matched.some((m) => m.path.includes("pathMatch"));
327
- } catch {
328
- return false;
329
- }
330
- }
331
-
332
- /** Every web app's URL, keyed like `TAppKey`, from the layer's runtime config. */
333
- function appUrls() {
334
- const p = appConfig?.public ?? {};
335
- return {
336
- org: String(p.APP_ORG ?? ""),
337
- security: String(p.APP_SECURITY ?? ""),
338
- "property-management": String(p.APP_PROPERTY_MANAGEMENT ?? ""),
339
- hygiene: String(p.APP_HYGIENE ?? ""),
340
- "mechanical-electrical": String(p.APP_MECHANICAL_ELECTRICAL ?? ""),
341
- "pest-control": String(p.APP_PEST_CONTROL ?? ""),
342
- landscaping: String(p.APP_LANDSCAPING ?? ""),
343
- "pool-maintenance": String(p.APP_POOL_MAINTENANCE ?? ""),
344
- };
345
- }
346
-
347
- /** The decision is `chooseNavigation` (unit-tested); this only carries it out. */
348
306
  async function goToSubject(item: Record<string, any>) {
349
- const nav = chooseNavigation(targetFor(item), {
350
- hasRoute: (name) => typeof router.hasRoute === "function" && router.hasRoute(name),
351
- hasPage: hasLocalPage,
352
- appUrls: appUrls(),
307
+ const target = notificationTarget(item as any, {
308
+ organization: String(route.params?.organization ?? ""),
353
309
  });
354
- if (!nav) return;
310
+ if (!target) return;
311
+ if (typeof router.hasRoute !== "function" || !router.hasRoute(target.name)) return;
355
312
 
356
313
  open.value = false;
357
- if (nav.type === "href") {
358
- window.location.assign(nav.href);
359
- return;
360
- }
361
314
  try {
362
- await router.push(nav.to as any);
315
+ await router.push(target);
363
316
  } catch {
364
317
  // A cancelled or duplicate navigation is not worth an error in the top bar.
365
318
  }
@@ -498,11 +451,6 @@ onBeforeUnmount(() => {
498
451
  background: var(--hover);
499
452
  }
500
453
 
501
- /* Nothing to open: still marks read on click, but does not look like a link. */
502
- .notif-card--plain {
503
- cursor: default;
504
- }
505
-
506
454
  .notif-card__top {
507
455
  display: flex;
508
456
  align-items: baseline;
@@ -18,7 +18,7 @@
18
18
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" /></svg>
19
19
  Full screen
20
20
  </v-btn>
21
- <v-btn v-if="canEditMap && !fleet" :variant="editing ? 'outlined' : 'flat'" :color="editing ? undefined : 'primary'" @click="editing = !editing">
21
+ <v-btn v-if="canEditMap" :variant="editing ? 'outlined' : 'flat'" :color="editing ? undefined : 'primary'" @click="editing = !editing">
22
22
  {{ editing ? "Exit edit mode" : "Edit map" }}
23
23
  </v-btn>
24
24
  </div>
@@ -26,10 +26,8 @@
26
26
  <div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
27
27
  <div v-else-if="!loading && emptyNote" class="hero-notice">{{ emptyNote }}</div>
28
28
 
29
- <div v-if="fleet" class="hero-notice" role="status">Live building from HERO Fleet Core - doors and lifts update every few seconds. It is managed by the fleet, so it cannot be edited here.</div>
30
-
31
29
  <RobotMapEditor
32
- v-if="editing && canEditMap && !fleet"
30
+ v-if="editing && canEditMap"
33
31
  ref="editor"
34
32
  :map="map"
35
33
  :floors="floors"
@@ -107,11 +105,9 @@
107
105
  class="hero-bmap__floor"
108
106
  :style="{ transform: `translateZ(${i * FLOOR_GAP_PX}px)`, opacity: f.dim, '--hero-fill': look.fill }"
109
107
  >
110
- <template v-if="!fleet">
111
- <span class="hero-bmap__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
112
- <span class="hero-bmap__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
113
- <span class="hero-bmap__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
114
- </template>
108
+ <span class="hero-bmap__room" style="left: 8%; top: 10%; width: 34%; height: 30%" aria-hidden="true" />
109
+ <span class="hero-bmap__room" style="left: 58%; top: 10%; width: 34%; height: 44%" aria-hidden="true" />
110
+ <span class="hero-bmap__room" style="left: 8%; top: 62%; width: 60%; height: 28%" aria-hidden="true" />
115
111
  <template v-if="!map">
116
112
  <span class="hero-bmap__door" style="left: 42%; top: 22%" aria-hidden="true" />
117
113
  <span class="hero-bmap__door" style="left: 58%; top: 30%" aria-hidden="true" />
@@ -120,7 +116,7 @@
120
116
  <template v-for="s in f.shapes" :key="s.id">
121
117
  <polygon v-if="s.kind === 'zone'" class="hero-bmap__zone" :points="svgPoints(s)" vector-effect="non-scaling-stroke" />
122
118
  <line v-else-if="s.kind === 'wall'" class="hero-bmap__wall" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
123
- <line v-else-if="s.kind === 'door'" class="hero-bmap__doorline" :class="{ 'hero-bmap__doorline--open': openDoors.has(String(s.meta?.name)) }" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
119
+ <line v-else-if="s.kind === 'door'" class="hero-bmap__doorline" :x1="s.points[0]!.x" :y1="s.points[0]!.y" :x2="s.points[1]!.x" :y2="s.points[1]!.y" vector-effect="non-scaling-stroke" />
124
120
  </template>
125
121
  </svg>
126
122
 
@@ -169,7 +165,6 @@
169
165
 
170
166
  <div class="hero-bmap__legend">
171
167
  <span><i class="hero-bmap__key hero-bmap__key--door" />Door</span>
172
- <span v-if="fleet"><i class="hero-bmap__key hero-bmap__key--dooropen" />Door open</span>
173
168
  <span><i class="hero-bmap__key hero-bmap__key--shaft" />Lift shaft</span>
174
169
  <span><i class="hero-bmap__key hero-bmap__key--bot" />Robot (live)</span>
175
170
  <span v-if="type === 'security'"><i class="hero-bmap__key hero-bmap__key--route" />Patrol route · numbered checkpoints</span>
@@ -202,7 +197,6 @@ import RobotMapEditor from "./MapEditor.vue";
202
197
  import RobotReconnecting from "./Reconnecting.vue";
203
198
  import useRobotBuilding from "../../composables/useRobotBuilding";
204
199
  import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
205
- import { openDoorNames } from "../../utils/robot-fleet-core";
206
200
  import {
207
201
  brandName,
208
202
  floorName,
@@ -264,12 +258,11 @@ const emit = defineEmits<{
264
258
  status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
265
259
  }>();
266
260
 
267
- const { rows, map, fleet, names, loading, error, reload } = useRobotBuilding(
261
+ const { rows, map, names, loading, error, reload } = useRobotBuilding(
268
262
  toRef(props, "organization"),
269
263
  toRef(props, "site"),
270
264
  toRef(props, "type"),
271
265
  );
272
- const openDoors = computed(() => openDoorNames(map.value));
273
266
  const siteLabel = computed(() => props.siteName || names.value.siteName || "This site");
274
267
 
275
268
  /* ---- live states + patrol routes of the robots on screen ---- */
@@ -388,8 +381,8 @@ function fullscreen(el?: HTMLElement | null) {
388
381
  else el?.requestFullscreen?.();
389
382
  }
390
383
  const editing = ref(false);
391
- watch([() => props.canEditMap, fleet], ([can, isFleet]) => {
392
- if (!can || isFleet) editing.value = false;
384
+ watch(() => props.canEditMap, (can) => {
385
+ if (!can) editing.value = false;
393
386
  });
394
387
  /** The robot a first save is attached to (maps are stored per robot): one of this site's. */
395
388
  const ownerRobotId = computed(() => String(map.value?.robotId ?? rows.value[0]?._id ?? ""));
@@ -16,7 +16,6 @@
16
16
  </div>
17
17
 
18
18
  <div v-if="error" class="hero-notice" role="alert">{{ error }}</div>
19
- <div v-else-if="fleet" class="hero-notice" role="status">Live lifts from HERO Fleet Core - the fleet calls and releases lifts itself.</div>
20
19
  <div v-else-if="!connected" class="hero-notice" role="status">Reconnecting… the lift queue shows its last reading.</div>
21
20
 
22
21
  <div v-if="loading && !cards.length" class="hero-lifts" role="status" aria-label="Loading the lift queue">
@@ -76,7 +75,7 @@
76
75
  <span class="hero-lift-row__wait">{{ q.wait }}</span>
77
76
  <span class="hero-chip" :class="`hero-tone--${liftState(q.state).tone}`">{{ liftState(q.state).label }}</span>
78
77
  <button
79
- v-if="canCommand && !fleet"
78
+ v-if="canCommand"
80
79
  type="button"
81
80
  class="hero-lift-row__release"
82
81
  :style="{ visibility: q.state === 'riding' || q.state === 'requested' ? 'visible' : 'hidden' }"
@@ -89,7 +88,7 @@
89
88
  </div>
90
89
  </div>
91
90
 
92
- <form v-if="canCommand && demoRobots.length && !fleet" class="hero-lift__ask" @submit.prevent="ask(l)">
91
+ <form v-if="canCommand && demoRobots.length" class="hero-lift__ask" @submit.prevent="ask(l)">
93
92
  <select v-model="askRobot[l.name]" class="hero-input hero-select hero-select--sm" aria-label="Robot">
94
93
  <option value="" disabled>Robot</option>
95
94
  <option v-for="r in demoRobots" :key="String(r._id)" :value="String(r._id)">{{ r.name }}</option>
@@ -116,7 +115,6 @@ import RobotToast from "./Toast.vue";
116
115
  import useRobotBuilding from "../../composables/useRobotBuilding";
117
116
  import { floorName, liftState, robotErrorMessage } from "../../utils/robot-display";
118
117
  import { liftCard, type TLiftRowView } from "../../utils/robot-building";
119
- import { fleetLiftItems } from "../../utils/robot-fleet-core";
120
118
 
121
119
  const props = withDefaults(
122
120
  defineProps<{
@@ -139,7 +137,7 @@ const emit = defineEmits<{
139
137
  status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
140
138
  }>();
141
139
 
142
- const { rows, all, map, fleet, names, loading: mapLoading, error: mapError } = useRobotBuilding(
140
+ const { rows, map, names, loading: mapLoading, error: mapError } = useRobotBuilding(
143
141
  toRef(props, "organization"),
144
142
  toRef(props, "site"),
145
143
  toRef(props, "type"),
@@ -156,8 +154,6 @@ const loading = computed(() => mapLoading.value || queueLoading.value);
156
154
  const error = computed(() => mapError.value);
157
155
 
158
156
  async function poll() {
159
- // The fleet building has no lift sessions of ours: its lifts come live with the building (5 s refresh).
160
- if (fleet.value) return void (items.value = fleetLiftItems(map.value, all.value), (queueLoading.value = false));
161
157
  const id = map.value?._id;
162
158
  if (!id) return void (items.value = []);
163
159
  if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
@@ -173,10 +169,9 @@ async function poll() {
173
169
  }
174
170
  let timer: ReturnType<typeof setInterval> | undefined;
175
171
  watch(
176
- () => (fleet.value ? map.value : map.value?._id),
172
+ () => map.value?._id,
177
173
  (id) => {
178
174
  clearInterval(timer);
179
- if (fleet.value) return void poll();
180
175
  items.value = [];
181
176
  if (!id) return;
182
177
  queueLoading.value = true;
@@ -38,6 +38,10 @@
38
38
  @input="dispatch({ type: 'rename', name: zoneName })"
39
39
  />
40
40
  <span class="hero-editor__hint">{{ TOOL_HINTS[tool] }}</span>
41
+ <v-btn variant="outlined" class="hero-editor__fs" @click="fullscreen">
42
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" /></svg>
43
+ Full screen
44
+ </v-btn>
41
45
  </div>
42
46
 
43
47
  <div v-if="state.notice" class="hero-editor__notice" :class="{ 'hero-editor__notice--ok': state.notice.ok }" role="status" aria-live="polite">{{ state.notice.text }}</div>
@@ -478,6 +482,9 @@ const isFs = ref(false);
478
482
  const onFs = () => (isFs.value = document.fullscreenElement === root.value);
479
483
  onMounted(() => document.addEventListener("fullscreenchange", onFs));
480
484
  onBeforeUnmount(() => document.removeEventListener("fullscreenchange", onFs));
481
- // Full screen is the page header's one button (BuildingMap), which full-screens `root` while editing.
485
+ function fullscreen() {
486
+ if (document.fullscreenElement) document.exitFullscreen?.();
487
+ else root.value?.requestFullscreen?.();
488
+ }
482
489
  defineExpose({ root });
483
490
  </script>
@@ -139,7 +139,7 @@
139
139
  </template>
140
140
  <v-list-item-title>
141
141
  <span class="text-capitalize role-create__row-title">
142
- {{ formatResourceLabel(String(resourceKey), props.type) }}
142
+ {{ formatResourceLabel(String(resourceKey)) }}
143
143
  </span>
144
144
  </v-list-item-title>
145
145
  <template #append>
@@ -279,7 +279,6 @@ const props = defineProps({
279
279
  },
280
280
  });
281
281
 
282
-
283
282
  const emit = defineEmits(["cancel", "success", "success:create-more"]);
284
283
 
285
284
  const validForm = ref(false);
@@ -538,14 +537,14 @@ const moduleSearch = ref<string | null>("");
538
537
  /** `safePermissions`, narrowed to what the search box matches. */
539
538
  const visiblePermissions = computed(() => {
540
539
  const query = (moduleSearch.value ?? "").trim().toLowerCase();
541
- const all = sortResourcesByLabel(safePermissions.value, props.type);
540
+ const all = sortResourcesByLabel(safePermissions.value);
542
541
  if (!query) return all;
543
542
 
544
543
  const matches: Record<string, any> = {};
545
544
  for (const [resource, actions] of Object.entries(all)) {
546
545
  const haystack = [
547
546
  resource,
548
- formatResourceLabel(resource, props.type),
547
+ formatResourceLabel(resource),
549
548
  ...Object.keys(actions ?? {}).flatMap((action) => [
550
549
  action,
551
550
  formatActionLabel(action),
@@ -102,7 +102,7 @@
102
102
  />
103
103
  </template>
104
104
  <v-list-item-title class="text-capitalize role-perm__row-title">
105
- {{ formatResourceLabel(String(resourceKey), props.type) }}
105
+ {{ formatResourceLabel(String(resourceKey)) }}
106
106
  </v-list-item-title>
107
107
  <template #append>
108
108
  <div class="role-perm__cat-actions">
@@ -191,7 +191,7 @@
191
191
  <v-divider v-if="resourceIndex > 0" />
192
192
  <v-list-item density="compact" :ripple="false">
193
193
  <v-list-item-title class="text-capitalize role-perm__row-title">
194
- {{ formatResourceLabel(String(resourceKey), props.type) }}
194
+ {{ formatResourceLabel(String(resourceKey)) }}
195
195
  </v-list-item-title>
196
196
  <v-list-item-subtitle class="role-perm__row-sub">
197
197
  {{
@@ -311,15 +311,8 @@ const props = defineProps({
311
311
  type: Number,
312
312
  default: 0,
313
313
  },
314
- // The role type the editor was opened for; only changes a module's NAME
315
- // where one app calls a key something else (`RESOURCE_LABELS_BY_TYPE`).
316
- type: {
317
- type: String,
318
- default: "",
319
- },
320
314
  });
321
315
 
322
-
323
316
  /** A role holding the wildcard has ONE stored entry and reaches everything. */
324
317
  const holdsWildcard = computed(() => (definedModel.value ?? []).includes("*"));
325
318
 
@@ -493,14 +486,14 @@ const moduleSearch = ref<string | null>("");
493
486
 
494
487
  const visiblePermissions = computed(() => {
495
488
  const query = (moduleSearch.value ?? "").trim().toLowerCase();
496
- const all = sortResourcesByLabel(safePermissions.value, props.type);
489
+ const all = sortResourcesByLabel(safePermissions.value);
497
490
  if (!query) return all;
498
491
 
499
492
  const matches: Record<string, any> = {};
500
493
  for (const [resource, actions] of Object.entries(all)) {
501
494
  const haystack = [
502
495
  resource,
503
- formatResourceLabel(resource, props.type),
496
+ formatResourceLabel(resource),
504
497
  ...Object.keys((actions as Record<string, unknown>) ?? {}).flatMap(
505
498
  (action) => [action, formatActionLabel(action)]
506
499
  ),
@@ -516,7 +509,7 @@ const visiblePermissions = computed(() => {
516
509
  // Same A-Z as the tree above it; a second list in a second order reads as a
517
510
  // different kind of thing.
518
511
  const sortedNotOffered = computed(() =>
519
- sortResourcesByLabel(props.notOfferedPermissions as Record<string, any>, props.type)
512
+ sortResourcesByLabel(props.notOfferedPermissions as Record<string, any>)
520
513
  );
521
514
 
522
515
  /*
@@ -120,7 +120,6 @@
120
120
  :can-update-role="props.canUpdateRole"
121
121
  :catalogue-size="permissionCatalogueSize"
122
122
  :module-count="permissionModuleCount"
123
- :type="props.type"
124
123
  />
125
124
  </v-dialog>
126
125
 
@@ -0,0 +1,155 @@
1
+ <template>
2
+ <!-- `AppCard` is the design's card - 14px radius, a 1px `--border` rule and
3
+ the one-pixel shadow on `--card`. The `v-card rounded="lg"
4
+ variant="outlined"` it replaced was Vuetify's own card at its own
5
+ radius and border weight. -->
6
+ <AppCard v-if="invitation" class="sp-invite-prompt">
7
+ <div class="d-flex align-start ga-3 pa-4">
8
+ <v-avatar color="primary" variant="tonal" size="44">
9
+ <v-icon>mdi-office-building-marker-outline</v-icon>
10
+ </v-avatar>
11
+ <div class="flex-grow-1">
12
+ <div class="screen-card-title">
13
+ {{ invitation.orgName }} has invited you to {{ invitation.siteName }}
14
+ </div>
15
+ <div class="screen-hint sp-invite-prompt__sub">
16
+ Approved by Seven365 · {{ expiryLine }}
17
+ </div>
18
+ </div>
19
+ </div>
20
+
21
+ <div class="px-4 sp-invite-prompt__body">
22
+ {{ invitation.orgName }} would like your company to provide services at
23
+ <strong>{{ invitation.siteName }}</strong
24
+ >. If you accept, this site will be added to your list of sites.
25
+ </div>
26
+
27
+ <div class="d-flex align-center ga-2 px-4 py-4">
28
+ <!-- Both stay `v-btn`s: `loading` is the only signal that an accept or a
29
+ decline is in flight, and `AppButton` has no equivalent. They take
30
+ the design's two button classes instead. -->
31
+ <v-btn
32
+ variant="flat"
33
+ class="screen-btn-primary"
34
+ :loading="working === 'accept'"
35
+ :disabled="Boolean(working) || !open"
36
+ @click="accept"
37
+ >
38
+ Accept
39
+ </v-btn>
40
+ <v-btn
41
+ variant="outlined"
42
+ class="screen-btn-ghost"
43
+ :loading="working === 'decline'"
44
+ :disabled="Boolean(working) || !open"
45
+ @click="decline"
46
+ >
47
+ Decline
48
+ </v-btn>
49
+ <v-spacer />
50
+ <!-- Was a raw `v-chip` on Vuetify's fixed `success` green, or no colour
51
+ at all for every other outcome. `StatusChip` maps the status WORD
52
+ through the shared `utils/status.ts` tones, so "complete" and
53
+ "declined" come out the same colours they do everywhere else. -->
54
+ <StatusChip
55
+ v-if="!open"
56
+ :status="invitation.status"
57
+ :label="invitation.statusLabel"
58
+ />
59
+ </div>
60
+ </AppCard>
61
+
62
+ <AppCard v-else-if="error">
63
+ <div class="pa-4 sp-invite-prompt__body">{{ error }}</div>
64
+ </AppCard>
65
+ </template>
66
+
67
+ <script setup lang="ts">
68
+ /**
69
+ * The invitation as the service provider sees it — inside the app they already
70
+ * use, from the session they already have.
71
+ *
72
+ * There is deliberately no sign-up and no one-time code anywhere on this
73
+ * screen: a company that already exists on iService365 never creates a second
74
+ * account. The email they receive only carries them here.
75
+ *
76
+ * An invitation that has not been approved by Seven365 is not readable at all —
77
+ * the server answers "not found" — so this component cannot show one.
78
+ */
79
+ const props = defineProps({
80
+ invitationId: { type: String, required: true },
81
+ });
82
+
83
+ const emit = defineEmits<{ (e: "answered", status: string): void }>();
84
+
85
+ const { getInvitation, declineInvitation } = useServiceProviderInvitation();
86
+ const { addViaInvite } = useCustomerSite();
87
+
88
+ const invitation = ref<any>(null);
89
+ const error = ref("");
90
+ const working = ref("");
91
+
92
+ const open = computed(() => invitation.value?.status === "pending");
93
+
94
+ const expiryLine = computed(() => {
95
+ const expires = invitation.value?.expireAt;
96
+ if (!expires) return "";
97
+ const days = Math.max(
98
+ 0,
99
+ Math.ceil((new Date(expires).getTime() - Date.now()) / 86400000),
100
+ );
101
+ if (!open.value) return "no longer waiting for an answer";
102
+ return days <= 1 ? "expires within a day" : `expires in ${days} days`;
103
+ });
104
+
105
+ async function load() {
106
+ try {
107
+ invitation.value = await getInvitation(props.invitationId);
108
+ } catch (err: any) {
109
+ error.value =
110
+ err?.data?.message || "This invitation is no longer available.";
111
+ }
112
+ }
113
+
114
+ async function accept() {
115
+ working.value = "accept";
116
+ try {
117
+ await addViaInvite(props.invitationId);
118
+ await load();
119
+ emit("answered", "complete");
120
+ } catch (err: any) {
121
+ error.value = err?.data?.message || "Could not accept this invitation.";
122
+ } finally {
123
+ working.value = "";
124
+ }
125
+ }
126
+
127
+ async function decline() {
128
+ working.value = "decline";
129
+ try {
130
+ await declineInvitation(props.invitationId);
131
+ await load();
132
+ emit("answered", "declined");
133
+ } catch (err: any) {
134
+ error.value = err?.data?.message || "Could not decline this invitation.";
135
+ } finally {
136
+ working.value = "";
137
+ }
138
+ }
139
+
140
+ await load();
141
+ </script>
142
+
143
+ <style scoped>
144
+ /* The title was a hand-set 1.05rem/600; `.screen-card-title` is the design's
145
+ 15.5px/800, so only the wrap and the leading are left here. */
146
+ .sp-invite-prompt :deep(.screen-card-title) {
147
+ display: block;
148
+ line-height: 1.35;
149
+ }
150
+
151
+ .sp-invite-prompt__sub,
152
+ .sp-invite-prompt__body {
153
+ font-size: var(--fs-cell);
154
+ }
155
+ </style>