@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.
- package/CHANGELOG.md +10 -4
- package/assets/css/primitives.css +0 -43
- package/assets/css/robot-hero.css +2 -2
- package/components/AccessCardAddForm.vue +4 -5
- package/components/AccessCardAssignToUnitForm.vue +2 -3
- package/components/AccessCardPreviewDialog.vue +3 -4
- package/components/AppSelect.vue +3 -77
- package/components/CameraForm.vue +6 -20
- package/components/DashboardMain.vue +60 -66
- package/components/Dialog/UpdateMoreAction.vue +1 -11
- package/components/DocumentForm.vue +112 -407
- package/components/DocumentManagement.vue +12 -213
- package/components/EntryPass/QrTemplatePreview.vue +25 -21
- package/components/EntryPassInformation.vue +24 -119
- package/components/Facility/BookingSetup.vue +0 -63
- package/components/HidAccessLogDashboard.vue +17 -74
- package/components/HidIntercomManagement.vue +14 -180
- package/components/HidQrCodeConfiguration.vue +195 -17
- package/components/HidReaderManagement.vue +0 -398
- package/components/HidReaderUserRoster.vue +3 -44
- package/components/HidUserEnrollment.vue +155 -845
- package/components/InvitationClientForm.vue +1 -19
- package/components/Layout/NavigationDrawer.vue +1 -4
- package/components/NotificationBell.vue +7 -59
- package/components/Robot/BuildingMap.vue +9 -16
- package/components/Robot/LiftQueue.vue +4 -9
- package/components/Robot/MapEditor.vue +8 -1
- package/components/RolePermissionFormCreate.vue +3 -4
- package/components/RolePermissionFormPreviewUpdate.vue +5 -12
- package/components/RolePermissionMain.vue +0 -1
- package/components/ServiceProviderInvitationPrompt.vue +155 -0
- package/components/ServiceProviderMain.vue +30 -178
- package/components/VisitorForm.vue +0 -12
- package/components/VisitorManagement.vue +10 -285
- package/components/VisitorSocketPopUp.vue +1 -2
- package/composables/useANPRSettings.ts +15 -45
- package/composables/useAccessManagement.ts +0 -5
- package/composables/useCleaningPermission.ts +0 -2
- package/composables/useCommonPermission.ts +0 -32
- package/composables/useDocument.ts +0 -7
- package/composables/useFacility.ts +0 -11
- package/composables/useHidAmico.ts +0 -159
- package/composables/useHidNavigation.ts +1 -25
- package/composables/usePeople.ts +0 -19
- package/composables/usePropertyPermission.ts +0 -19
- package/composables/useRobotBuilding.ts +3 -22
- package/composables/useRobots.ts +0 -13
- package/composables/useSecurityPermission.ts +0 -19
- package/composables/useServiceProvider.ts +0 -3
- package/composables/useServiceProviderInvitation.ts +3 -101
- package/composables/useSiteEntryPassSettings.ts +0 -26
- package/composables/useWebUsb.ts +11 -14
- package/package.json +2 -2
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/plugins/API.ts +0 -11
- package/types/document.d.ts +0 -14
- package/types/facility.d.ts +0 -4
- package/types/people.d.ts +1 -28
- package/utils/module-routes.ts +1 -8
- package/utils/nav-sections.ts +2 -8
- package/utils/notification-target.ts +8 -277
- package/utils/permission-labels.ts +2 -20
- package/components/ClientInvitations.vue +0 -291
- package/components/HidAccessPermissions.vue +0 -424
- package/components/HidProfileQrCode.vue +0 -332
- package/composables/useHidReaderSelection.ts +0 -50
- package/composables/useSiteCategory.ts +0 -45
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +0 -23
- package/utils/dashboard-widget-prefs.ts +0 -54
- package/utils/hid-permission-assignments.ts +0 -190
- package/utils/hid-reader-selection.ts +0 -52
- package/utils/occupancy-role.ts +0 -109
- package/utils/robot-fleet-core.ts +0 -57
- package/utils/sp-inviter-row.ts +0 -86
package/composables/useWebUsb.ts
CHANGED
|
@@ -345,19 +345,16 @@ export default function useWebUsb() {
|
|
|
345
345
|
70,
|
|
346
346
|
);
|
|
347
347
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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,
|
|
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`
|
|
408
|
-
* (that one
|
|
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.
|
|
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/
|
|
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 physical HID cards."
|
|
7
7
|
>
|
|
8
|
-
<HidUserEnrollment :site="siteId"
|
|
8
|
+
<HidUserEnrollment :site="siteId" card-management />
|
|
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)
|
package/types/document.d.ts
CHANGED
|
@@ -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
|
};
|
package/types/facility.d.ts
CHANGED
|
@@ -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
|
-
|
|
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"
|
package/utils/module-routes.ts
CHANGED
|
@@ -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",
|
package/utils/nav-sections.ts
CHANGED
|
@@ -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 ? "" :
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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 (
|
|
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
|
|
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
|
|
122
|
+
formatResourceLabel(a).localeCompare(formatResourceLabel(b), undefined, {
|
|
141
123
|
sensitivity: "base",
|
|
142
124
|
numeric: true,
|
|
143
125
|
}),
|