@7365admin1/layer-common 3.2.8-staging.210 → 3.3.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,554 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 3.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - b92f73f: Remove a duplicate `:site` attribute on `HidReaderManagement.vue`.
8
+
9
+ `HidReaderForm` was given `:site` twice - `props.site` and then a bare `site`, which is
10
+ not a name that exists in that component's scope. Vite's Vue plugin treats a duplicate
11
+ attribute as a compile error, so every consuming application's `nuxt build` failed on
12
+ this file. The layer's own playground build does not import the component, which is why
13
+ it was not caught at publish time.
14
+
15
+ ## 3.3.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 078521d: Add a dedicated HID Cards module with PACS and CSN assignment, card enrollment
20
+ at an Amico reader, removal, and device-backed card status. HID Users keeps the
21
+ same card action, and the Card identification toggle now updates the reader
22
+ configuration as well as site metadata.
23
+ - f368275: Add reader capability and mode configuration, site-linked HID identities,
24
+ reader-profile physical card handling, and device-backed pagination for HID
25
+ users and access logs.
26
+ - 330e103: Draw icons with the redesign's Material Symbols Outlined font instead of Material Design Icons.
27
+
28
+ Call sites are unchanged - a component still asks for `mdi-close`. The Vuetify icon set
29
+ translates 233 names (the 204 used here plus Vuetify's own internal aliases) into the
30
+ Material Symbols ligature; anything unmapped still renders through Iconify as before, so no
31
+ icon can come out as a blank box. Every consuming application picks this up with no change
32
+ of its own.
33
+
34
+ Also in this release:
35
+
36
+ - `TableWithButton`, `ScheduleTaskAreaUpdateMoreAction` and `ScheduleTaskAreaFormDialog` are
37
+ **removed** - no repository in the organisation referenced them.
38
+ - The published tarball now carries only what a consumer loads (`files`), not the changesets,
39
+ CI workflows, tests and render harness.
40
+ - The check-out label in `VisitorsReportPreview` asked for a `--error` token that does not
41
+ exist and inherited its colour; it now uses `--err`.
42
+
43
+ - bb18a57: Make the Pending tab's invitation actions reachable, and say which state an invitation is really in
44
+
45
+ The row menu in the service-provider list was drawn only for provider rows, so a
46
+ pending invitation offered no actions at all — including the cancel that was
47
+ already wired up behind it. Invitation rows now carry Cancel, Resend and Delete,
48
+ each behind a confirmation that names the company and the site, and Resend is
49
+ disabled with the reason while Seven365 has not approved the invitation yet.
50
+
51
+ Every invitation also used to read "Pending" whatever had happened to it. Rows
52
+ now show the state they are actually in — Waiting for Seven365, Waiting for the
53
+ provider, Not approved, Declined, Cancelled, Expired — in those words, with
54
+ Seven365's reason shown underneath a rejected one.
55
+
56
+ Also here: a new ServiceProviderInvitationPrompt, which is how an existing
57
+ provider answers an invitation from inside the app they already use, with no
58
+ sign-up and no one-time code.
59
+
60
+ Three contrast fixes found while rendering these screens: the list's tab strip
61
+ was a hardcoded light grey that turned into a white slab in dark mode and took
62
+ the tab labels with it; the service type column read the raw stored slug
63
+ ("security_agency") instead of "Security"; and `on-warning`, `on-info` and
64
+ dark's `on-error` are now set explicitly, because Vuetify derives white for them
65
+ and that measures 2.37:1, 3.12:1 and 3.60:1 on those fills — every filled
66
+ warning, info and error chip, alert and button in the product.
67
+
68
+ ### Patch Changes
69
+
70
+ - 4ee5fb9: Fix the attach-file icon rendering too faint in `FileInputWithList.vue`.
71
+
72
+ The `mdi-paperclip` prepend icon was drawing at Vuetify's default
73
+ `.v-input__prepend > .v-icon { opacity: var(--v-medium-emphasis-opacity) }`
74
+ (0.64) - the same field-icon dimming roughly 200 other call sites across this
75
+ package intentionally rely on (see `utils/theme.ts`), so this is fixed only
76
+ inside `FileInputWithList.vue`, not in the shared token.
77
+
78
+ The icon now renders at full opacity in `--text2`, the same colour
79
+ `.screen-icon-btn` already uses, so it reads at the same weight as a sibling
80
+ icon drawn outside a Vuetify field (which was never subject to this opacity
81
+ rule in the first place). No other icon in this component sits inside
82
+ `.v-input__prepend`/`.v-input__append`, so nothing else here was affected.
83
+
84
+ - 84d6a1f: Top bar breadcrumb: name the page, not the folder in front of the context
85
+ parameter. Every screen of the organization application read "Org", because the
86
+ route name alone cannot tell that `org` in `/org/:organization/...` is a folder
87
+ and not a parameter. The rule moves to `utils/breadcrumb.ts` with unit tests
88
+ covering each application's route shapes; the site applications are unchanged.
89
+ - dd0849e: Draw the camera wall from the gated wall endpoint, and show the server's own
90
+ reason on a tile.
91
+
92
+ `CameraWall` fetched its cameras from `GET /api/site-cameras` - the Settings
93
+ panel's paginated CRUD list, which took its site from an optional query
94
+ parameter and checked nothing about the caller. Any signed-in user from any
95
+ organisation could read every camera in the estate through it, `host` and
96
+ `username` included.
97
+
98
+ It now calls `GET /api/site-cameras/site/:siteId/wall`, which already exists,
99
+ which the React Native monitoring app was built against, and which is
100
+ authorised server-side: the caller must be a member of the site, of its owning
101
+ organisation, or work for an organisation actively engaged to serve it. That
102
+ endpoint also returns `type: "ip"` cameras only and each camera's capability
103
+ descriptor, so a wall cannot be handed an ANPR unit and a tile can explain
104
+ itself.
105
+
106
+ Two consequences worth stating:
107
+
108
+ - **The pager is gone.** The wall endpoint returns the site's cameras in one
109
+ answer. Paging a video wall was an artefact of borrowing the CRUD list.
110
+ - **A tile now prefers the server's `unavailableReason`** over the reason it
111
+ used to work out itself. The server knows things the browser cannot - chiefly
112
+ "No recorder is configured for this camera's relay", which is the true answer
113
+ for every camera in the estate until the API host is configured, and which the
114
+ wall used to replace with its own guess. Offline still beats everything, since
115
+ it explains every tile at once.
116
+
117
+ No permission gate was invented here. Each application still gates its own menu
118
+ entry; this component draws what it is given, and the server decides.
119
+
120
+ Two smaller things in the same area:
121
+
122
+ - **A refused wall now says so.** The catch-all message told every failure to
123
+ "check your connection", which sends somebody who simply may not see that
124
+ site off to debug their wifi. A 401/403/404 now reads "You do not have access
125
+ to this site's cameras." The server answers "not yours" and "does not exist"
126
+ identically, so this wording does not distinguish them either.
127
+ - **`middleware/member.ts` is removed.** It read a cookie into an unused
128
+ variable and did nothing else - a file named like a membership gate that was
129
+ not one. No page in any of the eleven web apps referenced it. The real check
130
+ is `plugins/secure-member.client.ts`, driven by `memberOnly` page meta, which
131
+ is untouched.
132
+
133
+ - f3fb808: Rewrite the camera wall's detail panel for the people who read it, and stop
134
+ repeating the same sentence four times.
135
+
136
+ The panel under the tiles was written for a developer. On the live wall it
137
+ read:
138
+
139
+ > BBQ AREA
140
+ > The video page loaded, but this browser cannot confirm a picture is arriving.
141
+ > The player belongs to the video service and a browser cannot look inside it.
142
+ > Judge the tile by what you can see, and report a blank or frozen picture.
143
+ > ● Recorder responding Last picture this server received: none yet
144
+ > Firmware and device clock are not available: this recorder is reachable over
145
+ > video only.
146
+
147
+ Every one of those statements is true, and the screen was still wrong: it
148
+ explained cross-origin isolation to a guard, repeated the camera's name the
149
+ tile already showed, put a green light beside the words "none yet", and printed
150
+ the same reason on four capability cards in a row.
151
+
152
+ Changes, all copy and layout — **no behaviour, no capability gating and no
153
+ permission check is touched**:
154
+
155
+ - **The badge reads "Not confirmed"**, not "Not verified". "Not verified"
156
+ describes a check we failed to make; "Not confirmed" describes the picture,
157
+ which is the thing the reader cares about. Same claim, and the wall still
158
+ refuses to say "Live" until the video service reports a decoded frame.
159
+ - **The sentence behind it is now an instruction**: "This page cannot confirm
160
+ the picture is arriving, so check the tile yourself. If it is blank or frozen,
161
+ report the camera." Why the software cannot confirm it is our problem, and it
162
+ has been removed from the product.
163
+ - **The camera's name is no longer a heading of its own.** It is the subject of
164
+ the status line — "BBQ AREA · Recorder responding" — so a reader on a 3x3 wall
165
+ still knows which tile the panel describes, without a second caption under the
166
+ first one.
167
+ - **"Last picture" is drawn only when there has been one.** A green "Recorder
168
+ responding" beside "none yet" read as a contradiction. They were never in
169
+ conflict, but a screen that has to be explained is wrong. The tile's own
170
+ health line has always behaved this way; the panel now matches it.
171
+ - **The firmware-and-device-clock sentence is gone**, from the panel and from
172
+ the tile's tooltip. This screen never showed either value, so explaining their
173
+ absence gave the reader nothing to do. The server still sends
174
+ `detailUnavailableReason`; nothing draws it.
175
+ - **One reason for the group instead of four copies.** When every switched-off
176
+ control is off for the same reason, that reason and its next step are stated
177
+ once above the row and each card keeps only its title and its state. When the
178
+ reasons differ — "the recorder is unreachable" and "this camera cannot move"
179
+ are two problems with two different people to ask — the group note is refused
180
+ and each card carries its own again. New rule 7, in `camera-wall.ts`, with
181
+ four tests.
182
+ - Shorter next-step and no-signal wording, and a subject on the recorder's
183
+ status labels ("Recorder not checked yet" rather than "Health not checked
184
+ yet").
185
+
186
+ Rules 3 and 6 are intact: the server's own sentence is still shown verbatim and
187
+ never rewritten, and the recorder's state is still a separate fact from the
188
+ tile's badge.
189
+
190
+ - e177e9e: Mark the selected camera on the wall, and stop telling the reader to raise a
191
+ ticket without saying what for.
192
+
193
+ Two things the owner found on the live wall after the last copy pass. Both are
194
+ copy and presentation only — **no behaviour, no capability gating, no permission
195
+ check and no request is changed.**
196
+
197
+ **1. The camera's name was still printed twice.**
198
+
199
+ The tile said `BBQ AREA` and the panel underneath said it again. The last pass
200
+ merged the panel's heading into its status line, which changed the layout and
201
+ left the repetition exactly where it was.
202
+
203
+ The name was there to answer "which of these nine tiles is this panel about?".
204
+ That is a real question and a name is the wrong answer to it — it makes the
205
+ reader scan the wall for a matching caption. So:
206
+
207
+ - **the selected tile is now visibly selected**: a 3 px accent ring with a scrim
208
+ hairline inside it, so the ring holds against a bright picture as well as a
209
+ dark one, plus the accent underline on the name chip that the toolbar already
210
+ uses to mark a selected tool. Both read at 1x1, 2x2 and 3x3;
211
+ - **the camera's name is removed from the panel entirely**. The panel opens on
212
+ the recorder's state — "● Recorder responding";
213
+ - `aria-current` marks the same tile for anyone not looking at the ring.
214
+
215
+ The highlight had never worked in the most common case, which is why the name
216
+ was load-bearing: it was bound to the raw clicked id, and before anyone clicks —
217
+ or after paging — nothing on the wall was marked while the panel was already
218
+ describing the first tile. It is now bound to the tile the panel actually
219
+ resolves to, so the two can never disagree.
220
+
221
+ **The panel can never be about a tile that is off screen**, so it never needs to
222
+ name one: the focused camera is resolved out of the current PAGE, so paging away
223
+ from a selection re-resolves to the first tile of the page in front of you and
224
+ the ring follows. Paging back restores the original selection. Extracted as
225
+ `focusedCamera()` in `camera-wall.ts` with four tests, including the 5-cameras
226
+ -across-2-pages case.
227
+
228
+ **2. "Ask your Seven365 administrator" was a dead end.**
229
+
230
+ It was the whole of the advice under every switched-off control, and it tells
231
+ the administrator nothing and a property manager only to go and wait. The screen
232
+ never said what the request should ask for.
233
+
234
+ The advice now names the two changes that are actually missing, because they are
235
+ two different jobs for two different people: **the site's network has to let our
236
+ server reach the camera recorder**, and **that recorder then has to be added to
237
+ the server's configuration**. Both are stated in plain words, with who arranges
238
+ them, and with the fact that neither is switchable from this page.
239
+
240
+ No environment variable name appears in any of it — those belong in
241
+ `iservice365-core/docs/camera-integration-config.md`, not on a property
242
+ manager's screen — and there is a test that keeps it that way. The other reason
243
+ codes (`device-http-disabled`, `device-http-unreachable`, `device-http-locked-out`,
244
+ `control-not-enabled`, `no-recorder-configured`, and the unknown-code fallback)
245
+ get the same treatment; the Site Settings ones already said what to do and are
246
+ unchanged.
247
+
248
+ The truthful-status rules are untouched: the wall still refuses to say "Live"
249
+ until the video service reports a decoded frame, and the recorder's state is
250
+ still a separate fact from the tile's badge.
251
+
252
+ - cf29add: Show each client's nature of business on the super-admin Client List, so the
253
+ one screen says who a client is, what kind of business they are, how many sites
254
+ they have and where their subscription stands.
255
+
256
+ The Client List and the admin app's Organizations list are the same screen for
257
+ the same job, and the Client List is the one that stays. Organizations showed
258
+ one thing the Client List did not: the organisation's **nature** — Property
259
+ Management Agency, Security Agency, Cleaning Services, and so on. That is what
260
+ a client actually _is_, and the list had no way to say it.
261
+
262
+ The value was already arriving. `GET /api/organizations/v2/orgs/subscriptions`
263
+ has always projected `nature`; the screen simply never read it. So this is one
264
+ column, no backend change, printed with the same `formatNature` helper the
265
+ Organizations list uses, so both screens say a nature the same way while both
266
+ exist.
267
+
268
+ Two changes pay for the width, because the table was already 1389px in a
269
+ 1386px viewport and a new column would have pushed it to 1405px:
270
+
271
+ - **The email moved under the organisation name.** It identifies the same
272
+ client the name does and had a full column to itself.
273
+ - **The Status column is gone.** The table is fetched with the status the
274
+ chosen tab asks for, so on the Active tab every row said "active" and on
275
+ Suspended every row said "suspended" — a column that could only ever repeat
276
+ the tab the reader was standing on. The status is still on the screen: it is
277
+ the tab, and the row menu offers Suspend or Activate accordingly.
278
+
279
+ Measured after the change: **1332px at a 1386px viewport and 1386px at 1440px,
280
+ with no horizontal overflow at either** — the first time this table has fitted
281
+ its own page.
282
+
283
+ A client whose record carries no nature reads "Not recorded" rather than
284
+ leaving a blank cell. `nature` is a required enum on the organisation model, so
285
+ that is defensive cover for older records, not an expected state.
286
+
287
+ No filter was added for it. The list's `$match` has no `nature` term, so the
288
+ only filter that could be built here would filter the ten rows on screen and
289
+ silently ignore the rest — the same kind of control that was removed from this
290
+ screen for pretending to work. A real one needs the aggregation to accept
291
+ `nature` first.
292
+
293
+ - e100fc9: Make the super-admin Client List say what a client's subscription actually is,
294
+ instead of printing "-".
295
+
296
+ `ClientMain.vue` showed a dash in Plan Type, Billing Cycle, Subscription Start
297
+ and Subscription End for every client with no subscription document - which is
298
+ most of them. Four dashes read as a broken screen. It is one ordinary state:
299
+ nobody has set that client up yet.
300
+
301
+ `utils/client-subscription.ts` derives the state from the row the endpoint
302
+ actually returns and never claims more than the record proves:
303
+
304
+ | Record says | Screen says |
305
+ | ------------------------------ | --------------------------- |
306
+ | no subscription document | No subscription set up |
307
+ | running | Active |
308
+ | `billingMode: "paid"` | Active (paying) |
309
+ | `billingMode: "complimentary"` | Complimentary (not charged) |
310
+ | `status: "suspended"` | Suspended |
311
+ | `status` ended / canceled | Ended |
312
+ | end date already passed | Ended (needs attention) |
313
+
314
+ Nothing sends a charging arrangement today, so a live record reads as plain
315
+ "Active" - not "paying", because nothing in the data proves that.
316
+
317
+ Removed as dead or misleading:
318
+
319
+ - The "Filter by plan type" dropdown. It was fed organization _nature_ values
320
+ (`security_agency`, `cleaning_services`, ...) and sent as `type`, which the
321
+ aggregation applies to `subscription.type` - whose only legal values are
322
+ `organization` and `affiliate`. Every option but "All" returned zero rows.
323
+ - `planTypeLabel`'s `sub.paidType` branch (the field exists in no repo) and its
324
+ `sub.description` branch (not in the endpoint's projection).
325
+ - `getSubscriptionByOrgId`, destructured and never called.
326
+
327
+ `ClientDetailForm.vue`'s Subscription Information section was six dropdowns and
328
+ two date boxes that looked editable and saved nothing - the plan list was two
329
+ invented names, and Start/End were always blank because nothing ever loaded
330
+ them. It now shows the same derived values as the list, read-only. The write
331
+ path is unchanged: Submit still saves the organisation name and contact.
332
+
333
+ Two smaller display fixes on the list: the two date column headers are "Start
334
+ Date" and "End Date" rather than "Subscription Start"/"Subscription End", and
335
+ the Sites badge is the count rather than "3 Sites" under a "Sites" header
336
+ (which also used to read "1 Sites"). Both were needed to keep the Actions
337
+ column on screen at 1440px.
338
+
339
+ - c256755: Let a Seven365 staff member actually set up and edit a client's subscription
340
+ from the super-admin Client List.
341
+
342
+ Phase 1 made that list tell the truth: a client nobody had set up read "No
343
+ subscription set up" instead of four dashes. There was still nothing anywhere
344
+ that could set one up. The API endpoints now exist
345
+ (`POST`/`PUT`/`GET /api/subscriptions/console/:id`, staff-only, `:id` being the
346
+ client's organisation), so this is the screen in front of them.
347
+
348
+ **What a person can now do**
349
+
350
+ - From a row that has no subscription: **Set up subscription**, straight off
351
+ the row.
352
+ - From the row menu, or from the client's own drawer: **Edit subscription** -
353
+ plan, start date, end date, and whether the client is complimentary or
354
+ paying.
355
+ - The list re-reads itself after a save, so the row shows the new state
356
+ immediately, using the same Phase 1 derivation (`utils/client-subscription.ts`).
357
+
358
+ **Four questions, and nothing else asked**
359
+
360
+ Plan, charging (complimentary or paying), start date, end date. Everything
361
+ else is worked out: a plan is priced per site, per month, so the form shows the
362
+ per-site price, this client's live site count and the monthly value that comes
363
+ out of the two - already multiplied, with "not charged - complimentary" beside
364
+ it when the client is free. A client with no sites yet is 0 and is said so
365
+ plainly rather than refused.
366
+
367
+ **Complimentary is the default**, because every client today is complimentary.
368
+ It is a real arrangement with real dates Seven365 monitors, not an absence of
369
+ one, and it is exactly as quick to set as a paying subscription.
370
+
371
+ **Refused in the form, before the request goes out** - in the API's own words,
372
+ so the two can never disagree: no plan chosen; a plan that does not exist or is
373
+ no longer active (named); no end date ("Every client has one, free or not."); an
374
+ end date on or before the start date; an unreadable date; a complimentary
375
+ subscription carrying a price. When the server refuses anyway, its message is
376
+ what the screen shows.
377
+
378
+ **Deliberately not here:** no card field, no payment step, no gateway - Red Dot
379
+ takes payment, and none of it belongs on a staff screen. No suspend and no
380
+ reactivate: that is Phase 3, and the API's edit endpoint does not accept a
381
+ status, so there is no control here that could set one. The drawer's
382
+ Subscription Information stays read-only; the button beside it opens this form.
383
+
384
+ `useSubscription` gains `getConsoleSubscriptionByOrgId`,
385
+ `setUpConsoleSubscription` and `updateConsoleSubscription`. Nothing existing
386
+ changed shape.
387
+
388
+ - 6b008c4: Split the dark theme's `primary` into a foreground colour and a fill, so text
389
+ using it is readable again.
390
+
391
+ Dark `primary` was set to the brand navy `#17506F` so the navigation drawer
392
+ would read as a surface. That is the right value for a fill — a white label on
393
+ it measures 8.70:1 — but Vuetify emits `.text-primary` from the same token, so
394
+ every `class="text-primary"` sentence and every `color="primary"` icon, spinner
395
+ and text button rendered navy on a dark background:
396
+
397
+ | | before | now |
398
+ | ----------------------------------------------- | ------ | ---------- |
399
+ | `primary` as text on the dark page `#0E1319` | 2.14:1 | **6.40:1** |
400
+ | `primary` as text on a dark card `#1B242F` | 1.80:1 | **5.38:1** |
401
+ | `primary` as text on `surface-bright` `#26313E` | 1.52:1 | **4.53:1** |
402
+
403
+ Dark `primary` is now `#5B9BE0`. That is not a new colour: it is the blue the
404
+ camera wall already draws its accents in (`--vms-accent`), so the two dark
405
+ surfaces in this product agree on one blue.
406
+
407
+ The navigation drawer no longer depends on `primary`. Both themes gain a
408
+ `brand-surface` colour — `#042134` light (identical to light `primary`, so
409
+ light mode renders exactly as before) and `#17506F` dark — and
410
+ `Layout/NavigationDrawer.vue` is painted with it. Its white label still reads
411
+ 16.51:1 light and 8.70:1 dark. `floating` was dropped from the drawer so it
412
+ draws its own edge: the fill is 2.14:1 against the dark page, under the 3:1 a
413
+ boundary wants, and the border at `border-opacity` 0.35 is 3.22:1.
414
+
415
+ Where `primary` is still used as a fill, Vuetify derives `on-primary` from it
416
+ and picks black, which reads at 7.21:1. What a light `primary` cannot carry is
417
+ a hardcoded white label, and a handful of app-side sites still set one — see
418
+ the pull request for the list.
419
+
420
+ The light theme is unchanged. The colours moved to `utils/theme.ts` and
421
+ `utils/theme.test.ts` now measures them, so a token cannot be moved for one use
422
+ and quietly broken for the other again.
423
+
424
+ - 626d1d7: Dashboard and filled-primary surfaces now follow the theme in dark mode.
425
+
426
+ - `DashboardMain` painted its panels, list rows and text with fixed hex values,
427
+ so the ACTIVE PATROL / STAFF STATUS / Feedbacks cards rendered white on the
428
+ dark page and the heading and KPI figures were dark grey on near-black
429
+ (1.19:1). They now use the theme's surface and foreground tokens.
430
+ - `DARK_THEME` had no `on-primary`, so Vuetify derived white for every filled
431
+ `primary` button, chip, alert, tab bar and snackbar - 2.91:1. It is now set
432
+ explicitly and measures 6.40:1.
433
+
434
+ The print / PDF / Word export styles are unchanged and stay ink-on-white.
435
+
436
+ - a39ce7f: Fix four hard-coded route names that do not exist in the apps that render them.
437
+
438
+ This layer is extended by apps whose pages sit at different depths, so a route
439
+ name written literally here is correct in some consumers and unknown in the
440
+ rest - and `router.push({ name })` on an unknown name throws, taking the click
441
+ (and, in a nav list, its neighbours) with it. Route names are now resolved
442
+ against the running app's own router via `utils/route-name.ts`
443
+ (`pickRouteName`), which returns the first candidate the app actually has, or
444
+ `null` so the caller can hide the control rather than navigate somewhere wrong.
445
+
446
+ - `QrTemplate/PrintDialog.vue` pushed `keys-visitor-pass-add`, which no
447
+ consumer has; both apps that render this dialog nest the page under
448
+ `[org]/[site]`. It also sent `site` as a query parameter where the
449
+ destination reads `org` and `site` as path parameters, so neither was
450
+ filled. Generating a pass from the print dialog threw.
451
+ - `ServiceProviderMain.vue` always rendered a Billing button
452
+ (`showBillingButton` defaults to `true`) pointing at
453
+ `org-site-service-provider-mgmt-billing`, a page only
454
+ web-app-property-management has. The button now renders only where the page
455
+ exists; property-management is unchanged.
456
+ - `require-customer.vue` used `org-organizations-customers-add`; the org app's
457
+ page is `pages/org/[organization]/customers/add.vue`, whose name is
458
+ `org-organization-customers-add` - singular. The only way off that dead-end
459
+ page threw. Every other app already took the cross-app URL branch and was
460
+ unaffected.
461
+ - `WorkOrder/Main.vue` used `work-order-details`, which no consumer has; the
462
+ detail screen is `org-site-work-orders-id`. No app currently renders this
463
+ component, so nothing was user-visible.
464
+
465
+ `require-organization-membership.vue` was reported alongside these and is NOT
466
+ changed: `organizations-create` is a real route in web-app-org, and that branch
467
+ only runs there.
468
+
469
+ - ae59ec4: Say when the Service Providers list and the Attendance Settings dialog failed to load, instead of showing an empty screen
470
+
471
+ Both surfaces treated a failed request as "there is nothing here". The Service Providers table defaulted a bad response to `[]` and drew the standard "No data available" row; the Attendance Settings dialog read its fields off whatever came back and, when that was not an object, simply opened with geofencing off and every field blank. Neither told the operator anything had gone wrong.
472
+
473
+ The table now renders the shared `DashboardEmptyState` in its error tone when the last load failed, and the dialog shows an alert saying what is on screen is not what is saved.
474
+
475
+ - 82d0091: Send the site with HID reader discovery. `HidReaderForm` now receives the site
476
+ from `HidReaderManagement` (which already has it from the page route) and
477
+ includes it in the `readers/discover` request, so the endpoint can authorize the
478
+ caller against that site. The request falls back to the old body once, and only
479
+ on the unknown-field validation error, so discovery keeps working against an API
480
+ that has not picked the change up yet.
481
+ - c887977: Add a Notification Settings screen, shared by every web app.
482
+
483
+ A person can now choose, per module and per delivery route, what they hear
484
+ about: in the app, on their phone, or by email. The screen only lists modules
485
+ their role actually gives them, and a route a module cannot use is shown dimmed
486
+ with the reason in plain words rather than hidden.
487
+
488
+ Modules are collapsed by default with a one-line summary of what is on, so
489
+ somebody with many modules gets a short list instead of thirty switches. There
490
+ is a master control for everything and one per module.
491
+
492
+ The choices are enforced where notifications are sent, in `@7365admin1/core` and
493
+ in the API, so a switch here actually stops the message.
494
+
495
+ - 5089b71: Group the sidebar's sections and give the list screens the design's page
496
+ scaffold.
497
+
498
+ - `withSections()` walked each application's menu in the order it was given and
499
+ printed a heading whenever the section changed, so Property Management drew
500
+ SERVICE DESK, PROPERTY and SECURITY OPERATIONS **twice each**. Items are now
501
+ grouped under one heading per section, in the order the design lists them,
502
+ with the application's own order kept inside each section. No menu item is
503
+ added, hidden, renamed, re-routed or re-gated.
504
+ - `ServiceProviderMain`, `FeedbackMain`, `InvitationMain` and `WorkOrder/Main`
505
+ had no page title, put their primary action top-left, floated the search
506
+ outside the card, and stacked the refresh/pager on a bar above the tabs. They
507
+ now use `PageHeader` / `AppCard` / `CardToolbar` / `AppButton` / `AppField`:
508
+ a 22px title with the action on the right, then one in-card row with the tabs
509
+ on the left and search, count and pager on the right.
510
+ - `SwitchContext` and the rail's application name print in full instead of
511
+ truncating to "Seventh Cond…" / "Property Managem…".
512
+ - The security dashboard's `ACTIVE PATROL` / `STAFF STATUS` panel headings are
513
+ written the way the design writes them, matching their sibling panels.
514
+
515
+ Placement and wording only: the same buttons open the same dialogs under the
516
+ same permissions, the same tabs load the same lists, and the tables, columns,
517
+ filters and row menus are untouched.
518
+
519
+ - 8813b26: Work Orders and Feedbacks: say why the list is empty instead of quoting an
520
+ internal id. A service-provider account viewing a site its company is not linked
521
+ to got an empty table and the toast "Service provider with siteId
522
+ 68f0...dadada not found" - an internal sentence, naming no action, on a screen
523
+ that then looked broken. The sidebar offers every site in the organisation, not
524
+ only the ones the account covers, so this is an ordinary mis-click rather than a
525
+ fault. `findServiceProviderIdBySite` still refuses (returning null would drop the
526
+ provider filter and show another provider's work orders) but now refuses with a
527
+ sentence the reader can act on, and no longer dies with a TypeError when the
528
+ account carries no provider list at all. Unit tests added in `utils/data.test.ts`.
529
+ Affects the six service-provider applications that call it: security, hygiene,
530
+ landscape, mechanical-electrical, pest-control and pool-mgmt.
531
+ - 309d0e3: Fix the duplicate `:headers` binding on `<v-data-table>` in `TableMain.vue`
532
+ that broke every consuming app's build.
533
+
534
+ The merge of `staging` into `feature/DV-0119` kept both sides' `:headers`
535
+ line: the branch's older `:headers="headers"` and staging's newer
536
+ `:headers="tableHeaders"`. Vue's template compiler rejects a duplicate
537
+ attribute outright ("Duplicate attribute."), so any app that compiles this
538
+ component fails to build. This package's own `yarn build` only builds
539
+ `.playground`, which never imports `TableMain.vue`, so the fault published
540
+ as `3.2.8-staging.201` and surfaced only in the eleven web apps.
541
+
542
+ `:headers="tableHeaders"` is the correct one and is kept.
543
+ `tableHeaders` returns `props.headers` unchanged unless a caller has put
544
+ `weight` on a column, so it is a strict superset of the old binding - no
545
+ caller behaviour changes, and the weighted-column support added in
546
+ "Bring the shared table card up to the design's table spec" stays working.
547
+
548
+ Also adds `test/sfc-compile.test.mjs`, which compiles every `.vue` template in
549
+ the package and fails on any template syntax error. It fails on the broken
550
+ file and passes on the fixed one.
551
+
3
552
  ## 3.2.7
4
553
 
5
554
  ### Patch Changes
@@ -616,7 +616,7 @@ async function openPermissionDialog(category: THidPermissionCategory | "intercom
616
616
  page: 1,
617
617
  limit: 500,
618
618
  });
619
- permissionCandidates.value = response.items ?? response.data?.items ?? [];
619
+ permissionCandidates.value = response.items || [];
620
620
  selectedPermissionIds.value = new Set(
621
621
  permissionCandidates.value
622
622
  .filter((candidate) => category === "intercom" ? candidate.intercom : candidate.selected)
@@ -936,7 +936,7 @@ async function loadSubjectCandidates() {
936
936
  page: 1,
937
937
  limit: 500,
938
938
  });
939
- permissionCandidates.value = response.items ?? response.data?.items ?? [];
939
+ permissionCandidates.value = response.items || [];
940
940
  } finally {
941
941
  loadingSubjects.value = false;
942
942
  }
@@ -80,18 +80,11 @@ type HidVisitorQrData = {
80
80
  type HidVisitorQrResponse = HidVisitorQrData | { data: HidVisitorQrData };
81
81
 
82
82
  type HidPermissionListResponse = {
83
- items?: THidPermissionCandidate[];
84
- page?: number;
85
- pages?: number;
86
- pageRange?: string;
87
- limit?: number;
88
- data?: {
89
- items?: THidPermissionCandidate[];
90
- page?: number;
91
- pages?: number;
92
- pageRange?: string;
93
- limit?: number;
94
- };
83
+ items: THidPermissionCandidate[];
84
+ page: number;
85
+ pages: number;
86
+ pageRange: string;
87
+ limit: number;
95
88
  };
96
89
 
97
90
  type HidFacialEnrollmentResult = {
@@ -28,66 +28,9 @@ export default function usePromoCode() {
28
28
  });
29
29
  }
30
30
 
31
- /** One record, by its id. The console's detail screen opens on this. */
32
- function getById(id: string) {
33
- return useNuxtApp().$api<Record<string, any>>(
34
- `/api/promo-codes/id/${id}`,
35
- {
36
- method: "GET",
37
- }
38
- );
39
- }
40
-
41
- /**
42
- * Edit a code.
43
- *
44
- * `code` is NOT sent. It is immutable on the server - a subscription records
45
- * the promo code it was bought with as text, so renaming one rewrites every
46
- * invoice that already quotes it. The API accepts a `code` key and ignores
47
- * it; leaving it out of the body means the screen can never look as though
48
- * it changed something it did not.
49
- */
50
- function update(id: string, value: Partial<TPromoCode>) {
51
- const { code, ...body } = value as Record<string, any>;
52
-
53
- return useNuxtApp().$api<Record<string, any>>(`/api/promo-codes/${id}`, {
54
- method: "PUT",
55
- body,
56
- });
57
- }
58
-
59
- /** Turn a code off, or back on. The only two states staff may set. */
60
- function updateStatus(id: string, status: "active" | "disabled") {
61
- return useNuxtApp().$api<Record<string, any>>(
62
- `/api/promo-codes/${id}/status`,
63
- {
64
- method: "PATCH",
65
- body: { status },
66
- }
67
- );
68
- }
69
-
70
- /**
71
- * Remove a code from the console.
72
- *
73
- * A SOFT delete on the server: the record stays and is marked disabled and
74
- * deleted, because subscriptions and invoices quote the code they were
75
- * bought with. It leaves the list and stops being redeemable; it is not
76
- * erased.
77
- */
78
- function remove(id: string) {
79
- return useNuxtApp().$api<Record<string, any>>(`/api/promo-codes/${id}`, {
80
- method: "DELETE",
81
- });
82
- }
83
-
84
31
  return {
85
32
  add,
86
33
  getPromoCodes,
87
34
  getByCode,
88
- getById,
89
- update,
90
- updateStatus,
91
- remove,
92
35
  };
93
36
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.8-staging.210",
5
+ "version": "3.3.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.",
@@ -1,437 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { test } from "node:test";
3
-
4
- import {
5
- promoExpiryDate,
6
- promoCodeFormValues,
7
- promoCodeUpdatePayload,
8
- validatePromoCodeEdit,
9
- promoCodeApiError,
10
- } from "./promo-code-form.ts";
11
-
12
- /**
13
- * THE PROMO CODE EDITOR AND THE THREE WRITE CALLS BEHIND IT.
14
- *
15
- * Two halves, both pinned here:
16
- *
17
- * 1. the rules the edit form applies before it sends anything - taken from
18
- * the MERGED backend (`promoCodeUpdateSchema` in `@7365admin1/core`), not
19
- * from a guess about it;
20
- * 2. what `usePromoCode()` actually puts on the wire. The composable is three
21
- * `$api` calls, and the thing that can silently be wrong about it is the
22
- * verb, the path or the body - a `PUT` to the wrong path, or a body that
23
- * carries `code` and so looks as though it renamed a code it cannot.
24
- *
25
- * The composable reaches for `useNuxtApp()`, which only exists inside a Nuxt
26
- * app, so one stub stands in for it and records the call.
27
- */
28
-
29
- const NOW = new Date(2026, 7, 20, 12, 0, 0); // 20 Aug 2026, midday
30
-
31
- // ---------------------------------------------------------------------------
32
- // The expiry rule
33
- // ---------------------------------------------------------------------------
34
-
35
- test("an expiry date is read as the END of its day, in both stored formats", () => {
36
- // A code dated today is good for all of today, so a check at midday passes.
37
- const usFormat = promoExpiryDate("08/20/2026");
38
- assert.ok(usFormat);
39
- assert.equal(usFormat!.getHours(), 23);
40
- assert.ok(usFormat!.getTime() > NOW.getTime());
41
-
42
- const iso = promoExpiryDate("2026-08-20");
43
- assert.ok(iso);
44
- assert.equal(iso!.getTime(), usFormat!.getTime());
45
- });
46
-
47
- test("a date that is not a date is no expiry at all, never an Invalid Date", () => {
48
- assert.equal(promoExpiryDate(""), null);
49
- assert.equal(promoExpiryDate(null), null);
50
- assert.equal(promoExpiryDate("whenever"), null);
51
- // Month 13 rolls into the next year in JavaScript rather than failing, so
52
- // it is caught explicitly - otherwise 13/01/2026 would read as Jan 2027.
53
- assert.equal(promoExpiryDate("13/01/2026"), null);
54
- });
55
-
56
- // ---------------------------------------------------------------------------
57
- // Loading a record into the form, and building the body back out
58
- // ---------------------------------------------------------------------------
59
-
60
- test("the form loads from the record and does NOT carry the code", () => {
61
- const values = promoCodeFormValues({
62
- _id: "652f1a2b3c4d5e6f70819293",
63
- code: "WELCOME2026",
64
- description: "Launch offer",
65
- type: "fixed",
66
- fixed_rate: 12,
67
- expiresAt: "12/31/2026",
68
- status: "active",
69
- });
70
-
71
- assert.equal((values as any).code, undefined);
72
- assert.deepEqual(values, {
73
- description: "Launch offer",
74
- type: "fixed",
75
- fixed_rate: 12,
76
- expiresAt: "12/31/2026",
77
- tiers: [],
78
- });
79
- });
80
-
81
- test("a tiered record keeps its bands; a fixed one is given none", () => {
82
- const tiered = promoCodeFormValues({
83
- type: "tiered",
84
- tiers: [{ min: 1, max: 10, price: 9 }, { min: 11, max: 0, price: 7 }],
85
- });
86
- assert.equal(tiered.tiers.length, 2);
87
- assert.deepEqual(tiered.tiers[1], { min: 11, max: 0, price: 7 });
88
-
89
- // A code stored as fixed but carrying leftover bands must not send them:
90
- // `promoCodeUpdateSchema` FORBIDS `tiers` on a fixed code and would 400.
91
- const fixed = promoCodeFormValues({
92
- type: "fixed",
93
- fixed_rate: 5,
94
- tiers: [{ min: 1, max: 10, price: 9 }],
95
- });
96
- assert.deepEqual(fixed.tiers, []);
97
- });
98
-
99
- test("an empty record gives a usable, valid, fixed-price form", () => {
100
- const values = promoCodeFormValues(null);
101
- assert.deepEqual(values, {
102
- description: "",
103
- type: "fixed",
104
- fixed_rate: 0,
105
- expiresAt: "",
106
- tiers: [],
107
- });
108
- assert.equal(validatePromoCodeEdit(promoCodeUpdatePayload(values), NOW), null);
109
- });
110
-
111
- test("the payload never contains code, _id or status", () => {
112
- const payload = promoCodeUpdatePayload({
113
- description: "Launch offer",
114
- type: "fixed",
115
- fixed_rate: 12,
116
- expiresAt: "12/31/2026",
117
- tiers: [],
118
- });
119
-
120
- assert.deepEqual(Object.keys(payload).sort(), [
121
- "description",
122
- "expiresAt",
123
- "fixed_rate",
124
- "type",
125
- ]);
126
- });
127
-
128
- test("a tiered payload sends bands and no fixed_rate, and the reverse", () => {
129
- const tiered = promoCodeUpdatePayload({
130
- description: "",
131
- type: "tiered",
132
- fixed_rate: 99,
133
- expiresAt: "",
134
- tiers: [{ min: 1, max: 10, price: 9 }],
135
- });
136
- assert.deepEqual(tiered.tiers, [{ min: 1, max: 10, price: 9 }]);
137
- assert.equal("fixed_rate" in tiered, false);
138
-
139
- const fixed = promoCodeUpdatePayload({
140
- description: "",
141
- type: "fixed",
142
- fixed_rate: 9,
143
- expiresAt: "",
144
- tiers: [{ min: 1, max: 10, price: 9 }],
145
- });
146
- assert.equal("tiers" in fixed, false);
147
- assert.equal(fixed.fixed_rate, 9);
148
- });
149
-
150
- test("a price nobody has typed is NOT quietly saved as free", () => {
151
- // `Number("")` is 0. Coercing a blank field would turn "not filled in" into
152
- // "every seat is free" - a real price, saved without anyone choosing it.
153
- const payload = promoCodeUpdatePayload({
154
- description: "",
155
- type: "fixed",
156
- fixed_rate: "" as any,
157
- expiresAt: "",
158
- tiers: [],
159
- });
160
- assert.equal(payload.fixed_rate, "");
161
- assert.match(
162
- String(validatePromoCodeEdit(payload, NOW)),
163
- /Enter a price per seat/
164
- );
165
-
166
- // A deliberate 0, however, is a real answer and goes through.
167
- const free = promoCodeUpdatePayload({
168
- description: "",
169
- type: "fixed",
170
- fixed_rate: 0,
171
- expiresAt: "",
172
- tiers: [],
173
- });
174
- assert.equal(free.fixed_rate, 0);
175
- assert.equal(validatePromoCodeEdit(free, NOW), null);
176
- });
177
-
178
- test("band numbers typed as text reach the API as numbers", () => {
179
- const payload = promoCodeUpdatePayload({
180
- description: "",
181
- type: "tiered",
182
- fixed_rate: 0,
183
- expiresAt: "",
184
- tiers: [{ min: "1", max: "10", price: "9.5" } as any],
185
- });
186
- assert.deepEqual(payload.tiers, [{ min: 1, max: 10, price: 9.5 }]);
187
- });
188
-
189
- // ---------------------------------------------------------------------------
190
- // What the form refuses, in the API's terms
191
- // ---------------------------------------------------------------------------
192
-
193
- test("a valid edit is refused nothing", () => {
194
- assert.equal(
195
- validatePromoCodeEdit(
196
- { type: "fixed", fixed_rate: 0, expiresAt: "", description: "" },
197
- NOW
198
- ),
199
- null
200
- );
201
- assert.equal(
202
- validatePromoCodeEdit(
203
- {
204
- type: "tiered",
205
- tiers: [{ min: 1, max: 10, price: 9 }, { min: 11, max: 0, price: 7 }],
206
- expiresAt: "12/31/2026",
207
- },
208
- NOW
209
- ),
210
- null
211
- );
212
- });
213
-
214
- test("an expiry already in the past is refused, and says what to do", () => {
215
- const message = validatePromoCodeEdit(
216
- { type: "fixed", fixed_rate: 5, expiresAt: "01/01/2020" },
217
- NOW
218
- );
219
- assert.match(String(message), /already passed/);
220
- assert.match(String(message), /clear the date/);
221
- });
222
-
223
- test("TODAY is not in the past - a code expiring today is still savable", () => {
224
- assert.equal(
225
- validatePromoCodeEdit(
226
- { type: "fixed", fixed_rate: 5, expiresAt: "08/20/2026" },
227
- NOW
228
- ),
229
- null
230
- );
231
- });
232
-
233
- test("no expiry at all is allowed - that is a code with no end date", () => {
234
- assert.equal(
235
- validatePromoCodeEdit({ type: "fixed", fixed_rate: 5, expiresAt: "" }, NOW),
236
- null
237
- );
238
- });
239
-
240
- test("an unreadable expiry names the format instead of guessing", () => {
241
- assert.match(
242
- String(
243
- validatePromoCodeEdit(
244
- { type: "fixed", fixed_rate: 5, expiresAt: "next Tuesday" },
245
- NOW
246
- )
247
- ),
248
- /MM\/DD\/YYYY/
249
- );
250
- });
251
-
252
- test("a free BAND is refused, because Joi's positive() refuses it", () => {
253
- const message = validatePromoCodeEdit(
254
- { type: "tiered", tiers: [{ min: 1, max: 10, price: 0 }] },
255
- NOW
256
- );
257
- assert.match(String(message), /^Band 1: /);
258
- assert.match(String(message), /above 0/);
259
- // ...and it says the way to actually make seats free, which is the other
260
- // pricing shape. `fixed_rate` is `min(0)`, not `positive()`.
261
- assert.equal(
262
- validatePromoCodeEdit({ type: "fixed", fixed_rate: 0 }, NOW),
263
- null
264
- );
265
- });
266
-
267
- test("the refused band is named, not just 'a band'", () => {
268
- assert.match(
269
- String(
270
- validatePromoCodeEdit(
271
- {
272
- type: "tiered",
273
- tiers: [
274
- { min: 1, max: 10, price: 9 },
275
- { min: 11, max: 5, price: 7 },
276
- ],
277
- },
278
- NOW
279
- )
280
- ),
281
- /^Band 2: /
282
- );
283
- });
284
-
285
- test("a band with no upper limit is allowed - 0 means 'and above'", () => {
286
- assert.equal(
287
- validatePromoCodeEdit(
288
- { type: "tiered", tiers: [{ min: 5, max: 0, price: 7 }] },
289
- NOW
290
- ),
291
- null
292
- );
293
- });
294
-
295
- test("a tiered code with no bands is refused, and offers the other shape", () => {
296
- assert.match(
297
- String(validatePromoCodeEdit({ type: "tiered", tiers: [] }, NOW)),
298
- /at least one quantity band/
299
- );
300
- });
301
-
302
- test("a negative fixed price is refused; a missing one is refused too", () => {
303
- assert.match(
304
- String(validatePromoCodeEdit({ type: "fixed", fixed_rate: -1 }, NOW)),
305
- /cannot be less than 0/
306
- );
307
- assert.match(
308
- String(validatePromoCodeEdit({ type: "fixed", fixed_rate: "" }, NOW)),
309
- /Enter a price per seat/
310
- );
311
- });
312
-
313
- test("a code with no pricing shape is refused first, before anything else", () => {
314
- assert.equal(
315
- validatePromoCodeEdit({ expiresAt: "01/01/2020" }, NOW),
316
- "Choose how this code prices seats."
317
- );
318
- });
319
-
320
- // ---------------------------------------------------------------------------
321
- // What a refusal from the server reads like
322
- // ---------------------------------------------------------------------------
323
-
324
- test("the API's own wording is what the screen shows", () => {
325
- assert.equal(
326
- promoCodeApiError(
327
- { response: { _data: { message: "Promo code not found." } } },
328
- "Could not save."
329
- ),
330
- "Promo code not found."
331
- );
332
- });
333
-
334
- test("'Not authorized.' is replaced with something a person can act on", () => {
335
- const message = promoCodeApiError(
336
- { data: { message: "Not authorized." } },
337
- "Could not save."
338
- );
339
- assert.match(message, /Seven365 staff account/);
340
- assert.match(message, /Ask a Seven365 administrator/);
341
- });
342
-
343
- test("an unreadable failure falls back rather than dumping the error", () => {
344
- assert.equal(
345
- promoCodeApiError(new Error("fetch failed"), "Could not save the changes."),
346
- "Could not save the changes."
347
- );
348
- });
349
-
350
- // ---------------------------------------------------------------------------
351
- // The composable: verb, path and body of every write
352
- // ---------------------------------------------------------------------------
353
-
354
- const calls: Array<{ path: string; options: any }> = [];
355
-
356
- (globalThis as any).useNuxtApp = () => ({
357
- $api: (path: string, options: any = {}) => {
358
- calls.push({ path, options });
359
- return Promise.resolve({ message: "ok" });
360
- },
361
- });
362
-
363
- const promoCodeApi = (await import("../composables/usePromoCode.ts")).default();
364
-
365
- function lastCall() {
366
- return calls[calls.length - 1];
367
- }
368
-
369
- const ID = "652f1a2b3c4d5e6f70819293";
370
-
371
- test("getById reads the one record, from the path the API mounts", async () => {
372
- await promoCodeApi.getById(ID);
373
- assert.equal(lastCall().path, `/api/promo-codes/id/${ID}`);
374
- assert.equal(lastCall().options.method, "GET");
375
- });
376
-
377
- test("update is a PUT to /api/promo-codes/:id", async () => {
378
- await promoCodeApi.update(ID, {
379
- description: "Launch offer",
380
- type: "fixed",
381
- fixed_rate: 12,
382
- expiresAt: "12/31/2026",
383
- } as any);
384
-
385
- assert.equal(lastCall().path, `/api/promo-codes/${ID}`);
386
- assert.equal(lastCall().options.method, "PUT");
387
- assert.equal(lastCall().options.body.description, "Launch offer");
388
- });
389
-
390
- test("update NEVER sends code, however it is called", async () => {
391
- // The screen loads a record and could easily send it back whole. `code` is
392
- // immutable on the server; sending it would be accepted and dropped, and
393
- // the person would watch a rename succeed and change nothing.
394
- await promoCodeApi.update(ID, {
395
- code: "RENAMED",
396
- type: "fixed",
397
- fixed_rate: 1,
398
- } as any);
399
-
400
- assert.equal("code" in lastCall().options.body, false);
401
- assert.equal(lastCall().options.body.type, "fixed");
402
- });
403
-
404
- test("updateStatus is a PATCH carrying only the new status", async () => {
405
- await promoCodeApi.updateStatus(ID, "disabled");
406
- assert.equal(lastCall().path, `/api/promo-codes/${ID}/status`);
407
- assert.equal(lastCall().options.method, "PATCH");
408
- assert.deepEqual(lastCall().options.body, { status: "disabled" });
409
-
410
- await promoCodeApi.updateStatus(ID, "active");
411
- assert.deepEqual(lastCall().options.body, { status: "active" });
412
- });
413
-
414
- test("remove is a DELETE, with no body to get wrong", async () => {
415
- await promoCodeApi.remove(ID);
416
- assert.equal(lastCall().path, `/api/promo-codes/${ID}`);
417
- assert.equal(lastCall().options.method, "DELETE");
418
- assert.equal(lastCall().options.body, undefined);
419
- });
420
-
421
- test("the four reads and writes are all on the composable", () => {
422
- for (const name of [
423
- "add",
424
- "getPromoCodes",
425
- "getByCode",
426
- "getById",
427
- "update",
428
- "updateStatus",
429
- "remove",
430
- ]) {
431
- assert.equal(
432
- typeof (promoCodeApi as any)[name],
433
- "function",
434
- `usePromoCode() is missing ${name}`
435
- );
436
- }
437
- });
@@ -1,246 +0,0 @@
1
- /**
2
- * THE STAFF CONSOLE'S PROMO CODE EDITOR, DECIDED IN ONE PLACE.
3
- *
4
- * A Seven365 staff member opens a code, changes what it prices or when it
5
- * stops, turns it off, turns it back on, or removes it. Everything the screen
6
- * has to be sure of before it sends the request is worked out here, so the
7
- * form and the API can never disagree about what is wrong.
8
- *
9
- * WHAT THE SERVER ACTUALLY ENFORCES, read from the merged backend rather than
10
- * assumed (`@7365admin1/core` `models/promo-code.model.ts` `promoCodeUpdate` /
11
- * `promoCodeUpdateSchema`, mounted by `API-core` as `PUT /api/promo-codes/:id`,
12
- * `PATCH /api/promo-codes/:id/status`, `DELETE /api/promo-codes/:id`):
13
- *
14
- * - `code` is IMMUTABLE. The update schema accepts the key and drops it,
15
- * because a subscription records the promo code it was bought with as
16
- * text. The form must not offer to change it, or a person would watch a
17
- * save succeed and the code stay as it was.
18
- * - `type` is required and is "fixed" or "tiered".
19
- * - a tiered code needs at least one band; every band starts at 1 or more,
20
- * ends at or after where it starts (0 means "and above"), and is priced at
21
- * MORE than 0 - Joi's `positive()`, so a free band is refused.
22
- * - a fixed code needs a price of 0 or more; 0 is legal and means free.
23
- * - `expiresAt` is free text to the server. It refuses nothing, which is
24
- * exactly why a date already in the past is caught HERE: saving one is not
25
- * an error, it is a code that silently stops working. Since core #1885 the
26
- * shared lookup DOES refuse an expired or disabled code at checkout, so a
27
- * past date is now a real off-switch rather than a decoration.
28
- *
29
- * Refusals are returned one at a time and in the API's order. A list of five
30
- * complaints about one form is harder to act on than the first thing to fix.
31
- *
32
- * Nothing here takes a card, quotes a total, or moves money. Red Dot is the
33
- * payment provider (owner decision 1); a promo code sets a price per seat.
34
- */
35
-
36
- // Imported rather than left to Nuxt's auto-import so this file also loads
37
- // under plain `node --test`, the same way `theme-aa-ledger.ts` imports
38
- // `theme.ts`. One reader of a failed request for the whole console.
39
- import { readApiError } from "./subscription-form.ts";
40
-
41
- /**
42
- * `expiresAt` is stored as free text and this console writes MM/DD/YYYY, but
43
- * nothing has ever stopped another caller storing ISO - so both are read.
44
- * Returns the END of the expiry day: a code dated today is good all of today.
45
- *
46
- * Deliberately the same rule as the org app's `utils/promo-code.js` and core's
47
- * `promo-code-currency.util.ts`. The three are separate copies on purpose -
48
- * the org app's is plain JS so `node --test` loads it directly, and core's
49
- * runs on the server - but they must never disagree in front of a customer,
50
- * so any change to one belongs in all three.
51
- */
52
- export function promoExpiryDate(value: any): Date | null {
53
- if (!value) return null;
54
- const raw = String(value).trim();
55
- if (!raw) return null;
56
-
57
- const parts = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
58
- if (parts) {
59
- const [, m, d, y] = parts;
60
- const date = new Date(Number(y), Number(m) - 1, Number(d), 23, 59, 59, 999);
61
- // `new Date(2026, 12, ...)` rolls into the next year rather than failing.
62
- return date.getMonth() === Number(m) - 1 ? date : null;
63
- }
64
-
65
- const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
66
- if (iso) {
67
- const [, y, m, d] = iso;
68
- const date = new Date(Number(y), Number(m) - 1, Number(d), 23, 59, 59, 999);
69
- return date.getMonth() === Number(m) - 1 ? date : null;
70
- }
71
-
72
- const parsed = new Date(raw);
73
- return isNaN(parsed.getTime()) ? null : parsed;
74
- }
75
-
76
- /** Nothing typed yet. Not the same thing as a zero, which is a real price. */
77
- function isBlank(value: any): boolean {
78
- return value === "" || value === null || value === undefined;
79
- }
80
-
81
- /** What the edit form holds while a person is filling it in. */
82
- export interface TPromoCodeFormValues {
83
- description: string;
84
- type: "fixed" | "tiered";
85
- fixed_rate: number;
86
- expiresAt: string;
87
- tiers: Array<{ min: number; max: number; price: number }>;
88
- }
89
-
90
- /**
91
- * Pre-fill the form from the record the API sent back.
92
- *
93
- * `code` is not a form value - it is not editable, so it is displayed from the
94
- * record and never carried through the form where a change could reach it.
95
- */
96
- export function promoCodeFormValues(
97
- record: Record<string, any> | null | undefined
98
- ): TPromoCodeFormValues {
99
- const promo = record ?? {};
100
- const type = promo.type === "tiered" ? "tiered" : "fixed";
101
-
102
- return {
103
- description: String(promo.description ?? ""),
104
- type,
105
- fixed_rate: Number(promo.fixed_rate ?? 0),
106
- expiresAt: String(promo.expiresAt ?? ""),
107
- tiers:
108
- type === "tiered" && Array.isArray(promo.tiers)
109
- ? promo.tiers.map((tier: any) => ({
110
- min: Number(tier?.min ?? 1),
111
- max: Number(tier?.max ?? 0),
112
- price: Number(tier?.price ?? 0),
113
- }))
114
- : [],
115
- };
116
- }
117
-
118
- /**
119
- * The body `PUT /api/promo-codes/:id` takes, and nothing more.
120
- *
121
- * No `code`, no `_id`, no `status`. The API would accept and ignore all three;
122
- * not sending them means this screen cannot appear to change something it
123
- * cannot. `status` in particular has its own endpoint - folding it in here
124
- * would give two answers to "is this code on".
125
- */
126
- export function promoCodeUpdatePayload(
127
- values: TPromoCodeFormValues
128
- ): Record<string, any> {
129
- const payload: Record<string, any> = {
130
- description: values.description ?? "",
131
- type: values.type,
132
- expiresAt: values.expiresAt ?? "",
133
- };
134
-
135
- // The schema FORBIDS `tiers` on a fixed code, so each shape sends only the
136
- // half that applies to it. `fixed_rate` is allowed either way but means
137
- // nothing on a tiered code, and the server zeroes it there regardless.
138
- if (values.type === "tiered") {
139
- payload.tiers = (values.tiers ?? []).map((tier) => ({
140
- min: Number(tier.min),
141
- max: Number(tier.max),
142
- price: Number(tier.price),
143
- }));
144
- } else {
145
- // A blank price is passed through blank rather than quietly becoming 0.
146
- // `Number("")` is 0, so coercing here would turn "they have not typed a
147
- // price yet" into "every seat is free" - a real price, silently saved.
148
- // The API refuses a blank `fixed_rate` too, so the two agree.
149
- payload.fixed_rate = isBlank(values.fixed_rate)
150
- ? ""
151
- : Number(values.fixed_rate);
152
- }
153
-
154
- return payload;
155
- }
156
-
157
- /**
158
- * Everything that must be true before the request goes out. Returns the ONE
159
- * thing to tell the person, or `null` when there is nothing to tell them.
160
- */
161
- export function validatePromoCodeEdit(
162
- payload: Record<string, any>,
163
- now: Date = new Date()
164
- ): string | null {
165
- if (payload?.type !== "fixed" && payload?.type !== "tiered") {
166
- return "Choose how this code prices seats.";
167
- }
168
-
169
- if (payload.type === "fixed") {
170
- const rate = Number(payload.fixed_rate);
171
- if (isBlank(payload.fixed_rate) || !Number.isFinite(rate)) {
172
- return "Enter a price per seat. Use 0 to make every seat free.";
173
- }
174
- if (rate < 0) {
175
- return "The price per seat cannot be less than 0.";
176
- }
177
- }
178
-
179
- if (payload.type === "tiered") {
180
- const tiers = Array.isArray(payload.tiers) ? payload.tiers : [];
181
- if (!tiers.length) {
182
- return "Add at least one quantity band, or switch this code to one price for every seat.";
183
- }
184
-
185
- for (let i = 0; i < tiers.length; i++) {
186
- const band = tiers[i] ?? {};
187
- const where = "Band " + (i + 1) + ": ";
188
- const min = Number(band.min);
189
- const max = Number(band.max);
190
- const price = Number(band.price);
191
-
192
- if (!Number.isInteger(min) || min < 1) {
193
- return where + "a band has to start at 1 seat or more.";
194
- }
195
- if (!Number.isInteger(max) || max < 0) {
196
- return (
197
- where +
198
- "the upper limit has to be a whole number of seats, or no limit."
199
- );
200
- }
201
- // `max: 0` is how the band editor stores "and above" - not a band that
202
- // ends at zero seats, which is why the API allows it.
203
- if (max !== 0 && max < min) {
204
- return (
205
- where + "the upper limit cannot be lower than where the band starts."
206
- );
207
- }
208
- if (!Number.isFinite(price) || price <= 0) {
209
- return (
210
- where +
211
- "every band needs a price above 0. To make seats free, use one price for every seat and set it to 0."
212
- );
213
- }
214
- }
215
- }
216
-
217
- if (payload.expiresAt) {
218
- const expiry = promoExpiryDate(payload.expiresAt);
219
- if (!expiry) {
220
- return "The expiry date is not a date we can read. Use MM/DD/YYYY.";
221
- }
222
- if (expiry.getTime() < now.getTime()) {
223
- return "That expiry date has already passed, so the code would stop working the moment it is saved. Pick a date in the future, or clear the date to remove the expiry.";
224
- }
225
- }
226
-
227
- return null;
228
- }
229
-
230
- /**
231
- * Turn a refused request into something a person can act on.
232
- *
233
- * `readApiError` is what every other console screen uses, and the API's promo
234
- * messages are already written to be read - "Promo code not found.", "This
235
- * promo code has expired." Only one answer needs rewording: the staff guard
236
- * says "Not authorized.", which is true and tells nobody what to do next.
237
- */
238
- export function promoCodeApiError(error: any, fallback: string): string {
239
- const message = readApiError(error, fallback);
240
-
241
- if (/^not authorized\.?$/i.test(String(message).trim())) {
242
- return "Your account is not a Seven365 staff account, so it cannot change promo codes. Ask a Seven365 administrator to make the change.";
243
- }
244
-
245
- return message;
246
- }