@7365admin1/layer-common 4.120.0 → 4.122.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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.122.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0a37446: Lift queue: each lift card shows its real lift gateway (Connected / Offline / lift not answering, floor and door once decoded, raw readings), and Seven365 Super Admin can create a gateway code (shown once), make a new one, or disconnect. At a HERO Fleet Core site the gateway is read from the saved map's lift of the same name.
8
+
9
+ ### Patch Changes
10
+
11
+ - 5df1cfd: A Security Ops client meeting notification is now clickable on the web. `client-meeting` joins `SITE_PAGES` and opens the site's Ops Review page, where the meetings are listed. Without the entry `notificationTarget` answered null for the kind and the row opened nothing at all, the same dead end the `security-ops` entry fixed for action alerts. Its own kind rather than `security-ops`, because the phone opens the meeting record itself while the web has no per-meeting page.
12
+
13
+ ## 4.121.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 19485b6: Data retention is managed by Seven365 Admin: `SiteDataRetention` becomes a read-only summary (rules, dates, removal certificates still download), and the new `DataRetentionBanner` shows staff of the selected site a scheduled permanent deletion (`useDataRetention().getDataRetentionBanner`).
18
+ - 495ae9c: Site Settings > Visitor registration: the NRIC / FIN setting is now a choice of "Ask and keep in full (as usual)", "Ask, but keep only the last 4 characters" or "Don't ask", with help text for each. On a last-4 site the visitor form tells staff that only the last 4 characters will be kept. Needs @7365admin1/core with visitorFields.nric = "last4".
19
+ - 42cdb65: The Security Ops permissions are registered in the canonical vocabulary, so a misspelling is caught rather than silently false. The role editor offers two one-click sets, Security Ops officer and Head of security, and a Security Ops notification now opens the Ops Review page instead of nothing.
20
+ - f663200: Site access has a switch marking a person as part of Security Ops: the officers who go out, walk a site and fill in the visit checklists. It applies to every site ticked in the dialog, shows a chip on each one, and reads the result back in a sentence. It grants nothing on its own, so the Role column still decides what a member may do.
21
+
3
22
  ## 4.120.0
4
23
 
5
24
  ### Minor Changes
@@ -3209,11 +3228,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3209
3228
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3210
3229
  own rule from two endpoints the console already calls, unprojected:
3211
3230
 
3212
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3213
- GET /api/roles/id/:role that membership's role document
3231
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3232
+ GET /api/roles/id/:role that membership's role document
3214
3233
 
3215
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3216
- staff = member.type === "admin" && role.type === "admin"
3234
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3235
+ staff = member.type === "admin" && role.type === "admin"
3217
3236
 
3218
3237
  `role.default` is the marker because it is the only property of a platform
3219
3238
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -1264,6 +1264,15 @@ div.hero-editor__wp.hero-editor__sel, div.hero-editor__fid.hero-editor__sel { ou
1264
1264
  .hero .hero-lift-row__release { border: 0; background: none; padding: 0; font: inherit; font-size: 12px; font-weight: 600; color: var(--hero-accent); cursor: pointer; white-space: nowrap; }
1265
1265
  .hero-lift__ask { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; font-size: 13px; }
1266
1266
  .hero-lift__simnote { font-size: 12px; }
1267
+ .hero-lift__gw { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; padding: 0 0 10px; font-size: 13px; min-width: 0; }
1268
+ .hero-lift__gwread { font-weight: 700; }
1269
+ .hero-lift__gwhelp { font-size: 12px; flex-basis: 100%; }
1270
+ .hero-lift__gwraw { flex-basis: 100%; font-size: 12px; }
1271
+ .hero-lift__gwraw summary { cursor: pointer; opacity: 0.7; }
1272
+ .hero-lift__gwreg { display: inline-block; margin: 4px 12px 0 0; font-family: monospace; }
1273
+ .hero-lift__gwact { margin-left: auto; display: flex; gap: 6px; }
1274
+ .hero-lift__gwcode { flex-basis: 100%; display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; padding: 8px 10px; border: 1px solid rgb(var(--v-theme-warning)); }
1275
+ .hero-lift__gwcode code { font-family: monospace; word-break: break-all; user-select: all; }
1267
1276
  @media (max-width: 600px) {
1268
1277
  .hero-lift { grid-template-columns: 1fr; }
1269
1278
  .hero-bmap__bar { right: 14px; }
@@ -0,0 +1,73 @@
1
+ <template>
2
+ <!--
3
+ Scheduled data removal at the selected site (owner 2026-10-14). Shown to
4
+ the people who work the site, never residents (the server answers nothing
5
+ to them), and only for a removal that will really happen: Seven365 Admin
6
+ allowed deletion at this site and the site's admins were already told.
7
+ A site with no data retention never shows it - the request answers
8
+ `{ banner: null }`.
9
+ -->
10
+ <v-alert
11
+ v-if="banner"
12
+ type="warning"
13
+ variant="tonal"
14
+ density="comfortable"
15
+ class="drb mb-3"
16
+ icon="mdi-delete-clock-outline"
17
+ data-test="drb"
18
+ >
19
+ <div class="drb__text">{{ banner.message }}</div>
20
+ <button type="button" class="drb__toggle" :aria-expanded="open" data-test="drb-details" @click="open = !open">
21
+ {{ open ? "Hide details" : "Details" }}
22
+ </button>
23
+ <div v-if="open" class="drb__details" data-test="drb-details-body">
24
+ <ul class="drb__list">
25
+ <li v-for="i in banner.items" :key="i.dataType">
26
+ {{ i.label }}: {{ Number(i.count).toLocaleString("en-SG") }} created before {{ day(i.cutoff) }}
27
+ </li>
28
+ </ul>
29
+ <div>
30
+ Only finished records are deleted (closed feedback and work orders, visitors who have checked out, decided
31
+ incident reports, past days' occurrence books). Use each list's export to keep a copy. To postpone this, contact
32
+ Seven365.
33
+ </div>
34
+ </div>
35
+ </v-alert>
36
+ </template>
37
+
38
+ <script lang="ts" setup>
39
+ const props = defineProps({
40
+ /** The selected site. Anything that is not a site id shows nothing. */
41
+ siteId: { type: String, default: "" },
42
+ });
43
+
44
+ const { getDataRetentionBanner } = useDataRetention();
45
+ const banner = ref<Record<string, any> | null>(null);
46
+ const open = ref(false);
47
+
48
+ const day = (d: string) =>
49
+ new Date(d).toLocaleDateString("en-SG", { day: "numeric", month: "short", year: "numeric", timeZone: "Asia/Singapore" });
50
+
51
+ async function load(site: string) {
52
+ banner.value = null;
53
+ open.value = false;
54
+ if (!/^[a-f0-9]{24}$/i.test(site)) return;
55
+ try {
56
+ const res = await getDataRetentionBanner(site);
57
+ // A late answer for a site the user already left is dropped.
58
+ if (props.siteId === site) banner.value = res?.banner ?? null;
59
+ } catch {
60
+ // Not reachable, a resident, or an older server: no banner.
61
+ banner.value = null;
62
+ }
63
+ }
64
+
65
+ watch(() => props.siteId, (s) => load(s), { immediate: true });
66
+ </script>
67
+
68
+ <style scoped>
69
+ .drb__text { word-break: break-word; }
70
+ .drb__toggle { text-decoration: underline; font-weight: 600; margin-top: 4px; background: none; border: 0; padding: 0; color: inherit; cursor: pointer; }
71
+ .drb__details { margin-top: 8px; font-size: 13px; }
72
+ .drb__list { padding-left: 18px; margin-bottom: 6px; }
73
+ </style>
@@ -34,6 +34,33 @@
34
34
  <p v-if="!choices.length" class="text-body-2">This organisation has no sites yet.</p>
35
35
 
36
36
  <div v-else>
37
+ <!--
38
+ WHO GOES OUT TO THE SITES. One switch for the whole person, not one
39
+ per row: an officer is an officer everywhere they are sent, and the
40
+ server writes the same value onto every site ticked below.
41
+
42
+ It grants NOTHING. What a member may do is still their role, which
43
+ is why this sits apart from the Role column rather than inside it.
44
+ -->
45
+ <v-switch
46
+ v-model="securityOps"
47
+ color="primary"
48
+ density="compact"
49
+ hide-details
50
+ inset
51
+ class="mb-2"
52
+ data-test="site-access-security-ops"
53
+ >
54
+ <template #label>
55
+ <div>
56
+ <div class="text-body-2">This person is part of Security Ops</div>
57
+ <div class="text-caption text-medium-emphasis">
58
+ They visit the sites ticked below and fill in the checklists.
59
+ </div>
60
+ </div>
61
+ </template>
62
+ </v-switch>
63
+
37
64
  <div class="d-flex align-center ga-2 mb-2">
38
65
  <v-select
39
66
  v-model="sameRole"
@@ -53,6 +80,7 @@
53
80
 
54
81
  <div v-for="(c, i) in choices" :key="c.site" class="d-flex align-center ga-2 py-1" data-test="site-access-row">
55
82
  <v-checkbox v-model="choices[i].on" density="compact" hide-details :label="c.name" style="min-width: 220px" />
83
+ <v-chip v-if="securityOps && choices[i].on" size="x-small" color="primary" variant="tonal">Security Ops</v-chip>
56
84
  <v-select
57
85
  v-if="c.roles.length"
58
86
  v-model="choices[i].role"
@@ -72,6 +100,10 @@
72
100
  </p>
73
101
  </div>
74
102
 
103
+ <p v-if="choices.length" class="text-caption mt-2" data-test="site-access-security-ops-note">
104
+ {{ securityOpsNote }}
105
+ </p>
106
+
75
107
  <p v-if="missing.length" class="text-error text-caption mt-2">Choose a role for: {{ missing.join(", ") }}</p>
76
108
  <p v-if="saveError" class="text-error mt-2">{{ saveError }}</p>
77
109
  </template>
@@ -87,7 +119,7 @@
87
119
 
88
120
  <script setup lang="ts">
89
121
  // Relative import: this layer's `utils/` is not auto-imported into consumers.
90
- import { applySameRole, initialChoices, roleItems, sharedRoleItems, toPayload, type SiteChoice } from "../utils/site-access";
122
+ import { applySameRole, initialChoices, initialSecurityOps, roleItems, sharedRoleItems, toPayload, type SiteChoice } from "../utils/site-access";
91
123
 
92
124
  const props = defineProps({
93
125
  org: { type: String, required: true },
@@ -106,6 +138,7 @@ const choices = ref<SiteChoice[]>([]);
106
138
  const orgWide = ref<Array<{ roleName?: string }>>([]);
107
139
  const removeOrgAccess = ref(false);
108
140
  const sameRole = ref<string | null>(null);
141
+ const securityOps = ref(false);
109
142
 
110
143
  const sharedItems = computed(() => sharedRoleItems(choices.value));
111
144
  const anyOn = computed(() => choices.value.some((c) => c.on));
@@ -119,6 +152,15 @@ function createOrgRole() {
119
152
  }
120
153
  const missing = computed(() => toPayload(choices.value).missing);
121
154
 
155
+ /** Say back what the switch and the ticks add up to, in the order they read. */
156
+ const securityOpsNote = computed(() => {
157
+ if (!securityOps.value) return "Not part of Security Ops.";
158
+ const names = choices.value.filter((c) => c.on).map((c) => c.name);
159
+ if (!names.length) return "Part of Security Ops, but no site is ticked yet.";
160
+ if (names.length === 1) return `Part of Security Ops at ${names[0]}.`;
161
+ return `Part of Security Ops at ${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}.`;
162
+ });
163
+
122
164
  function applySame() {
123
165
  if (sameRole.value) choices.value = applySameRole(choices.value, sameRole.value);
124
166
  }
@@ -130,6 +172,7 @@ async function load() {
130
172
  const res = await getSiteAccess(props.org, props.user);
131
173
  choices.value = initialChoices(res?.sites ?? []);
132
174
  orgWide.value = res?.orgWide ?? [];
175
+ securityOps.value = initialSecurityOps(res?.sites ?? []);
133
176
  } catch (e: any) {
134
177
  loadError.value = e?.data?.message || "Site access could not be loaded. Only the organisation's owner or an admin can change it.";
135
178
  }
@@ -141,7 +184,13 @@ async function save() {
141
184
  saveError.value = "";
142
185
  try {
143
186
  const { sites } = toPayload(choices.value);
144
- await saveSiteAccess({ org: props.org, user: props.user, sites, removeOrgAccess: removeOrgAccess.value });
187
+ await saveSiteAccess({
188
+ org: props.org,
189
+ user: props.user,
190
+ sites,
191
+ securityOps: securityOps.value,
192
+ removeOrgAccess: removeOrgAccess.value,
193
+ });
145
194
  emit("saved", sites.length ? `Site access saved: ${sites.length} site(s).` : "Site access saved: no sites.");
146
195
  } catch (e: any) {
147
196
  saveError.value = e?.data?.message || "Site access could not be saved.";
@@ -37,6 +37,33 @@
37
37
  <span class="hero-lift__car"><span class="hero-lift__cardot" :class="[`hero-lift__cardot--${l.carTone}`, { 'hero-lift__cardot--blink': l.riding }]" aria-hidden="true" />{{ l.carText }}</span>
38
38
  </div>
39
39
 
40
+ <!-- The real lift's read-only gateway link (Inovance B0S over RS-485): status, readings, setup. -->
41
+ <div v-if="l.gateway || (canEditMap && setupMapId)" class="hero-lift__gw" :data-status="l.gateway?.status ?? 'none'">
42
+ <template v-if="l.gateway">
43
+ <span class="hero-chip" :class="`hero-tone--${l.gateway.tone}`" role="status">{{ l.gateway.text }}</span>
44
+ <span v-if="l.gateway.decoded" class="hero-lift__gwread">{{ l.gateway.decoded }}</span>
45
+ <span v-else-if="l.gateway.undecoded" class="hero-muted hero-lift__gwread">Real lift answering · floor/door not decoded yet (fill [decode] in the gateway settings)</span>
46
+ <span v-if="l.gateway.help" class="hero-muted hero-lift__gwhelp">{{ l.gateway.help }}</span>
47
+ <details v-if="l.gateway.registers.length" class="hero-lift__gwraw">
48
+ <summary>Raw readings ({{ l.gateway.registers.length }})</summary>
49
+ <span v-for="r in l.gateway.registers" :key="r.reg" class="hero-lift__gwreg">{{ r.reg }} = {{ r.value }}</span>
50
+ </details>
51
+ </template>
52
+ <span v-else class="hero-muted">No real lift connected · the car below is simulated.</span>
53
+ <span v-if="canEditMap && setupMapId" class="hero-lift__gwact">
54
+ <v-btn size="small" variant="outlined" :loading="busy === `gw:${l.name}`" @click="connectGateway(l.name, !!l.gateway)">
55
+ {{ l.gateway ? "New code" : "Connect gateway" }}
56
+ </v-btn>
57
+ <v-btn v-if="l.gateway" size="small" variant="text" :disabled="busy === `gw:${l.name}`" @click="disconnectGateway(l.name)">Disconnect</v-btn>
58
+ </span>
59
+ <div v-if="newCode?.lift === l.name" class="hero-lift__gwcode" role="alert">
60
+ <b>Gateway code (shown once):</b>
61
+ <code>{{ newCode.code }}</code>
62
+ <v-btn size="x-small" variant="tonal" @click="copyCode">Copy</v-btn>
63
+ <span class="hero-muted">Paste it as <code>code =</code> in the gateway's lift-gateway.ini, then start the gateway. This card turns Connected within seconds.</span>
64
+ </div>
65
+ </div>
66
+
40
67
  <div class="hero-lift__shaft" :style="{ height: `${l.shaftH}px` }" :title="l.simulated ? 'Simulated lift - no real lift is controlled yet' : undefined">
41
68
  <div class="hero-lift__labels">
42
69
  <div v-for="f in l.rails" :key="f.floorNo" class="hero-lift__floor" :class="{ 'hero-lift__floor--lit': f.lit }" :style="{ top: `${f.top}px` }">
@@ -139,13 +166,13 @@ const emit = defineEmits<{
139
166
  status: [status: { connected: boolean; faults: number; siteName: string; clientName: string }];
140
167
  }>();
141
168
 
142
- const { rows, all, map, fleet, names, loading: mapLoading, error: mapError } = useRobotBuilding(
169
+ const { rows, all, map, saved, fleet, names, loading: mapLoading, error: mapError } = useRobotBuilding(
143
170
  toRef(props, "organization"),
144
171
  toRef(props, "site"),
145
172
  toRef(props, "type"),
146
173
  );
147
174
  const kicker = computed(() => [props.clientName || names.value.clientName, props.siteName || names.value.siteName].filter(Boolean).join(" · "));
148
- const { getLiftQueue, requestLift, releaseLiftSession } = useRobots();
175
+ const { getLiftQueue, requestLift, releaseLiftSession, connectLiftGateway, disconnectLiftGateway } = useRobots();
149
176
 
150
177
  /* ---- the queue, every 3 s while the page is visible ---- */
151
178
  const items = ref<Array<Record<string, any>>>([]);
@@ -157,7 +184,16 @@ const error = computed(() => mapError.value);
157
184
 
158
185
  async function poll() {
159
186
  // The fleet building has no lift sessions of ours: its lifts come live with the building (5 s refresh).
160
- if (fleet.value) return void (items.value = fleetLiftItems(map.value, all.value), (queueLoading.value = false));
187
+ if (fleet.value) {
188
+ // The fleet building's lifts, plus each real lift's gateway from the saved map (same lift name).
189
+ const list = fleetLiftItems(map.value, all.value);
190
+ const gw = saved.value?._id ? await getLiftQueue(String(saved.value._id)).catch(() => null) : null;
191
+ const byName = new Map<string, any>((gw?.items ?? []).map((x: any) => [String(x.liftName), x.gateway ?? null]));
192
+ items.value = list.map((x: Record<string, any>) => ({ ...x, gateway: byName.get(String(x.liftName)) ?? null }));
193
+ queueLoading.value = false;
194
+ now.value = Date.now();
195
+ return;
196
+ }
161
197
  const id = map.value?._id;
162
198
  if (!id) return void (items.value = []);
163
199
  if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
@@ -217,6 +253,52 @@ watch(cards, (list) => {
217
253
  });
218
254
  const busy = ref("");
219
255
  const toast = ref("");
256
+ /*
257
+ * ---- the real lift's gateway (Seven365 Super Admin = canEditMap) ----
258
+ * Set up on the SAVED map's lift (at a fleet site, the imported Fleet Core building);
259
+ * the server re-checks Super Admin and stores only the code's sha256.
260
+ */
261
+ const setupMapId = computed(() => String((fleet.value ? saved.value?._id : map.value?._id) ?? ""));
262
+ const newCode = ref<{ lift: string; code: string } | null>(null);
263
+ async function connectGateway(lift: string, replacing: boolean) {
264
+ if (replacing && typeof window !== "undefined" && !window.confirm(`Make a new code for ${lift}? The gateway's current code stops working until you paste the new one.`)) return;
265
+ busy.value = `gw:${lift}`;
266
+ try {
267
+ const res = await connectLiftGateway(setupMapId.value, lift);
268
+ newCode.value = { lift, code: res.code };
269
+ await poll();
270
+ } catch (e) {
271
+ toast.value = robotErrorMessage(
272
+ e,
273
+ fleet.value ? "Could not create the code. Import the HERO Fleet Core building on Building map → Edit map first, so this lift is on the saved map." : "Could not create the gateway code.",
274
+ );
275
+ } finally {
276
+ busy.value = "";
277
+ }
278
+ }
279
+ async function disconnectGateway(lift: string) {
280
+ if (typeof window !== "undefined" && !window.confirm(`Disconnect the gateway from ${lift}? Its code stops working.`)) return;
281
+ busy.value = `gw:${lift}`;
282
+ try {
283
+ await disconnectLiftGateway(setupMapId.value, lift);
284
+ if (newCode.value?.lift === lift) newCode.value = null;
285
+ toast.value = `Gateway disconnected · ${lift}`;
286
+ await poll();
287
+ } catch (e) {
288
+ toast.value = robotErrorMessage(e, "The gateway could not be disconnected.");
289
+ } finally {
290
+ busy.value = "";
291
+ }
292
+ }
293
+ async function copyCode() {
294
+ try {
295
+ await navigator.clipboard.writeText(newCode.value?.code ?? "");
296
+ toast.value = "Code copied";
297
+ } catch {
298
+ toast.value = "Select the code and copy it by hand.";
299
+ }
300
+ }
301
+
220
302
  async function ask(l: { name: string }) {
221
303
  const robotId = askRobot.value[l.name];
222
304
  if (!robotId || !map.value?._id) return;
@@ -1,94 +1,48 @@
1
1
  <template>
2
2
  <!--
3
- Data retention (PDPA), per site (TLR, 2026-10-13). Off by default: a site
4
- that never switched it on keeps everything, as today. When on, the site's
5
- admins are warned (in the app and by email) `noticeDays` before old records
6
- are removed, and every removal leaves a record with a downloadable
7
- certificate. Who may change it is decided by the server (the site's owning
8
- organisation, or Seven365).
3
+ Data retention (PDPA), per site. Since 2026-10-14 (owner) it is managed by
4
+ the Seven365 Super Admin in Seven365 Admin; here it is a read-only summary
5
+ for the site's managers: the rules, the dates, and every removal with its
6
+ certificate. A site that never had it switched on keeps everything, as today.
9
7
  -->
10
8
  <div class="sdr" data-test="sdr">
11
- <p class="sdr-help">
12
- Remove old records automatically after a set time, to meet your data protection (PDPA) policy. Off means
13
- everything is kept.
14
- </p>
15
-
16
- <v-switch
17
- :model-value="enabled"
18
- color="primary"
19
- inset
20
- hide-details
21
- label="Remove old records automatically"
22
- :disabled="!canEdit || saving"
23
- data-test="sdr-enabled"
24
- @update:model-value="onToggle"
25
- />
26
- <div v-if="!canEdit" class="sdr-sub" data-test="sdr-owner-only">
27
- Only the client that owns this site can change data retention.
9
+ <div class="sdr-managed" data-test="sdr-managed">
10
+ Managed by Seven365 Admin. To change how long this site keeps its records, contact Seven365.
28
11
  </div>
29
- <div class="sdr-sub">Before anything is removed, your site admins get a notice with a link to download the records.</div>
30
12
 
31
- <template v-if="enabled">
32
- <div class="sdr-label">How long to keep each kind of record</div>
33
- <div class="sdr-sub">
34
- Leave blank to keep that kind of record forever. Only finished records are removed: closed feedback and work
35
- orders, visitors who have checked out, decided incident reports and past days' occurrence books.
13
+ <div v-if="!metadata" class="sdr-sub">Loading...</div>
14
+ <template v-else-if="!setting.enabled">
15
+ <div class="sdr-label">Off</div>
16
+ <div class="sdr-sub" data-test="sdr-off">Every record at this site is kept. Nothing is deleted automatically.</div>
17
+ </template>
18
+ <template v-else>
19
+ <div class="sdr-label">On</div>
20
+ <div class="sdr-sub" data-test="sdr-deletion">
21
+ {{
22
+ setting.deletionApproved
23
+ ? "Old records are permanently deleted on the dates below."
24
+ : "Requested - waiting for Seven365 approval. Nothing is warned about or deleted until Seven365 approves it."
25
+ }}
36
26
  </div>
37
- <div v-for="t in RETENTION_TYPES" :key="t.value" class="sdr-row">
27
+
28
+ <div class="sdr-label">How long each kind of record is kept</div>
29
+ <div v-for="t in RETENTION_TYPES" :key="t.value" class="sdr-row" :data-test="`sdr-rule-${t.value}`">
38
30
  <span class="sdr-row__name">{{ t.label }}</span>
39
- <v-text-field
40
- v-model="months[t.value]"
41
- type="number"
42
- min="1"
43
- max="120"
44
- suffix="months"
45
- density="compact"
46
- variant="outlined"
47
- hide-details="auto"
48
- placeholder="Keep"
49
- class="sdr-row__field"
50
- :disabled="!canEdit || saving"
51
- :rules="[monthRule]"
52
- :aria-label="`Keep ${t.label} for how many months`"
53
- :data-test="`sdr-months-${t.value}`"
54
- />
31
+ <span>{{ keep(t.value) }}</span>
32
+ </div>
33
+ <div class="sdr-sub mt-2">
34
+ Only finished records are deleted: closed feedback and work orders, visitors who have checked out, decided
35
+ incident reports and past days' occurrence books.
55
36
  </div>
56
37
 
57
- <div class="sdr-label">Notice before removal</div>
58
- <v-text-field
59
- v-model="noticeDays"
60
- type="number"
61
- min="7"
62
- max="90"
63
- suffix="days"
64
- density="compact"
65
- variant="outlined"
66
- hide-details="auto"
67
- class="sdr-row__field"
68
- :disabled="!canEdit || saving"
69
- :rules="[noticeRule]"
70
- aria-label="Days of notice before removal"
71
- data-test="sdr-notice"
72
- />
73
- <div class="sdr-sub">Between 7 and 90 days. Records are removed only after this notice.</div>
38
+ <div class="sdr-label">Dates</div>
39
+ <div class="sdr-sub">Your site admins are told {{ setting.noticeDays ?? 14 }} days before each deletion, and reminded the day before.</div>
40
+ <div v-if="setting.startFrom" class="sdr-sub" data-test="sdr-start">Nothing is deleted before {{ day(setting.startFrom) }}.</div>
41
+ <div v-if="pending" class="sdr-sub" data-test="sdr-next">
42
+ <strong>{{ setting.deletionApproved ? "Next deletion" : "Warning sent for" }}: {{ day(pending.dueAt) }}</strong>
43
+ </div>
74
44
  </template>
75
45
 
76
- <div class="sdr-actions">
77
- <v-btn class="screen-btn-ghost" variant="outlined" :loading="previewing" :disabled="!enabled || !rules.length" data-test="sdr-preview" @click="runPreview">
78
- Preview
79
- </v-btn>
80
- <v-btn class="screen-btn-primary" variant="flat" color="primary-button" :loading="saving" :disabled="!metadata || !canEdit || !valid || !dirty" data-test="sdr-save" @click="save">
81
- Save
82
- </v-btn>
83
- </div>
84
-
85
- <div v-if="preview" class="sdr-box" data-test="sdr-preview-result">
86
- <div class="sdr-box__title">If this ran today, it would remove:</div>
87
- <ul class="sdr-list">
88
- <li v-for="i in preview.items" :key="i.dataType">{{ i.count }} {{ i.label.toLowerCase() }} created before {{ day(i.cutoff) }}</li>
89
- </ul>
90
- <div class="sdr-sub">Nothing is removed by Preview. The first removal comes only after the notice above.</div>
91
- </div>
92
46
  <div v-if="error" class="sdr-error" role="alert">{{ error }}</div>
93
47
 
94
48
  <div class="sdr-label mt-6">Data removal records</div>
@@ -115,127 +69,45 @@
115
69
  </v-btn>
116
70
  </div>
117
71
  </div>
118
-
119
- <ConfirmDialog v-model="confirmOn">
120
- <template #title>Remove old records automatically?</template>
121
- <template #description>
122
- Records older than the times you set will be permanently deleted at this site. Your site admins will be told
123
- {{ noticeNumber }} days before each removal, with a link to download them first. Deleted records cannot be
124
- brought back.
125
- </template>
126
- <template #footer>
127
- <v-btn class="screen-btn-ghost" variant="outlined" @click="confirmOn = false">Cancel</v-btn>
128
- <v-btn class="screen-btn-primary" variant="flat" color="primary-button" data-test="sdr-confirm" @click="turnOn">
129
- Turn on
130
- </v-btn>
131
- </template>
132
- </ConfirmDialog>
133
72
  </template>
134
73
 
135
74
  <script lang="ts" setup>
136
- import { RETENTION_TYPES, type TRetentionRule } from "../composables/useDataRetention";
75
+ import { RETENTION_TYPES } from "../composables/useDataRetention";
137
76
 
138
77
  const props = defineProps({
139
78
  siteId: { type: String, required: true },
140
- /** The site's stored metadata (`{}` once read for a site with none; null = not read yet, Save stays off). */
79
+ /** The site's stored metadata (`{}` once read for a site with none; null = not read yet). */
141
80
  metadata: { type: Object as PropType<Record<string, any> | null>, default: null },
142
- /** Only the site's owning organisation (or Seven365) may change it; the server checks too. */
81
+ /** No longer used: the summary is read-only for everyone. Kept so a host that still passes it renders as before. */
143
82
  canEdit: { type: Boolean, default: false },
144
83
  });
145
- const emit = defineEmits<{ (e: "saved"): void; (e: "error", message: string): void }>();
146
84
 
147
- const { saveSiteFeature } = useSiteFeatureSettings();
148
- const { previewDataRetention, listDataPurgeRecords, downloadDataPurgeCertificate } = useDataRetention();
85
+ const { listDataPurgeRecords, downloadDataPurgeCertificate } = useDataRetention();
149
86
 
150
- const enabled = ref(false);
151
- const months = reactive<Record<string, string | number>>({});
152
- const noticeDays = ref<string | number>(14);
153
- const saving = ref(false);
154
- const previewing = ref(false);
155
- const preview = ref<Record<string, any> | null>(null);
156
- const error = ref("");
157
- const confirmOn = ref(false);
158
87
  const records = ref<Record<string, any>[]>([]);
159
88
  const recordsLoading = ref(false);
160
89
  const downloading = ref("");
161
- const stored = ref("");
162
-
163
- function load(meta: Record<string, any> | null) {
164
- const d = meta?.dataRetention ?? {};
165
- enabled.value = d.enabled === true;
166
- for (const t of RETENTION_TYPES) months[t.value] = "";
167
- for (const r of Array.isArray(d.rules) ? d.rules : []) months[r.dataType] = r.keepMonths;
168
- noticeDays.value = d.noticeDays ?? 14;
169
- stored.value = JSON.stringify(value());
170
- }
171
-
172
- const toInt = (v: unknown) => (v === "" || v === null || v === undefined ? null : Number(v));
173
- const monthRule = (v: unknown) => {
174
- const n = toInt(v);
175
- return n === null || (Number.isInteger(n) && n >= 1 && n <= 120) || "1 to 120 months, or blank";
176
- };
177
- const noticeRule = (v: unknown) => {
178
- const n = toInt(v);
179
- return (n !== null && Number.isInteger(n) && n >= 7 && n <= 90) || "7 to 90 days";
180
- };
181
- const noticeNumber = computed(() => toInt(noticeDays.value) ?? 14);
182
-
183
- const rules = computed<TRetentionRule[]>(() =>
184
- RETENTION_TYPES.map((t) => ({ dataType: t.value, keepMonths: toInt(months[t.value]) as number })).filter((r) => r.keepMonths !== null),
185
- );
186
- const valid = computed(
187
- () => RETENTION_TYPES.every((t) => monthRule(months[t.value]) === true) && (!enabled.value || noticeRule(noticeDays.value) === true),
188
- );
189
- function value() {
190
- return enabled.value
191
- ? { enabled: true, rules: rules.value, noticeDays: toInt(noticeDays.value) ?? 14 }
192
- : { enabled: false, rules: rules.value, ...(toInt(noticeDays.value) ? { noticeDays: toInt(noticeDays.value) } : {}) };
193
- }
194
- const dirty = computed(() => JSON.stringify(value()) !== stored.value);
195
-
196
- watch(() => props.metadata, (m) => load(m), { immediate: true });
90
+ const error = ref("");
197
91
 
198
- function turnOn() {
199
- enabled.value = true;
200
- confirmOn.value = false;
201
- }
92
+ const setting = computed<Record<string, any>>(() => {
93
+ const d = props.metadata?.dataRetention;
94
+ return d && d.enabled === true ? d : { enabled: false };
95
+ });
96
+ const pending = computed(() => records.value.find((r) => r.status === "pending") ?? null);
202
97
 
203
- function onToggle(v: boolean | null) {
204
- preview.value = null;
205
- if (v) confirmOn.value = true;
206
- else enabled.value = false;
98
+ function keep(type: string) {
99
+ const r = (setting.value.rules ?? []).find((x: any) => x.dataType === type);
100
+ return r ? `${r.keepMonths} month${r.keepMonths === 1 ? "" : "s"}` : "Kept";
207
101
  }
208
102
 
209
103
  const message = (e: any) => e?.data?.message || e?.message || "Something went wrong. Please try again.";
210
- const day = (d: string) => new Date(d).toLocaleDateString("en-SG", { day: "numeric", month: "short", year: "numeric" });
211
-
212
- async function save() {
213
- if (!props.metadata) return;
214
- saving.value = true;
215
- error.value = "";
216
- try {
217
- await saveSiteFeature(props.siteId, "dataRetention", value());
218
- stored.value = JSON.stringify(value());
219
- emit("saved");
220
- } catch (e) {
221
- error.value = message(e);
222
- emit("error", error.value);
223
- } finally {
224
- saving.value = false;
225
- }
226
- }
227
-
228
- async function runPreview() {
229
- previewing.value = true;
230
- error.value = "";
231
- try {
232
- preview.value = await previewDataRetention(props.siteId, { rules: rules.value });
233
- } catch (e) {
234
- error.value = message(e);
235
- } finally {
236
- previewing.value = false;
237
- }
238
- }
104
+ const day = (d: string) =>
105
+ new Date(/^\d{4}-\d{2}-\d{2}$/.test(d) ? `${d}T00:00:00+08:00` : d).toLocaleDateString("en-SG", {
106
+ day: "numeric",
107
+ month: "short",
108
+ year: "numeric",
109
+ timeZone: "Asia/Singapore",
110
+ });
239
111
 
240
112
  function statusText(r: Record<string, any>) {
241
113
  if (r.status === "pending") return `Removal planned for ${day(r.dueAt)}`;
@@ -271,16 +143,11 @@ onMounted(loadRecords);
271
143
  </script>
272
144
 
273
145
  <style scoped>
274
- .sdr-help { margin-bottom: 8px; }
146
+ .sdr-managed { font-weight: 600; margin-bottom: 8px; }
275
147
  .sdr-sub { font-size: 13px; opacity: 0.75; margin-bottom: 8px; }
276
148
  .sdr-label { font-weight: 600; margin-top: 16px; }
277
- .sdr-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 6px 0; }
149
+ .sdr-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 4px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.06); }
278
150
  .sdr-row__name { flex: 1 1 160px; min-width: 140px; }
279
- .sdr-row__field { flex: 0 1 180px; max-width: 180px; min-width: 140px; }
280
- .sdr-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
281
- .sdr-box { border: 1px solid rgba(0, 0, 0, 0.12); border-radius: 8px; padding: 12px; margin-top: 12px; }
282
- .sdr-box__title { font-weight: 600; margin-bottom: 4px; }
283
- .sdr-list { padding-left: 18px; margin-bottom: 6px; }
284
151
  .sdr-error { color: rgb(var(--v-theme-error)); margin-top: 8px; }
285
152
  .sdr-record { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 0; border-bottom: 1px solid rgba(0, 0, 0, 0.08); word-break: break-word; }
286
153
  </style>
@@ -4,16 +4,18 @@
4
4
  that never saved them shows "as usual" everywhere and behaves as today.
5
5
  -->
6
6
  <div data-test="visitor-registration-settings">
7
- <v-switch
8
- v-model="askNric"
9
- label="Ask for NRIC / FIN"
10
- color="primary"
7
+ <v-select
8
+ v-model="nric"
9
+ :items="nricItems"
10
+ label="NRIC / FIN"
11
+ density="comfortable"
12
+ variant="outlined"
11
13
  hide-details
12
14
  :disabled="saving || !canManage"
13
15
  data-test="visitor-fields-nric"
14
16
  />
15
- <p class="text-caption text-medium-emphasis mb-4">
16
- Turn off if your site does not collect ID numbers. Existing records are not changed.
17
+ <p class="text-caption text-medium-emphasis mt-1 mb-4" data-test="visitor-fields-nric-help">
18
+ {{ nricHelp[nric] }} Existing records are not changed.
17
19
  </p>
18
20
 
19
21
  <v-select
@@ -70,6 +72,17 @@ const props = defineProps({
70
72
  });
71
73
  const emit = defineEmits<{ (e: "saved"): void; (e: "message", text: string, color: string): void }>();
72
74
 
75
+ const nricItems = [
76
+ { title: "Ask and keep in full (as usual)", value: "default" },
77
+ { title: "Ask, but keep only the last 4 characters", value: "last4" },
78
+ { title: "Don't ask", value: "hidden" },
79
+ ];
80
+ const nricHelp: Record<string, string> = {
81
+ default: "The full ID number is saved, and staff can find a returning visitor by typing it.",
82
+ last4:
83
+ "The full number is still typed at registration, but only the last 4 characters are saved (e.g. *****567A), for data protection. Returning visitors are then found by those last 4 characters, so check the name before using a suggestion.",
84
+ hidden: "Choose this if your site does not collect ID numbers.",
85
+ };
73
86
  const contactItems = [
74
87
  { title: "Ask as usual", value: "default" },
75
88
  { title: "Don't ask", value: "hidden" },
@@ -80,7 +93,7 @@ const vehicleItems = [
80
93
  { title: "Always required", value: "required" },
81
94
  ];
82
95
 
83
- const askNric = ref(true);
96
+ const nric = ref<"default" | "hidden" | "last4">("default");
84
97
  const contact = ref("default");
85
98
  const vehicle = ref("default");
86
99
  const saving = ref(false);
@@ -89,7 +102,7 @@ watch(
89
102
  () => props.siteData,
90
103
  (site) => {
91
104
  const v = getVisitorFieldSwitches(site);
92
- askNric.value = v.nric !== "hidden";
105
+ nric.value = v.nric;
93
106
  contact.value = v.contact;
94
107
  vehicle.value = v.vehicle;
95
108
  },
@@ -103,7 +116,7 @@ async function save() {
103
116
  saving.value = true;
104
117
  try {
105
118
  await saveSiteFeature(props.siteId, "visitorFields", {
106
- nric: askNric.value ? "default" : "hidden",
119
+ nric: nric.value,
107
120
  contact: contact.value,
108
121
  vehicle: vehicle.value,
109
122
  });
@@ -48,6 +48,10 @@
48
48
  </div>
49
49
  </v-list>
50
50
  </v-menu>
51
+ <p v-if="visitorFieldSwitches.nric === 'last4'" class="text-caption text-medium-emphasis mt-1"
52
+ data-test="visitor-nric-last4-hint">
53
+ {{ NRIC_LAST4_HINT }}
54
+ </p>
51
55
  </v-col>
52
56
  </v-row>
53
57
  </v-col>
@@ -613,7 +617,7 @@ import useUtils from "../composables/useUtils";
613
617
  import useSiteSettings from "../composables/useSiteSettings";
614
618
  import useLocalAuth from "../composables/useLocalAuth";
615
619
  import useBuilding from "../composables/useBuilding";
616
- import useVisitor, { getVisitorFieldSwitches, VEHICLE_REQUIRED_MESSAGE } from "../composables/useVisitor";
620
+ import useVisitor, { getVisitorFieldSwitches, NRIC_LAST4_HINT, VEHICLE_REQUIRED_MESSAGE } from "../composables/useVisitor";
617
621
  import useSiteEntryPassSettings from "../composables/useSiteEntryPassSettings";
618
622
  import useAccessManagement from "../composables/useAccessManagement";
619
623
  import usePeople from "../composables/usePeople";
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * PDPA data retention per site (`/api/data-retention`, API-core). The setting
3
- * itself is one site switch (`metadata.dataRetention`, useSiteFeatureSettings).
3
+ * itself is one site switch (`metadata.dataRetention`), managed by the Seven365
4
+ * Super Admin in Seven365 Admin since 2026-10-14; the apps only read it.
4
5
  * Documents are not part of data retention in this version.
5
6
  */
6
7
  export type TRetentionRule = { dataType: string; keepMonths: number };
@@ -39,5 +40,10 @@ export default function useDataRetention() {
39
40
  window.URL.revokeObjectURL(url);
40
41
  }
41
42
 
42
- return { previewDataRetention, listDataPurgeRecords, downloadDataPurgeCertificate };
43
+ /** A scheduled removal at the site, for the people who work it (`{ banner: null }` when none). */
44
+ function getDataRetentionBanner(site: string) {
45
+ return api()<{ banner: Record<string, any> | null }>("/api/data-retention/banner", { method: "GET", query: { site } });
46
+ }
47
+
48
+ return { previewDataRetention, listDataPurgeRecords, downloadDataPurgeCertificate, getDataRetentionBanner };
43
49
  }
@@ -201,6 +201,12 @@ export default function useMember() {
201
201
  org: string;
202
202
  user: string;
203
203
  sites: Array<{ site: string; role: string }>;
204
+ /**
205
+ * Is this person one of the Security Ops officers who visits sites? One
206
+ * value for the whole person, written onto every site they end up with.
207
+ * Omit it and the server leaves the mark exactly as it is.
208
+ */
209
+ securityOps?: boolean;
204
210
  removeOrgAccess?: boolean;
205
211
  }) {
206
212
  return useNuxtApp().$api<Record<string, any>>("/api/members/site-access", {
@@ -559,6 +559,25 @@ export default function useRobots() {
559
559
  );
560
560
  }
561
561
 
562
+ /**
563
+ * A real lift's gateway code (Seven365 Super Admin): a new `lgw_...` code,
564
+ * shown ONCE in the answer; asking again replaces it (the old one stops working).
565
+ */
566
+ function connectLiftGateway(mapId: string, liftName: string) {
567
+ return useNuxtApp().$api<{ code: string; codeHint: string }>(
568
+ `/api/robots/building-maps/${mapId}/lifts/${encodeURIComponent(liftName)}/gateway`,
569
+ { method: "POST" },
570
+ );
571
+ }
572
+
573
+ /** The gateway's code stops working; the card goes back to the simulated car. */
574
+ function disconnectLiftGateway(mapId: string, liftName: string) {
575
+ return useNuxtApp().$api<Record<string, any>>(
576
+ `/api/robots/building-maps/${mapId}/lifts/${encodeURIComponent(liftName)}/gateway`,
577
+ { method: "DELETE" },
578
+ );
579
+ }
580
+
562
581
  /** Give the lift back, or leave the queue. */
563
582
  function releaseLiftSession(sessionId: string) {
564
583
  return useNuxtApp().$api<Record<string, any>>(`/api/robots/lift-sessions/${sessionId}/release`, {
@@ -595,6 +614,8 @@ export default function useRobots() {
595
614
  getLiftQueue,
596
615
  requestLift,
597
616
  releaseLiftSession,
617
+ connectLiftGateway,
618
+ disconnectLiftGateway,
598
619
  getRobotLiftSession,
599
620
  getRobots,
600
621
  getSiteFleet,
@@ -279,7 +279,7 @@ export default function () {
279
279
  * Anything a site never saved reads as "default" = today's form.
280
280
  */
281
281
  export type TVisitorFieldSwitches = {
282
- nric: "default" | "hidden";
282
+ nric: "default" | "hidden" | "last4";
283
283
  contact: "default" | "hidden" | "required";
284
284
  vehicle: "default" | "required";
285
285
  };
@@ -287,11 +287,14 @@ export type TVisitorFieldSwitches = {
287
287
  export function getVisitorFieldSwitches(site: any): TVisitorFieldSwitches {
288
288
  const v = site?.metadata?.visitorFields ?? {};
289
289
  return {
290
- nric: v.nric === "hidden" ? "hidden" : "default",
290
+ nric: v.nric === "hidden" || v.nric === "last4" ? v.nric : "default",
291
291
  contact: v.contact === "hidden" || v.contact === "required" ? v.contact : "default",
292
292
  vehicle: v.vehicle === "required" ? "required" : "default",
293
293
  };
294
294
  }
295
295
 
296
+ /** Shown under the NRIC field on a "keep only the last 4" site. */
297
+ export const NRIC_LAST4_HINT = "Only the last 4 characters will be kept.";
298
+
296
299
  /** Same words as the server's refusal, so the guard reads one message. */
297
300
  export const VEHICLE_REQUIRED_MESSAGE = "This site requires a vehicle number for every visitor.";
@@ -342,6 +342,18 @@ export const PERMISSIONS = {
342
342
  // Owner answer Q10, 2026-09-11. Mirrors `start-virtual-patrol-route`.
343
343
  "start-nfc-patrol",
344
344
  ],
345
+ // Security Ops. Both strings are enforced by the server (API-core
346
+ // `SITE_VISIT_PERMISSIONS`) and read by the security mobile app's own
347
+ // catalogue, so the spelling is maintained by hand in three places and this
348
+ // is the one that makes a typo visible.
349
+ //
350
+ // `see-all-site-visits` also decides who is told about an action: a head of
351
+ // security holds it together with `site-settings:manage-geofence`. A wildcard
352
+ // deliberately does NOT qualify for that, because a wildcard says somebody
353
+ // administers this site, not that they run security at it - measured
354
+ // 2026-10-13, every wildcard holder at a live site was an org owner, a test
355
+ // account or a service provider's administrator.
356
+ "security-ops": ["start-site-visit", "see-all-site-visits"],
345
357
  // Owner answer Q9, 2026-09-11: seeing the CCTV wall. Distinct from
346
358
  // `site-settings:manage-cctv-camera`, which is camera setup.
347
359
  "cctv-mgmt": ["see-all-cctv", "view-recordings"],
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.120.0",
5
+ "version": "4.122.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.",
@@ -56,8 +56,50 @@ export const INVENTORY_PRESETS: TPermissionPreset[] = [
56
56
  },
57
57
  ];
58
58
 
59
- /** Presets per module key; only inventory has them today. */
60
- export const PERMISSION_PRESETS: Record<string, TPermissionPreset[]> = { inventory: INVENTORY_PRESETS };
59
+ /**
60
+ * Security Ops in plain words: the two jobs the module actually has.
61
+ *
62
+ * WHY A PRESET AND NOT A NEW PERMISSION. Neither of these is a new right: both
63
+ * sets are made of strings that already exist. The preset only spares somebody
64
+ * hunting a permission tree, and that matters here because the alternative
65
+ * people reach for is a wildcard role, which is how a service provider's
66
+ * administrator ends up supervising a security site.
67
+ *
68
+ * GEOFENCING IS NOT IN EITHER SET, and cannot be: `applyPermissionPreset` is
69
+ * scoped to one resource, and `manage-geofence` belongs to `site-settings`.
70
+ * That matters because the owner's definition of a head of security
71
+ * (2026-10-13) is this permission AND geofencing, and the server reads exactly
72
+ * that pair to decide who is told when an action is raised or sent for
73
+ * verification. So the preset's description has to send the administrator to
74
+ * tick the other box, rather than quietly leaving the job half done.
75
+ *
76
+ * `start-site-visit` is deliberately NOT in the head of security set: it is the
77
+ * right to go and do a visit, and somebody who never walks a site should not
78
+ * have to hold it to be told what is happening on one. Holding it as well does
79
+ * not disqualify them.
80
+ */
81
+ export const SECURITY_OPS_PRESETS: TPermissionPreset[] = [
82
+ {
83
+ key: "officer",
84
+ label: "Security Ops officer",
85
+ description:
86
+ "Goes to the site, works through the visit checklists and checks out. Sees their own work.",
87
+ actions: ["start-site-visit"],
88
+ },
89
+ {
90
+ key: "head",
91
+ label: "Head of security",
92
+ description:
93
+ "Sees every officer's visits and actions. Also tick Manage geofencing under Site settings: that pair is what decides who is told when an action is raised or sent for verification.",
94
+ actions: ["see-all-site-visits"],
95
+ },
96
+ ];
97
+
98
+ /** Presets per module key. */
99
+ export const PERMISSION_PRESETS: Record<string, TPermissionPreset[]> = {
100
+ inventory: INVENTORY_PRESETS,
101
+ "security-ops": SECURITY_OPS_PRESETS,
102
+ };
61
103
 
62
104
  /**
63
105
  * The permission list with `resource`'s grants replaced by `preset`'s actions --
@@ -204,6 +204,28 @@ const SITE_PAGES: Record<string, TSitePage[]> = {
204
204
  robotAlert: [{ apps: ["security", "property-management", "hygiene"], page: () => "robot-management/alerts" }],
205
205
  // hygiene has its Service Providers page too since 2026-09-28
206
206
  serviceProviderInviteAnswer: [{ apps: SERVICE_APPS, page: () => "service-providers" }],
207
+ /**
208
+ * Security Ops: an action was raised, sent for verification, signed off,
209
+ * reopened, or went overdue. Lands on the Ops Review page, which is where the
210
+ * register is read.
211
+ *
212
+ * WITHOUT THIS ENTRY THE ROW IS A DEAD END. `notificationTarget` answers null
213
+ * for a kind it has no page for, so every overdue escalation ever written has
214
+ * opened nothing at all. Adding it repairs those too, not only the new alerts.
215
+ */
216
+ "security-ops": [{ apps: ["security"], page: () => "security-ops" }],
217
+ /**
218
+ * Security Ops: a client meeting was scheduled, moved or called off, or one
219
+ * is happening today.
220
+ *
221
+ * ITS OWN KIND, NOT `security-ops`, because the two land in different places:
222
+ * an action belongs to the register and a meeting belongs to the meeting
223
+ * record. The web has no per-meeting page, so this opens Ops Review, where
224
+ * the meetings tab lists them; the phone, which does have one, opens the
225
+ * meeting itself. Same kind, different answer per surface, which is what this
226
+ * table is for.
227
+ */
228
+ "client-meeting": [{ apps: ["security"], page: () => "security-ops" }],
207
229
  };
208
230
  SITE_PAGES.myVisitors = SITE_PAGES.visitors;
209
231
 
@@ -18,7 +18,7 @@ import {
18
18
  pushUndo,
19
19
  type TMapPointM,
20
20
  } from "./building-map";
21
- import { floorName } from "./robot-display";
21
+ import { agoText, floorName } from "./robot-display";
22
22
 
23
23
  /* ================================================================ camera */
24
24
 
@@ -1475,5 +1475,64 @@ export function liftCard(lift: Record<string, any>, now = Date.now()) {
1475
1475
  })),
1476
1476
  rows,
1477
1477
  simulated: lift?.simulated === true,
1478
+ gateway: liftGatewayLine(lift?.gateway ?? null),
1479
+ };
1480
+ }
1481
+
1482
+ const DOOR_TEXT = ["Doors closed", "Doors moving", "Doors open"];
1483
+ const MOTION_TEXT = ["Stopped", "Going up", "Going down", "Moving"];
1484
+ const MODE_TEXT = ["Mode unknown", "Normal", "Robot mode", "FIRE mode", "Out of service", "EMERGENCY"];
1485
+
1486
+ /**
1487
+ * The real lift's gateway (read-only link to the Inovance board), in words:
1488
+ * one status line, a tone, and the decoded floor/door/motion/mode once the
1489
+ * gateway's [decode] settings are filled in. `null` = no gateway: the car is simulated.
1490
+ */
1491
+ export function liftGatewayLine(g: Record<string, any> | null) {
1492
+ if (!g) return null;
1493
+ const ago = g.ageS == null ? "" : agoText(0, Number(g.ageS) * 1000);
1494
+ const byStatus: Record<string, { text: string; tone: string; help: string }> = {
1495
+ waiting: {
1496
+ text: `Waiting for the gateway (code …${g.codeHint ?? ""})`,
1497
+ tone: "neutral",
1498
+ help: "Put the code in the gateway's settings and start it. It turns Connected within seconds.",
1499
+ },
1500
+ connected: { text: `Connected · last reading ${ago}`, tone: "success", help: "" },
1501
+ "no-lift": {
1502
+ text: "Gateway online · lift not answering",
1503
+ tone: "warning",
1504
+ help: g.lastError || "Check the RS-485 wiring (A/B), baud, parity and unit address.",
1505
+ },
1506
+ offline: {
1507
+ text: `Offline · last heard ${ago}`,
1508
+ tone: "error",
1509
+ help: "Nothing from the gateway for 30 s: check its power, network, and that the program is running.",
1510
+ },
1511
+ };
1512
+ const line = byStatus[g.status] ?? byStatus.offline!;
1513
+ const st = g.status === "connected" ? g.state : null;
1514
+ const decoded = st
1515
+ ? [
1516
+ st.currentFloor ? `Floor ${st.currentFloor}` : "",
1517
+ st.doorState != null ? DOOR_TEXT[st.doorState] ?? "" : "",
1518
+ st.motionState != null ? MOTION_TEXT[st.motionState] ?? "" : "",
1519
+ st.currentMode != null ? MODE_TEXT[st.currentMode] ?? "" : "",
1520
+ ].filter(Boolean).join(" · ")
1521
+ : "";
1522
+ const registers = Object.entries((g.registers ?? {}) as Record<string, number>)
1523
+ .map(([reg, value]) => ({ reg, value }))
1524
+ .sort((a, b) => Number(a.reg) - Number(b.reg));
1525
+ return {
1526
+ ...line,
1527
+ status: String(g.status ?? "offline"),
1528
+ connected: g.status === "connected",
1529
+ decoded,
1530
+ // Until the Inovance register list is in the gateway's settings, only raw numbers exist.
1531
+ undecoded: g.status === "connected" && !decoded,
1532
+ registers,
1533
+ port: String(g.port ?? ""),
1534
+ version: String(g.gatewayVersion ?? ""),
1535
+ codeHint: String(g.codeHint ?? ""),
1536
+ readings: Number(g.readings ?? 0),
1478
1537
  };
1479
1538
  }
@@ -8,7 +8,7 @@ export type SiteAccessRole = { _id: string; name: string; type?: string; scope?:
8
8
  export type SiteAccessRow = {
9
9
  site: string;
10
10
  name: string;
11
- membership: { role: string; roleName?: string } | null;
11
+ membership: { role: string; roleName?: string; securityOps?: boolean } | null;
12
12
  roles: SiteAccessRole[];
13
13
  };
14
14
  export type SiteChoice = { site: string; name: string; on: boolean; role: string; roles: SiteAccessRole[]; replacedRole?: string };
@@ -31,6 +31,18 @@ export function initialChoices(rows: SiteAccessRow[]): SiteChoice[] {
31
31
  });
32
32
  }
33
33
 
34
+ /**
35
+ * Is this person already marked as Security Ops?
36
+ *
37
+ * ONE ANSWER FOR THE WHOLE PERSON, because that is the question the switch
38
+ * asks: an officer is an officer everywhere they are sent. Any marked site
39
+ * means yes, so a person whose rows disagree (only reachable by an older client
40
+ * or a hand edit) reads as marked, and saving settles every row to one value.
41
+ */
42
+ export function initialSecurityOps(rows: SiteAccessRow[]): boolean {
43
+ return (rows ?? []).some((r) => Boolean(r.membership?.securityOps));
44
+ }
45
+
34
46
  /** One site's Role list: "Organisation roles" first, then "<Site> roles". */
35
47
  export function roleItems(choice: Pick<SiteChoice, "name" | "roles">): RoleItem[] {
36
48
  const org = choice.roles.filter(isOrgRole);