@7365admin1/layer-common 4.35.1-staging.384 → 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 -86
@@ -345,19 +345,16 @@ export default function useWebUsb() {
345
345
  70,
346
346
  );
347
347
 
348
- // The pass used to print five copies of the same QR - four 140px ones at
349
- // the corners around a 250px centre - so a strip could be cut apart. It now
350
- // prints a single large QR filling that same band (y 90 to 440, above the
351
- // company name at y 470), which scans far more reliably on a thermal print.
352
- // Generated at the size it is drawn at: the old corner copies were
353
- // downscaled from the 250px canvas, which softened their modules.
354
- const qrSize = 350;
355
- const qrCanvas = await QRCode.toCanvas(qrUrlImage, {
356
- width: qrSize,
357
- margin: 1,
358
- });
348
+ const qrSmall = 140;
349
+ const qrCenter = 250;
350
+ const margin = 20;
351
+ const qrCanvas = await QRCode.toCanvas(qrUrlImage, { width: qrCenter });
359
352
 
360
- ctx.drawImage(qrCanvas, (canvas.width - qrSize) / 2, 90, qrSize, qrSize);
353
+ ctx.drawImage(qrCanvas, margin, 90, qrSmall, qrSmall);
354
+ ctx.drawImage(qrCanvas, canvas.width - qrSmall - margin, 90, qrSmall, qrSmall);
355
+ ctx.drawImage(qrCanvas, (canvas.width - qrCenter) / 2, 110, qrCenter, qrCenter);
356
+ ctx.drawImage(qrCanvas, margin, 300, qrSmall, qrSmall);
357
+ ctx.drawImage(qrCanvas, canvas.width - qrSmall - margin, 300, qrSmall, qrSmall);
361
358
 
362
359
  ctx.font = "25px Arial";
363
360
  ctx.fillText(companyName || "", canvas.width / 2, 470);
@@ -404,8 +401,8 @@ export default function useWebUsb() {
404
401
  /**
405
402
  * The self-service onboarding QR: one large QR code, a "SCAN QR CODE"-style
406
403
  * subtext, and a dark site-name banner along the bottom - a single-purpose
407
- * gate sign, not the visitor-pass receipt `createReceiptLayout` produces
408
- * (that one pairs its QR with company, date, access and instruction lines).
404
+ * gate sign, not the multi-copy visitor-pass receipt `createReceiptLayout`
405
+ * produces (that one prints several QRs to be cut apart and handed out).
409
406
  */
410
407
  async function createOnboardingQrLayout(
411
408
  qrUrlImage: string,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.35.1-staging.384",
5
+ "version": "4.36.0",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -36,7 +36,7 @@
36
36
  "build": "nuxt build .playground",
37
37
  "generate": "nuxt generate .playground",
38
38
  "preview": "nuxt preview .playground",
39
- "test": "esbuild composables/useANPRSettings.ts --format=esm --outfile=test/.build/useANPRSettings.mjs --log-level=error && esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
39
+ "test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
40
40
  "test:units": "node --experimental-strip-types --test \"utils/*.test.ts\"",
41
41
  "release": "yarn run build && changeset publish"
42
42
  },
@@ -5,7 +5,7 @@
5
5
  :org="orgId"
6
6
  message="Enable HID as a service for this site before managing HID users."
7
7
  >
8
- <HidUserEnrollment :site="siteId" :org="orgId" />
8
+ <HidUserEnrollment :site="siteId" />
9
9
  </HidEnabledGate>
10
10
  </v-container>
11
11
  </template>
@@ -5,7 +5,7 @@
5
5
  :org="orgId"
6
6
  message="Enable HID as a service for this site before managing physical HID cards."
7
7
  >
8
- <HidUserEnrollment :site="siteId" :org="orgId" card-management />
8
+ <HidUserEnrollment :site="siteId" card-management />
9
9
  </HidEnabledGate>
10
10
  </v-container>
11
11
  </template>
@@ -5,7 +5,7 @@
5
5
  :org="orgId"
6
6
  message="Enable HID as a service for this site before managing HID readers."
7
7
  >
8
- <HidReaderManagement :site="siteId" :org="orgId" />
8
+ <HidReaderManagement :site="siteId" />
9
9
  </HidEnabledGate>
10
10
  </v-container>
11
11
  </template>
package/plugins/API.ts CHANGED
@@ -11,17 +11,6 @@ export default defineNuxtPlugin((nuxtApp) => {
11
11
  onRequest({ options }) {
12
12
  const sid = useCookie("sid", cookieConfig).value ?? "";
13
13
  options.headers.set("Authorization", sid);
14
- // Which of the user's memberships this screen is working in (DV-0396):
15
- // this app's member type and the organisation in the address. With
16
- // several memberships pinned to one site, the server then checks the
17
- // same role the screen was drawn from (core `requireSiteReach`). Only
18
- // chooses among the user's own memberships; it grants nothing.
19
- const app = useRuntimeConfig().public.APP;
20
- if (app) options.headers.set("X-App", String(app));
21
- const org = (nuxtApp as any).$router?.currentRoute?.value?.params?.org;
22
- if (typeof org === "string" && /^[0-9a-f]{24}$/i.test(org)) {
23
- options.headers.set("X-Org", org);
24
- }
25
14
  },
26
15
  // "You do not have permission to ..." - the role changed since the page
27
16
  // read it. Re-read it in the background (plugins/secure-member.client.ts)
@@ -3,18 +3,4 @@ declare type TDocument = {
3
3
  name: string;
4
4
  attachment: string;
5
5
  type: string;
6
- /**
7
- * Set at upload or renewal, absent when the document never expires.
8
- *
9
- * An ISO instant on the way out of the API and a `YYYY-MM-DD` string on the
10
- * way back in, which is what `InputDatePicker` binds to - hence the loose
11
- * type rather than `string`.
12
- */
13
- expiryDate?: string | Date | null;
14
- remarks?: string;
15
- size?: number;
16
- parentId?: string | null;
17
- createdAt?: string | Date;
18
- updatedAt?: string | Date;
19
- status?: string;
20
6
  };
@@ -73,10 +73,6 @@ declare type TFacility = {
73
73
  hourInterval?: number;
74
74
  isBookingFeeEnabled: boolean;
75
75
  isGuestEnabled: boolean;
76
- /** Residents may complete this facility's checklist themselves. */
77
- isResidentChecklistEnabled?: boolean;
78
- /** Security may complete it. Mutually exclusive with the above. */
79
- isSecurityChecklistEnabled?: boolean;
80
76
  bookingAmount: number | null;
81
77
  peakBookingFeeAmount?: number | null;
82
78
  bookingFeeGuestAmount?: number | null;
package/types/people.d.ts CHANGED
@@ -22,38 +22,11 @@ declare type TPeople = {
22
22
  email?: string;
23
23
  files?: { name: string, id: string }[];
24
24
  isOwner?: boolean;
25
- /**
26
- * Owner / occupant / person-in-charge, as the BUSINESS people form asks it.
27
- * Absent on everyone else - `isOwner` is still the flag that decides unit
28
- * ownership, and the server derives it from this when it is sent. See
29
- * `OccupancyRoles` in core's `person.model.ts`.
30
- */
31
- occupancyRole?: TOccupancyRole;
32
- // Link to this person's `users` row. Empty for the person types that carry
33
- // no login, and empty for a resident whose account was never written.
34
- user?: string;
35
- };
36
-
37
- declare type TOccupancyRole = "owner" | "occupant" | "person-in-charge";
38
-
39
- /** One standing and everything the product says about it. See `utils/occupancy-role.ts`. */
40
- declare type TOccupancyRoleOption = {
41
- value: TOccupancyRole;
42
- /** The label a stored role is printed with. */
43
- title: string;
44
- /** The row menu's wording. */
45
- action: string;
46
- /** The confirmation's heading and its sentence. */
47
- prompt: string;
48
- description: string;
49
25
  };
50
26
 
51
27
  declare type TPlateNumber = { plateNumber: string, recNo?: string, _id?: string, status: TVehicleStatus, type: TVehicleType, anprCameras?: TVehicleAnprCamera[] };
52
28
 
53
29
 
54
- // `""` is the FORM's "nothing picked yet" - it is stripped from the payload
55
- // before it is sent, because absence is what the server reads as "never
56
- // asked". See `PeopleFormMgmt.vue`.
57
- declare type TPeoplePayload = Pick<TGuest, "name" | "block" | "level" | "unit" | "unitName" | "contact" | "plateNumber" | "nric" | "contact" | "remarks" | "org" | "site" | "start" | "end" | "type" | "isOwner"> & { status?: string; user?: string; occupancyRole?: TOccupancyRole | "" }
30
+ declare type TPeoplePayload = Pick<TGuest, "name" | "block" | "level" | "unit" | "unitName" | "contact" | "plateNumber" | "nric" | "contact" | "remarks" | "org" | "site" | "start" | "end" | "type" | "isOwner"> & { status?: string }
58
31
 
59
32
  declare type TPeopleType = "guest" | "resident" | "tenant"
@@ -12,7 +12,7 @@
12
12
  * (checked against every app's own `useLocalPermission` nav gate, 2026-09-11).
13
13
  * Shared or ambiguous pages stay out: calendar, keys (gated on `site-settings`),
14
14
  * access-mgmt (`site-settings:manage-entry-pass` also opens it), equipment and
15
- * inventory, service-providers and support tickets.
15
+ * inventory, the property CCTV wall, service-providers and support tickets.
16
16
  *
17
17
  * NEVER MAPPED: the dashboard, settings, members, invitations and roles. A client
18
18
  * must always be able to run its own people and roles; the server never denies
@@ -40,13 +40,6 @@ export const MODULE_ROUTES: Readonly<Record<string, string>> = Object.freeze({
40
40
  // Includes its CCTV page: security's drawer shows it to whoever can see
41
41
  // Virtual Patrol, so it follows the same module.
42
42
  "org-site-virtual-patrol": "virtual-patrol",
43
- // Property Management's camera wall (owner approval 2026-09-25, item 3). Its
44
- // menu, page and the server's wall endpoint all read the four legacy
45
- // `virtual-patrol:` strings (`useCameraViewPermission`, core
46
- // `CAMERA_VIEW_PERMISSIONS`), and the PM role editor now calls that row
47
- // "CCTV". So it follows the same module as Security's own CCTV page above.
48
- // Fails open like every entry: a client with no module list keeps it.
49
- "org-site-cctv": "virtual-patrol",
50
43
  "org-site-robot-management": "robot-mgmt",
51
44
  "org-site-attendance": "attendance-mgmt",
52
45
  "org-site-my-attendance": "my-attendance",
@@ -125,13 +125,7 @@ export const SECTION_ORDER: string[] = [
125
125
  * objects; only the order changes.
126
126
  */
127
127
  export function withSections<T extends { title?: string }>(
128
- items: T[],
129
- /**
130
- * An application's own name for a heading, e.g. Property Management prints
131
- * SECURITY OPERATIONS as ESTATE SECURITY (owner approval 2026-09-25, item 6).
132
- * Changes the printed label only; grouping and order are unchanged.
133
- */
134
- labels: Readonly<Record<string, string>> = {}
128
+ items: T[]
135
129
  ): Array<TSectionedNavItem<T>> {
136
130
  // An unmapped title inherits the section of the item above it.
137
131
  let current = "";
@@ -150,7 +144,7 @@ export function withSections<T extends { title?: string }>(
150
144
 
151
145
  let printed = "";
152
146
  return grouped.map(({ item, section }) => {
153
- const label = section === printed ? "" : labels[section] ?? section;
147
+ const label = section === printed ? "" : section;
154
148
  printed = section;
155
149
  return { item, section: label };
156
150
  });
@@ -24,68 +24,25 @@
24
24
  * Eleven web apps render this bell and only `iservice365-web-app-org` has an
25
25
  * order queue. Rather than make eleven apps configure a map, the name is
26
26
  * written down once here and the bell asks its own router whether the route
27
- * exists before pushing.
28
- *
29
- * EVERY KIND SINCE 2026-09-24 (owner: "the notification never routes me")
30
- * Each row names what it is about (`params.kind`, else its `module`), and the
31
- * tables below say which web apps have that page. The bell opens this app's
32
- * own page when it has one and otherwise jumps to the app that does, by its
33
- * `APP_*` URL, with the organisation and site in the path.
27
+ * exists before pushing — so in the ten apps without the page the click stays
28
+ * exactly what it was: mark as read, go nowhere.
34
29
  */
35
30
 
36
31
  /** Only the fields a destination is decided from. */
37
32
  export type TTargetRow = {
38
33
  module?: string;
39
- screen?: string;
40
- title?: string;
41
- appSlug?: string;
42
- siteId?: string | null;
43
- /**
44
- * `kind` is the click-through target every sender sets since 2026-09-24
45
- * (core `withNotificationKind`); `site` stands in for `siteId` on rows kept
46
- * unscoped on purpose. Older rows have neither and are read from `module`.
47
- */
48
- params?: { id?: string; org?: string; kind?: string; site?: string } | null;
34
+ params?: { id?: string } | null;
49
35
  };
50
36
 
51
37
  /** What the surrounding app knows that the stored row does not. */
52
38
  export type TTargetContext = {
53
39
  /** The organization in the current URL. Org routes are all nested under it. */
54
40
  organization?: string;
55
- /** The site in the current URL, used when the row names none. */
56
- site?: string;
57
41
  };
58
42
 
59
- /** The web apps a click can land in, keyed like their `APP_*` runtime URLs. */
60
- export type TAppKey =
61
- | "org"
62
- | "security"
63
- | "property-management"
64
- | "hygiene"
65
- | "mechanical-electrical"
66
- | "pest-control"
67
- | "landscaping"
68
- | "pool-maintenance";
69
-
70
- /** One place the record can be opened: a path inside one web app. */
71
- export type TCandidate = { app: TAppKey; path: string };
72
-
73
43
  export type TNotificationTarget = {
74
- /** A route name in the current app, tried first (the two original targets). */
75
- name?: string;
76
- params?: Record<string, string>;
77
- query?: Record<string, string>;
78
- /**
79
- * Set when the page lives in another web app. The bell opens
80
- * `<that app's URL><path>` when its own router has no route by `name`.
81
- */
82
- app?: TAppKey;
83
- path?: string;
84
- /**
85
- * Every page that shows the record, best first. The bell opens the first one
86
- * its own router has; otherwise it jumps to the first whose app URL is set.
87
- */
88
- candidates?: TCandidate[];
44
+ name: string;
45
+ params: Record<string, string>;
89
46
  };
90
47
 
91
48
  /** Nuxt's generated names for `pages/org/[organization]/marketplace/orders/`. */
@@ -95,175 +52,13 @@ export const ORDER_DETAIL_ROUTE = "org-organization-marketplace-orders-id";
95
52
  /** The stored `module` tag on a row sent to a vendor's staff. */
96
53
  export const VENDOR_ORDER_MODULE = "marketplace-vendor-order";
97
54
 
98
- /**
99
- * "<Company> has invited you to <site>" — core `serviceProviderInvited` stores
100
- * `module: "other"` (the apps route on it; it never changes) with this
101
- * `screen`, the invitation id in `params.id`, and, since 2026-09-24, the
102
- * invited company's own organisation in `params.org`.
103
- *
104
- * It opens the organisation app's Invitations page, where "Invitations from
105
- * other organisations" lists it with Accept / Decline. Only the organisation
106
- * app has that page; every other app sends the click there by URL.
107
- */
108
- export const SP_INVITATION_SCREEN = "/service-provider-invitation";
109
- export const INVITATIONS_ROUTE = "org-organization-client-invitations";
110
-
111
- /**
112
- * What a row is about. `params.kind` when the sender set it; for older rows the
113
- * `module`, except where the module is shared: `platform` (a new client, an
114
- * accepted invitation, a robot) and `other` (both halves of a service-provider
115
- * invitation) are told apart by the fixed titles their senders use.
116
- */
117
- export function notificationKind(item: TTargetRow | null | undefined): string {
118
- const kind = String(item?.params?.kind ?? "").trim();
119
- if (kind) return kind;
120
- const module = String(item?.module ?? "");
121
- const title = String(item?.title ?? "");
122
- if (String(item?.screen ?? "") === SP_INVITATION_SCREEN) return "serviceProviderInvite";
123
- if (module === "platform") {
124
- if (/^robot /i.test(title)) return "robot";
125
- if (/^new client/i.test(title)) return "client";
126
- if (/^invitation accepted/i.test(title)) return "clientInvitation";
127
- return "";
128
- }
129
- if (module === "other") {
130
- return /(accepted|declined) your invitation$/i.test(title) ? "serviceProviderInviteAnswer" : "";
131
- }
132
- return module;
133
- }
134
-
135
- /**
136
- * The web app a mobile-tagged row's work lives in. A work order sent to the
137
- * iClean365 app is managed in the Hygiene web app, and so on; anything from the
138
- * resident or iMA365 apps is the property manager's.
139
- */
140
- const APP_FOR_SLUG: Record<string, TAppKey> = {
141
- "iservice365-hygiene-mobile-app": "hygiene",
142
- "iservice365-m-and-e-mobile-app": "mechanical-electrical",
143
- "iservice365-pest-control-mobile-app": "pest-control",
144
- "iservice365-pool-maintenance-mobile-app": "pool-maintenance",
145
- "iservice365-landscaping-mobile-app": "landscaping",
146
- "iservice365-landscape-mobile-app": "landscaping",
147
- };
148
-
149
- const SERVICE_APPS: TAppKey[] = [
150
- "property-management",
151
- "security",
152
- "hygiene",
153
- "mechanical-electrical",
154
- "pest-control",
155
- "landscaping",
156
- "pool-maintenance",
157
- ];
158
-
159
- /**
160
- * Site pages, `/<org>/<site>/<page>` in the service web apps, and which apps
161
- * have them. "any" = every service app has the page; the row's own app leads.
162
- */
163
- type TSitePage = { apps: TAppKey[] | "any"; page: (id: string) => string };
164
- const withId = (list: string) => (id: string) => (id ? `${list}/${id}` : list);
165
- const SITE_PAGES: Record<string, TSitePage[]> = {
166
- workOrder: [{ apps: "any", page: withId("work-orders") }],
167
- feedback: [{ apps: "any", page: withId("feedbacks") }],
168
- bulletinBoard: [{ apps: "any", page: () => "bulletin-board" }],
169
- inventory: [{ apps: "any", page: () => "inventory" }],
170
- event: [{ apps: ["property-management"], page: () => "event-mgmt" }],
171
- facilityBooking: [{ apps: ["property-management"], page: () => "facility-booking" }],
172
- onlineForm: [{ apps: ["property-management"], page: () => "online-forms" }],
173
- documentExpiry: [{ apps: ["property-management"], page: () => "document-mgmt" }],
174
- emergencyContact: [{ apps: ["property-management"], page: () => "emergency-contacts" }],
175
- incidentReport: [{ apps: ["security", "property-management"], page: withId("incident-reports") }],
176
- visitors: [
177
- { apps: ["security"], page: () => "visitor-management" },
178
- { apps: ["property-management"], page: () => "visitor-mgmt" },
179
- ],
180
- virtualPatrol: [{ apps: ["security"], page: () => "virtual-patrol/logs" }],
181
- cameraFault: [
182
- { apps: ["security"], page: () => "virtual-patrol/cctv" },
183
- { apps: ["property-management"], page: () => "cctv" },
184
- ],
185
- serviceProviderInviteAnswer: [
186
- { apps: SERVICE_APPS.filter((a) => a !== "hygiene"), page: () => "service-providers" },
187
- ],
188
- };
189
- SITE_PAGES.myVisitors = SITE_PAGES.visitors;
190
-
191
- /** Every kind the web can open, for tests and for anyone asking "is this clickable?". */
192
- export const WEB_TARGET_KINDS = [
193
- ...Object.keys(SITE_PAGES),
194
- "serviceProviderInvite",
195
- VENDOR_ORDER_MODULE,
196
- "robot",
197
- "client",
198
- "clientInvitation",
199
- ];
200
-
201
- function sitePageTarget(
202
- kind: string,
203
- item: TTargetRow,
204
- organization: string,
205
- site: string,
206
- ): TNotificationTarget | null {
207
- const pages = SITE_PAGES[kind];
208
- if (!pages || !organization || !site) return null;
209
- const id = encodeURIComponent(String(item.params?.id ?? "").trim());
210
- const home = APP_FOR_SLUG[String(item.appSlug ?? "")] ?? "property-management";
211
- const base = `/${encodeURIComponent(organization)}/${encodeURIComponent(site)}/`;
212
- const candidates: TCandidate[] = [];
213
- for (const { apps, page } of pages) {
214
- const list = apps === "any" ? [home, ...SERVICE_APPS.filter((a) => a !== home)] : apps;
215
- for (const app of list) candidates.push({ app, path: base + page(id) });
216
- }
217
- return { candidates };
218
- }
219
-
220
- /** Seven365 staff pages, in the organisation app. */
221
- function staffTarget(kind: string, item: TTargetRow): TNotificationTarget | null {
222
- const id = String(item.params?.id ?? "").trim();
223
- if (kind === "robot") return { candidates: [{ app: "org", path: "/super-admin/robots" }] };
224
- if (kind === "clientInvitation") {
225
- return { candidates: [{ app: "org", path: "/super-admin/client-list" }] };
226
- }
227
- if (kind === "client") {
228
- return id
229
- ? { candidates: [{ app: "org", path: "/super-admin/client-details" }], query: { org: id } }
230
- : { candidates: [{ app: "org", path: "/super-admin/client-list" }] };
231
- }
232
- return null;
233
- }
234
-
235
55
  export function notificationTarget(
236
56
  item: TTargetRow | null | undefined,
237
57
  context: TTargetContext = {},
238
58
  ): TNotificationTarget | null {
239
59
  const organization = String(context.organization ?? "").trim();
240
- const kind = notificationKind(item);
241
-
242
- if (kind === "serviceProviderInvite") {
243
- // The invited company's organisation when the row names it (the invitation
244
- // belongs there), otherwise the one on screen. The list on that page is
245
- // keyed on the signed-in account, so either shows the invitation.
246
- const org = String(item?.params?.org ?? "").trim() || organization;
247
- if (!org) return null;
248
- const id = String(item?.params?.id ?? "").trim();
249
- const query: Record<string, string> = id ? { invite: id } : {};
250
- return {
251
- name: INVITATIONS_ROUTE,
252
- params: { organization: org },
253
- query,
254
- app: "org",
255
- path: `/org/${encodeURIComponent(org)}/client-invitations`,
256
- };
257
- }
258
-
259
- if (!item) return null;
260
- const staff = staffTarget(kind, item);
261
- if (staff) return staff;
262
60
 
263
- if (kind !== VENDOR_ORDER_MODULE) {
264
- const site = String(item.siteId ?? item.params?.site ?? context.site ?? "").trim();
265
- return sitePageTarget(kind, item, organization, site);
266
- }
61
+ if (String(item?.module ?? "") !== VENDOR_ORDER_MODULE) return null;
267
62
 
268
63
  // Every order route is nested under the organization. Without one there is no
269
64
  // URL to build, and guessing an organization for somebody who belongs to
@@ -271,74 +66,10 @@ export function notificationTarget(
271
66
  if (!organization) return null;
272
67
 
273
68
  const id = String(item?.params?.id ?? "").trim();
274
- const orders = `/org/${encodeURIComponent(organization)}/marketplace/orders`;
275
69
 
276
70
  // No id — an older row, or one whose params were dropped — still has an
277
71
  // honest destination: the queue the order is sitting in.
278
- if (!id) {
279
- return {
280
- name: ORDER_QUEUE_ROUTE,
281
- params: { organization },
282
- candidates: [{ app: "org", path: orders }],
283
- };
284
- }
72
+ if (!id) return { name: ORDER_QUEUE_ROUTE, params: { organization } };
285
73
 
286
- return {
287
- name: ORDER_DETAIL_ROUTE,
288
- params: { organization, id },
289
- candidates: [{ app: "org", path: `${orders}/${encodeURIComponent(id)}` }],
290
- };
291
- }
292
-
293
- /**
294
- * The full URL for a target whose page lives in another web app, or "" when
295
- * there is none to build (no path, or that app's URL is not configured).
296
- */
297
- export function externalTargetUrl(
298
- target: TNotificationTarget | null | undefined,
299
- appUrls: Partial<Record<TAppKey, string>> = {},
300
- ): string {
301
- const options: TCandidate[] = [
302
- ...(target?.app && target.path ? [{ app: target.app, path: target.path }] : []),
303
- ...(target?.candidates ?? []),
304
- ];
305
- const qs = new URLSearchParams(target?.query ?? {}).toString();
306
- for (const { app, path } of options) {
307
- const base = String(appUrls[app] ?? "").trim().replace(/\/+$/, "");
308
- if (base) return `${base}${path}${qs ? `?${qs}` : ""}`;
309
- }
310
- return "";
311
- }
312
-
313
- /** What the bell does with a click: move inside this app, open another app, or nothing. */
314
- export type TNavigation =
315
- | {
316
- type: "route";
317
- to: { name?: string; params?: Record<string, string>; path?: string; query?: Record<string, string> };
318
- }
319
- | { type: "href"; href: string }
320
- | null;
321
-
322
- /**
323
- * This app's own page first — by route name, then by path (the site is in the
324
- * path, so opening it switches the site) — else the first app that has the
325
- * page and whose URL is configured. The page itself still checks the person may
326
- * see it: a link grants nothing.
327
- */
328
- export function chooseNavigation(
329
- target: TNotificationTarget | null | undefined,
330
- app: {
331
- hasRoute: (name: string) => boolean;
332
- hasPage: (path: string) => boolean;
333
- appUrls?: Partial<Record<TAppKey, string>>;
334
- },
335
- ): TNavigation {
336
- if (!target) return null;
337
- if (target.name && app.hasRoute(target.name)) {
338
- return { type: "route", to: { name: target.name, params: target.params, query: target.query } };
339
- }
340
- const local = target.candidates?.find((c) => app.hasPage(c.path));
341
- if (local) return { type: "route", to: { path: local.path, query: target.query } };
342
- const href = externalTargetUrl(target, app.appUrls);
343
- return href ? { type: "href", href } : null;
74
+ return { name: ORDER_DETAIL_ROUTE, params: { organization, id } };
344
75
  }
@@ -55,31 +55,14 @@ export const RESOURCE_LABELS: Record<string, string> = {
55
55
  roles: "Roles and permissions",
56
56
  };
57
57
 
58
- /**
59
- * Names that differ BY APPLICATION, keyed by the role `type` the editor is
60
- * opened with. Only where one key means a different screen in one app.
61
- *
62
- * Owner approval 2026-09-25, item 2: in the Property Management editor the
63
- * `virtual-patrol` row is the CCTV camera wall (the four legacy strings
64
- * `useCameraViewPermission` and core `CAMERA_VIEW_PERMISSIONS` read), not the
65
- * Security app's Virtual Patrol. Label only: the key and every stored string
66
- * stay `virtual-patrol:*`, so every role that holds them keeps working.
67
- */
68
- export const RESOURCE_LABELS_BY_TYPE: Record<string, Record<string, string>> = {
69
- property_management_agency: { "virtual-patrol": "CCTV" },
70
- real_estate_developer: { "virtual-patrol": "CCTV" },
71
- };
72
-
73
58
  /**
74
59
  * `work_orders` -> `Work orders`.
75
60
  *
76
61
  * Sentence case, not Title Case: a list of 47 Title Cased phrases is harder to
77
62
  * scan, not easier.
78
63
  */
79
- export function formatResourceLabel(key: string, type?: string): string {
64
+ export function formatResourceLabel(key: string): string {
80
65
  if (!key) return "";
81
- const byType = type ? RESOURCE_LABELS_BY_TYPE[type]?.[key] : undefined;
82
- if (byType) return byType;
83
66
  if (RESOURCE_LABELS[key]) return RESOURCE_LABELS[key];
84
67
 
85
68
  const words = key
@@ -132,12 +115,11 @@ export function formatActionLabel(action: string): string {
132
115
  */
133
116
  export function sortResourcesByLabel<T>(
134
117
  tree: Record<string, T> | null | undefined,
135
- type?: string,
136
118
  ): Record<string, T> {
137
119
  if (!tree || typeof tree !== "object") return {};
138
120
  const sorted: Record<string, T> = {};
139
121
  for (const key of Object.keys(tree).sort((a, b) =>
140
- formatResourceLabel(a, type).localeCompare(formatResourceLabel(b, type), undefined, {
122
+ formatResourceLabel(a).localeCompare(formatResourceLabel(b), undefined, {
141
123
  sensitivity: "base",
142
124
  numeric: true,
143
125
  }),