@7365admin1/layer-common 4.41.0 → 4.43.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 +22 -4
- package/components/DashboardMain.vue +66 -60
- package/components/Layout/NavigationDrawer.vue +4 -1
- package/components/RolePermissionFormCreate.vue +4 -3
- package/components/RolePermissionFormPreviewUpdate.vue +12 -5
- package/components/RolePermissionMain.vue +1 -0
- package/components/ServiceProviderMain.vue +171 -17
- package/composables/useCleaningPermission.ts +2 -0
- package/composables/useCommonPermission.ts +32 -0
- package/composables/usePropertyPermission.ts +2 -0
- package/composables/useServiceProvider.ts +3 -0
- package/composables/useServiceProviderInvitation.ts +25 -1
- package/package.json +1 -1
- package/utils/dashboard-widget-prefs.ts +54 -0
- package/utils/module-routes.ts +8 -1
- package/utils/nav-sections.ts +8 -2
- package/utils/permission-labels.ts +20 -2
- package/utils/sp-inviter-row.ts +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.43.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bb75a19: Service Providers screen (inviter's side): accepted providers appear under Service Providers for the site; the Invitations tab shows only this site's invitations with the company that accepted, who invited and when, when it was answered or expired, and a View provider panel; "Members" is now "Staff on site" and is not shown for invitations.
|
|
8
|
+
|
|
9
|
+
## 4.42.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 531a883: Dashboard Customize: the widget toggles use the platform switch (`app-switch`), so ON and OFF are clearly different in light and dark mode, and Submit uses the theme's primary colour. The Customize choice is now remembered per signed-in user and per app (one `dashboard-widgets` cookie storing hidden widgets), so another user on the same browser, or another service app, no longer inherits it, and new widgets appear by default. Widget visibility follows the estate permission rule (`grantsPermission`): a saved role with an empty permission list sees every widget, and every shipped spelling of a grant counts. Hiding a KPI tile no longer leaves a gap in the row.
|
|
14
|
+
|
|
15
|
+
## 4.42.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 5472953: Roles and permissions (owner approval 2026-09-25): Robots offered in the Property Management and Cleaning role editors; the PM editor's camera-wall row reads "CCTV" (label only, `virtual-patrol:*` strings unchanged); PM's CCTV page follows the client's module list (`org-site-cctv` -> `virtual-patrol`, fails open); `NavigationDrawer` takes `sectionLabels` so an app can rename a sidebar heading.
|
|
20
|
+
|
|
3
21
|
## 4.41.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
|
@@ -1471,11 +1489,11 @@
|
|
|
1471
1489
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1472
1490
|
own rule from two endpoints the console already calls, unprojected:
|
|
1473
1491
|
|
|
1474
|
-
|
|
1475
|
-
|
|
1492
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1493
|
+
GET /api/roles/id/:role that membership's role document
|
|
1476
1494
|
|
|
1477
|
-
|
|
1478
|
-
|
|
1495
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1496
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1479
1497
|
|
|
1480
1498
|
`role.default` is the marker because it is the only property of a platform
|
|
1481
1499
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -317,7 +317,7 @@
|
|
|
317
317
|
cols="12"
|
|
318
318
|
sm="6"
|
|
319
319
|
md="4"
|
|
320
|
-
lg="
|
|
320
|
+
:lg="kpiColLg"
|
|
321
321
|
class="d-flex"
|
|
322
322
|
>
|
|
323
323
|
<!--
|
|
@@ -1107,14 +1107,19 @@
|
|
|
1107
1107
|
{{ widget.description }}
|
|
1108
1108
|
</div>
|
|
1109
1109
|
</div>
|
|
1110
|
+
<!--
|
|
1111
|
+
`app-switch` is the platform toggle (primitives.css): blue
|
|
1112
|
+
track + white knob right when ON, grey knob left when OFF, in
|
|
1113
|
+
both themes. The old blue-grey colour drew ON as near-black on the
|
|
1114
|
+
dark card and grey on the light one - on and off looked alike.
|
|
1115
|
+
-->
|
|
1110
1116
|
<v-switch
|
|
1111
1117
|
v-model="draftVisibleWidgetKeys"
|
|
1112
1118
|
:value="widget.key"
|
|
1113
|
-
|
|
1114
|
-
inset
|
|
1119
|
+
class="app-switch flex-grow-0"
|
|
1115
1120
|
hide-details
|
|
1116
1121
|
density="compact"
|
|
1117
|
-
aria-label="
|
|
1122
|
+
:aria-label="`Show ${widget.title}`"
|
|
1118
1123
|
/>
|
|
1119
1124
|
</div>
|
|
1120
1125
|
</v-card>
|
|
@@ -1136,7 +1141,7 @@
|
|
|
1136
1141
|
</v-btn>
|
|
1137
1142
|
<v-btn
|
|
1138
1143
|
variant="flat"
|
|
1139
|
-
color="
|
|
1144
|
+
color="primary"
|
|
1140
1145
|
class="text-none font-weight-bold px-6"
|
|
1141
1146
|
style="min-width: 110px"
|
|
1142
1147
|
@click="submitCustomize"
|
|
@@ -1227,6 +1232,12 @@ import type { CookieOptions } from "#app";
|
|
|
1227
1232
|
// `plugins/vuetify.ts`), and a bare call resolved to nothing when driven.
|
|
1228
1233
|
import { buildWorkOrderStatus } from "../utils/dashboard";
|
|
1229
1234
|
import { permissionsDenied } from "../utils/module-routes";
|
|
1235
|
+
import { grantsPermission } from "../utils/permission-spellings";
|
|
1236
|
+
import {
|
|
1237
|
+
hiddenDashboardWidgets,
|
|
1238
|
+
saveHiddenDashboardWidgets,
|
|
1239
|
+
type TDashboardWidgetPrefs,
|
|
1240
|
+
} from "../utils/dashboard-widget-prefs";
|
|
1230
1241
|
|
|
1231
1242
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
1232
1243
|
|
|
@@ -1482,21 +1493,20 @@ function canViewWidget(key: string) {
|
|
|
1482
1493
|
return false;
|
|
1483
1494
|
}
|
|
1484
1495
|
|
|
1485
|
-
|
|
1496
|
+
// The estate rule (`grantsPermission`): a saved role with an EMPTY list, or
|
|
1497
|
+
// `*`, means everything. core seeds default roles with `permissions: []`, so
|
|
1498
|
+
// an exact `includes` test showed those accounts an empty dashboard while
|
|
1499
|
+
// every menu in the app let them in. `grantsPermission` also accepts every
|
|
1500
|
+
// shipped spelling of a grant (`work-orders:` / `workOrder:` / ...).
|
|
1501
|
+
if (!permissions.length || permissions.includes("*")) return true;
|
|
1486
1502
|
|
|
1487
1503
|
if (bySchedule) {
|
|
1488
1504
|
return hasSchedulePermission(permissions, "see-all-schedules");
|
|
1489
1505
|
}
|
|
1490
1506
|
|
|
1491
|
-
if (isPropertyMode.value && modulePermissions) {
|
|
1492
|
-
return modulePermissions.some((permission) =>
|
|
1493
|
-
permissions.includes(permission)
|
|
1494
|
-
);
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
1507
|
if (modulePermissions) {
|
|
1498
1508
|
return modulePermissions.some((permission) =>
|
|
1499
|
-
permissions
|
|
1509
|
+
grantsPermission(permissions, permission)
|
|
1500
1510
|
);
|
|
1501
1511
|
}
|
|
1502
1512
|
|
|
@@ -2049,33 +2059,28 @@ const securityDefaultWidgetKeys = [
|
|
|
2049
2059
|
];
|
|
2050
2060
|
|
|
2051
2061
|
/**
|
|
2052
|
-
* THE CUSTOMISE CHOICE, REMEMBERED.
|
|
2062
|
+
* THE CUSTOMISE CHOICE, REMEMBERED - per signed-in user, per app.
|
|
2053
2063
|
*
|
|
2054
|
-
*
|
|
2055
|
-
*
|
|
2064
|
+
* A cookie on the layer's own `cookieConfig` (the parent-domain cookie `theme`
|
|
2065
|
+
* and `landing-page` already use; localStorage would be per-hostname and there
|
|
2066
|
+
* is no preferences API on the server). What is stored and why it is keyed by
|
|
2067
|
+
* user and app is in `utils/dashboard-widget-prefs.ts`.
|
|
2056
2068
|
*
|
|
2057
|
-
*
|
|
2058
|
-
*
|
|
2059
|
-
* travel on (see `composables/useThemePreference.ts` for the full reasoning).
|
|
2060
|
-
* localStorage would be per-origin, and this is one component shipped into
|
|
2061
|
-
* seven applications on seven hostnames. No new storage mechanism, and no
|
|
2062
|
-
* invented endpoint - there is no save API on the server for this.
|
|
2063
|
-
*
|
|
2064
|
-
* One cookie per dashboard MODE, because the three modes have different widget
|
|
2065
|
-
* sets and a security member's choice must not decide what a hygiene member
|
|
2066
|
-
* sees.
|
|
2069
|
+
* The earlier `dashboard-widgets-<mode>` cookies are not read: they belonged
|
|
2070
|
+
* to whoever last used the browser, and to five apps at once.
|
|
2067
2071
|
*/
|
|
2068
|
-
const
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2072
|
+
const cookieOptions = runtimeConfig.public.cookieConfig as CookieOptions<any> & {
|
|
2073
|
+
readonly?: false;
|
|
2074
|
+
};
|
|
2075
|
+
const widgetPrefsCookie = useCookie<TDashboardWidgetPrefs | null>(
|
|
2076
|
+
"dashboard-widgets",
|
|
2077
|
+
cookieOptions
|
|
2078
|
+
);
|
|
2079
|
+
// The `user` cookie holds the signed-in user's id (`useLocalAuth`).
|
|
2080
|
+
const widgetPrefsUser = String(useCookie("user", cookieOptions).value ?? "");
|
|
2081
|
+
const widgetPrefsApp = String(props.serviceType ?? "");
|
|
2082
|
+
const hiddenByUser = ref<string[]>(
|
|
2083
|
+
hiddenDashboardWidgets(widgetPrefsCookie.value, widgetPrefsUser, widgetPrefsApp)
|
|
2079
2084
|
);
|
|
2080
2085
|
|
|
2081
2086
|
const defaultVisibleWidgetKeys = computed(() => {
|
|
@@ -2096,26 +2101,10 @@ const defaultVisibleWidgetKeys = computed(() => {
|
|
|
2096
2101
|
watch(
|
|
2097
2102
|
defaultVisibleWidgetKeys,
|
|
2098
2103
|
(keys) => {
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
// A remembered choice wins on first paint, filtered against the keys this
|
|
2102
|
-
// mode actually has so a stale cookie cannot resurrect a removed widget.
|
|
2103
|
-
// `Array.isArray` rather than a length check: "the user hid everything"
|
|
2104
|
-
// and "there is no cookie" are different answers.
|
|
2105
|
-
const remembered = Array.isArray(widgetPrefsCookie.value)
|
|
2106
|
-
? widgetPrefsCookie.value.filter((k) => nextKeys.includes(k))
|
|
2107
|
-
: null;
|
|
2108
|
-
const initial = remembered ?? nextKeys;
|
|
2109
|
-
visibleWidgetKeys.value = initial;
|
|
2110
|
-
draftVisibleWidgetKeys.value = [...initial];
|
|
2111
|
-
return;
|
|
2112
|
-
}
|
|
2113
|
-
visibleWidgetKeys.value = visibleWidgetKeys.value.filter((k) =>
|
|
2114
|
-
nextKeys.includes(k)
|
|
2115
|
-
);
|
|
2116
|
-
draftVisibleWidgetKeys.value = draftVisibleWidgetKeys.value.filter((k) =>
|
|
2117
|
-
nextKeys.includes(k)
|
|
2104
|
+
visibleWidgetKeys.value = keys.filter(
|
|
2105
|
+
(k) => !hiddenByUser.value.includes(k)
|
|
2118
2106
|
);
|
|
2107
|
+
draftVisibleWidgetKeys.value = [...visibleWidgetKeys.value];
|
|
2119
2108
|
},
|
|
2120
2109
|
{ immediate: true }
|
|
2121
2110
|
);
|
|
@@ -2546,6 +2535,13 @@ const visibleKpiCards = computed(() =>
|
|
|
2546
2535
|
kpiCards.value.filter((card) => isWidgetVisible(card.key))
|
|
2547
2536
|
);
|
|
2548
2537
|
|
|
2538
|
+
// Four tiles fill a row at `lg`; hiding one in Customize left a hole where it
|
|
2539
|
+
// had been. Fewer tiles share the row instead.
|
|
2540
|
+
const kpiColLg = computed(() => {
|
|
2541
|
+
const shown = visibleKpiCards.value.length;
|
|
2542
|
+
return shown >= 4 || shown === 0 ? 3 : 12 / shown;
|
|
2543
|
+
});
|
|
2544
|
+
|
|
2549
2545
|
/**
|
|
2550
2546
|
* Nothing to draw, and which of the two reasons it is.
|
|
2551
2547
|
*
|
|
@@ -3599,10 +3595,20 @@ function resetCustomizeDraft() {
|
|
|
3599
3595
|
}
|
|
3600
3596
|
|
|
3601
3597
|
function submitCustomize() {
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3598
|
+
const hidden = defaultVisibleWidgetKeys.value.filter(
|
|
3599
|
+
(k) => !draftVisibleWidgetKeys.value.includes(k)
|
|
3600
|
+
);
|
|
3601
|
+
hiddenByUser.value = hidden;
|
|
3602
|
+
visibleWidgetKeys.value = defaultVisibleWidgetKeys.value.filter(
|
|
3603
|
+
(k) => !hidden.includes(k)
|
|
3604
|
+
);
|
|
3605
|
+
// The write is the whole point - see `widgetPrefsCookie`.
|
|
3606
|
+
widgetPrefsCookie.value = saveHiddenDashboardWidgets(
|
|
3607
|
+
widgetPrefsCookie.value,
|
|
3608
|
+
widgetPrefsUser,
|
|
3609
|
+
widgetPrefsApp,
|
|
3610
|
+
hidden
|
|
3611
|
+
);
|
|
3606
3612
|
widgetSearch.value = "";
|
|
3607
3613
|
customizeDialog.value = false;
|
|
3608
3614
|
}
|
|
@@ -129,6 +129,9 @@ 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: () => ({}) },
|
|
132
135
|
});
|
|
133
136
|
|
|
134
137
|
const emit = defineEmits(["navigate"]);
|
|
@@ -196,7 +199,7 @@ const navigationItems = computed(() => {
|
|
|
196
199
|
return withoutDeniedRoutes(items, userAppModulesDenied.value);
|
|
197
200
|
});
|
|
198
201
|
|
|
199
|
-
const sectionedItems = computed(() => withSections(navigationItems.value));
|
|
202
|
+
const sectionedItems = computed(() => withSections(navigationItems.value, props.sectionLabels as Record<string, string>));
|
|
200
203
|
|
|
201
204
|
/**
|
|
202
205
|
* The design's brand tile carries the product's own mark. One icon for eleven
|
|
@@ -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)) }}
|
|
142
|
+
{{ formatResourceLabel(String(resourceKey), props.type) }}
|
|
143
143
|
</span>
|
|
144
144
|
</v-list-item-title>
|
|
145
145
|
<template #append>
|
|
@@ -279,6 +279,7 @@ const props = defineProps({
|
|
|
279
279
|
},
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
|
|
282
283
|
const emit = defineEmits(["cancel", "success", "success:create-more"]);
|
|
283
284
|
|
|
284
285
|
const validForm = ref(false);
|
|
@@ -537,14 +538,14 @@ const moduleSearch = ref<string | null>("");
|
|
|
537
538
|
/** `safePermissions`, narrowed to what the search box matches. */
|
|
538
539
|
const visiblePermissions = computed(() => {
|
|
539
540
|
const query = (moduleSearch.value ?? "").trim().toLowerCase();
|
|
540
|
-
const all = sortResourcesByLabel(safePermissions.value);
|
|
541
|
+
const all = sortResourcesByLabel(safePermissions.value, props.type);
|
|
541
542
|
if (!query) return all;
|
|
542
543
|
|
|
543
544
|
const matches: Record<string, any> = {};
|
|
544
545
|
for (const [resource, actions] of Object.entries(all)) {
|
|
545
546
|
const haystack = [
|
|
546
547
|
resource,
|
|
547
|
-
formatResourceLabel(resource),
|
|
548
|
+
formatResourceLabel(resource, props.type),
|
|
548
549
|
...Object.keys(actions ?? {}).flatMap((action) => [
|
|
549
550
|
action,
|
|
550
551
|
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)) }}
|
|
105
|
+
{{ formatResourceLabel(String(resourceKey), props.type) }}
|
|
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)) }}
|
|
194
|
+
{{ formatResourceLabel(String(resourceKey), props.type) }}
|
|
195
195
|
</v-list-item-title>
|
|
196
196
|
<v-list-item-subtitle class="role-perm__row-sub">
|
|
197
197
|
{{
|
|
@@ -311,8 +311,15 @@ 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
|
+
},
|
|
314
320
|
});
|
|
315
321
|
|
|
322
|
+
|
|
316
323
|
/** A role holding the wildcard has ONE stored entry and reaches everything. */
|
|
317
324
|
const holdsWildcard = computed(() => (definedModel.value ?? []).includes("*"));
|
|
318
325
|
|
|
@@ -486,14 +493,14 @@ const moduleSearch = ref<string | null>("");
|
|
|
486
493
|
|
|
487
494
|
const visiblePermissions = computed(() => {
|
|
488
495
|
const query = (moduleSearch.value ?? "").trim().toLowerCase();
|
|
489
|
-
const all = sortResourcesByLabel(safePermissions.value);
|
|
496
|
+
const all = sortResourcesByLabel(safePermissions.value, props.type);
|
|
490
497
|
if (!query) return all;
|
|
491
498
|
|
|
492
499
|
const matches: Record<string, any> = {};
|
|
493
500
|
for (const [resource, actions] of Object.entries(all)) {
|
|
494
501
|
const haystack = [
|
|
495
502
|
resource,
|
|
496
|
-
formatResourceLabel(resource),
|
|
503
|
+
formatResourceLabel(resource, props.type),
|
|
497
504
|
...Object.keys((actions as Record<string, unknown>) ?? {}).flatMap(
|
|
498
505
|
(action) => [action, formatActionLabel(action)]
|
|
499
506
|
),
|
|
@@ -509,7 +516,7 @@ const visiblePermissions = computed(() => {
|
|
|
509
516
|
// Same A-Z as the tree above it; a second list in a second order reads as a
|
|
510
517
|
// different kind of thing.
|
|
511
518
|
const sortedNotOffered = computed(() =>
|
|
512
|
-
sortResourcesByLabel(props.notOfferedPermissions as Record<string, any
|
|
519
|
+
sortResourcesByLabel(props.notOfferedPermissions as Record<string, any>, props.type)
|
|
513
520
|
);
|
|
514
521
|
|
|
515
522
|
/*
|
|
@@ -101,9 +101,14 @@
|
|
|
101
101
|
style="max-height: calc(100vh - 280px)"
|
|
102
102
|
>
|
|
103
103
|
<template #item.companyName="{ item }">
|
|
104
|
-
<span
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
<span
|
|
105
|
+
class="d-block text-truncate sp-col-company"
|
|
106
|
+
:class="{ 'sp-muted': item.companyKnown === false }"
|
|
107
|
+
>{{ item.companyName }}</span>
|
|
108
|
+
<div v-if="item.contactPhone" class="sp-sub">{{ item.contactPhone }}</div>
|
|
109
|
+
</template>
|
|
110
|
+
<template #item.invited="{ item }">
|
|
111
|
+
<span class="d-block text-truncate">{{ item.invitedLine || "-" }}</span>
|
|
107
112
|
</template>
|
|
108
113
|
<template #item.email="{ item }">
|
|
109
114
|
<span class="d-block text-truncate sp-col-email">{{
|
|
@@ -128,9 +133,10 @@
|
|
|
128
133
|
-->
|
|
129
134
|
<StatusChip
|
|
130
135
|
class="text-none"
|
|
131
|
-
:status="item.statusRaw || item.status"
|
|
136
|
+
:status="item.viewStatus || item.statusRaw || item.status"
|
|
132
137
|
:label="item.status"
|
|
133
138
|
/>
|
|
139
|
+
<div v-if="item.statusLine" class="mt-1 sp-sub">{{ item.statusLine }}</div>
|
|
134
140
|
<!-- Was `text-caption text-medium-emphasis` - Roboto 12px at
|
|
135
141
|
Vuetify's 60% opacity. The design's sub-line is `--muted`. -->
|
|
136
142
|
<div v-if="item.rejectionReason" class="mt-1 sp-reject-reason">
|
|
@@ -157,6 +163,11 @@
|
|
|
157
163
|
</v-btn>
|
|
158
164
|
</template>
|
|
159
165
|
<v-list density="compact" min-width="220">
|
|
166
|
+
<v-list-item
|
|
167
|
+
v-if="item.providerOrgId"
|
|
168
|
+
title="View provider"
|
|
169
|
+
@click="openProvider(item)"
|
|
170
|
+
/>
|
|
160
171
|
<v-list-item
|
|
161
172
|
title="Cancel invitation"
|
|
162
173
|
:disabled="!canCancelInvite(item)"
|
|
@@ -178,6 +189,21 @@
|
|
|
178
189
|
/>
|
|
179
190
|
</v-list>
|
|
180
191
|
</v-menu>
|
|
192
|
+
<!--
|
|
193
|
+
A provider on this site through an accepted invitation. Its row is
|
|
194
|
+
the engagement itself, not a stored service-providers record, so
|
|
195
|
+
the In-Active move (which acts on that record) does not apply.
|
|
196
|
+
-->
|
|
197
|
+
<v-btn
|
|
198
|
+
v-else-if="item.source === 'engagement'"
|
|
199
|
+
icon
|
|
200
|
+
variant="text"
|
|
201
|
+
density="comfortable"
|
|
202
|
+
aria-label="View provider"
|
|
203
|
+
@click="openProvider(item)"
|
|
204
|
+
>
|
|
205
|
+
<v-icon>mdi-information-outline</v-icon>
|
|
206
|
+
</v-btn>
|
|
181
207
|
<v-menu v-else-if="item.source === 'provider'" location="bottom end">
|
|
182
208
|
<template #activator="{ props: menuProps }">
|
|
183
209
|
<v-btn
|
|
@@ -239,6 +265,26 @@
|
|
|
239
265
|
|
|
240
266
|
Same members, same two columns, same loading and empty copy, same close.
|
|
241
267
|
-->
|
|
268
|
+
<!-- The provider, as the organisation that invited it may see it. -->
|
|
269
|
+
<v-dialog v-model="providerDialog" max-width="480">
|
|
270
|
+
<v-card class="screen-modal">
|
|
271
|
+
<v-card-title>{{ providerRow?.companyName }}</v-card-title>
|
|
272
|
+
<v-card-text class="screen-modal__body">
|
|
273
|
+
<dl class="sp-details">
|
|
274
|
+
<template v-for="line in providerLines" :key="line[0]">
|
|
275
|
+
<dt>{{ line[0] }}</dt>
|
|
276
|
+
<dd>{{ line[1] }}</dd>
|
|
277
|
+
</template>
|
|
278
|
+
</dl>
|
|
279
|
+
</v-card-text>
|
|
280
|
+
<div class="screen-modal__footer">
|
|
281
|
+
<v-btn class="screen-btn-ghost" variant="outlined" @click="providerDialog = false">
|
|
282
|
+
Close
|
|
283
|
+
</v-btn>
|
|
284
|
+
</div>
|
|
285
|
+
</v-card>
|
|
286
|
+
</v-dialog>
|
|
287
|
+
|
|
242
288
|
<v-dialog v-model="viewDialog" max-width="514">
|
|
243
289
|
<v-card class="screen-modal">
|
|
244
290
|
<v-card-title>Members ({{ memberDialogCount }})</v-card-title>
|
|
@@ -669,6 +715,12 @@ import useVerification from "../composables/useVerification";
|
|
|
669
715
|
// import useRole from "../composables/useRole";
|
|
670
716
|
import { errorConverter } from "../utils/data";
|
|
671
717
|
import { pickRouteName } from "../utils/route-name";
|
|
718
|
+
import {
|
|
719
|
+
sentCompany,
|
|
720
|
+
sentInvitedLine,
|
|
721
|
+
sentStatusLine,
|
|
722
|
+
spDay,
|
|
723
|
+
} from "../utils/sp-inviter-row";
|
|
672
724
|
|
|
673
725
|
type ListTab = "active" | "pending" | "inactive";
|
|
674
726
|
|
|
@@ -681,9 +733,21 @@ type TableRow = {
|
|
|
681
733
|
type: string;
|
|
682
734
|
members: string;
|
|
683
735
|
status: string;
|
|
684
|
-
|
|
736
|
+
/** provider: a stored row; engagement: on the site through an accepted
|
|
737
|
+
* invitation; invite: an invitation this organisation sent. */
|
|
738
|
+
source: "provider" | "engagement" | "invite";
|
|
685
739
|
/** the stored status, for deciding which actions a row may offer */
|
|
686
740
|
statusRaw?: string;
|
|
741
|
+
/** what the row reads as (a lapsed "pending" is "expired") - the chip tone */
|
|
742
|
+
viewStatus?: string;
|
|
743
|
+
statusLine?: string;
|
|
744
|
+
companyKnown?: boolean;
|
|
745
|
+
invitedLine?: string;
|
|
746
|
+
providerOrgId?: string;
|
|
747
|
+
contactEmail?: string;
|
|
748
|
+
contactPhone?: string;
|
|
749
|
+
answeredAt?: string | null;
|
|
750
|
+
onSite?: boolean;
|
|
687
751
|
/** shown under a "Not approved" row, in Seven365's own words */
|
|
688
752
|
rejectionReason?: string;
|
|
689
753
|
siteName?: string;
|
|
@@ -774,7 +838,7 @@ const billingRouteName = pickRouteName(
|
|
|
774
838
|
"service-provider-mgmt-billing"
|
|
775
839
|
);
|
|
776
840
|
|
|
777
|
-
const
|
|
841
|
+
const providerHeaders = [
|
|
778
842
|
{
|
|
779
843
|
title: "Company name",
|
|
780
844
|
value: "companyName",
|
|
@@ -797,7 +861,8 @@ const headers = [
|
|
|
797
861
|
maxWidth: "180px",
|
|
798
862
|
},
|
|
799
863
|
{
|
|
800
|
-
|
|
864
|
+
// The provider's people assigned to this site - not its whole headcount.
|
|
865
|
+
title: "Staff on site",
|
|
801
866
|
value: "members",
|
|
802
867
|
sortable: false,
|
|
803
868
|
width: "88px",
|
|
@@ -819,6 +884,20 @@ const headers = [
|
|
|
819
884
|
},
|
|
820
885
|
];
|
|
821
886
|
|
|
887
|
+
/** The Invitations tab: who it went to, who sent it and when, what happened. */
|
|
888
|
+
const inviteHeaders = [
|
|
889
|
+
{ ...providerHeaders[0], title: "Company" },
|
|
890
|
+
{ ...providerHeaders[1], title: "Invited email" },
|
|
891
|
+
{ ...providerHeaders[2], title: "Service" },
|
|
892
|
+
{ title: "Invited", value: "invited", sortable: false, minWidth: "150px" },
|
|
893
|
+
{ ...providerHeaders[4], width: "200px" },
|
|
894
|
+
providerHeaders[5],
|
|
895
|
+
];
|
|
896
|
+
|
|
897
|
+
const headers = computed(() =>
|
|
898
|
+
listTab.value === "pending" ? inviteHeaders : providerHeaders
|
|
899
|
+
);
|
|
900
|
+
|
|
822
901
|
const { natureOfBusinessV3 } = useLocal();
|
|
823
902
|
const { requiredRule, emailRule, debounce } = useUtils();
|
|
824
903
|
const { getUserByEmail } = useUser();
|
|
@@ -1171,6 +1250,37 @@ function typeDisplay(type: string, nature?: string) {
|
|
|
1171
1250
|
return hit?.title ?? type ?? "-";
|
|
1172
1251
|
}
|
|
1173
1252
|
|
|
1253
|
+
const providerDialog = ref(false);
|
|
1254
|
+
const providerRow = ref<TableRow | null>(null);
|
|
1255
|
+
|
|
1256
|
+
function openProvider(row: TableRow) {
|
|
1257
|
+
providerRow.value = row;
|
|
1258
|
+
providerDialog.value = true;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/** Only what the inviting organisation needs; nothing about the provider's
|
|
1262
|
+
* own staff or other clients. */
|
|
1263
|
+
const providerLines = computed<Array<[string, string]>>(() => {
|
|
1264
|
+
const row = providerRow.value;
|
|
1265
|
+
if (!row) return [];
|
|
1266
|
+
const lines: Array<[string, string]> = [
|
|
1267
|
+
["Service", row.type],
|
|
1268
|
+
["Email", row.contactEmail || row.email],
|
|
1269
|
+
];
|
|
1270
|
+
if (row.contactPhone) lines.push(["Phone", row.contactPhone]);
|
|
1271
|
+
if (row.source === "invite") {
|
|
1272
|
+
lines.push(["Invited", row.invitedLine || "-"]);
|
|
1273
|
+
if (row.answeredAt) lines.push(["Accepted", spDay(row.answeredAt)]);
|
|
1274
|
+
lines.push([
|
|
1275
|
+
"On this site",
|
|
1276
|
+
row.onSite ? "Yes" : "No - removed since it accepted",
|
|
1277
|
+
]);
|
|
1278
|
+
} else {
|
|
1279
|
+
lines.push(["Staff on this site", row.members]);
|
|
1280
|
+
}
|
|
1281
|
+
return lines.filter((line) => line[1]);
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1174
1284
|
function statusDisplay(raw: string, source: TableRow["source"]) {
|
|
1175
1285
|
if (source === "invite") return "Pending";
|
|
1176
1286
|
const s = (raw || "").toLowerCase();
|
|
@@ -1228,12 +1338,14 @@ function mapProviderRows(raw: Record<string, any>[]): TableRow[] {
|
|
|
1228
1338
|
_id: String(row._id ?? ""),
|
|
1229
1339
|
userId: String(row.user ?? ""),
|
|
1230
1340
|
serviceProviderOrgId: String(row.serviceProviderOrgId ?? ""),
|
|
1231
|
-
companyName: row.name
|
|
1341
|
+
companyName: row.name || "-",
|
|
1232
1342
|
email: formatEmailCell(row.email),
|
|
1343
|
+
contactEmail: String(row.email ?? ""),
|
|
1344
|
+
contactPhone: String(row.contact ?? ""),
|
|
1233
1345
|
type: typeDisplay(row.type ?? "", row.nature),
|
|
1234
1346
|
members: formatMembersCell(row.members),
|
|
1235
1347
|
status: statusDisplay(row.status ?? "active", "provider"),
|
|
1236
|
-
source: "provider",
|
|
1348
|
+
source: row.source === "engagement" ? "engagement" : "provider",
|
|
1237
1349
|
}));
|
|
1238
1350
|
}
|
|
1239
1351
|
|
|
@@ -1247,24 +1359,35 @@ function inviteTypeLabel(t: string) {
|
|
|
1247
1359
|
}
|
|
1248
1360
|
|
|
1249
1361
|
function mapInviteRows(raw: Record<string, any>[]): TableRow[] {
|
|
1250
|
-
return raw.map((row) =>
|
|
1362
|
+
return raw.map((row) => {
|
|
1363
|
+
const company = sentCompany(row);
|
|
1364
|
+
return {
|
|
1251
1365
|
_id: String(row._id ?? ""),
|
|
1252
1366
|
userId: String(row.user ?? ""),
|
|
1253
|
-
serviceProviderOrgId: String(row.metadata?.serviceProviderOrgId
|
|
1254
|
-
companyName:
|
|
1367
|
+
serviceProviderOrgId: String(row.providerOrgId || row.metadata?.serviceProviderOrgId || ""),
|
|
1368
|
+
companyName: company.text,
|
|
1369
|
+
companyKnown: company.known,
|
|
1255
1370
|
email: formatEmailCell(row.email),
|
|
1256
1371
|
type: row.metadata?.app
|
|
1257
1372
|
? typeDisplay(row.metadata.app)
|
|
1258
1373
|
: inviteTypeLabel(row.type ?? ""),
|
|
1259
|
-
members: "
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
status: row.statusLabel || serviceProviderInviteLabel(row.status),
|
|
1374
|
+
members: "",
|
|
1375
|
+
status:
|
|
1376
|
+
row.statusLabel || serviceProviderInviteLabel(row.viewStatus || row.status),
|
|
1263
1377
|
statusRaw: String(row.status ?? ""),
|
|
1378
|
+
viewStatus: String(row.viewStatus || row.status || ""),
|
|
1379
|
+
statusLine: sentStatusLine(row),
|
|
1380
|
+
invitedLine: sentInvitedLine(row),
|
|
1381
|
+
providerOrgId: String(row.providerOrgId ?? ""),
|
|
1382
|
+
contactEmail: String(row.contactEmail ?? ""),
|
|
1383
|
+
contactPhone: String(row.contactPhone ?? ""),
|
|
1384
|
+
answeredAt: row.answeredAt ?? null,
|
|
1385
|
+
onSite: Boolean(row.onSite),
|
|
1264
1386
|
rejectionReason: row.rejectionReason ?? "",
|
|
1265
1387
|
siteName: row.metadata?.siteName ?? "",
|
|
1266
1388
|
source: "invite",
|
|
1267
|
-
|
|
1389
|
+
};
|
|
1390
|
+
});
|
|
1268
1391
|
}
|
|
1269
1392
|
|
|
1270
1393
|
async function loadList() {
|
|
@@ -1294,6 +1417,8 @@ async function loadList() {
|
|
|
1294
1417
|
// cancellation is visible here instead of the row simply vanishing.
|
|
1295
1418
|
const data = await getInvitations({
|
|
1296
1419
|
org: props.orgId,
|
|
1420
|
+
// this site's invitations, not every site the organisation has
|
|
1421
|
+
siteId: props.siteId,
|
|
1297
1422
|
status:
|
|
1298
1423
|
"awaiting-approval,pending,complete,rejected,declined,cancelled,expired",
|
|
1299
1424
|
page: page.value,
|
|
@@ -1313,6 +1438,9 @@ async function loadList() {
|
|
|
1313
1438
|
search: searchText.value?.trim() || "",
|
|
1314
1439
|
limit: pageSize,
|
|
1315
1440
|
status: statusParam,
|
|
1441
|
+
// the active tab also lists providers on this site through an
|
|
1442
|
+
// accepted invitation
|
|
1443
|
+
...(statusParam === "active" ? { include: "engagements" } : {}),
|
|
1316
1444
|
});
|
|
1317
1445
|
items.value = mapProviderRows(rowsOf(data));
|
|
1318
1446
|
pages.value = data.pages ?? 0;
|
|
@@ -1569,6 +1697,32 @@ async function submitServiceProviderInvite() {
|
|
|
1569
1697
|
|
|
1570
1698
|
/* The design's sub-line: `--muted` at 12px, the same figure batch I's
|
|
1571
1699
|
`AttachmentsViewer` settled on. There is no `--fs-sub` token. */
|
|
1700
|
+
.sp-muted {
|
|
1701
|
+
color: var(--muted);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
.sp-sub {
|
|
1705
|
+
font-family: var(--font-sans);
|
|
1706
|
+
font-size: 12px;
|
|
1707
|
+
color: var(--muted);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
.sp-details {
|
|
1711
|
+
display: grid;
|
|
1712
|
+
grid-template-columns: max-content 1fr;
|
|
1713
|
+
gap: 8px 16px;
|
|
1714
|
+
margin: 0;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
.sp-details dt {
|
|
1718
|
+
color: var(--muted);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
.sp-details dd {
|
|
1722
|
+
margin: 0;
|
|
1723
|
+
overflow-wrap: anywhere;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1572
1726
|
.sp-reject-reason,
|
|
1573
1727
|
.sp-no-permission,
|
|
1574
1728
|
.sp-members-note {
|
|
@@ -13,6 +13,7 @@ export default function useCleaningPermission() {
|
|
|
13
13
|
bulletinBoardPermissions,
|
|
14
14
|
|
|
15
15
|
inventoryPermissions,
|
|
16
|
+
robotPermissions,
|
|
16
17
|
} = useCommonPermissions();
|
|
17
18
|
const permissions: TPermissions = {
|
|
18
19
|
inventory: inventoryPermissions,
|
|
@@ -291,6 +292,7 @@ export default function useCleaningPermission() {
|
|
|
291
292
|
"Allows the user to view detailed information for item checkouts.",
|
|
292
293
|
},
|
|
293
294
|
},
|
|
295
|
+
"robot-mgmt": robotPermissions,
|
|
294
296
|
};
|
|
295
297
|
|
|
296
298
|
return {
|
|
@@ -331,7 +331,39 @@ export function useCommonPermissions() {
|
|
|
331
331
|
},
|
|
332
332
|
};
|
|
333
333
|
|
|
334
|
+
/*
|
|
335
|
+
* ROBOTS - owner approval 2026-09-25, item 1: the Property Management and
|
|
336
|
+
* Cleaning role editors offer Robots, so a narrower role can be given robot
|
|
337
|
+
* access. Same five strings, spelling and wording as `useSecurityPermission`
|
|
338
|
+
* and exactly what core `robot-permission.util.ts ROBOT_PERMISSIONS` enforces.
|
|
339
|
+
* Catalogue-only: offering an entry grants nobody anything until it is ticked.
|
|
340
|
+
*/
|
|
341
|
+
const robotPermissions = {
|
|
342
|
+
"add-robot": {
|
|
343
|
+
check: true,
|
|
344
|
+
description: "Allows the user to add a new robot to the system.",
|
|
345
|
+
},
|
|
346
|
+
"see-all-robots": {
|
|
347
|
+
check: true,
|
|
348
|
+
description: "Allows the user to view the list of all robots.",
|
|
349
|
+
},
|
|
350
|
+
"see-robot-details": {
|
|
351
|
+
check: true,
|
|
352
|
+
description: "Allows the user to view the details of a specific robot.",
|
|
353
|
+
},
|
|
354
|
+
"update-robot": {
|
|
355
|
+
check: true,
|
|
356
|
+
description: "Allows the user to update robot details.",
|
|
357
|
+
},
|
|
358
|
+
"delete-robot": {
|
|
359
|
+
check: true,
|
|
360
|
+
description:
|
|
361
|
+
"Allows the user to remove a robot from the system permanently.",
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
|
|
334
365
|
return {
|
|
366
|
+
robotPermissions,
|
|
335
367
|
inventoryPermissions,
|
|
336
368
|
invitationPermissions,
|
|
337
369
|
memberPermissions,
|
|
@@ -42,6 +42,7 @@ export default function usePropertyPermission() {
|
|
|
42
42
|
siteSettingsPermissions,
|
|
43
43
|
|
|
44
44
|
inventoryPermissions,
|
|
45
|
+
robotPermissions,
|
|
45
46
|
} = useCommonPermissions();
|
|
46
47
|
|
|
47
48
|
|
|
@@ -618,6 +619,7 @@ export default function usePropertyPermission() {
|
|
|
618
619
|
"Allows the user to view the list of all resident transactions on mobile. Checked by the iMA365 mobile app.",
|
|
619
620
|
},
|
|
620
621
|
},
|
|
622
|
+
"robot-mgmt": robotPermissions,
|
|
621
623
|
"site-settings": siteSettingsPermissions
|
|
622
624
|
};
|
|
623
625
|
|
|
@@ -58,6 +58,8 @@ export default function useServiceProvider() {
|
|
|
58
58
|
orgId = "",
|
|
59
59
|
siteId = "",
|
|
60
60
|
status = "",
|
|
61
|
+
/** "engagements": also providers engaged through an accepted invitation. */
|
|
62
|
+
include = "",
|
|
61
63
|
} = {}) {
|
|
62
64
|
return useNuxtApp().$api<Record<string, any>>("/api/service-providers", {
|
|
63
65
|
method: "GET",
|
|
@@ -69,6 +71,7 @@ export default function useServiceProvider() {
|
|
|
69
71
|
orgId,
|
|
70
72
|
siteId,
|
|
71
73
|
...(status ? { status } : {}),
|
|
74
|
+
...(include ? { include } : {}),
|
|
72
75
|
},
|
|
73
76
|
});
|
|
74
77
|
}
|
|
@@ -18,6 +18,21 @@ export type TServiceProviderInvitation = {
|
|
|
18
18
|
expireAt?: string;
|
|
19
19
|
deletedAt?: string | null;
|
|
20
20
|
metadata?: Record<string, any>;
|
|
21
|
+
/** A lapsed "pending" reads "expired"; `status` stays the stored value. */
|
|
22
|
+
viewStatus?: string;
|
|
23
|
+
/** The company that accepted it, or the new company's name (sign-up). */
|
|
24
|
+
companyName?: string;
|
|
25
|
+
providerOrgId?: string;
|
|
26
|
+
service?: string;
|
|
27
|
+
invitedByName?: string;
|
|
28
|
+
invitedAt?: string | null;
|
|
29
|
+
/** When it was accepted, declined, cancelled or expired. */
|
|
30
|
+
answeredAt?: string | null;
|
|
31
|
+
/** Shown only once accepted. */
|
|
32
|
+
contactEmail?: string;
|
|
33
|
+
contactPhone?: string;
|
|
34
|
+
/** Accepted and still engaged on the site. */
|
|
35
|
+
onSite?: boolean;
|
|
21
36
|
history?: Array<{
|
|
22
37
|
action: string;
|
|
23
38
|
at: string;
|
|
@@ -73,7 +88,9 @@ export default function useServiceProviderInvitation() {
|
|
|
73
88
|
|
|
74
89
|
function getInvitations({
|
|
75
90
|
org = "",
|
|
91
|
+
siteId = "",
|
|
76
92
|
status = "",
|
|
93
|
+
search = "",
|
|
77
94
|
page = 1,
|
|
78
95
|
limit = 20,
|
|
79
96
|
} = {}): Promise<{
|
|
@@ -83,7 +100,14 @@ export default function useServiceProviderInvitation() {
|
|
|
83
100
|
}> {
|
|
84
101
|
return useNuxtApp().$api(base, {
|
|
85
102
|
method: "GET",
|
|
86
|
-
query: {
|
|
103
|
+
query: {
|
|
104
|
+
org,
|
|
105
|
+
status,
|
|
106
|
+
page,
|
|
107
|
+
limit,
|
|
108
|
+
...(siteId ? { siteId } : {}),
|
|
109
|
+
...(search ? { search } : {}),
|
|
110
|
+
},
|
|
87
111
|
});
|
|
88
112
|
}
|
|
89
113
|
|
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.43.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.",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's Customize choice, as it is remembered in one cookie.
|
|
3
|
+
*
|
|
4
|
+
* WHOSE and WHICH APP. The old cookie was keyed only by dashboard mode, so the
|
|
5
|
+
* next person to sign in on the same browser inherited the last person's
|
|
6
|
+
* choice, and Hygiene, M&E, Pest, Landscape and Pool all shared ONE choice -
|
|
7
|
+
* hiding a panel in one hid it in the other four.
|
|
8
|
+
*
|
|
9
|
+
* HIDDEN, not shown. The old cookie listed the widgets to show, so any widget
|
|
10
|
+
* added after someone once pressed Submit stayed hidden for them forever.
|
|
11
|
+
* Remembering what was switched OFF lets a new widget appear by default.
|
|
12
|
+
*
|
|
13
|
+
* Still a cookie on the layer's `cookieConfig` (the same parent-domain cookie
|
|
14
|
+
* `theme` and `landing-page` use): there is no preferences API on the server.
|
|
15
|
+
* One cookie holds the signed-in user's choice for every app; a different user
|
|
16
|
+
* signing in starts from the defaults and replaces it on their first Submit.
|
|
17
|
+
*/
|
|
18
|
+
export type TDashboardWidgetPrefs = {
|
|
19
|
+
user: string;
|
|
20
|
+
hidden: Record<string, string[]>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function prefsFor(stored: unknown, user: string) {
|
|
24
|
+
if (!user || !stored || typeof stored !== "object") return null;
|
|
25
|
+
const prefs = stored as Partial<TDashboardWidgetPrefs>;
|
|
26
|
+
if (prefs.user !== user) return null;
|
|
27
|
+
if (!prefs.hidden || typeof prefs.hidden !== "object") return null;
|
|
28
|
+
return prefs.hidden;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The widgets this user switched off in this app. Empty = the defaults. */
|
|
32
|
+
export function hiddenDashboardWidgets(
|
|
33
|
+
stored: unknown,
|
|
34
|
+
user: string,
|
|
35
|
+
app: string,
|
|
36
|
+
): string[] {
|
|
37
|
+
const list = prefsFor(stored, user)?.[app];
|
|
38
|
+
return Array.isArray(list)
|
|
39
|
+
? list.filter((key): key is string => typeof key === "string")
|
|
40
|
+
: [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The cookie value after this user saves `hidden` for this app. */
|
|
44
|
+
export function saveHiddenDashboardWidgets(
|
|
45
|
+
stored: unknown,
|
|
46
|
+
user: string,
|
|
47
|
+
app: string,
|
|
48
|
+
hidden: readonly string[],
|
|
49
|
+
): TDashboardWidgetPrefs {
|
|
50
|
+
return {
|
|
51
|
+
user,
|
|
52
|
+
hidden: { ...(prefsFor(stored, user) ?? {}), [app]: [...hidden] },
|
|
53
|
+
};
|
|
54
|
+
}
|
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,
|
|
15
|
+
* inventory, 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,6 +40,13 @@ 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",
|
|
43
50
|
"org-site-robot-management": "robot-mgmt",
|
|
44
51
|
"org-site-attendance": "attendance-mgmt",
|
|
45
52
|
"org-site-my-attendance": "my-attendance",
|
package/utils/nav-sections.ts
CHANGED
|
@@ -125,7 +125,13 @@ 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[]
|
|
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>> = {}
|
|
129
135
|
): Array<TSectionedNavItem<T>> {
|
|
130
136
|
// An unmapped title inherits the section of the item above it.
|
|
131
137
|
let current = "";
|
|
@@ -144,7 +150,7 @@ export function withSections<T extends { title?: string }>(
|
|
|
144
150
|
|
|
145
151
|
let printed = "";
|
|
146
152
|
return grouped.map(({ item, section }) => {
|
|
147
|
-
const label = section === printed ? "" : section;
|
|
153
|
+
const label = section === printed ? "" : labels[section] ?? section;
|
|
148
154
|
printed = section;
|
|
149
155
|
return { item, section: label };
|
|
150
156
|
});
|
|
@@ -55,14 +55,31 @@ 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
|
+
|
|
58
73
|
/**
|
|
59
74
|
* `work_orders` -> `Work orders`.
|
|
60
75
|
*
|
|
61
76
|
* Sentence case, not Title Case: a list of 47 Title Cased phrases is harder to
|
|
62
77
|
* scan, not easier.
|
|
63
78
|
*/
|
|
64
|
-
export function formatResourceLabel(key: string): string {
|
|
79
|
+
export function formatResourceLabel(key: string, type?: string): string {
|
|
65
80
|
if (!key) return "";
|
|
81
|
+
const byType = type ? RESOURCE_LABELS_BY_TYPE[type]?.[key] : undefined;
|
|
82
|
+
if (byType) return byType;
|
|
66
83
|
if (RESOURCE_LABELS[key]) return RESOURCE_LABELS[key];
|
|
67
84
|
|
|
68
85
|
const words = key
|
|
@@ -115,11 +132,12 @@ export function formatActionLabel(action: string): string {
|
|
|
115
132
|
*/
|
|
116
133
|
export function sortResourcesByLabel<T>(
|
|
117
134
|
tree: Record<string, T> | null | undefined,
|
|
135
|
+
type?: string,
|
|
118
136
|
): Record<string, T> {
|
|
119
137
|
if (!tree || typeof tree !== "object") return {};
|
|
120
138
|
const sorted: Record<string, T> = {};
|
|
121
139
|
for (const key of Object.keys(tree).sort((a, b) =>
|
|
122
|
-
formatResourceLabel(a).localeCompare(formatResourceLabel(b), undefined, {
|
|
140
|
+
formatResourceLabel(a, type).localeCompare(formatResourceLabel(b, type), undefined, {
|
|
123
141
|
sensitivity: "base",
|
|
124
142
|
numeric: true,
|
|
125
143
|
}),
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inviting organisation's view of one service-provider invitation, in
|
|
3
|
+
* words. Pure, so it is tested without a browser.
|
|
4
|
+
*
|
|
5
|
+
* The company name comes from the server (`companyName`: the company that
|
|
6
|
+
* accepted, or the new company's name a sign-up invitation carried). While an
|
|
7
|
+
* invitation is unanswered the company is genuinely not known yet - the cell
|
|
8
|
+
* says so instead of drawing "-".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type TSentInvitation = {
|
|
12
|
+
status?: string;
|
|
13
|
+
viewStatus?: string;
|
|
14
|
+
statusLabel?: string;
|
|
15
|
+
companyName?: string;
|
|
16
|
+
metadata?: Record<string, any>;
|
|
17
|
+
invitedByName?: string;
|
|
18
|
+
invitedAt?: string | null;
|
|
19
|
+
createdAt?: string | null;
|
|
20
|
+
answeredAt?: string | null;
|
|
21
|
+
onSite?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ANSWERED: Record<string, string> = {
|
|
25
|
+
complete: "Accepted",
|
|
26
|
+
declined: "Declined",
|
|
27
|
+
cancelled: "Cancelled",
|
|
28
|
+
rejected: "Not approved",
|
|
29
|
+
expired: "Expired",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function spDay(value?: string | null): string {
|
|
33
|
+
if (!value) return "";
|
|
34
|
+
const d = new Date(value);
|
|
35
|
+
return Number.isNaN(d.getTime())
|
|
36
|
+
? ""
|
|
37
|
+
: d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The company cell: the name, or why there is none yet. */
|
|
41
|
+
export function sentCompany(row: TSentInvitation): { text: string; known: boolean } {
|
|
42
|
+
const name = String(row.companyName || row.metadata?.name || "").trim();
|
|
43
|
+
if (name) return { text: name, known: true };
|
|
44
|
+
const status = row.viewStatus || row.status || "";
|
|
45
|
+
return {
|
|
46
|
+
text: ["pending", "awaiting-approval"].includes(status) ? "Not accepted yet" : "Not accepted",
|
|
47
|
+
known: false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** "Invited by Tommy on 24 Sep 2026". */
|
|
52
|
+
export function sentInvitedLine(row: TSentInvitation): string {
|
|
53
|
+
const who = String(row.invitedByName ?? "").trim();
|
|
54
|
+
const when = spDay(row.invitedAt ?? row.createdAt);
|
|
55
|
+
return [who ? `by ${who}` : "", when ? `on ${when}` : ""].filter(Boolean).join(" ");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The line under the status: when it was answered, and what it means now. */
|
|
59
|
+
export function sentStatusLine(row: TSentInvitation): string {
|
|
60
|
+
const status = row.viewStatus || row.status || "";
|
|
61
|
+
const when = spDay(row.answeredAt);
|
|
62
|
+
if (status === "complete" && row.onSite === false) {
|
|
63
|
+
return `Accepted${when ? ` on ${when}` : ""} · no longer on this site`;
|
|
64
|
+
}
|
|
65
|
+
if (ANSWERED[status]) return when ? `${ANSWERED[status]} on ${when}` : "";
|
|
66
|
+
if (status === "awaiting-approval") return "Seven365 checks it before it is sent";
|
|
67
|
+
if (status === "pending") return "Sent - waiting for their answer";
|
|
68
|
+
return "";
|
|
69
|
+
}
|