@7365admin1/layer-common 3.1.4-staging.53 → 3.1.4-staging.55

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.
@@ -0,0 +1,333 @@
1
+ <template>
2
+ <div
3
+ ref="root"
4
+ class="vms-tile"
5
+ :class="{ 'vms-tile--dense': dense, 'vms-tile--focused': focused, 'vms-tile--empty': !camera }"
6
+ :tabindex="camera ? 0 : -1"
7
+ :aria-label="camera ? `${camera.name || camera._id}, ${view.label}` : 'Empty position'"
8
+ @click="camera && $emit('focus', camera)"
9
+ @dblclick="camera && toggleFullscreen()"
10
+ @keydown.enter="camera && toggleFullscreen()"
11
+ >
12
+ <!--
13
+ The picture, when there is one to show. This is an embed of the video
14
+ service's own player page: the stored address IS the live view, and there
15
+ is no other transport for live web video today.
16
+ -->
17
+ <iframe
18
+ v-if="camera && view.showPlayer"
19
+ :key="camera.host"
20
+ :src="camera.host"
21
+ class="vms-tile__frame"
22
+ :title="`Live view of ${camera.name || 'camera'}`"
23
+ allowfullscreen
24
+ referrerpolicy="no-referrer"
25
+ @load="onPlayerLoad"
26
+ />
27
+
28
+ <!--
29
+ A tile with nothing to show says so across the whole tile, not in a
30
+ corner. At nine tiles a supervisor is scanning, and a one-line reason
31
+ tucked under a black rectangle is exactly the thing that gets missed.
32
+ -->
33
+ <div v-if="view.detail" class="vms-tile__overlay">
34
+ <p class="vms-tile__reason">{{ view.detail }}</p>
35
+ </div>
36
+
37
+ <!-- Chrome sits OVER the picture, never around it. -->
38
+ <div v-if="camera" class="vms-tile__status">
39
+ <span class="vms-tile__dot" :class="`vms-tile__dot--${view.state}`" />
40
+ <!--
41
+ At nine tiles the badge is the DOT ALONE: a status word beside a 6 px
42
+ dot on a small tile is unreadable at the distance a wall is scanned
43
+ from, and the dot is what is being read anyway. The word returns at
44
+ four tiles and one.
45
+ -->
46
+ <span v-if="!dense" class="vms-tile__state">{{ view.label }}</span>
47
+ </div>
48
+
49
+ <div v-if="camera" class="vms-tile__chrome">
50
+ <!-- The NAME wins the fight for room: it is the tile's entire job. -->
51
+ <span class="vms-tile__name">{{ camera.name || camera._id }}</span>
52
+ </div>
53
+
54
+ <button
55
+ v-if="camera"
56
+ type="button"
57
+ class="vms-tile__expand"
58
+ title="Fill the screen with this camera"
59
+ aria-label="Fill the screen with this camera"
60
+ @click.stop="toggleFullscreen()"
61
+ >
62
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
63
+ <path
64
+ d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
65
+ fill="none"
66
+ stroke="currentColor"
67
+ stroke-width="2"
68
+ stroke-linecap="square"
69
+ />
70
+ </svg>
71
+ </button>
72
+ </div>
73
+ </template>
74
+
75
+ <script setup lang="ts">
76
+ import { computed, onBeforeUnmount, ref, watch } from "vue";
77
+
78
+ import {
79
+ LIVE_CONNECT_TIMEOUT_MS,
80
+ tileView,
81
+ type TPlayerState,
82
+ type TWallCamera,
83
+ } from "../utils/camera-wall";
84
+
85
+ /**
86
+ * ONE TILE OF THE CAMERA WALL.
87
+ *
88
+ * Everything this component decides comes from `utils/camera-wall.ts`; what is
89
+ * left here is the picture, the chrome over it, and the one thing only a
90
+ * browser can know — whether the player page ever loaded.
91
+ *
92
+ * ## The ceiling worth knowing before "improving" the live badge
93
+ *
94
+ * We can tell when the player PAGE loads. We cannot tell when a FRAME decodes:
95
+ * the page belongs to the video service, not to us, and learning more would
96
+ * mean reaching into another origin's document — which is both blocked by the
97
+ * browser and would break silently the day that page changes. So **"Live" means
98
+ * the player page is up**. If the service is up and the camera behind it is
99
+ * dead, the operator sees that service's own empty player, exactly as they do
100
+ * in the app they use today. Closing that honestly is a change on the video
101
+ * service's side, not here.
102
+ */
103
+
104
+ const props = withDefaults(
105
+ defineProps<{
106
+ camera: TWallCamera | null;
107
+ /** Nine tiles: drop the status word, keep the dot. */
108
+ dense?: boolean;
109
+ /** Browser connectivity. One dead tile and a dead browser look identical otherwise. */
110
+ online?: boolean;
111
+ focused?: boolean;
112
+ }>(),
113
+ { dense: false, online: true, focused: false }
114
+ );
115
+
116
+ defineEmits<{ (e: "focus", camera: TWallCamera): void }>();
117
+
118
+ const root = ref<HTMLElement | null>(null);
119
+ const player = ref<TPlayerState>("loading");
120
+ let timer: ReturnType<typeof setTimeout> | null = null;
121
+
122
+ const view = computed(() =>
123
+ tileView(props.camera, { online: props.online, player: player.value })
124
+ );
125
+
126
+ function onPlayerLoad() {
127
+ player.value = "ready";
128
+ clearTimer();
129
+ }
130
+
131
+ function clearTimer() {
132
+ if (timer) {
133
+ clearTimeout(timer);
134
+ timer = null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Restart the clock whenever the address changes — a tile reused for a
140
+ * different camera must not inherit the previous one's verdict, in either
141
+ * direction.
142
+ */
143
+ watch(
144
+ () => props.camera?.host,
145
+ (host) => {
146
+ clearTimer();
147
+ player.value = "loading";
148
+ if (!host) return;
149
+ timer = setTimeout(() => {
150
+ if (player.value === "loading") player.value = "timeout";
151
+ }, LIVE_CONNECT_TIMEOUT_MS);
152
+ },
153
+ { immediate: true }
154
+ );
155
+
156
+ onBeforeUnmount(clearTimer);
157
+
158
+ /** The browser's own fullscreen. Nothing to build, and it survives the F11 key. */
159
+ async function toggleFullscreen() {
160
+ const el = root.value;
161
+ if (!el) return;
162
+ try {
163
+ if (document.fullscreenElement) await document.exitFullscreen();
164
+ else await el.requestFullscreen();
165
+ } catch {
166
+ // A browser that refuses fullscreen (permissions policy, an iframe, an old
167
+ // Safari) is not an error worth interrupting a supervisor over — the tile
168
+ // keeps working exactly as it did.
169
+ }
170
+ }
171
+ </script>
172
+
173
+ <style scoped>
174
+ .vms-tile {
175
+ position: relative;
176
+ overflow: hidden;
177
+ background: var(--vms-tile, #101418);
178
+ border-radius: var(--vms-radius, 3px);
179
+ outline: none;
180
+ min-height: 0;
181
+ }
182
+
183
+ .vms-tile--focused {
184
+ box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
185
+ }
186
+
187
+ .vms-tile:focus-visible {
188
+ box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
189
+ }
190
+
191
+ .vms-tile--empty {
192
+ background: transparent;
193
+ border: 1px dashed var(--vms-line, #232b36);
194
+ }
195
+
196
+ .vms-tile__frame {
197
+ width: 100%;
198
+ height: 100%;
199
+ border: none;
200
+ display: block;
201
+ background: #000;
202
+ }
203
+
204
+ .vms-tile__overlay {
205
+ position: absolute;
206
+ inset: 0;
207
+ display: flex;
208
+ align-items: center;
209
+ justify-content: center;
210
+ padding: 10px;
211
+ background: var(--vms-scrim-solid, rgba(7, 9, 12, 0.82));
212
+ pointer-events: none;
213
+ }
214
+
215
+ .vms-tile__reason {
216
+ margin: 0;
217
+ text-align: center;
218
+ color: var(--vms-text-dim, #94a0b0);
219
+ font-size: 12px;
220
+ line-height: 16px;
221
+ max-width: 32ch;
222
+ }
223
+
224
+ .vms-tile--dense .vms-tile__reason {
225
+ font-size: 11px;
226
+ line-height: 15px;
227
+ }
228
+
229
+ .vms-tile__status {
230
+ position: absolute;
231
+ top: 6px;
232
+ right: 6px;
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 5px;
236
+ padding: 2px 6px;
237
+ border-radius: var(--vms-radius, 3px);
238
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
239
+ pointer-events: none;
240
+ }
241
+
242
+ .vms-tile__dot {
243
+ width: 7px;
244
+ height: 7px;
245
+ border-radius: 50%;
246
+ background: var(--vms-text-faint, #5c6675);
247
+ flex: none;
248
+ }
249
+
250
+ /* State colours only. The chrome itself stays achromatic on purpose. */
251
+ .vms-tile__dot--live {
252
+ background: #4caf50;
253
+ }
254
+ .vms-tile__dot--connecting {
255
+ background: #fb8c00;
256
+ }
257
+ .vms-tile__dot--no-signal,
258
+ .vms-tile__dot--offline {
259
+ background: #e0241c;
260
+ }
261
+ .vms-tile__dot--unavailable {
262
+ background: var(--vms-text-faint, #5c6675);
263
+ }
264
+
265
+ .vms-tile__state {
266
+ color: var(--vms-text, #e8ecf1);
267
+ font-size: 11px;
268
+ line-height: 14px;
269
+ white-space: nowrap;
270
+ }
271
+
272
+ .vms-tile__chrome {
273
+ position: absolute;
274
+ left: 6px;
275
+ right: 6px;
276
+ bottom: 5px;
277
+ display: flex;
278
+ pointer-events: none;
279
+ }
280
+
281
+ .vms-tile__name {
282
+ color: var(--vms-text, #e8ecf1);
283
+ font-size: 12px;
284
+ line-height: 16px;
285
+ font-weight: 600;
286
+ padding: 1px 6px;
287
+ border-radius: var(--vms-radius, 3px);
288
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
289
+ overflow: hidden;
290
+ text-overflow: ellipsis;
291
+ white-space: nowrap;
292
+ }
293
+
294
+ .vms-tile--dense .vms-tile__name {
295
+ font-size: 11px;
296
+ line-height: 15px;
297
+ }
298
+
299
+ .vms-tile__expand {
300
+ position: absolute;
301
+ top: 6px;
302
+ left: 6px;
303
+ display: flex;
304
+ align-items: center;
305
+ justify-content: center;
306
+ width: 24px;
307
+ height: 24px;
308
+ border-radius: var(--vms-radius, 3px);
309
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
310
+ color: var(--vms-text, #e8ecf1);
311
+ border: none;
312
+ cursor: pointer;
313
+ opacity: 0;
314
+ transition: opacity 120ms ease;
315
+ }
316
+
317
+ /* The control is always THERE; it only stops competing with the picture. */
318
+ .vms-tile:hover .vms-tile__expand,
319
+ .vms-tile:focus-within .vms-tile__expand {
320
+ opacity: 1;
321
+ }
322
+
323
+ .vms-tile__expand:focus-visible {
324
+ opacity: 1;
325
+ outline: 2px solid var(--vms-accent, #5b9be0);
326
+ }
327
+
328
+ /* Fullscreen: the picture takes the whole screen, the chrome stays legible. */
329
+ .vms-tile:fullscreen {
330
+ border-radius: 0;
331
+ background: #000;
332
+ }
333
+ </style>
@@ -1483,6 +1483,7 @@ const props = defineProps({
1483
1483
  },
1484
1484
  title: { type: String, default: "" },
1485
1485
  extraWidgetKeys: { type: Array as () => string[], default: () => [] },
1486
+ hiddenWidgetKeys: { type: Array as () => string[], default: () => [] },
1486
1487
  showHeaderActions: { type: Boolean, default: true },
1487
1488
  appKey: {
1488
1489
  type: String as () =>
@@ -1530,51 +1531,26 @@ const { downloadPDF } = usePDFDownload();
1530
1531
  const route = useRoute();
1531
1532
 
1532
1533
  const propertyWidgetPermissions: Record<string, string[]> = {
1533
- workOrders: [
1534
- "work_orders:see-all-work-orders",
1535
- "work-order:see-all-work-orders",
1536
- ],
1534
+ workOrders: ["work_orders:see-all-work-orders"],
1537
1535
  incidents: ["incident-reports:see-incident-reports"],
1538
- visitors: ["visitorManagement:see-all-visitor"],
1536
+ visitors: ["visitor-mgmt:see-all-visitor"],
1539
1537
  facilityBookings: ["facility-booking-mgmt:see-all-facility-booking"],
1540
- activity: [
1541
- "work_orders:see-all-work-orders",
1542
- "work-order:see-all-work-orders",
1543
- ],
1538
+ activity: ["work_orders:see-all-work-orders"],
1544
1539
  upcomingEvents: ["event-mgmt:see-all-event"],
1545
1540
  todayReminders: ["event-mgmt:see-all-event"],
1546
1541
  todayAttentions: [
1547
1542
  "incident-reports:see-incident-reports",
1548
1543
  "facility-booking-mgmt:see-all-facility-booking",
1549
1544
  ],
1550
- workOrderStatus: [
1551
- "work_orders:see-all-work-orders",
1552
- "work-order:see-all-work-orders",
1553
- ],
1545
+ workOrderStatus: ["work_orders:see-all-work-orders"],
1554
1546
  feedbacks: ["feedbacks:see-all-feedback"],
1555
1547
  activePatrol: ["virtual-patrol:see-all-virtual-patrol-logs"],
1556
1548
  };
1557
1549
 
1558
1550
  const moduleWidgetPermissions: Record<string, string[]> = {
1559
- openWorkOrder: [
1560
- "work_orders:see-all-work-orders",
1561
- "work_order:see-all-work-orders",
1562
- "work-order:see-all-work-orders",
1563
- "work-orders:see-all-work-orders",
1564
- ],
1565
- workOrders: [
1566
- "work_orders:see-all-work-orders",
1567
- "work_order:see-all-work-orders",
1568
- "work-order:see-all-work-orders",
1569
- "work-orders:see-all-work-orders",
1570
- ],
1571
- activity: [
1572
- "work_orders:see-all-work-orders",
1573
- "work_order:see-all-work-orders",
1574
- "work-order:see-all-work-orders",
1575
- "work-orders:see-all-work-orders",
1576
- "work_orders:see-all-work-orders",
1577
- ],
1551
+ openWorkOrder: ["work_orders:see-all-work-orders"],
1552
+ workOrders: ["work_orders:see-all-work-orders"],
1553
+ activity: ["work_orders:see-all-work-orders"],
1578
1554
  recentActivity: [
1579
1555
  "work_orders:see-all-work-orders",
1580
1556
  "work_order:see-all-work-orders",
@@ -1594,7 +1570,7 @@ const moduleWidgetPermissions: Record<string, string[]> = {
1594
1570
  "incident-reports:see-incident-reports",
1595
1571
  "incident-reports:see-all-incident-reports",
1596
1572
  ],
1597
- visitors: ["visitor:see-all-visitor"],
1573
+ visitors: ["visitor-mgmt:see-all-visitor"],
1598
1574
  facilityBookings: ["facility-booking:see-all-facility-booking"],
1599
1575
  patrolCompliance: [
1600
1576
  "virtual-patrol:see-all-virtual-patrol-logs",
@@ -1610,6 +1586,7 @@ function hasSchedulePermission(permissions: string[], action: string) {
1610
1586
  }
1611
1587
 
1612
1588
  function canViewWidget(key: string) {
1589
+ if (props.hiddenWidgetKeys.includes(key)) return false;
1613
1590
  const permissions = userAppRole.value?.permissions;
1614
1591
  const modulePermissions = isPropertyMode.value
1615
1592
  ? propertyWidgetPermissions[key]
@@ -80,7 +80,7 @@ export function useEquipmentManagementPermission() {
80
80
  userAppRole.value,
81
81
  permissions,
82
82
  "supply-mgmt",
83
- "request-item"
83
+ "checkout-item"
84
84
  );
85
85
  });
86
86
 
@@ -76,7 +76,7 @@ export function useEquipmentPermission() {
76
76
  userAppRole.value,
77
77
  permissions,
78
78
  "supply-mgmt",
79
- "request-item"
79
+ "checkout-item"
80
80
  );
81
81
  });
82
82
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.1.4-staging.53",
5
+ "version": "3.1.4-staging.55",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -0,0 +1,121 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import {
5
+ capabilityView,
6
+ isDenseLayout,
7
+ tileView,
8
+ wallCameras,
9
+ wallLayout,
10
+ wallTiles,
11
+ } from "./camera-wall.ts";
12
+
13
+ const ip = (over: Record<string, unknown> = {}) => ({
14
+ _id: "c1",
15
+ name: "GYM AREA",
16
+ type: "ip",
17
+ status: "active",
18
+ host: "https://example.invalid/4",
19
+ ...over,
20
+ });
21
+
22
+ /* ---------------------------------------------------------------- rule 1 */
23
+
24
+ test("an ANPR camera can never reach the wall", () => {
25
+ const items = [ip(), { _id: "c2", type: "anpr", host: "http://example.invalid:1234" }];
26
+ const kept = wallCameras(items);
27
+ assert.equal(kept.length, 1);
28
+ assert.equal(kept[0]._id, "c1");
29
+ });
30
+
31
+ test("a junk response is an empty wall, not a crash", () => {
32
+ assert.deepEqual(wallCameras(undefined), []);
33
+ assert.deepEqual(wallCameras([null, 3, "x"]), []);
34
+ });
35
+
36
+ /* ---------------------------------------------------------- layout + order */
37
+
38
+ test("an unknown layout key falls back to four tiles", () => {
39
+ assert.equal(wallLayout("7x7").tiles, 4);
40
+ assert.equal(isDenseLayout("3x3"), true);
41
+ assert.equal(isDenseLayout("2x2"), false);
42
+ });
43
+
44
+ test("no selection means the site's own order, not a blank wall", () => {
45
+ const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
46
+ const tiles = wallTiles(cams, [], "2x2");
47
+ assert.equal(tiles.length, 4);
48
+ assert.equal(tiles[0]._id, "a");
49
+ assert.equal(tiles[1]._id, "b");
50
+ assert.equal(tiles[2], null);
51
+ });
52
+
53
+ test("a selection sets the order, and a stale id is dropped rather than drawn", () => {
54
+ const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
55
+ const tiles = wallTiles(cams, ["b", "gone", "a"], "1x1");
56
+ assert.equal(tiles.length, 1);
57
+ assert.equal(tiles[0]._id, "b");
58
+ });
59
+
60
+ /* ---------------------------------------------------------------- rule 2/3 */
61
+
62
+ test("no descriptor at all counts as NOT available", () => {
63
+ const v = capabilityView(ip(), "ptz");
64
+ assert.equal(v.available, false);
65
+ assert.equal(v.tag, "Not checked yet");
66
+ assert.match(v.detail, /has not been asked/);
67
+ });
68
+
69
+ test("the server's own sentence is shown, never rewritten", () => {
70
+ const cam = ip({
71
+ capabilities: {
72
+ playback: {
73
+ state: "unsupported",
74
+ detail: "No direct connection to this camera's recorder is configured on this server.",
75
+ },
76
+ },
77
+ });
78
+ const v = capabilityView(cam, "playback");
79
+ assert.equal(v.tag, "Not set up yet");
80
+ assert.equal(
81
+ v.detail,
82
+ "No direct connection to this camera's recorder is configured on this server."
83
+ );
84
+ });
85
+
86
+ test("a supported capability wears no tag", () => {
87
+ const cam = ip({ capabilities: { playback: { state: "supported", detail: null } } });
88
+ assert.equal(capabilityView(cam, "playback").available, true);
89
+ assert.equal(capabilityView(cam, "playback").tag, null);
90
+ });
91
+
92
+ /* ------------------------------------------------------------------ rule 4 */
93
+
94
+ test("a page that has not loaded is Connecting, never Live", () => {
95
+ assert.equal(tileView(ip(), { player: "loading" }).state, "connecting");
96
+ assert.equal(tileView(ip(), { player: "ready" }).label, "Live");
97
+ assert.equal(tileView(ip(), { player: "timeout" }).state, "no-signal");
98
+ });
99
+
100
+ test("the browser being offline outranks everything and says so differently", () => {
101
+ assert.equal(tileView(ip(), { online: false, player: "ready" }).label, "No connection");
102
+ assert.equal(tileView(ip(), { player: "timeout" }).label, "No signal");
103
+ });
104
+
105
+ test("an inactive camera, a camera with no address, and an empty tile all explain themselves", () => {
106
+ assert.match(tileView(ip({ status: "inactive" })).detail, /not active/);
107
+ assert.match(tileView(ip({ host: "" })).detail, /no address/);
108
+ assert.match(tileView(null).detail, /No camera in this position/);
109
+ assert.equal(tileView(ip({ host: "" })).showPlayer, false);
110
+ });
111
+
112
+ test("a server that says live video is unsupported is believed", () => {
113
+ const cam = ip({
114
+ capabilities: {
115
+ liveVideo: { state: "unsupported", detail: "This camera is not active." },
116
+ },
117
+ });
118
+ const v = tileView(cam, { player: "ready" });
119
+ assert.equal(v.showPlayer, false);
120
+ assert.equal(v.detail, "This camera is not active.");
121
+ });