@7365admin1/layer-common 4.73.1-staging.462 → 4.73.1-staging.464

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.
@@ -47,14 +47,22 @@
47
47
  <div>
48
48
  <div class="card-details__label">User</div>
49
49
  <div class="card-details__value">
50
- {{ details.userCred?.name ?? details.user?.name ?? "N/A" }}
51
- <template v-if="details.user?.email">({{ details.user.email }})</template>
50
+ <template v-if="holderState === 'named'">
51
+ {{ holderName }}
52
+ <template v-if="details.user?.email">({{ details.user.email }})</template>
53
+ </template>
54
+ <!-- Assigned, but the server did not send the name back. Saying
55
+ so beats "N/A", which reads as "nobody holds this card". -->
56
+ <template v-else-if="holderState === 'assigned'">
57
+ <span class="text-medium-emphasis">Assigned — name unavailable</span>
58
+ </template>
59
+ <template v-else>Unassigned</template>
52
60
  </div>
53
61
  </div>
54
62
 
55
- <div v-if="details.userType">
63
+ <div v-if="holderType">
56
64
  <div class="card-details__label">User Type</div>
57
- <div class="card-details__value">{{ details.userType }}</div>
65
+ <div class="card-details__value">{{ holderType }}</div>
58
66
  </div>
59
67
 
60
68
  <!-- Visitor / Resident Credentials -->
@@ -216,6 +224,51 @@ const sortedHistory = computed(() =>
216
224
  )
217
225
  );
218
226
 
227
+ /**
228
+ * WHO HOLDS THIS CARD.
229
+ *
230
+ * `userCred` is written by the visitor-pass flow and carries the person's
231
+ * details on the card itself. `user` is the resolved account - the card only
232
+ * stores `userId`, so the server has to join it.
233
+ *
234
+ * `card-details` does not do that join today (the lookup is commented out in
235
+ * core's `getCardDetailsRepo`), so this is empty for every card assigned
236
+ * through Assign to Person and the field falls through to `holderState`.
237
+ * Nothing here needs to change when the join is restored.
238
+ */
239
+ const holderName = computed(
240
+ () => details.value?.userCred?.name ?? details.value?.user?.name ?? "",
241
+ );
242
+
243
+ /**
244
+ * A card with a `userId` IS assigned, whether or not the name came back.
245
+ *
246
+ * Printing "N/A" for both cases is the actual defect here: it reads as "nobody
247
+ * holds this card", which is the opposite of the truth for an assigned one and
248
+ * could have someone hand out a card that is already in use.
249
+ */
250
+ const holderState = computed<"named" | "assigned" | "unassigned">(() => {
251
+ if (holderName.value) return "named";
252
+ return details.value?.userId ? "assigned" : "unassigned";
253
+ });
254
+
255
+ /**
256
+ * WHICH KIND OF PERSON HOLDS THIS CARD — Visitor/Resident, Management or
257
+ * Service Provider, matching the groups Assign to Person offers.
258
+ *
259
+ * `holderType` is what the server should send once it records the category the
260
+ * card was assigned under. `userType` is NOT that: it is the card's own pool,
261
+ * and `getCardDetailsRepo` filters on `EAccessCardUserTypes.DEFAULT`, so every
262
+ * card this dialog can load has the literal value "Visitor/Resident" whoever
263
+ * actually holds it. Preferring `holderType` means a card given to Management
264
+ * stops describing itself as a resident's the day the field arrives.
265
+ *
266
+ * Falls back to `userType` so nothing disappears in the meantime.
267
+ */
268
+ const holderType = computed(
269
+ () => details.value?.holderType ?? details.value?.userType ?? "",
270
+ );
271
+
219
272
  watch(
220
273
  () => props.modelValue,
221
274
  async (val) => {
@@ -453,7 +453,7 @@ const emit = defineEmits<{
453
453
  const { getResidents, assignUser } = useAccessManagement();
454
454
  const { getEncryptedAcmUrl } = useSiteEntryPassSettings();
455
455
  const { getAll: getMembers } = useMember();
456
- const { getAll: getServiceProviders } = useServiceProvider();
456
+ const { getSiteMembers } = useServiceProvider();
457
457
 
458
458
  const historyDialog = ref(false);
459
459
  const showAssignForm = ref(false);
@@ -552,14 +552,12 @@ const personTypeLabel = (person: Record<string, any>) => {
552
552
  const assignNoDataText = computed(() => {
553
553
  if (peopleLoading.value) return "Loading...";
554
554
  if (assignCategory.value === "management") return "No management members assigned to this unit";
555
- if (assignCategory.value === "providers") return "No service providers assigned to this unit";
555
+ if (assignCategory.value === "providers") return "No service provider members assigned to this unit";
556
556
  return "No residents or tenants found for this unit";
557
557
  });
558
558
 
559
- const assignUnassignableText = computed(() =>
560
- assignCategory.value === "providers"
561
- ? "A card is held by a person, and this is the provider company. Assigning to service providers needs the server to say which account the card belongs to."
562
- : "This person has no user account, so a card cannot be assigned to them.",
559
+ const assignUnassignableText = computed(
560
+ () => "This person has no account on record, so a card cannot be assigned to them.",
563
561
  );
564
562
 
565
563
  /** Residents and tenants of THIS UNIT - the list the dialog has always shown. */
@@ -619,41 +617,48 @@ async function loadManagementRows(): Promise<AssignRow[]> {
619
617
  }
620
618
 
621
619
  /**
622
- * Service Providers ATTACHED TO THIS UNIT.
620
+ * Service Providers - the provider PEOPLE attached to this unit.
623
621
  *
624
- * `GET /api/service-providers?siteId&status=active&unit=<id>` - the same list
625
- * and the same unit filter the Unit Management tab uses (core #2515), so the
626
- * providers offered are the ones this unit actually has.
622
+ * `GET /api/service-providers/site-members?siteId&unit=<id>`. A card is held by
623
+ * a person, so this lists provider staff rather than the companies
624
+ * `GET /api/service-providers` returns, and the `unit` filter keeps it to the
625
+ * people that unit actually has - the same scoping Management uses.
627
626
  *
628
- * These rows are COMPANIES, not people: the list has `name`, `email` and the
629
- * service `type`, and there is no user account behind a company. See
630
- * `assigneeId` below.
627
+ * The response also carries a `companies` summary; only `items` is people.
631
628
  */
632
629
  async function loadProviderRows(): Promise<AssignRow[]> {
633
630
  if (!props.siteId || !props.unit?._id) return [];
634
- const res: any = await getServiceProviders({
635
- siteId: props.siteId,
636
- unit: props.unit._id,
637
- status: "active",
638
- page: 1,
639
- limit: 50,
640
- });
641
- const rows: Record<string, any>[] = res?.items ?? [];
642
- return rows.map((provider) => ({
631
+ const res = await getSiteMembers(props.siteId, props.unit._id);
632
+ const rows = res?.items ?? [];
633
+ return rows.map((member: Record<string, any>) => ({
634
+ /*
635
+ * `user` when the endpoint returns it, otherwise the MEMBERSHIP `_id`.
636
+ *
637
+ * `site-members` does not carry `user` today (the repository projects it,
638
+ * the service layer drops it), and the product decision is to assign on
639
+ * `_id` rather than wait. Worth knowing what that means: the card's
640
+ * `userId` then holds a `service-provider-members` id, so the card list's
641
+ * `userId -> users` lookup finds nothing and the holder column reads blank
642
+ * for these cards. The door access itself is unaffected - the ACM command
643
+ * is built from the CARD's id, not the assignee's.
644
+ *
645
+ * `user` is preferred so that the day it starts arriving, these cards
646
+ * begin resolving to real people with no change here.
647
+ */
648
+ assigneeId: String(member.user ?? member._id ?? ""),
649
+ name: member.name || member.email || "",
650
+ subtitle: member.email ?? "",
643
651
  /*
644
- * Empty on purpose, and not a missing field: a card is assigned by writing
645
- * a USER id onto it, and a provider company has no user account. Which id a
646
- * company-held card should carry - the `service-providers` row, the
647
- * provider `orgId`, or a person from that company - is a backend decision
648
- * that has not been made. Sending one anyway would write an id the server
649
- * stores without checking and that matches no user.
652
+ * The service the company provides, plus this person's role - two people
653
+ * from the same company differ only by role ("VMS Access" vs "Security
654
+ * Access"). `typeLabel` is computed by the endpoint; the local converter is
655
+ * the fallback for a row that predates it.
650
656
  */
651
- assigneeId: "",
652
- name: provider.name ?? "",
653
- subtitle: provider.email ?? "",
654
- // The service code is a database value; show what people call it.
655
- meta: serviceTypeLabel(provider.type),
656
- raw: provider,
657
+ meta:
658
+ [member.typeLabel || serviceTypeLabel(member.type), member.role]
659
+ .filter(Boolean)
660
+ .join(" · "),
661
+ raw: member,
657
662
  }));
658
663
  }
659
664
 
@@ -633,17 +633,18 @@ watch([reportsPosition, online], ([r, o], old) => {
633
633
  const busy = ref<TRobotCommandKind | "spot" | null>(null);
634
634
  /** The last command's real outcome, kept on screen (a toast vanishes in 2.6 s). */
635
635
  const commandResult = ref<{ text: string; warn: boolean } | null>(null);
636
- const watching = ref<{ kind: string; at: number; what?: string } | null>(null);
636
+ const watching = ref<{ kind: string; at: number; what?: string; label?: string; faultBefore?: string } | null>(null);
637
637
  watch([lastAt, now], () => {
638
638
  const w = watching.value;
639
639
  const at = lastAt.value?.getTime() ?? 0;
640
640
  // Only a reading taken after the command was accepted counts (never the optimistic status).
641
641
  if (!w || at <= w.at + 300) return;
642
- const out = commandOutcome(w.kind, polled.value?.ok ? polled.value : null, Date.now() - w.at, { name: record.value?.name, what: w.what });
642
+ const out = commandOutcome(w.kind, polled.value?.ok ? polled.value : null, Date.now() - w.at, { name: record.value?.name, what: w.what, faultBefore: w.faultBefore });
643
643
  if (!out) return;
644
644
  // Keep following (Leaving the charger… -> On the way to P4… -> Arrived at P4) until it is done.
645
645
  if (out.done) watching.value = null;
646
- commandResult.value = { text: out.text, warn: !out.ok };
646
+ // Each line names its own command, so a new command never looks like the last one's result.
647
+ commandResult.value = { text: w.label ? `${w.label}: ${out.text}` : out.text, warn: !out.ok };
647
648
  if (!out.ok) optimistic.value = null;
648
649
  });
649
650
  watch(
@@ -656,6 +657,8 @@ watch(
656
657
  async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknown>, what?: string) {
657
658
  const name = record.value?.name ?? "the robot";
658
659
  const before = statusMode.value;
660
+ // A fault already showing is not the result of this command (commandOutcome).
661
+ const faultBefore = polled.value?.ok && typeof polled.value.fault?.message === "string" ? polled.value.fault.message : undefined;
659
662
  const docked = before === "CHARGED" || before === "CHARGING_CYCLE" || /At charger/.test(String(live.value?.ok ? live.value.statusName ?? "" : ""));
660
663
  busy.value = kind;
661
664
  optimistic.value = { mode: optimisticMode(kind, before), after: Number.POSITIVE_INFINITY };
@@ -673,7 +676,7 @@ async function run(kind: TRobotCommandKind | "spot", body: Record<string, unknow
673
676
  // "Accepted" is not "done": watch real readings for up to 30 s (commandOutcome).
674
677
  const outcomeKind = body.kind === "go-to-point" ? "go-to-point" : kind;
675
678
  if (commandOutcome(outcomeKind, { mode: "" }, COMMAND_WATCH_MS) !== null) {
676
- watching.value = { kind: String(outcomeKind), at: Date.now(), what };
679
+ watching.value = { kind: String(outcomeKind), at: Date.now(), what, label, faultBefore };
677
680
  fastUntil.value = Date.now() + COMMAND_FOLLOW_MS;
678
681
  commandResult.value = { text: sentText(docked), warn: false };
679
682
  } else commandResult.value = { text: `${label} → ${name} · accepted.`, warn: false };
@@ -18,6 +18,13 @@ export type TSiteProviderMember = {
18
18
  /** The building unit this person is attached to (a unit's Management tab). */
19
19
  unit?: string | null;
20
20
  unitName?: string;
21
+ /**
22
+ * The person's user account. NOT yet returned by the endpoint — the
23
+ * repository projects it but the service layer drops it. Anything acting on
24
+ * the PERSON (assigning an access card writes it onto the card as `userId`)
25
+ * needs this rather than `_id`, which identifies the membership.
26
+ */
27
+ user?: string | null;
21
28
  };
22
29
 
23
30
  export type TSiteProviderCompany = {
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.73.1-staging.462",
5
+ "version": "4.73.1-staging.464",
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.",
@@ -1823,7 +1823,7 @@ export function commandOutcome(
1823
1823
  kind: unknown,
1824
1824
  reading: { online?: unknown; mode?: unknown; statusName?: unknown; fault?: { message?: unknown } | null } | null | undefined,
1825
1825
  elapsedMs: number,
1826
- input: { name?: unknown; what?: unknown } = {},
1826
+ input: { name?: unknown; what?: unknown; faultBefore?: unknown } = {},
1827
1827
  ): { ok: boolean; text: string; done: boolean } | null {
1828
1828
  const started = STARTED[String(kind)];
1829
1829
  if (!started || !reading) return null;
@@ -1837,6 +1837,10 @@ export function commandOutcome(
1837
1837
  }
1838
1838
  if (elapsedMs < COMMAND_WATCH_MS) return null;
1839
1839
  const fault = typeof reading.fault?.message === "string" ? reading.fault.message : "";
1840
+ // The fault was already there when this command was sent: it is not this command's result.
1841
+ if (fault && fault === input.faultBefore) {
1842
+ return { ok: false, done: true, text: `${who} is still stopped by an earlier fault (${fault}). Clear it on the robot or in its maker's app, then try again.` };
1843
+ }
1840
1844
  const reason = reading.online === false ? "it stopped reporting" : fault || (said && !RESTING.test(said) ? said : "");
1841
1845
  return {
1842
1846
  ok: false,