@7365admin1/layer-common 4.0.0 → 4.0.2

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,129 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - ce3ece5: Suspend and reactivate a client from the staff console, owner-only, and fix
8
+ four controls on the console screens that failed without saying so.
9
+
10
+ ## Suspend / reactivate (owner decision 4 and 9)
11
+
12
+ `ClientMain.vue` already called `PATCH /api/organizations/:id/status`. The
13
+ backend for it merged as `iservice365-core` #1884 and is now owner-gated
14
+ (`requirePlatformOwner`), so the screen has to know which tier it is drawing
15
+ for - and it had no way to tell the owner from ordinary Seven365 staff.
16
+
17
+ `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
18
+ own rule from two endpoints the console already calls, unprojected:
19
+
20
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
21
+ GET /api/roles/id/:role that membership's role document
22
+
23
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
24
+ staff = member.type === "admin" && role.type === "admin"
25
+
26
+ `role.default` is the marker because it is the only property of a platform
27
+ staff role no API caller can set - `role.controller.ts` validates create and
28
+ update with Joi object schemas that do not list it, `role.repo.ts` never writes
29
+ it, `MRole` defaults it to `false`, and only `user.service.ts
30
+ createDefaultUser()` sets it at API boot. A role NAME proves nothing: staging
31
+ carries an ordinary organisation role merely named "Super Admin".
32
+
33
+ **This decides what is DRAWN, and nothing else.** The server re-decides on
34
+ every call from the session id, so a browser that flips the flag gets a visible
35
+ button and a 401. Anything the front end cannot positively prove is `none` -
36
+ it fails closed.
37
+
38
+ The suspend confirmation now says what actually happens in the owner's terms:
39
+ everyone who belongs to the client is blocked from signing in - staff,
40
+ residents and guards - nothing is deleted, and it is reversible. Reactivating
41
+ confirms without a warning. After either, the list re-reads and the row moves
42
+ to the tab matching its new status; the state shown comes from the existing
43
+ `utils/client-subscription.ts` derivation, not a second copy of the rule.
44
+
45
+ ## Four controls that failed silently
46
+
47
+ - **`ClientMain.vue` - the row menu had no background.** It teleports to
48
+ `<body>`, which is outside the element Vuetify puts `v-theme--light` /
49
+ `v-theme--dark` on, and every token in `tokens.css` is declared under
50
+ `[class*="v-theme--"]`. `background: var(--card)` therefore resolved to
51
+ nothing: measured `rgba(0, 0, 0, 0)` in both themes, with the table's own
52
+ text showing through the menu items. Its border, ink and hover were dead for
53
+ the same reason. The menu now names its theme on itself.
54
+ - **`ClientMain.vue` - a refused suspend said nothing.** Both handlers ended in
55
+ `console.error`, so pressing Suspend looked exactly like pressing nothing -
56
+ which is what an ordinary staff member now gets from the owner-gated
57
+ endpoint. The refusal is shown on the dialog, which stays open.
58
+ - **`ClientDetailForm.vue` - a failed save and a failed load both drew as
59
+ normal.** `handleSubmit` swallowed the error into `console.error`; the two
60
+ loaders had no `catch` at all, so a failed request rejected inside the
61
+ watcher and the drawer drew every field blank and an empty site table - an
62
+ error shown as "this client has nothing in it". Both now say so. An
63
+ organisation with no service application says that too, instead of an empty
64
+ list.
65
+ - **`MemberMain.vue` - two error paths.** A refused role change set the
66
+ snackbar's text but never opened it, so it said nothing at all; a failed
67
+ status change with no `message` on the error drew an empty red snackbar.
68
+
69
+ No endpoint, request shape or response shape changed, and nothing new is called
70
+ that did not already exist.
71
+
72
+ - 6d654d6: Remove a duplicate `:site` attribute on `HidReaderManagement.vue`.
73
+
74
+ `HidReaderForm` was given `:site` twice - `props.site` and then a bare `site`, which is
75
+ not a name that exists in that component's scope. Vite's Vue plugin treats a duplicate
76
+ attribute as a compile error, so every consuming application's `nuxt build` failed on
77
+ this file. The layer's own playground build does not import the component, which is why
78
+ it was not caught at publish time.
79
+
80
+ - c4683d1: Promo codes can be edited, turned off and removed from the staff console.
81
+
82
+ `usePromoCode()` gains `getById`, `update`, `updateStatus` and `remove`, matching the
83
+ `PUT /api/promo-codes/:id`, `PATCH /api/promo-codes/:id/status` and
84
+ `DELETE /api/promo-codes/:id` endpoints. `update` never sends `code`, which the server
85
+ treats as immutable because subscriptions and invoices quote a code by its text.
86
+
87
+ `utils/promo-code-form.ts` holds the editor's rules - loading a record into the form,
88
+ building the request body, and refusing what the API refuses, in wording a person can
89
+ act on.
90
+
91
+ - 780112d: Stop the Roles & Permissions screen firing an id-less request on every visit, and
92
+ remove a dead, malformed member-lookup function.
93
+
94
+ `RolePermissionMain.vue` fetched a role by id as soon as the component mounted,
95
+ before any role had been picked, sending `GET /api/roles/id` with no id on the
96
+ end. The real fetch already runs correctly once a row is clicked (a `watchEffect`
97
+ handles that); the mount-time fetch was just extra noise hitting the API for
98
+ nothing. It now waits for a real id (`immediate: false`).
99
+
100
+ Also removes `useMember.getByMemberId`, which built the malformed path
101
+ `/api//user/${user}` and had no caller anywhere in this package or the 11 web
102
+ apps.
103
+
104
+ ## 4.0.1
105
+
106
+ ### Patch Changes
107
+
108
+ - 7100594: Read HID permission candidates whether or not the API wraps them in `data`.
109
+
110
+ `GET` on the HID permission list returns its rows at the top level on some routes and
111
+ nested under `data` on others. The composable only read the top-level shape, so the
112
+ wrapped responses produced an empty permission picker with no error - the QR
113
+ configuration and user enrolment screens simply showed nothing to choose from.
114
+ `useHidAmico` now accepts both shapes.
115
+
116
+ - 8dd7f47: Promo codes can be edited, turned off and removed from the staff console.
117
+
118
+ `usePromoCode()` gains `getById`, `update`, `updateStatus` and `remove`, matching the
119
+ `PUT /api/promo-codes/:id`, `PATCH /api/promo-codes/:id/status` and
120
+ `DELETE /api/promo-codes/:id` endpoints. `update` never sends `code`, which the server
121
+ treats as immutable because subscriptions and invoices quote a code by its text.
122
+
123
+ `utils/promo-code-form.ts` holds the editor's rules - loading a record into the form,
124
+ building the request body, and refusing what the API refuses, in wording a person can
125
+ act on.
126
+
3
127
  ## 4.0.0
4
128
 
5
129
  ### Major Changes
@@ -130,6 +130,7 @@
130
130
 
131
131
  <script setup lang="ts">
132
132
  import useSiteSettings from "../composables/useSiteSettings";
133
+ import { cameraErrorConverter } from "../utils/data";
133
134
 
134
135
  const prop = defineProps({
135
136
  title: {
@@ -298,11 +299,7 @@ async function submit() {
298
299
 
299
300
  emit("success", `${prop.title} added successfully!`);
300
301
  } catch (error: any) {
301
- console.log(
302
- "error",
303
- error.response?._data?.message || "Failed to add camera"
304
- );
305
- emit("error", error.response?._data?.message || "Failed to add camera");
302
+ emit("error", cameraErrorConverter(error, prop.type));
306
303
  } finally {
307
304
  disable.value = false;
308
305
  }
@@ -248,6 +248,7 @@
248
248
  <script setup lang="ts">
249
249
  import type { PropType } from "vue";
250
250
  import useSiteSettings from "../composables/useSiteSettings";
251
+ import { cameraErrorConverter } from "../utils/data";
251
252
 
252
253
  const events = defineEmits(["update:value", "row-click"]);
253
254
 
@@ -363,8 +364,7 @@ function openDialogDelete() {
363
364
  }
364
365
 
365
366
  function handleError(msg: string) {
366
- const text = prop.type === "ip" ? "CCTV camera already exist" : msg;
367
- showMessage(text, "error");
367
+ showMessage(msg, "error");
368
368
  }
369
369
 
370
370
  function handleSuccess() {
@@ -416,10 +416,7 @@ async function submitDelete() {
416
416
  await getCameraRefresh();
417
417
  dialogDelete.value = false;
418
418
  } catch (error: any) {
419
- showMessage(
420
- error?.response?._data?.message || "Failed to delete camera.",
421
- "error"
422
- );
419
+ showMessage(cameraErrorConverter(error, prop.type), "error");
423
420
  }
424
421
  }
425
422
  </script>
@@ -9,10 +9,15 @@
9
9
 
10
10
  <div class="actions">
11
11
  <button class="btn-cancel" @click="$emit('cancel')">Cancel</button>
12
- <button class="btn-submit" @click="handleSubmit">Submit</button>
12
+ <button class="btn-submit" :disabled="saving" @click="handleSubmit">
13
+ {{ saving ? 'Saving...' : 'Submit' }}
14
+ </button>
13
15
  </div>
14
16
  </div>
15
17
 
18
+ <p v-if="loadError" class="form-error" role="alert">{{ loadError }}</p>
19
+ <p v-else-if="formError" class="form-error" role="alert">{{ formError }}</p>
20
+
16
21
  <!-- ===== CLIENT INFORMATION ===== -->
17
22
  <section class="section">
18
23
  <h3>Client Information</h3>
@@ -133,7 +138,12 @@
133
138
  <!-- ===== INCLUDED APPLICATION ===== -->
134
139
  <section class="section">
135
140
  <h3>Included Application</h3>
136
- <ul class="app-list">
141
+ <!-- A plain organisation has no service application of its own, so the
142
+ list is legitimately empty. An empty <ul> reads as a broken box. -->
143
+ <p v-if="form.includedApps.length === 0" class="app-none">
144
+ No service application recorded for this client.
145
+ </p>
146
+ <ul v-else class="app-list">
137
147
  <li v-for="(app, idx) in form.includedApps" :key="idx">{{ app }}</li>
138
148
  </ul>
139
149
  </section>
@@ -266,6 +276,10 @@ const errors = reactive({
266
276
  const phonePrefix = ref('+65')
267
277
  const phoneNumber = ref('')
268
278
 
279
+ const saving = ref(false)
280
+ const loadError = ref('')
281
+ const formError = ref('')
282
+
269
283
  /* ── FORM STATE ── */
270
284
  const form = reactive({
271
285
  name: '',
@@ -348,6 +362,9 @@ async function handleSubmit() {
348
362
 
349
363
  form.phone = `${phonePrefix.value} ${phoneNumber.value}`
350
364
 
365
+ saving.value = true
366
+ formError.value = ''
367
+
351
368
  try {
352
369
  if (userId.value) {
353
370
  await userApi.updateUserFieldById(userId.value, 'name', form.name)
@@ -359,8 +376,16 @@ async function handleSubmit() {
359
376
  })
360
377
 
361
378
  emit('submit', { ...form })
362
- } catch (error) {
363
- console.error(error)
379
+ } catch (error: any) {
380
+ // A failed save used to reach `console.error` and nothing else, so Submit
381
+ // looked exactly like a button that did nothing - the screen stayed put
382
+ // with the typed values still in it, which reads as "not saved yet".
383
+ formError.value =
384
+ error?.response?._data?.message ??
385
+ error?.data?.message ??
386
+ 'Could not save this client. Nothing was changed.'
387
+ } finally {
388
+ saving.value = false
364
389
  }
365
390
  }
366
391
  const userId = ref('')
@@ -419,7 +444,19 @@ watch(
419
444
  async client => {
420
445
  if (!client?._id) return
421
446
 
422
- await Promise.all([loadOrganization(), loadSites()])
447
+ loadError.value = ''
448
+
449
+ try {
450
+ await Promise.all([loadOrganization(), loadSites()])
451
+ } catch (error: any) {
452
+ // Neither loader caught anything, so a failed request rejected inside
453
+ // this watcher and the drawer drew every field blank and an empty site
454
+ // table - an error shown as "this client has nothing in it".
455
+ loadError.value =
456
+ error?.response?._data?.message ??
457
+ error?.data?.message ??
458
+ 'Could not load this client. Nothing here has been saved or changed.'
459
+ }
423
460
  },
424
461
  {
425
462
  immediate: true
@@ -654,6 +691,29 @@ watch(
654
691
  color: var(--err);
655
692
  }
656
693
 
694
+ /* A failed load or a failed save, said once at the top of the form where the
695
+ reader already is. `--err-bg` fill + `--err` ink, the design's error pair. */
696
+ .form-error {
697
+ margin: 0 28px 18px;
698
+ padding: 10px 12px;
699
+ border-radius: 8px;
700
+ background: var(--err-bg);
701
+ color: var(--err);
702
+ font-size: 13px;
703
+ font-weight: 500;
704
+ line-height: 1.45;
705
+ }
706
+ .btn-submit:disabled {
707
+ opacity: 0.7;
708
+ cursor: not-allowed;
709
+ }
710
+ /* `--text2` is the reading token for secondary copy; `--muted` measures below
711
+ AA at this size. */
712
+ .app-none {
713
+ font-size: 13.5px;
714
+ color: var(--text2);
715
+ }
716
+
657
717
  /*
658
718
  * MEASURED at 360: the form overflowed the viewport by 147px in both themes.
659
719
  * Its two-column grid, its 28px side padding and its header row are all fixed,
@@ -56,7 +56,15 @@
56
56
  <div class="table-card">
57
57
 
58
58
  <!-- Toolbar row -->
59
- <div v-if="savedNote" class="saved-note" role="status">
59
+ <!-- Confirmations AND failures. A suspend that the server refused used
60
+ to reach `console.error` and nothing else, so the control read as
61
+ one that quietly did nothing. -->
62
+ <div
63
+ v-if="savedNote"
64
+ class="saved-note"
65
+ :class="{ 'saved-note-error': noteIsError }"
66
+ :role="noteIsError ? 'alert' : 'status'"
67
+ >
60
68
  {{ savedNote }}
61
69
  <button class="saved-note-close" @click="savedNote = ''">&times;</button>
62
70
  </div>
@@ -266,27 +274,74 @@
266
274
  </div>
267
275
  </div>
268
276
 
269
- <!-- ===== SUSPEND CONFIRMATION DIALOG ===== -->
270
- <div v-if="confirmSuspendItem" class="overlay" @click.self="cancelSuspend">
277
+ <!--
278
+ SUSPEND / REACTIVATE CONFIRMATION.
279
+
280
+ One dialog for both directions, because one endpoint serves both. The
281
+ suspend wording says what actually happens in the owner's own terms
282
+ (owner decision 4) rather than "are you sure": the people who lose access
283
+ are not only the client's office staff, and the two things a person most
284
+ needs to know before pressing it are that nothing is deleted and that it
285
+ can be undone. Reactivating needs no warning, but it still confirms - it
286
+ is a real change to who can sign in.
287
+ -->
288
+ <div v-if="confirmItem" class="overlay" @click.self="cancelConfirm">
271
289
  <div class="confirm-box">
272
- <h3 class="confirm-title">Suspend account</h3>
273
- <p class="confirm-text">
274
- Are you sure you want to suspend <strong>{{ confirmSuspendItem?.name }}</strong>'s account?
275
- </p>
290
+ <template v-if="confirmAction === 'suspended'">
291
+ <h3 class="confirm-title">Suspend {{ confirmItem?.name }}?</h3>
292
+ <p class="confirm-text">
293
+ Everyone who belongs to this client will be blocked from signing in
294
+ &mdash; their staff, their residents and their guards.
295
+ </p>
296
+ <p class="confirm-text">
297
+ Nothing is deleted. All of their data is kept.
298
+ </p>
299
+ <p class="confirm-text">
300
+ You can reactivate them at any time and they get straight back in.
301
+ </p>
302
+ </template>
303
+
304
+ <template v-else>
305
+ <h3 class="confirm-title">Reactivate {{ confirmItem?.name }}?</h3>
306
+ <p class="confirm-text">
307
+ Their staff, residents and guards will be able to sign in again.
308
+ </p>
309
+ </template>
310
+
311
+ <p v-if="confirmError" class="confirm-error" role="alert">{{ confirmError }}</p>
312
+
276
313
  <div class="confirm-actions">
277
- <button class="btn-cancel" @click="cancelSuspend">Cancel</button>
278
- <button class="btn-confirm-danger" :disabled="confirmSuspendLoading" @click="confirmSuspend">
279
- <span v-if="confirmSuspendLoading" class="spinner spinner-light" />
280
- <span v-else>Suspend</span>
314
+ <button class="btn-cancel" :disabled="confirmLoading" @click="cancelConfirm">Cancel</button>
315
+ <button
316
+ :class="confirmAction === 'suspended' ? 'btn-confirm-danger' : 'btn-confirm'"
317
+ :disabled="confirmLoading"
318
+ @click="runConfirm"
319
+ >
320
+ <span v-if="confirmLoading" class="spinner spinner-light" />
321
+ <span v-else>{{ confirmAction === 'suspended' ? 'Suspend' : 'Reactivate' }}</span>
281
322
  </button>
282
323
  </div>
283
324
  </div>
284
325
  </div>
285
326
 
327
+ <!--
328
+ THE ROW MENU CARRIES ITS OWN THEME CLASS.
329
+
330
+ It teleports to `<body>`, which is OUTSIDE the `v-application` element
331
+ Vuetify puts `v-theme--light` / `v-theme--dark` on - and every design
332
+ token in `tokens.css` is declared under `[class*="v-theme--"]`. So
333
+ `background: var(--card)` resolved to NOTHING and the menu drew with a
334
+ fully transparent background: measured `rgba(0, 0, 0, 0)` in both themes,
335
+ with the table's own text showing straight through the menu items. Its
336
+ border, ink and hover were dead for the same reason. Naming the theme on
337
+ the element itself brings all of them back without moving the teleport,
338
+ which is what keeps the menu clear of the table's `overflow`.
339
+ -->
286
340
  <Teleport to="body">
287
341
  <div
288
342
  v-if="openMenuId"
289
343
  class="dropdown"
344
+ :class="`v-theme--${currentTheme}`"
290
345
  :style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
291
346
  >
292
347
  <button class="dd-item" @click="viewClient(activeMenuItem)">
@@ -295,20 +350,29 @@
295
350
  <button class="dd-item" @click="openSubscription(activeMenuItem)">
296
351
  {{ activeMenuItem?.sub?.state === 'none' ? 'Set up subscription' : 'Edit subscription' }}
297
352
  </button>
298
- <button
299
- v-if="activeMenuItem?.status === 'suspended'"
300
- class="dd-item"
301
- @click="activateClient(activeMenuItem)"
302
- >
303
- Activate
304
- </button>
305
- <button
306
- v-else
307
- class="dd-item danger"
308
- @click="askSuspend(activeMenuItem)"
309
- >
310
- Suspend
311
- </button>
353
+ <!--
354
+ OWNER ONLY (owner decision 9). Ordinary Seven365 staff may view
355
+ clients and set up subscriptions; only the owner may stop a client's
356
+ people signing in. The server decides this again on every call -
357
+ `requirePlatformOwner` reads the session, not anything the browser
358
+ sends - so this hides a control it does not guard.
359
+ -->
360
+ <template v-if="isOwner">
361
+ <button
362
+ v-if="activeMenuItem?.status === 'suspended'"
363
+ class="dd-item"
364
+ @click="askConfirm(activeMenuItem, 'active')"
365
+ >
366
+ Reactivate
367
+ </button>
368
+ <button
369
+ v-else
370
+ class="dd-item danger"
371
+ @click="askConfirm(activeMenuItem, 'suspended')"
372
+ >
373
+ Suspend
374
+ </button>
375
+ </template>
312
376
  </div>
313
377
  </Teleport>
314
378
  </div>
@@ -319,6 +383,8 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
319
383
  import useOrg from '../composables/useOrg'
320
384
  import useVerification from '../composables/useVerification'
321
385
  import useRole from '../composables/useRole'
386
+ import useConsoleTier from '../composables/useConsoleTier'
387
+ import useThemePreference from '../composables/useThemePreference'
322
388
  import useUtils from '../composables/useUtils'
323
389
  import InvitationClientForm from './InvitationClientForm.vue'
324
390
  import ClientDetailForm from './ClientDetailForm.vue'
@@ -335,6 +401,10 @@ const { getOrganizationsWithSubscription, updateStatus } = useOrg()
335
401
  const { getVerifications, cancelUserInvitation } = useVerification()
336
402
  const { getCustomerSites: getCustomerSitesByOrgId } = useCustomerSite()
337
403
  const { getRoleById } = useRole()
404
+ // Owner decision 9. Only decides what is DRAWN - see `utils/console-tier.ts`.
405
+ const { isOwner, load: loadConsoleTier } = useConsoleTier()
406
+ // Only for the teleported menu - see the comment above the Teleport.
407
+ const { current: currentTheme } = useThemePreference()
338
408
  // The same helper the admin app's Organizations list uses to print a nature,
339
409
  // so both screens say the value the same way while both exist.
340
410
  const { formatNature } = useUtils()
@@ -378,9 +448,12 @@ const selectedClient = ref<any>(null)
378
448
  const subscriptionClient = ref<any>(null)
379
449
  const savedNote = ref('')
380
450
 
381
- /* suspend confirmation */
382
- const confirmSuspendItem = ref<any>(null)
383
- const confirmSuspendLoading = ref(false)
451
+ /* suspend / reactivate confirmation */
452
+ const confirmItem = ref<any>(null)
453
+ const confirmAction = ref<'active' | 'suspended'>('suspended')
454
+ const confirmLoading = ref(false)
455
+ const confirmError = ref('')
456
+ const noteIsError = ref(false)
384
457
 
385
458
  /* ── COMPUTED ── */
386
459
  const pageRange = computed(() => {
@@ -409,37 +482,35 @@ function parseTotalItems(pageRangeStr: string, fallback: number): number {
409
482
  return match ? Number(match[1]) : fallback
410
483
  }
411
484
 
412
- async function suspendClient(item: any) {
413
- try {
414
- await updateStatus(item._id, "suspended")
415
- closeMenu()
416
- await fetchData()
417
- } catch (err) {
418
- console.error(err)
419
- }
485
+ /**
486
+ * Say what went wrong, in the words the API used where it gave any.
487
+ *
488
+ * `updateStatus` is owner-gated on the server, so the refusal an ordinary
489
+ * staff member gets here is a real, expected answer - not a crash. It used to
490
+ * land in `console.error`, which meant pressing Suspend looked exactly like
491
+ * pressing nothing.
492
+ */
493
+ function apiMessage(err: any, fallback: string) {
494
+ return err?.response?._data?.message ?? err?.data?.message ?? fallback
420
495
  }
421
496
 
422
- async function activateClient(item: any) {
423
- try {
424
- await updateStatus(item._id, "active")
425
- closeMenu()
426
- await fetchData()
427
- } catch (err) {
428
- console.error(err)
429
- }
497
+ function showNote(text: string, isError = false) {
498
+ savedNote.value = text
499
+ noteIsError.value = isError
430
500
  }
431
501
 
432
502
  /* ── SUBSCRIPTION ── */
433
503
  function openSubscription(item: any) {
434
504
  if (!item?._id) return
435
505
  savedNote.value = ''
506
+ noteIsError.value = false
436
507
  subscriptionClient.value = item
437
508
  closeMenu()
438
509
  }
439
510
 
440
511
  async function onSubscriptionSaved(message: string) {
441
512
  subscriptionClient.value = null
442
- savedNote.value = message
513
+ showNote(message)
443
514
  // The list derives every subscription state from the record itself, so
444
515
  // re-reading it is all that is needed for the row to tell the truth again.
445
516
  await fetchData()
@@ -453,25 +524,57 @@ async function onSubscriptionSaved(message: string) {
453
524
  }
454
525
  }
455
526
 
456
- /* ── SUSPEND CONFIRMATION ── */
457
- function askSuspend(item: any) {
458
- confirmSuspendItem.value = item
527
+ /* ── SUSPEND / REACTIVATE CONFIRMATION ── */
528
+ function askConfirm(item: any, action: 'active' | 'suspended') {
529
+ if (!item?._id) return
530
+ confirmItem.value = item
531
+ confirmAction.value = action
532
+ confirmError.value = ''
459
533
  closeMenu()
460
534
  }
461
535
 
462
- function cancelSuspend() {
463
- if (confirmSuspendLoading.value) return
464
- confirmSuspendItem.value = null
536
+ function cancelConfirm() {
537
+ if (confirmLoading.value) return
538
+ confirmItem.value = null
539
+ confirmError.value = ''
465
540
  }
466
541
 
467
- async function confirmSuspend() {
468
- if (!confirmSuspendItem.value) return
469
- confirmSuspendLoading.value = true
542
+ async function runConfirm() {
543
+ const item = confirmItem.value
544
+ const action = confirmAction.value
545
+ if (!item?._id) return
546
+
547
+ confirmLoading.value = true
548
+ confirmError.value = ''
549
+
470
550
  try {
471
- await suspendClient(confirmSuspendItem.value)
472
- confirmSuspendItem.value = null
551
+ await updateStatus(item._id, action)
552
+ confirmItem.value = null
553
+
554
+ // The row's state is derived from the record, never held here, so
555
+ // re-reading the list is the whole update - `describeClientSubscription`
556
+ // then reports "Suspended" from the subscription status the endpoint
557
+ // writes alongside the organisation's. The client also moves to the tab
558
+ // that matches its new status, which is why it leaves this one.
559
+ await fetchData()
560
+
561
+ showNote(
562
+ action === 'suspended'
563
+ ? `${item.name} is suspended. Their staff, residents and guards can no longer sign in, and all their data is kept.`
564
+ : `${item.name} is active again. Their staff, residents and guards can sign in.`,
565
+ )
566
+ } catch (err: any) {
567
+ // Stays on the dialog. Closing it and dropping a banner would read as
568
+ // "done, with a note"; the action did not happen and the person is still
569
+ // standing in front of the decision.
570
+ confirmError.value = apiMessage(
571
+ err,
572
+ action === 'suspended'
573
+ ? 'Could not suspend this client. Only the Seven365 owner may suspend a client.'
574
+ : 'Could not reactivate this client. Only the Seven365 owner may reactivate a client.',
575
+ )
473
576
  } finally {
474
- confirmSuspendLoading.value = false
577
+ confirmLoading.value = false
475
578
  }
476
579
  }
477
580
 
@@ -574,8 +677,8 @@ function cancelView() {
574
677
  }
575
678
 
576
679
  async function submitClient() {
577
- // await update API
578
-
680
+ // `ClientDetailForm` has already saved by the time it emits - this closes the
681
+ // drawer and re-reads the row so the list shows what was saved.
579
682
  viewMode.value = 'table'
580
683
  fetchData()
581
684
  }
@@ -672,6 +775,9 @@ async function cancelInvite(item: any) {
672
775
  }
673
776
 
674
777
  onMounted(() => {
778
+ // Not awaited: the list is the screen, and the tier only adds or removes two
779
+ // menu items on a menu nobody has opened yet.
780
+ loadConsoleTier()
675
781
  fetchData()
676
782
  document.addEventListener('click', handleOutsideClick)
677
783
  document.addEventListener('scroll', closeMenu, true)
@@ -1170,4 +1276,40 @@ tbody td { vertical-align: middle; }
1170
1276
  }
1171
1277
  .btn-confirm-danger:hover:not(:disabled) { background: var(--err); }
1172
1278
  .btn-confirm-danger:disabled { opacity: 0.7; cursor: not-allowed; }
1279
+ /* Reactivating is an ordinary action, so it is not dressed as a destructive
1280
+ one. Same box as the danger button so the dialog does not jump between the
1281
+ two directions. */
1282
+ .btn-confirm {
1283
+ display: inline-flex;
1284
+ align-items: center;
1285
+ justify-content: center;
1286
+ gap: 6px;
1287
+ min-width: 78px;
1288
+ padding: 8px 16px;
1289
+ font-size: 13px;
1290
+ font-weight: 500;
1291
+ border: none;
1292
+ border-radius: 8px;
1293
+ background: var(--accent-strong);
1294
+ color: var(--on-accent-strong);
1295
+ cursor: pointer;
1296
+ }
1297
+ .btn-confirm:disabled { opacity: 0.7; cursor: not-allowed; }
1298
+ .btn-cancel:disabled { opacity: 0.7; cursor: not-allowed; }
1299
+ /* The refusal, on the dialog it belongs to. `--err` on `--err-bg`, not a bare
1300
+ red on the card. */
1301
+ .confirm-error {
1302
+ margin-bottom: 16px;
1303
+ padding: 9px 12px;
1304
+ border-radius: 8px;
1305
+ background: var(--err-bg);
1306
+ color: var(--err);
1307
+ font-size: 13px;
1308
+ font-weight: 500;
1309
+ line-height: 1.45;
1310
+ }
1311
+ .saved-note-error {
1312
+ background: var(--err-bg);
1313
+ color: var(--err);
1314
+ }
1173
1315
  </style>