@7365admin1/layer-common 4.39.0 → 4.40.1

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,23 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.40.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 783ffe2: Edit Camera: the device password is write-only. On edit the Password field is optional, starts blank with the placeholder "Saved · leave blank to keep", and is sent only when a new one is typed, so an admin can switch a camera's Status without re-typing its login. A status-only change now enables Submit. Password stays required when adding a camera.
8
+
9
+ ## 4.40.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 3a96438: Client invitations: an accepted invitation whose site never reached the company's Sites list now says so in plain words and offers "Add <site> to your Sites" (`linkInvitationSite`, `PUT /service-provider-invitations/:id/link-site`). Needs core 3.100.0.
14
+
15
+ ## 4.39.1
16
+
17
+ ### Patch Changes
18
+
19
+ - b1c2177: Visitor Mgmt: the Unregistered tab now appears on first load and after switching site, without a hard refresh. `useANPRSettings` takes a ref or getter for the site and keeps one entry per site; the Visitor Mgmt page reads its own site's ANPR switches instead of relying on the layout's plate toast, which kept the first site's switches after a site switch. A plate-toast link to the Unregistered tab is no longer bounced to Registered while the switches are still loading.
20
+
3
21
  ## 4.39.0
4
22
 
5
23
  ### Minor Changes
@@ -1447,11 +1465,11 @@
1447
1465
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1448
1466
  own rule from two endpoints the console already calls, unprojected:
1449
1467
 
1450
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1451
- GET /api/roles/id/:role that membership's role document
1468
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1469
+ GET /api/roles/id/:role that membership's role document
1452
1470
 
1453
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1454
- staff = member.type === "admin" && role.type === "admin"
1471
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1472
+ staff = member.type === "admin" && role.type === "admin"
1455
1473
 
1456
1474
  `role.default` is the marker because it is the only property of a platform
1457
1475
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -161,14 +161,21 @@
161
161
  </v-col>
162
162
 
163
163
  <v-col v-if="isANPR" cols="12">
164
- <InputLabel class="text-capitalize" title="Password" required />
164
+ <!-- Write-only: the saved password is never sent to the browser, so
165
+ on edit a blank field keeps it. Required only for a new camera. -->
166
+ <InputLabel
167
+ class="text-capitalize"
168
+ title="Password"
169
+ :required="!isEdit"
170
+ />
165
171
  <v-text-field
166
172
  v-model.trim="camera.password"
167
173
  type="password"
174
+ autocomplete="new-password"
168
175
  class="filter-field"
169
176
  density="comfortable"
170
- :rules="[requiredRule]"
171
- placeholder="Enter password"
177
+ :rules="isEdit ? [] : [requiredRule]"
178
+ :placeholder="isEdit ? 'Saved · leave blank to keep' : 'Enter password'"
172
179
  />
173
180
  </v-col>
174
181
 
@@ -419,8 +426,12 @@ const camera = ref<TSiteCamera>({
419
426
 
420
427
  camera.value.site = prop.site;
421
428
 
422
- if (prop.mode === "edit") {
429
+ const isEdit = prop.mode === "edit";
430
+
431
+ if (isEdit) {
423
432
  camera.value = JSON.parse(JSON.stringify(prop.camera));
433
+ // Never pre-filled: blank means "keep the saved password".
434
+ camera.value.password = "";
424
435
  }
425
436
  camera.value.site = prop.site;
426
437
  camera.value.type = prop.type;
@@ -576,7 +587,9 @@ const hasChanges = computed(() => {
576
587
  return (
577
588
  camera.value.host !== prop.camera.host ||
578
589
  camera.value.username !== prop.camera.username ||
579
- camera.value.password !== prop.camera.password ||
590
+ // A typed password is a change; blank keeps the saved one.
591
+ !!camera.value.password ||
592
+ camera.value.status !== prop.camera.status ||
580
593
  camera.value.category !== prop.camera.category ||
581
594
  camera.value.guardPost !== prop.camera.guardPost ||
582
595
  camera.value.direction !== prop.camera.direction ||
@@ -895,7 +908,8 @@ async function save(
895
908
  const res = await updateSiteCamera(camera.value._id ?? "", {
896
909
  ...addressPayload(),
897
910
  username: camera.value.username,
898
- password: camera.value.password,
911
+ // Sent only when typed; left out, the server keeps the saved one.
912
+ ...(camera.value.password ? { password: camera.value.password } : {}),
899
913
  category: camera.value.category,
900
914
  guardPost: camera.value.guardPost,
901
915
  direction: camera.value.direction,
@@ -78,7 +78,17 @@
78
78
  </div>
79
79
 
80
80
  <div v-else-if="item.status === 'complete'" class="px-4 pb-4">
81
- <NuxtLink v-if="item.onThisOrg" :to="`/org/${org}/sites`">Open Sites</NuxtLink>
81
+ <v-btn
82
+ v-if="item.needsSiteLink"
83
+ variant="flat"
84
+ class="screen-btn-primary"
85
+ :loading="working[item._id] === 'link'"
86
+ :disabled="Boolean(working[item._id])"
87
+ @click="addToSites(item)"
88
+ >
89
+ Add {{ item.siteName }} to your Sites
90
+ </v-btn>
91
+ <NuxtLink v-else-if="item.onThisOrg" :to="`/org/${org}/sites`">Open Sites</NuxtLink>
82
92
  <NuxtLink
83
93
  v-else-if="item.providerOrgId && item.providerOrgId !== org"
84
94
  :to="`/org/${item.providerOrgId}/sites`"
@@ -124,8 +134,13 @@ const tabs: Array<{ value: Tab; label: string }> = [
124
134
  ];
125
135
 
126
136
  const route = useRoute();
127
- const { getReceivedInvitations, getInvitation, acceptInvitation, declineInvitation } =
128
- useServiceProviderInvitation();
137
+ const {
138
+ getReceivedInvitations,
139
+ getInvitation,
140
+ acceptInvitation,
141
+ declineInvitation,
142
+ linkInvitationSite,
143
+ } = useServiceProviderInvitation();
129
144
 
130
145
  const tab = ref<Tab>("pending");
131
146
  const items = ref<TReceivedServiceProviderInvitation[]>([]);
@@ -180,6 +195,9 @@ function stateLine(item: TReceivedServiceProviderInvitation) {
180
195
  if (item.onThisOrg) {
181
196
  return `Accepted${on(item.answeredAt)} — ${item.siteName} is on your Sites list as a site you serve for ${item.orgName}.`;
182
197
  }
198
+ if (item.needsSiteLink) {
199
+ return "This invitation was accepted, but the site wasn't added to your Sites. Add it now.";
200
+ }
183
201
  if (!item.providerOrgId || item.providerOrgId === props.org) {
184
202
  return `Accepted${on(item.answeredAt)}, but ${item.siteName} is not on this company's Sites list. Please contact Seven365 support so we can add it.`;
185
203
  }
@@ -228,6 +246,20 @@ async function answer(item: TReceivedServiceProviderInvitation, action: "accept"
228
246
  }
229
247
  }
230
248
 
249
+ /** Accepted but never added to the Sites list: add it now, then show it. */
250
+ async function addToSites(item: TReceivedServiceProviderInvitation) {
251
+ errors[item._id] = "";
252
+ working[item._id] = "link";
253
+ try {
254
+ await linkInvitationSite(item._id, props.org);
255
+ await load();
256
+ } catch (err: any) {
257
+ errors[item._id] = err?.data?.message || "Could not add this site to your Sites.";
258
+ } finally {
259
+ working[item._id] = "";
260
+ }
261
+ }
262
+
231
263
  // Opened from the bell: start on the tab the linked invitation is in.
232
264
  if (linked.value) {
233
265
  try {
@@ -1502,8 +1502,17 @@ const { org: orgId, site: siteId } = route.params as {
1502
1502
  site: string;
1503
1503
  };
1504
1504
 
1505
- const { enableUnregistered } = useANPRSettings(siteId);
1505
+ // The page reads its own site's switches rather than relying on the layout's
1506
+ // toast to have fetched them - that copy could belong to the previous site.
1507
+ const {
1508
+ enableUnregistered,
1509
+ loaded: anprSettingsLoaded,
1510
+ ensureANPRSettings,
1511
+ } = useANPRSettings(siteId);
1506
1512
  const enableUnregisteredTab = computed(() => enableUnregistered.value);
1513
+ ensureANPRSettings(true).catch(() => {
1514
+ // No settings = no Unregistered tab, same as before.
1515
+ });
1507
1516
 
1508
1517
  const {
1509
1518
  getVisitors,
@@ -2933,7 +2942,11 @@ watch([searchInput, dateFrom, dateTo, filterTypes, activeTab], () => {
2933
2942
  const syncVisitorFiltersFromRoute = () => {
2934
2943
  const requestedTab = (route.query.tab as string) || "registered";
2935
2944
  const nextTab =
2936
- (!enableUnregisteredTab.value && requestedTab === "unregistered") ||
2945
+ // Until the switches are read, keep a requested Unregistered tab (the plate
2946
+ // toast links here) instead of bouncing it to Registered.
2947
+ (anprSettingsLoaded.value &&
2948
+ !enableUnregisteredTab.value &&
2949
+ requestedTab === "unregistered") ||
2937
2950
  (!showResidentTransactionsTab.value && requestedTab === "resident-transactions")
2938
2951
  ? "registered"
2939
2952
  : requestedTab;
@@ -2992,9 +3005,9 @@ const { socketUnregisteredVisitorTrigger, visitorSocketData } =
2992
3005
  useVisitorSocket();
2993
3006
 
2994
3007
  watch(
2995
- enableUnregisteredTab,
2996
- (enabled) => {
2997
- if (!enabled && activeTab.value === "unregistered") {
3008
+ [enableUnregisteredTab, anprSettingsLoaded],
3009
+ ([enabled]) => {
3010
+ if (!enabled && anprSettingsLoaded.value && activeTab.value === "unregistered") {
2998
3011
  activeTab.value = "registered";
2999
3012
  }
3000
3013
  },
@@ -137,8 +137,9 @@ const config = useRuntimeConfig();
137
137
  const publicConfig = config.public as Record<string, string | undefined>;
138
138
  const route = useRoute();
139
139
  const { tab } = useVisitor();
140
+ // A getter, not the string: this component outlives site switches.
140
141
  const { enableUnregistered, ensureANPRSettings } = useANPRSettings(
141
- props.siteId
142
+ () => props.siteId
142
143
  );
143
144
  const enableUnregisteredSnackbar = computed(() => enableUnregistered.value);
144
145
  const { socketUnregisteredVisitorTrigger, visitorSocketData } =
@@ -1,19 +1,50 @@
1
- export default function (siteId?: string) {
2
- const stateKey = computed(() =>
3
- siteId ? `anpr-settings-${siteId}` : "anpr-settings"
4
- );
1
+ import { computed, toValue, type MaybeRefOrGetter } from "vue";
2
+
3
+ /*
4
+ * A site's ANPR switches (e.g. "Enable Unregistered Vehicles"), shared by every
5
+ * caller on the page: the Visitor Mgmt tabs, the plate toast in the layout, and
6
+ * Site Settings.
7
+ *
8
+ * `siteId` may be a ref or a getter. The layout's toast lives across site
9
+ * switches, so a plain string captured once kept answering for the FIRST site
10
+ * it saw - and the Visitor Mgmt page never fetched at all, so its
11
+ * "Unregistered" tab showed only when that stale copy happened to be for the
12
+ * right site (i.e. after a hard refresh on the page itself).
13
+ *
14
+ * One map keyed by site id: a key that is present means "fetched", even when
15
+ * the site has no settings yet (stored as `{}`).
16
+ */
17
+ export default function (siteId?: MaybeRefOrGetter<string | undefined>) {
18
+ const site = computed(() => toValue(siteId) || "");
5
19
 
6
- const anprSettings = useState<Record<string, any> | null>(
7
- stateKey.value,
8
- () => null
20
+ const bySite = useState<Record<string, Record<string, any>>>(
21
+ "anpr-settings-by-site",
22
+ () => ({})
9
23
  );
10
24
 
25
+ const anprSettings = computed<Record<string, any> | null>({
26
+ get: () => bySite.value[site.value] ?? null,
27
+ set: (value) => {
28
+ bySite.value = { ...bySite.value, [site.value]: value ?? {} };
29
+ },
30
+ });
31
+
32
+ /** True once this site's settings have been read (or saved) at least once. */
33
+ const loaded = computed(() => site.value in bySite.value);
34
+
11
35
  const enableUnregistered = computed(
12
36
  () => anprSettings.value?.ANPRSwitches?.enableUnregistered === true
13
37
  );
14
38
 
39
+ function store(forSite: unknown, response: Record<string, any> | null) {
40
+ const key = String(forSite ?? "");
41
+ if (!key) return;
42
+ bySite.value = { ...bySite.value, [key]: response ?? {} };
43
+ }
44
+
15
45
  async function fetchANPRSettings(data?: any) {
16
- const payload = data ?? (siteId ? { site: siteId, userType: "site" } : undefined);
46
+ const payload =
47
+ data ?? (site.value ? { site: site.value, userType: "site" } : undefined);
17
48
 
18
49
  if (!payload) {
19
50
  return anprSettings.value;
@@ -27,23 +58,21 @@ export default function (siteId?: string) {
27
58
  }
28
59
  );
29
60
 
30
- if (response) {
31
- anprSettings.value = response;
32
- }
61
+ store(payload.site, response);
33
62
 
34
63
  return response;
35
64
  }
36
65
 
37
66
  async function ensureANPRSettings(force = false) {
38
- if (!siteId) {
67
+ if (!site.value) {
39
68
  return anprSettings.value;
40
69
  }
41
70
 
42
- if (!force && anprSettings.value) {
71
+ if (!force && loaded.value) {
43
72
  return anprSettings.value;
44
73
  }
45
74
 
46
- return await fetchANPRSettings({ site: siteId, userType: "site" });
75
+ return await fetchANPRSettings({ site: site.value, userType: "site" });
47
76
  }
48
77
 
49
78
  async function getANPRSettingBySite(data: any) {
@@ -60,7 +89,7 @@ export default function (siteId?: string) {
60
89
  );
61
90
 
62
91
  if (response) {
63
- anprSettings.value = response;
92
+ store(data?.site, response);
64
93
  }
65
94
 
66
95
  return response;
@@ -68,6 +97,7 @@ export default function (siteId?: string) {
68
97
 
69
98
  return {
70
99
  anprSettings,
100
+ loaded,
71
101
  enableUnregistered,
72
102
  ensureANPRSettings,
73
103
  getANPRSettingBySite,
@@ -48,6 +48,8 @@ export type TReceivedServiceProviderInvitation = {
48
48
  providerOrgName?: string;
49
49
  /** Accepted AND on the Sites list of the company on screen. */
50
50
  onThisOrg?: boolean;
51
+ /** Accepted, but the site is on no company's Sites list: offer to add it. */
52
+ needsSiteLink?: boolean;
51
53
  };
52
54
 
53
55
  /** The words on screen. Kept beside the server's own list on purpose — a
@@ -171,6 +173,17 @@ export default function useServiceProviderInvitation() {
171
173
  });
172
174
  }
173
175
 
176
+ /**
177
+ * An accepted invitation whose site never reached this company's Sites
178
+ * list: add it now. Safe to repeat - the server adds it once.
179
+ */
180
+ function linkInvitationSite(id: string, org: string) {
181
+ return useNuxtApp().$api<{ message: string }>(`${base}/${id}/link-site`, {
182
+ method: "PUT",
183
+ body: { org },
184
+ });
185
+ }
186
+
174
187
  /**
175
188
  * "Client invitations": one tab of the invitations sent to the signed-in
176
189
  * account, plus the count in every tab. `org` is the company on screen.
@@ -200,6 +213,7 @@ export default function useServiceProviderInvitation() {
200
213
  getInvitation,
201
214
  declineInvitation,
202
215
  acceptInvitation,
216
+ linkInvitationSite,
203
217
  getReceivedInvitations,
204
218
  };
205
219
  }
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.39.0",
5
+ "version": "4.40.1",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -36,7 +36,7 @@
36
36
  "build": "nuxt build .playground",
37
37
  "generate": "nuxt generate .playground",
38
38
  "preview": "nuxt preview .playground",
39
- "test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
39
+ "test": "esbuild composables/useANPRSettings.ts --format=esm --outfile=test/.build/useANPRSettings.mjs --log-level=error && esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && esbuild composables/usePlateToastWindow.ts --format=esm --outfile=test/.build/usePlateToastWindow.mjs --log-level=error && esbuild utils/client-invite-modules.ts --bundle --platform=node --format=esm --outfile=test/.build/client-invite-modules.mjs --log-level=error && esbuild utils/robot-display.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-display.mjs --log-level=error && esbuild utils/building-map.ts --bundle --platform=node --format=esm --outfile=test/.build/building-map.mjs --log-level=error && esbuild utils/robot-building.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-building.mjs --log-level=error && esbuild utils/robot-fleet-core.ts --bundle --platform=node --format=esm --outfile=test/.build/robot-fleet-core.mjs --log-level=error && node --experimental-strip-types --test \"test/*.test.mjs\" && yarn test:units",
40
40
  "test:units": "node --experimental-strip-types --test \"utils/*.test.ts\"",
41
41
  "release": "yarn run build && changeset publish"
42
42
  },