@7365admin1/layer-common 4.77.0 → 4.79.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 +16 -4
- package/assets/css/robot-hero.css +7 -0
- package/components/MemberSiteAccess.vue +27 -9
- package/components/Robot/CleaningRecords.vue +93 -5
- package/components/Robot/Detail.vue +13 -12
- package/components/Robot/LivePlan.vue +12 -1
- package/components/RolePermissionMain.vue +6 -0
- package/composables/useRole.ts +3 -1
- package/package.json +1 -1
- package/utils/robot-maps.ts +55 -0
- package/utils/robot-records.ts +79 -1
- package/utils/site-access.ts +38 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.79.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 324da82: Site access lists "Organisation roles" and "<Site> roles" in every site's Role list and in "Same role for all selected sites" (organisation roles offered before any site is ticked), with a "Create an organisation role" link. RolePermissionMain takes `scope` ("organisation" = roles with no site); getRoles sends it only when set.
|
|
8
|
+
|
|
9
|
+
## 4.78.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 7050e64: Robot detail: virtual walls, safe and slow zones and the taught route drawn on the robot map (with a line naming them); records card adds status history, map details (walls, zones, versions, floor map history, obstacle picture), changes made in the maker's app and read-only robot settings; the 0-jobs state says the maker reports no finished cleaning jobs. Mobile robot-rules copy now carries the records rows.
|
|
14
|
+
|
|
3
15
|
## 4.77.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1920,11 +1932,11 @@
|
|
|
1920
1932
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
1921
1933
|
own rule from two endpoints the console already calls, unprojected:
|
|
1922
1934
|
|
|
1923
|
-
|
|
1924
|
-
|
|
1935
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
1936
|
+
GET /api/roles/id/:role that membership's role document
|
|
1925
1937
|
|
|
1926
|
-
|
|
1927
|
-
|
|
1938
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
1939
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
1928
1940
|
|
|
1929
1941
|
`role.default` is the marker because it is the only property of a platform
|
|
1930
1942
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -671,6 +671,13 @@
|
|
|
671
671
|
.hero-plan__pt { position: absolute; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: var(--hero-accent); pointer-events: none; }
|
|
672
672
|
.hero-plan__ptname { position: absolute; transform: translate(6px, -50%); font-size: 11px; white-space: nowrap; pointer-events: none; color: var(--hero-text); }
|
|
673
673
|
.hero-plan__overlay { position: absolute; inset: 0; width: 100%; height: 100%; max-width: none; object-fit: fill; pointer-events: none; user-select: none; opacity: 0.35; }
|
|
674
|
+
.hero-plan__zones { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
|
|
675
|
+
.hero-plan__zones polyline, .hero-plan__zones polygon { vector-effect: non-scaling-stroke; stroke-width: 2; stroke-linejoin: round; }
|
|
676
|
+
.hero-plan__wall { fill: none; stroke: #e53935; }
|
|
677
|
+
.hero-plan__safe { fill: rgba(67, 160, 71, 0.18); stroke: #43a047; }
|
|
678
|
+
.hero-plan__slow { fill: rgba(251, 140, 0, 0.18); stroke: #fb8c00; }
|
|
679
|
+
.hero-plan__taught { fill: none; stroke: #1e88e5; stroke-dasharray: 4 3; }
|
|
680
|
+
.hero-rec__obstacles { display: block; max-width: 100%; max-height: 320px; margin-top: 4px; border: 1px solid var(--hero-line, rgba(127, 127, 127, 0.3)); image-rendering: pixelated; }
|
|
674
681
|
.hero-plan__area { position: absolute; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; padding: 0 4px; white-space: nowrap; pointer-events: none; color: var(--hero-text); background: var(--hero-bg); border: 1px solid var(--hero-accent); }
|
|
675
682
|
.hero-rec { margin-top: 16px; }
|
|
676
683
|
.hero-rec__table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
<v-card-text class="screen-modal__body">
|
|
5
5
|
<p class="text-body-2 mb-3">
|
|
6
6
|
Choose the sites this person can open and their role at each. They will see only these sites.
|
|
7
|
+
A role can be one made for that site, or an <strong>organisation role</strong> (made once in Roles &
|
|
8
|
+
Permissions) - either way it gives its permissions at that site only.
|
|
9
|
+
</p>
|
|
10
|
+
<p class="text-body-2 mb-3">
|
|
11
|
+
<a href="#" data-test="site-access-create-org-role" @click.prevent="createOrgRole()">Create an organisation role</a>
|
|
12
|
+
to use at many sites.
|
|
7
13
|
</p>
|
|
8
14
|
|
|
9
15
|
<div v-if="loading" class="py-4 text-center"><v-progress-circular indeterminate /></div>
|
|
@@ -31,7 +37,9 @@
|
|
|
31
37
|
<div class="d-flex align-center ga-2 mb-2">
|
|
32
38
|
<v-select
|
|
33
39
|
v-model="sameRole"
|
|
34
|
-
:items="
|
|
40
|
+
:items="sharedItems"
|
|
41
|
+
item-title="title"
|
|
42
|
+
item-value="value"
|
|
35
43
|
label="Same role for all selected sites"
|
|
36
44
|
density="compact"
|
|
37
45
|
hide-details
|
|
@@ -39,27 +47,28 @@
|
|
|
39
47
|
class="filter-field"
|
|
40
48
|
data-test="site-access-same-role"
|
|
41
49
|
/>
|
|
42
|
-
<v-btn variant="outlined" :disabled="!sameRole" @click="applySame()">Apply</v-btn>
|
|
50
|
+
<v-btn variant="outlined" :disabled="!sameRole || !anyOn" @click="applySame()">Apply</v-btn>
|
|
43
51
|
</div>
|
|
52
|
+
<p v-if="sameRole && !anyOn" class="text-caption mb-2">Tick the sites first, then Apply.</p>
|
|
44
53
|
|
|
45
54
|
<div v-for="(c, i) in choices" :key="c.site" class="d-flex align-center ga-2 py-1" data-test="site-access-row">
|
|
46
55
|
<v-checkbox v-model="choices[i].on" density="compact" hide-details :label="c.name" style="min-width: 220px" />
|
|
47
56
|
<v-select
|
|
48
57
|
v-if="c.roles.length"
|
|
49
58
|
v-model="choices[i].role"
|
|
50
|
-
:items="c
|
|
51
|
-
item-title="
|
|
52
|
-
item-value="
|
|
59
|
+
:items="roleItems(c)"
|
|
60
|
+
item-title="title"
|
|
61
|
+
item-value="value"
|
|
53
62
|
:disabled="!c.on"
|
|
54
63
|
label="Role"
|
|
55
64
|
density="compact"
|
|
56
65
|
hide-details
|
|
57
66
|
class="filter-field"
|
|
58
67
|
/>
|
|
59
|
-
<span v-else class="text-caption">No roles
|
|
68
|
+
<span v-else class="text-caption">No roles for {{ c.name }} yet — create an organisation role (link above) or one in that site's Roles & Permissions.</span>
|
|
60
69
|
</div>
|
|
61
70
|
<p v-for="c in choices.filter((x) => x.on && !x.role && x.replacedRole)" :key="`r-${c.site}`" class="text-caption">
|
|
62
|
-
{{ c.name }}: "{{ c.replacedRole }}" is not
|
|
71
|
+
{{ c.name }}: "{{ c.replacedRole }}" is not a role for this site — choose an organisation role or one of {{ c.name }}'s roles.
|
|
63
72
|
</p>
|
|
64
73
|
</div>
|
|
65
74
|
|
|
@@ -78,7 +87,7 @@
|
|
|
78
87
|
|
|
79
88
|
<script setup lang="ts">
|
|
80
89
|
// Relative import: this layer's `utils/` is not auto-imported into consumers.
|
|
81
|
-
import { applySameRole, initialChoices,
|
|
90
|
+
import { applySameRole, initialChoices, roleItems, sharedRoleItems, toPayload, type SiteChoice } from "../utils/site-access";
|
|
82
91
|
|
|
83
92
|
const props = defineProps({
|
|
84
93
|
org: { type: String, required: true },
|
|
@@ -98,7 +107,16 @@ const orgWide = ref<Array<{ roleName?: string }>>([]);
|
|
|
98
107
|
const removeOrgAccess = ref(false);
|
|
99
108
|
const sameRole = ref<string | null>(null);
|
|
100
109
|
|
|
101
|
-
const
|
|
110
|
+
const sharedItems = computed(() => sharedRoleItems(choices.value));
|
|
111
|
+
const anyOn = computed(() => choices.value.some((c) => c.on));
|
|
112
|
+
|
|
113
|
+
// Organization app -> Roles & Permissions, on the organisation's own service;
|
|
114
|
+
// Create there comes back here (`returnTo`), and the box reloads its roles on open.
|
|
115
|
+
function createOrgRole() {
|
|
116
|
+
emit("cancel");
|
|
117
|
+
const back = useRoute().fullPath;
|
|
118
|
+
navigateTo({ path: `/org/${props.org}/roles-permissions`, query: { service: "own", returnTo: back } });
|
|
119
|
+
}
|
|
102
120
|
const missing = computed(() => toPayload(choices.value).missing);
|
|
103
121
|
|
|
104
122
|
function applySame() {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<!--
|
|
3
3
|
Robot detail's records (`/robots/:id/records`, read-only): recent cleaning
|
|
4
|
-
jobs, alert history, then - folded - every reading the
|
|
5
|
-
wear, the power schedule and lifts
|
|
4
|
+
jobs, alert history, status history, then - folded - every reading the
|
|
5
|
+
robot sends, parts wear, the power schedule and lifts, map details (walls,
|
|
6
|
+
zones, versions, obstacles), changes made in the maker's app and the
|
|
7
|
+
robot's settings. A section shows only when the brand sends its part;
|
|
8
|
+
brands without records render nothing.
|
|
6
9
|
A part the maker refused is said in words, never left blank.
|
|
7
10
|
-->
|
|
8
11
|
<section v-if="records?.supported" class="hero-rec" aria-label="Cleaning records">
|
|
@@ -13,7 +16,9 @@
|
|
|
13
16
|
<v-btn variant="text" size="small" :loading="loading" @click="load(true)">Refresh</v-btn>
|
|
14
17
|
</div>
|
|
15
18
|
<div v-for="e in errors" :key="e" class="hero-notice hero-notice--error" role="status">{{ e }}</div>
|
|
16
|
-
<div v-if="!jobs.length" class="hero-sched__empty">
|
|
19
|
+
<div v-if="!jobs.length && !records.tasks?.error" class="hero-sched__empty">
|
|
20
|
+
{{ noJobs }} {{ noJobsMore }}
|
|
21
|
+
</div>
|
|
17
22
|
<div v-else class="hero-rec__wrap">
|
|
18
23
|
<table class="hero-rec__table">
|
|
19
24
|
<thead>
|
|
@@ -38,6 +43,14 @@
|
|
|
38
43
|
</div>
|
|
39
44
|
<v-btn v-if="alerts.length > 5" variant="text" size="small" @click="allAlerts = !allAlerts">{{ allAlerts ? "Show fewer" : `See all ${alerts.length}` }}</v-btn>
|
|
40
45
|
|
|
46
|
+
<template v-if="has.has('statusLog')">
|
|
47
|
+
<div class="hero-section__title mt-4">Status history</div>
|
|
48
|
+
<div class="hero-sched__note">What the robot was doing in the last 7 days (cleaning, returning, charging and so on).</div>
|
|
49
|
+
<div v-if="!statusLog.length" class="hero-sched__empty">No status changes in the last 7 days.</div>
|
|
50
|
+
<div v-for="l in shownLog" :key="l.key" class="hero-sched__row"><b>{{ l.status }}</b><span>{{ l.when }}</span></div>
|
|
51
|
+
<v-btn v-if="statusLog.length > 5" variant="text" size="small" @click="allLog = !allLog">{{ allLog ? "Show fewer" : `See all ${statusLog.length}` }}</v-btn>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
41
54
|
<details class="hero-can hero-can--fold" :open="attention.length > 0">
|
|
42
55
|
<summary class="hero-can__title">
|
|
43
56
|
Robot readings{{ attention.length ? ` · needs attention: ${attention.join(", ")}` : "" }}
|
|
@@ -68,21 +81,84 @@
|
|
|
68
81
|
<div v-if="!lifts.length" class="hero-sched__empty">No lifts linked to this robot's building.</div>
|
|
69
82
|
<div v-for="l in lifts" :key="l.key" class="hero-sched__row"><b>{{ l.name }}</b><span>{{ l.state }}</span></div>
|
|
70
83
|
</details>
|
|
84
|
+
|
|
85
|
+
<details v-if="has.has('mapVersions') || has.has('mapHistory') || has.has('mapLayers') || has.has('obstacles')" class="hero-can hero-can--fold">
|
|
86
|
+
<summary class="hero-can__title">Map details: walls, zones, versions and obstacles</summary>
|
|
87
|
+
<div class="hero-sched__note">Walls and zones on the robot's map (also drawn on the map above)</div>
|
|
88
|
+
<div v-if="!zones.length" class="hero-sched__empty">No virtual walls, safe zones or slow zones on this map.</div>
|
|
89
|
+
<div v-for="z in zones" :key="z.key" class="hero-sched__row"><b>{{ z.kind }}</b><span>{{ z.names }}</span></div>
|
|
90
|
+
<template v-if="has.has('mapVersions')">
|
|
91
|
+
<div class="hero-sched__note mt-2">Versions of this map (each re-scan or edit in {{ makerApp }})</div>
|
|
92
|
+
<div v-if="!versions.length" class="hero-sched__empty">No versions listed.</div>
|
|
93
|
+
<div v-for="v in versions" :key="v.key" class="hero-sched__row"><b>{{ v.version }}</b><span>{{ v.when }}</span><span>{{ v.state }}</span></div>
|
|
94
|
+
</template>
|
|
95
|
+
<template v-if="has.has('mapHistory')">
|
|
96
|
+
<div class="hero-sched__note mt-2">Every map made for this floor</div>
|
|
97
|
+
<div v-if="!history.length" class="hero-sched__empty">No maps listed for this floor.</div>
|
|
98
|
+
<div v-for="m in history" :key="m.key" class="hero-sched__row"><b>{{ m.name }}</b><span>{{ m.size }} · {{ m.when }}</span><span>{{ m.state }}</span></div>
|
|
99
|
+
</template>
|
|
100
|
+
<template v-if="has.has('obstacles')">
|
|
101
|
+
<div class="hero-sched__note mt-2">Obstacles the robot's sensors have marked (as {{ brand || "the maker" }} draws them)</div>
|
|
102
|
+
<img v-if="obstacles.image" class="hero-rec__obstacles" :src="obstacles.image" alt="Obstacles marked by the robot's sensors" />
|
|
103
|
+
<div v-else class="hero-sched__empty">{{ obstacles.note }}</div>
|
|
104
|
+
</template>
|
|
105
|
+
</details>
|
|
106
|
+
|
|
107
|
+
<details v-if="has.has('appActions')" class="hero-can hero-can--fold">
|
|
108
|
+
<summary class="hero-can__title">Changes made in {{ makerApp }} · last 30 days</summary>
|
|
109
|
+
<div v-if="!actions.length" class="hero-sched__empty">Nobody used {{ makerApp }} on this robot in the last 30 days.</div>
|
|
110
|
+
<div v-for="a in actions" :key="a.key" class="hero-sched__row"><b>{{ a.action }}</b><span>{{ a.when }}</span><span>{{ a.by }}</span></div>
|
|
111
|
+
</details>
|
|
112
|
+
|
|
113
|
+
<details v-if="has.has('settings')" class="hero-can hero-can--fold">
|
|
114
|
+
<summary class="hero-can__title">Robot settings (read-only)</summary>
|
|
115
|
+
<div class="hero-sched__note">As the robot reports them. Settings are changed in {{ makerApp }}.</div>
|
|
116
|
+
<div v-if="!settings.length" class="hero-sched__empty">The robot sent no settings.</div>
|
|
117
|
+
<div v-for="g in settings" :key="g.group" class="mb-2">
|
|
118
|
+
<div class="hero-sched__note">{{ g.group }}</div>
|
|
119
|
+
<div class="hero-rec__grid">
|
|
120
|
+
<div v-for="r in g.rows" :key="r.key" class="hero-can__row">
|
|
121
|
+
<span class="hero-can__label">{{ r.label }}</span>
|
|
122
|
+
<span>{{ r.value }}</span>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</details>
|
|
71
127
|
</section>
|
|
72
128
|
</template>
|
|
73
129
|
|
|
74
130
|
<script setup lang="ts">
|
|
75
131
|
import { computed, ref, watch } from "vue";
|
|
76
132
|
import { robotErrorMessage } from "../../utils/robot-display";
|
|
77
|
-
import {
|
|
133
|
+
import {
|
|
134
|
+
alertHistoryRows,
|
|
135
|
+
appActionRows,
|
|
136
|
+
attentionReadings,
|
|
137
|
+
jobRows,
|
|
138
|
+
liftRows,
|
|
139
|
+
mapHistoryRows,
|
|
140
|
+
mapVersionRows,
|
|
141
|
+
noJobsHint,
|
|
142
|
+
noJobsText,
|
|
143
|
+
obstaclePicture,
|
|
144
|
+
partRows,
|
|
145
|
+
powerRows,
|
|
146
|
+
readingGroups,
|
|
147
|
+
recordErrors,
|
|
148
|
+
recordParts,
|
|
149
|
+
settingGroups,
|
|
150
|
+
statusLogRows,
|
|
151
|
+
zoneRows,
|
|
152
|
+
} from "../../utils/robot-records";
|
|
78
153
|
|
|
79
|
-
const props = defineProps<{ robotId: string; makerApp: string }>();
|
|
154
|
+
const props = defineProps<{ robotId: string; makerApp: string; brand?: string }>();
|
|
80
155
|
const { getRobotRecords } = useRobots();
|
|
81
156
|
|
|
82
157
|
const records = ref<Record<string, any> | null>(null);
|
|
83
158
|
const loading = ref(false);
|
|
84
159
|
const allJobs = ref(false);
|
|
85
160
|
const allAlerts = ref(false);
|
|
161
|
+
const allLog = ref(false);
|
|
86
162
|
|
|
87
163
|
// ponytail: read on open and on Refresh only - the maker rate-limits these reads (the API caches 2 min).
|
|
88
164
|
async function load(force = false) {
|
|
@@ -104,6 +180,7 @@ watch(
|
|
|
104
180
|
records.value = null;
|
|
105
181
|
allJobs.value = false;
|
|
106
182
|
allAlerts.value = false;
|
|
183
|
+
allLog.value = false;
|
|
107
184
|
load(true);
|
|
108
185
|
},
|
|
109
186
|
{ immediate: true },
|
|
@@ -119,4 +196,15 @@ const parts = computed(() => partRows(records.value));
|
|
|
119
196
|
const power = computed(() => powerRows(records.value));
|
|
120
197
|
const lifts = computed(() => liftRows(records.value));
|
|
121
198
|
const errors = computed(() => recordErrors(records.value));
|
|
199
|
+
const noJobs = computed(() => noJobsText(props.brand ?? ""));
|
|
200
|
+
const noJobsMore = computed(() => noJobsHint(props.brand ?? ""));
|
|
201
|
+
const has = computed(() => recordParts(records.value));
|
|
202
|
+
const statusLog = computed(() => statusLogRows(records.value));
|
|
203
|
+
const shownLog = computed(() => (allLog.value ? statusLog.value : statusLog.value.slice(0, 5)));
|
|
204
|
+
const actions = computed(() => appActionRows(records.value));
|
|
205
|
+
const settings = computed(() => settingGroups(records.value));
|
|
206
|
+
const zones = computed(() => zoneRows(records.value));
|
|
207
|
+
const versions = computed(() => mapVersionRows(records.value));
|
|
208
|
+
const history = computed(() => mapHistoryRows(records.value));
|
|
209
|
+
const obstacles = computed(() => obstaclePicture(records.value));
|
|
122
210
|
</script>
|
|
@@ -113,12 +113,15 @@
|
|
|
113
113
|
:points="selectedMap?.points ?? []"
|
|
114
114
|
:overlay="selectedMap?.overlay ?? null"
|
|
115
115
|
:areas="selectedMap?.areas ?? []"
|
|
116
|
+
:zones="selectedMap?.zones ?? []"
|
|
117
|
+
:taught-route="selectedMap?.taughtRoute ?? []"
|
|
116
118
|
:route-moving="statusMode === 'WORKING' && robotOnShownMap"
|
|
117
119
|
:spot="spot"
|
|
118
120
|
:pick="spotOn"
|
|
119
121
|
:tick-ms="tickMs"
|
|
120
122
|
@pick="(p) => spot.push(p)"
|
|
121
123
|
/>
|
|
124
|
+
<p v-if="mapLegend" class="hero-muted hero-detail__lag">{{ mapLegend }}</p>
|
|
122
125
|
<p v-if="selectedMap?.approximate" class="hero-muted hero-detail__lag">
|
|
123
126
|
Robot position is approximate: {{ brand }} has not yet confirmed which corner their map is measured from.
|
|
124
127
|
</p>
|
|
@@ -193,7 +196,7 @@
|
|
|
193
196
|
</section>
|
|
194
197
|
|
|
195
198
|
<!-- Cleaning robots: recent jobs, alert history, every reading, parts wear, power schedule, lifts (read-only). -->
|
|
196
|
-
<RobotCleaningRecords v-if="!noInterface && record && robotType !== 'security'" :robot-id="robotId" :maker-app="makerApp" />
|
|
199
|
+
<RobotCleaningRecords v-if="!noInterface && record && robotType !== 'security'" :robot-id="robotId" :maker-app="makerApp" :brand="brand" />
|
|
197
200
|
|
|
198
201
|
<!-- What THIS robot (brand + model) can do, in our words; folded so it never pushes the camera down. -->
|
|
199
202
|
<details v-if="abilities.length" class="hero-can hero-can--fold">
|
|
@@ -301,7 +304,7 @@ import RobotLiveView from "./LiveView.vue";
|
|
|
301
304
|
import RobotManualDrive from "./ManualDrive.vue";
|
|
302
305
|
import RobotPointPicker from "./PointPicker.vue";
|
|
303
306
|
import { faultPhotos } from "../../utils/robot-control";
|
|
304
|
-
import { robotMapDefault, robotMapViews } from "../../utils/robot-maps";
|
|
307
|
+
import { cleanAreaChoices, cleanAreaCommand, mapLayerLegend, robotMapDefault, robotMapViews } from "../../utils/robot-maps";
|
|
305
308
|
import useRobotLive, { siteStatesFeed } from "../../composables/useRobotLive";
|
|
306
309
|
import {
|
|
307
310
|
attentionText,
|
|
@@ -574,6 +577,7 @@ const mapItems = ref<Array<Record<string, any>>>([]);
|
|
|
574
577
|
const mapViews = computed(() => robotMapViews(mapItems.value, live.value?.mapId ?? null, patrol.value?.routes ?? []));
|
|
575
578
|
const selectedMapId = ref<string | null>(null);
|
|
576
579
|
const selectedMap = computed(() => mapViews.value.find((m) => m.mapId === selectedMapId.value) ?? null);
|
|
580
|
+
const mapLegend = computed(() => mapLayerLegend(selectedMap.value));
|
|
577
581
|
watch(mapViews, (views) => {
|
|
578
582
|
if (!views.some((m) => m.mapId === selectedMapId.value)) selectedMapId.value = robotMapDefault(mapItems.value, views);
|
|
579
583
|
});
|
|
@@ -717,10 +721,9 @@ async function send(kind: TRobotCommandKind) {
|
|
|
717
721
|
}
|
|
718
722
|
// Clean area (cleaning robots): the API cleans named areas, so pick them from the robot's map.
|
|
719
723
|
if (kind === "area" && robotType.value !== "security") {
|
|
720
|
-
const
|
|
721
|
-
const zones = map?.areas.map((a) => a.name) ?? [];
|
|
724
|
+
const { areas: zones, empty } = cleanAreaChoices(mapViews.value, selectedMapId.value, makerApp.value);
|
|
722
725
|
if (!zones.length) {
|
|
723
|
-
commandResult.value = { text:
|
|
726
|
+
commandResult.value = { text: empty, warn: true };
|
|
724
727
|
return;
|
|
725
728
|
}
|
|
726
729
|
confirming.value = { kind: "area", body: {}, text: { title: "", body: "", action: "" }, zones, zoneNames: [zones[0]!] };
|
|
@@ -753,13 +756,11 @@ const confirming = ref<{
|
|
|
753
756
|
function pickZones(names: unknown) {
|
|
754
757
|
const c = confirming.value;
|
|
755
758
|
if (!c?.zones) return;
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
c.
|
|
759
|
-
c.
|
|
760
|
-
c.
|
|
761
|
-
const text = commandConfirm("area", { type: robotType.value, name: record.value?.name });
|
|
762
|
-
c.text = { ...text, body: picked.length ? `${record.value?.name ?? "The robot"} starts cleaning ${list}, one pass.` : "Pick at least one area." };
|
|
759
|
+
const cmd = cleanAreaCommand(record.value?.name, c.zones, names);
|
|
760
|
+
c.zoneNames = cmd.body.areas;
|
|
761
|
+
c.body = cmd.body;
|
|
762
|
+
c.what = cmd.what;
|
|
763
|
+
c.text = { ...commandConfirm("area", { type: robotType.value, name: record.value?.name }), body: cmd.sentence };
|
|
763
764
|
}
|
|
764
765
|
/** The route picked in Start patrol's confirm: its exact words, and the body naming it. */
|
|
765
766
|
function pickRoute(id: unknown) {
|
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
-->
|
|
11
11
|
<RobotFloorPlan detail :pick="pick" :image="image" :aspect="aspect" @pick="emit('pick', $event)">
|
|
12
12
|
<img v-if="image && overlay" class="hero-plan__overlay" :src="overlay" alt="" aria-hidden="true" draggable="false" referrerpolicy="no-referrer" />
|
|
13
|
+
<svg v-if="image && (zones.length || taughtRoute.length > 1)" class="hero-plan__zones" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
14
|
+
<template v-for="(z, i) in zones" :key="`${z.kind}-${i}`">
|
|
15
|
+
<polyline v-if="z.kind === 'wall'" class="hero-plan__wall" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polyline>
|
|
16
|
+
<polygon v-else :class="z.kind === 'safe' ? 'hero-plan__safe' : 'hero-plan__slow'" :points="svgPoints(z.points)"><title>{{ z.name }}</title></polygon>
|
|
17
|
+
</template>
|
|
18
|
+
<polyline v-if="taughtRoute.length > 1" class="hero-plan__taught" :points="svgPoints(taughtRoute)" />
|
|
19
|
+
</svg>
|
|
13
20
|
<span v-for="a in placedAreas" :key="a.name" class="hero-plan__area" :style="{ left: `${a.at!.x}%`, top: `${a.at!.y}%` }">{{ a.name }}</span>
|
|
14
21
|
<template v-for="p in points" :key="p.targetPointId">
|
|
15
22
|
<span class="hero-plan__pt" :style="{ left: `${p.x}%`, top: `${p.y}%` }" aria-hidden="true" />
|
|
@@ -61,6 +68,9 @@ const props = withDefaults(
|
|
|
61
68
|
/** The maker's coloured area layer over the map image (Ounitech), and its areas' names + label spots. */
|
|
62
69
|
overlay?: string | null;
|
|
63
70
|
areas?: Array<{ name: string; at: { x: number; y: number } | null }>;
|
|
71
|
+
/** Virtual walls / safe zones / slow zones and the taught route on the map shown (0-100 shares). */
|
|
72
|
+
zones?: Array<{ kind: "wall" | "safe" | "slow"; name: string; points: Array<{ x: number; y: number }> }>;
|
|
73
|
+
taughtRoute?: Array<{ x: number; y: number }>;
|
|
64
74
|
/** Saved points on the map shown (0-100 shares). */
|
|
65
75
|
points?: Array<{ targetPointId: string; name: string; x: number; y: number }>;
|
|
66
76
|
/** The route's dash walks only while the robot patrols. */
|
|
@@ -72,8 +82,9 @@ const props = withDefaults(
|
|
|
72
82
|
/** The last reading kept through a short gap: drawn faded, never as live. */
|
|
73
83
|
stale?: boolean;
|
|
74
84
|
}>(),
|
|
75
|
-
{ stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, overlay: null, areas: () => [], points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
85
|
+
{ stale: false, name: "", statusLabel: "", point: null, heading: 0, trail: () => [], readout: "", lost: false, route: null, routes: null, image: null, aspect: null, overlay: null, areas: () => [], zones: () => [], taughtRoute: () => [], points: () => [], routeMoving: false, spot: () => [], pick: false, tickMs: 1200 },
|
|
76
86
|
);
|
|
87
|
+
const svgPoints = (pts: Array<{ x: number; y: number }>) => pts.map((p) => `${p.x},${p.y}`).join(" ");
|
|
77
88
|
const placedAreas = computed(() => (props.image ? props.areas.filter((a) => a.at) : []));
|
|
78
89
|
const emit = defineEmits<{ pick: [point: { x: number; y: number }] }>();
|
|
79
90
|
// The tag stays inside the plan (flipped left near the right edge, below near the top) - never clipped.
|
|
@@ -227,6 +227,11 @@ const props = defineProps({
|
|
|
227
227
|
type: String,
|
|
228
228
|
default: "",
|
|
229
229
|
},
|
|
230
|
+
/** "organisation": list only roles with no site (Organization app service roles). */
|
|
231
|
+
scope: {
|
|
232
|
+
type: String,
|
|
233
|
+
default: "",
|
|
234
|
+
},
|
|
230
235
|
/*
|
|
231
236
|
* Whether the Create-role dialog shows its (read-only) Site field.
|
|
232
237
|
* The dialog defaulted this to `true` but this component never declared or
|
|
@@ -429,6 +434,7 @@ const {
|
|
|
429
434
|
type: props.listType ?? props.type,
|
|
430
435
|
org: props.orgId,
|
|
431
436
|
site: props.siteId,
|
|
437
|
+
scope: props.scope,
|
|
432
438
|
}),
|
|
433
439
|
{
|
|
434
440
|
watch: [page, headerSearch],
|
package/composables/useRole.ts
CHANGED
|
@@ -24,10 +24,12 @@ export default function useRole() {
|
|
|
24
24
|
type = "",
|
|
25
25
|
org = "",
|
|
26
26
|
site = "",
|
|
27
|
+
/** "organisation": only roles with no site (needs core 3.134.0+ - an older API refuses the key with 400) */
|
|
28
|
+
scope = "",
|
|
27
29
|
} = {}) {
|
|
28
30
|
return useNuxtApp().$api<Record<string, any>>("/api/roles", {
|
|
29
31
|
method: "GET",
|
|
30
|
-
query: { search, page, limit, type, org, site },
|
|
32
|
+
query: { search, page, limit, type, org, site, ...(scope ? { scope } : {}) },
|
|
31
33
|
});
|
|
32
34
|
}
|
|
33
35
|
|
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.79.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.",
|
package/utils/robot-maps.ts
CHANGED
|
@@ -28,8 +28,36 @@ export type TRobotMapView = {
|
|
|
28
28
|
areas: Array<{ name: string; at: { x: number; y: number } | null }>;
|
|
29
29
|
/** The maker has not confirmed the map's corner, so the robot pin may be off: say "position approximate". */
|
|
30
30
|
approximate: boolean;
|
|
31
|
+
/** Virtual walls (never crossed), safe zones and slow zones on this map, as 0-100 shares. */
|
|
32
|
+
zones: TRobotMapZone[];
|
|
33
|
+
/** The route the robot was taught on this map (0-100 shares); empty when none. */
|
|
34
|
+
taughtRoute: Array<{ x: number; y: number }>;
|
|
31
35
|
};
|
|
32
36
|
|
|
37
|
+
export type TRobotMapZone = { kind: "wall" | "safe" | "slow"; name: string; points: Array<{ x: number; y: number }> };
|
|
38
|
+
|
|
39
|
+
const share = (p: any) => isNum(p?.x) && isNum(p?.y) && p.x >= 0 && p.x <= 100 && p.y >= 0 && p.y <= 100;
|
|
40
|
+
function mapZones(list: unknown): TRobotMapZone[] {
|
|
41
|
+
return (Array.isArray(list) ? list : []).flatMap((z: any) => {
|
|
42
|
+
const points = (Array.isArray(z?.points) ? z.points : []).filter(share).map((p: any) => ({ x: p.x, y: p.y }));
|
|
43
|
+
return (z?.kind === "wall" || z?.kind === "safe" || z?.kind === "slow") && points.length >= 2 ? [{ kind: z.kind, name: String(z.name ?? ""), points }] : [];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One line under the map naming what is drawn on it: "Drawn on the map: 1 virtual wall (red) · 1 slow zone (amber) · taught route (blue dashes)". */
|
|
48
|
+
export function mapLayerLegend(view: Pick<TRobotMapView, "zones" | "taughtRoute"> | null | undefined): string {
|
|
49
|
+
if (!view) return "";
|
|
50
|
+
const n = (k: TRobotMapZone["kind"]) => view.zones.filter((z) => z.kind === k).length;
|
|
51
|
+
const part = (count: number, one: string, many: string, colour: string) => (count ? `${count} ${count === 1 ? one : many} (${colour})` : "");
|
|
52
|
+
const parts = [
|
|
53
|
+
part(n("wall"), "virtual wall", "virtual walls", "red"),
|
|
54
|
+
part(n("safe"), "safe zone", "safe zones", "green"),
|
|
55
|
+
part(n("slow"), "slow zone", "slow zones", "amber"),
|
|
56
|
+
view.taughtRoute.length >= 2 ? "taught route (blue dashes)" : "",
|
|
57
|
+
].filter(Boolean);
|
|
58
|
+
return parts.length ? `Drawn on the map: ${parts.join(" · ")}` : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
33
61
|
const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
34
62
|
const levelName = (n: number) => (n > 0 ? `L${n}` : n < 0 ? `B${-n}` : "G");
|
|
35
63
|
const TYPE_WORDS: Readonly<Record<number, string>> = { 0: "Point", 3: "Lift", 4: "Loading", 6: "Access door", 10: "Charging" };
|
|
@@ -90,6 +118,8 @@ export function robotMapViews(
|
|
|
90
118
|
.filter((a: any) => typeof a?.name === "string" && a.name.trim())
|
|
91
119
|
.map((a: any) => ({ name: String(a.name).trim(), at: a.at && isNum(a.at.x) && isNum(a.at.y) ? { x: a.at.x, y: a.at.y } : null })),
|
|
92
120
|
approximate: m.positionApproximate === true,
|
|
121
|
+
zones: mapZones(m.zones),
|
|
122
|
+
taughtRoute: (Array.isArray(m.taughtRoute) ? m.taughtRoute : []).filter(share).map((p: any) => ({ x: p.x, y: p.y })),
|
|
93
123
|
};
|
|
94
124
|
});
|
|
95
125
|
}
|
|
@@ -119,3 +149,28 @@ export function robotMapsByFloor(views: TRobotMapView[]): Map<number, TRobotMapV
|
|
|
119
149
|
for (const v of views) if (v.floorNo !== null) out.set(v.floorNo, [...(out.get(v.floorNo) ?? []), v]);
|
|
120
150
|
return out;
|
|
121
151
|
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Clean area (cleaning robots, web and phones): the area names on the map the
|
|
155
|
+
* robot is on (else the map shown). `empty` is the sentence when there are none.
|
|
156
|
+
*/
|
|
157
|
+
export function cleanAreaChoices(views: TRobotMapView[], selectedMapId: string | null | undefined, makerApp: string): { mapLabel: string; areas: string[]; empty: string } {
|
|
158
|
+
const map = views.find((v) => v.current) ?? views.find((v) => v.mapId === selectedMapId) ?? null;
|
|
159
|
+
const areas = map ? map.areas.map((a) => a.name) : [];
|
|
160
|
+
return {
|
|
161
|
+
mapLabel: map?.label ?? "",
|
|
162
|
+
areas,
|
|
163
|
+
empty: areas.length ? "" : `There are no cleaning areas on ${map ? map.label : "this robot's map"} yet. Areas are drawn in ${makerApp || "the maker's app"}.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The picked areas as the command body, the outcome's words and the confirm sentence (only names that are on the map). */
|
|
168
|
+
export function cleanAreaCommand(robotName: string | null | undefined, areas: string[], picked: unknown): { body: { kind: "area"; areas: string[] }; what: string | undefined; sentence: string } {
|
|
169
|
+
const names = (Array.isArray(picked) ? picked : []).filter((n): n is string => typeof n === "string" && areas.includes(n));
|
|
170
|
+
const list = names.length > 2 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names.join(" and ");
|
|
171
|
+
return {
|
|
172
|
+
body: { kind: "area", areas: names },
|
|
173
|
+
what: names.length ? `cleaning ${list}` : undefined,
|
|
174
|
+
sentence: names.length ? `${robotName || "The robot"} starts cleaning ${list}, one pass.` : "Pick at least one area.",
|
|
175
|
+
};
|
|
176
|
+
}
|
package/utils/robot-records.ts
CHANGED
|
@@ -24,7 +24,8 @@ export function whenText(iso: unknown, timeZone?: string): string {
|
|
|
24
24
|
const t = typeof iso === "string" ? Date.parse(iso) : NaN;
|
|
25
25
|
if (!Number.isFinite(t)) return "—";
|
|
26
26
|
const parts = Object.fromEntries(
|
|
27
|
-
|
|
27
|
+
// Fixed "30 Sep, 08:15" parts (en-GB), never the device locale; the zone is the viewer's unless one is given.
|
|
28
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: timeZone || undefined, day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" })
|
|
28
29
|
.formatToParts(t)
|
|
29
30
|
.map((p) => [p.type, p.value]),
|
|
30
31
|
);
|
|
@@ -124,6 +125,12 @@ export function recordErrors(records: Record<string, any> | null | undefined): s
|
|
|
124
125
|
lifts: "lifts",
|
|
125
126
|
readings: "robot readings",
|
|
126
127
|
station: "base station",
|
|
128
|
+
statusLog: "status history",
|
|
129
|
+
appActions: "changes made in the maker's app",
|
|
130
|
+
settings: "robot settings",
|
|
131
|
+
obstacles: "obstacle picture",
|
|
132
|
+
mapVersions: "map versions",
|
|
133
|
+
mapHistory: "floor's map history",
|
|
127
134
|
};
|
|
128
135
|
if (!records) return [];
|
|
129
136
|
const out = typeof records.error === "string" && records.error ? [records.error] : [];
|
|
@@ -133,3 +140,74 @@ export function recordErrors(records: Record<string, any> | null | undefined): s
|
|
|
133
140
|
}
|
|
134
141
|
return out;
|
|
135
142
|
}
|
|
143
|
+
|
|
144
|
+
/** The empty cleaning-jobs line, in plain words (the maker's own list is empty, not ours). */
|
|
145
|
+
export function noJobsText(brand: string, days = 14): string {
|
|
146
|
+
return `${brand || "The maker"} reports no finished cleaning jobs in the last ${days} days.`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The line under it: what would make a job appear, and that the maker has been asked. */
|
|
150
|
+
export function noJobsHint(brand: string): string {
|
|
151
|
+
const who = brand || "the maker";
|
|
152
|
+
return `A job started here or by a schedule is listed once ${who} records it; if this robot was meant to clean, we have asked ${who} why nothing is listed.`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type TWhenRow = { key: string; when: string };
|
|
156
|
+
|
|
157
|
+
/** The robot's status changes (cleaning, charging, returning ...), newest first as sent. */
|
|
158
|
+
export function statusLogRows(records: { statusLog?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): Array<TWhenRow & { status: string }> {
|
|
159
|
+
return items(records?.statusLog).map((l, i) => ({ key: `${l.at ?? ""}-${i}`, when: whenText(l.at, timeZone), status: String(l.status || "—") }));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What people did to the robot in the maker's app ("Started a cleaning job · Leong"). */
|
|
163
|
+
export function appActionRows(records: { appActions?: TPart<Record<string, any>> } | null | undefined, timeZone?: string): Array<TWhenRow & { action: string; by: string }> {
|
|
164
|
+
return items(records?.appActions).map((a, i) => ({ key: `${a.at ?? ""}-${i}`, when: whenText(a.at, timeZone), action: String(a.action || "—"), by: a.by ? String(a.by) : "—" }));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The robot's settings, grouped in the API's order ("Cleaning", "Battery", ... "Other settings" last). */
|
|
168
|
+
export function settingGroups(records: { settings?: TPart<Record<string, any>> } | null | undefined) {
|
|
169
|
+
return readingGroups({ readings: records?.settings });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The current map's walls and zones by kind: "2 virtual walls (Entrance, …)". */
|
|
173
|
+
export function zoneRows(records: { mapLayers?: TPart<Record<string, any>> } | null | undefined): Array<{ key: string; kind: string; names: string }> {
|
|
174
|
+
const zones: Array<{ kind?: string; name?: string }> = items(records?.mapLayers).flatMap((m) => (Array.isArray(m?.zones) ? m.zones : []));
|
|
175
|
+
const words: Record<string, [string, string]> = { wall: ["virtual wall", "virtual walls"], safe: ["safe zone", "safe zones"], slow: ["slow zone", "slow zones"] };
|
|
176
|
+
return (["wall", "safe", "slow"] as const).flatMap((k) => {
|
|
177
|
+
const names = zones.filter((z) => z.kind === k).map((z) => String(z.name ?? ""));
|
|
178
|
+
if (!names.length) return [];
|
|
179
|
+
const [one, many] = words[k]!;
|
|
180
|
+
return [{ key: k, kind: `${names.length} ${names.length === 1 ? one : many}`, names: names.join(", ") }];
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Each version of the current map (re-scan or edit), newest first. */
|
|
185
|
+
export function mapVersionRows(records: { mapVersions?: TPart<Record<string, any>> } | null | undefined, timeZone?: string) {
|
|
186
|
+
return items(records?.mapVersions)
|
|
187
|
+
.slice()
|
|
188
|
+
.sort((a, b) => (isNum(b.version) ? b.version : 0) - (isNum(a.version) ? a.version : 0))
|
|
189
|
+
.map((v, i) => ({ key: `${v.version ?? i}`, version: isNum(v.version) ? `Version ${v.version}` : "Version —", when: whenText(v.createdAt, timeZone), state: v.latest ? "In use" : "Older" }));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every map made for the robot's floor: name, size, in use or not. */
|
|
193
|
+
export function mapHistoryRows(records: { mapHistory?: TPart<Record<string, any>> } | null | undefined, timeZone?: string) {
|
|
194
|
+
return items(records?.mapHistory).map((m, i) => ({
|
|
195
|
+
key: `${m.name}-${i}`,
|
|
196
|
+
name: String(m.name ?? "Map"),
|
|
197
|
+
size: isNum(m.areaM2) ? `${m.areaM2} m²` : "—",
|
|
198
|
+
when: `made ${whenText(m.createdAt, timeZone)}${m.updatedAt ? ` · changed ${whenText(m.updatedAt, timeZone)}` : ""}`,
|
|
199
|
+
state: m.latest ? "Latest map" : m.inUse ? "Switched on" : "Old map",
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The obstacle picture as a data URL, or a sentence when it is too large / not sent. */
|
|
204
|
+
export function obstaclePicture(records: { obstacles?: TPart<Record<string, any>> } | null | undefined): { image: string | null; note: string } {
|
|
205
|
+
const o = items(records?.obstacles)[0];
|
|
206
|
+
if (o && typeof o.image === "string" && /^data:image\/(png|jpeg);base64,/.test(o.image)) return { image: o.image, note: "" };
|
|
207
|
+
return { image: null, note: o?.tooLarge ? "The obstacle picture is too large to show here." : "No obstacle picture was sent." };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Which records parts this brand sends (a section shows only when its part is there - capability-driven). */
|
|
211
|
+
export function recordParts(records: Record<string, any> | null | undefined): Set<string> {
|
|
212
|
+
return new Set(Object.keys(records ?? {}).filter((k) => records![k] && typeof records![k] === "object" && "items" in records![k]));
|
|
213
|
+
}
|
package/utils/site-access.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Site access editor state (owner request 2026-09-28). One row per site of the
|
|
3
|
-
* organisation; a chosen site needs one
|
|
4
|
-
* organisation
|
|
3
|
+
* organisation; a chosen site needs a role: one created FOR that site, or one of
|
|
4
|
+
* the organisation's service roles (owner request 2026-09-30, `scope:
|
|
5
|
+
* "organisation"`). Either way the server confines the membership to that site.
|
|
5
6
|
*/
|
|
6
|
-
export type SiteAccessRole = { _id: string; name: string; type?: string };
|
|
7
|
+
export type SiteAccessRole = { _id: string; name: string; type?: string; scope?: "site" | "organisation" };
|
|
7
8
|
export type SiteAccessRow = {
|
|
8
9
|
site: string;
|
|
9
10
|
name: string;
|
|
@@ -11,33 +12,57 @@ export type SiteAccessRow = {
|
|
|
11
12
|
roles: SiteAccessRole[];
|
|
12
13
|
};
|
|
13
14
|
export type SiteChoice = { site: string; name: string; on: boolean; role: string; roles: SiteAccessRole[]; replacedRole?: string };
|
|
15
|
+
/** A v-select entry: a role, or a group heading. */
|
|
16
|
+
export type RoleItem = { title: string; value?: string; type?: "subheader" };
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
const isOrgRole = (r: SiteAccessRole) => r.scope === "organisation";
|
|
19
|
+
|
|
20
|
+
/** Start from what the member has now. A current role the site does not offer is dropped. */
|
|
16
21
|
export function initialChoices(rows: SiteAccessRow[]): SiteChoice[] {
|
|
17
22
|
return (rows ?? []).map((r) => {
|
|
18
23
|
const current = r.membership?.role ?? "";
|
|
19
24
|
const valid = r.roles.some((x) => x._id === current);
|
|
20
25
|
return {
|
|
21
26
|
site: r.site, name: r.name, on: !!r.membership, role: valid ? current : "", roles: r.roles ?? [],
|
|
22
|
-
// their current role here is not one
|
|
23
|
-
//
|
|
27
|
+
// their current role here is not one this site offers (e.g. another site's
|
|
28
|
+
// role) - say so rather than blank it silently
|
|
24
29
|
...(r.membership && !valid ? { replacedRole: r.membership.roleName || "their current role" } : {}),
|
|
25
30
|
};
|
|
26
31
|
});
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
/**
|
|
30
|
-
export function
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
/** One site's Role list: "Organisation roles" first, then "<Site> roles". */
|
|
35
|
+
export function roleItems(choice: Pick<SiteChoice, "name" | "roles">): RoleItem[] {
|
|
36
|
+
const org = choice.roles.filter(isOrgRole);
|
|
37
|
+
const own = choice.roles.filter((r) => !isOrgRole(r));
|
|
38
|
+
const items: RoleItem[] = [];
|
|
39
|
+
if (org.length) items.push({ title: "Organisation roles", type: "subheader" }, ...org.map((r) => ({ title: r.name, value: r._id })));
|
|
40
|
+
if (own.length) items.push({ title: `${choice.name} roles`, type: "subheader" }, ...own.map((r) => ({ title: r.name, value: r._id })));
|
|
41
|
+
return items;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* "Same role for all selected sites", grouped: the organisation's roles (they
|
|
46
|
+
* work at every site, so they are listed even before a site is ticked), then
|
|
47
|
+
* the names of the ticked sites' own roles.
|
|
48
|
+
*/
|
|
49
|
+
export function sharedRoleItems(choices: SiteChoice[]): RoleItem[] {
|
|
50
|
+
const org = new Set<string>();
|
|
51
|
+
for (const c of choices) for (const r of c.roles) if (isOrgRole(r)) org.add(r.name);
|
|
52
|
+
const own = new Set<string>();
|
|
53
|
+
for (const c of choices) if (c.on) for (const r of c.roles) if (!isOrgRole(r) && !org.has(r.name)) own.add(r.name);
|
|
54
|
+
const sorted = (s: Set<string>) => [...s].sort((a, b) => a.localeCompare(b)).map((n) => ({ title: n, value: n }));
|
|
55
|
+
const items: RoleItem[] = [];
|
|
56
|
+
if (org.size) items.push({ title: "Organisation roles", type: "subheader" }, ...sorted(org));
|
|
57
|
+
if (own.size) items.push({ title: "Roles of the selected sites", type: "subheader" }, ...sorted(own));
|
|
58
|
+
return items;
|
|
34
59
|
}
|
|
35
60
|
|
|
36
|
-
/** Give every selected site the role with this name, where that site has one. */
|
|
61
|
+
/** Give every selected site the role with this name (an organisation role first), where that site has one. */
|
|
37
62
|
export function applySameRole(choices: SiteChoice[], roleName: string): SiteChoice[] {
|
|
38
63
|
return choices.map((c) => {
|
|
39
64
|
if (!c.on) return c;
|
|
40
|
-
const match = c.roles.find((r) => r.name === roleName);
|
|
65
|
+
const match = c.roles.find((r) => r.name === roleName && isOrgRole(r)) ?? c.roles.find((r) => r.name === roleName);
|
|
41
66
|
return match ? { ...c, role: match._id } : c;
|
|
42
67
|
});
|
|
43
68
|
}
|