@7365admin1/layer-common 3.2.8-staging.202 → 3.2.8-staging.204

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.
@@ -0,0 +1,527 @@
1
+ <template>
2
+ <div class="overlay" @click.self="close">
3
+ <div class="sub-dialog" role="dialog" aria-modal="true" aria-labelledby="sub-dialog-title">
4
+
5
+ <!-- ===== HEADER ===== -->
6
+ <div class="sub-head">
7
+ <h3 id="sub-dialog-title">
8
+ {{ isEdit ? 'Edit subscription' : 'Set up subscription' }}
9
+ </h3>
10
+ <p class="sub-sub">{{ clientName }}</p>
11
+ </div>
12
+
13
+ <!-- ===== BODY ===== -->
14
+ <div class="sub-body">
15
+
16
+ <p v-if="loading" class="sub-loading">Loading this client's subscription…</p>
17
+
18
+ <template v-else>
19
+ <!--
20
+ One message at a time, and always a sentence a person can act on.
21
+ The API's own refusals are written for a reader, so when the server
22
+ says no this is exactly what the server said.
23
+ -->
24
+ <p v-if="problem" class="sub-error" role="alert">{{ problem }}</p>
25
+
26
+ <!-- Plan -->
27
+ <div class="sub-field">
28
+ <label for="sub-plan">Plan</label>
29
+ <select id="sub-plan" v-model="values.plan" :disabled="saving">
30
+ <option value="">Choose a plan</option>
31
+ <option v-for="p in plans" :key="p._id" :value="p._id">
32
+ {{ planOptionLabel(p) }}
33
+ </option>
34
+ </select>
35
+ <span v-if="!plansLoading && plans.length === 0" class="sub-hint">
36
+ No active plans exist yet, so there is nothing to put this client on.
37
+ </span>
38
+ </div>
39
+
40
+ <!-- Charging -->
41
+ <fieldset class="sub-field">
42
+ <legend>Charging</legend>
43
+ <label class="sub-radio">
44
+ <input
45
+ type="radio"
46
+ name="billingMode"
47
+ :value="BILLING_COMPLIMENTARY"
48
+ v-model="values.billingMode"
49
+ :disabled="saving"
50
+ />
51
+ <span>
52
+ <strong>Complimentary (not charged)</strong>
53
+ <em>Free use, with a real start and end date Seven365 monitors.</em>
54
+ </span>
55
+ </label>
56
+ <label class="sub-radio">
57
+ <input
58
+ type="radio"
59
+ name="billingMode"
60
+ :value="BILLING_PAID"
61
+ v-model="values.billingMode"
62
+ :disabled="saving"
63
+ />
64
+ <span>
65
+ <strong>Paying</strong>
66
+ <em>Charged the monthly value below. Payment is taken by Red Dot, not here.</em>
67
+ </span>
68
+ </label>
69
+ </fieldset>
70
+
71
+ <!-- Dates: native pickers, because the browser already has one -->
72
+ <div class="sub-row">
73
+ <div class="sub-field">
74
+ <label for="sub-start">Start date</label>
75
+ <input id="sub-start" v-model="values.startDate" type="date" :disabled="saving" />
76
+ </div>
77
+ <div class="sub-field">
78
+ <label for="sub-end">End date</label>
79
+ <input id="sub-end" v-model="values.endDate" type="date" :disabled="saving" />
80
+ <span class="sub-hint">Required. Every client has one, free or not.</span>
81
+ </div>
82
+ </div>
83
+
84
+ <!--
85
+ WHAT THIS MEANS FOR THIS CLIENT. A plan is priced per site, per
86
+ month, so the person is shown the arithmetic already done: the
87
+ per-site price, this client's live site count, and the monthly
88
+ value that comes out of the two.
89
+ -->
90
+ <div class="sub-value">
91
+ <div class="sub-value-line">
92
+ <span class="sub-value-label">Monthly value</span>
93
+ <span class="sub-value-figure" :class="{ 'is-free': isComplimentary }">
94
+ {{ formatMoney(monthly) }}
95
+ <span v-if="isComplimentary" class="sub-free-badge">not charged — complimentary</span>
96
+ </span>
97
+ </div>
98
+ <p class="sub-value-working">
99
+ {{ workingLine }}
100
+ </p>
101
+ <p v-if="siteCount === 0" class="sub-hint">
102
+ This client has no sites yet, so the monthly value is
103
+ {{ formatMoney(0) }} until sites are added.
104
+ </p>
105
+ <!-- Owner decision 5: switching a client from free to paying is an
106
+ ordinary change of state, so it is worth naming when it is
107
+ about to happen rather than letting it pass silently. -->
108
+ <p v-if="wasComplimentary && !isComplimentary" class="sub-hint sub-hint-strong">
109
+ This changes {{ clientName }} from complimentary to paying.
110
+ </p>
111
+ <p v-if="isEdit && changedValue" class="sub-hint sub-hint-strong">
112
+ Monthly value changes from {{ formatMoney(originalMonthly) }} to
113
+ {{ formatMoney(monthly) }}.
114
+ </p>
115
+ </div>
116
+ </template>
117
+ </div>
118
+
119
+ <!-- ===== FOOTER ===== -->
120
+ <div class="sub-actions">
121
+ <button class="btn-cancel" :disabled="saving" @click="close">Cancel</button>
122
+ <button class="btn-save" :disabled="saving || loading" @click="save">
123
+ <span v-if="saving" class="spinner spinner-light" />
124
+ <span v-else>{{ isEdit ? 'Save changes' : 'Set up subscription' }}</span>
125
+ </button>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </template>
130
+
131
+ <script setup lang="ts">
132
+ /**
133
+ * SET UP A CLIENT'S SUBSCRIPTION, OR CHANGE THE ONE THEY HAVE.
134
+ *
135
+ * The same four questions either way, so there is one form and not two: which
136
+ * plan, complimentary or paying, from when, until when. Editing simply arrives
137
+ * with the answers already filled in.
138
+ *
139
+ * It talks to the three staff-only console endpoints
140
+ * (`/api/subscriptions/console/:id`, where `:id` is the CLIENT'S organisation).
141
+ * It cannot suspend or reactivate anybody - that is Phase 3, and the edit
142
+ * endpoint does not accept a status, so there is deliberately no control here
143
+ * that could set one. It takes no card details and calls no payment gateway:
144
+ * Red Dot is the payment provider and none of that belongs on a staff screen.
145
+ */
146
+ import { computed, onMounted, reactive, ref } from 'vue'
147
+ import useSubscription from '../composables/useSubscription'
148
+ import useSubscriptionPlan from '../composables/useSubscriptionPlan'
149
+ import {
150
+ BILLING_COMPLIMENTARY,
151
+ BILLING_PAID,
152
+ buildConsolePayload,
153
+ formatMoney,
154
+ formValuesFrom,
155
+ monthlyValue,
156
+ planOptionLabel,
157
+ readApiError,
158
+ validateConsoleSubscription,
159
+ type TPlanOption,
160
+ } from '../utils/subscription-form'
161
+
162
+ const props = defineProps<{
163
+ /** The client's organisation id - what `:id` on the console routes means. */
164
+ orgId: string
165
+ clientName?: string
166
+ /** The live site count the Client List already fetched. Never typed in. */
167
+ siteCount?: number
168
+ }>()
169
+
170
+ const emit = defineEmits<{
171
+ (e: 'cancel'): void
172
+ (e: 'saved', message: string): void
173
+ }>()
174
+
175
+ const {
176
+ getConsoleSubscriptionByOrgId,
177
+ setUpConsoleSubscription,
178
+ updateConsoleSubscription,
179
+ } = useSubscription()
180
+ const { getAll: getPlans } = useSubscriptionPlan()
181
+
182
+ const plans = ref<TPlanOption[]>([])
183
+ const plansLoading = ref(true)
184
+ const loading = ref(true)
185
+ const saving = ref(false)
186
+ const problem = ref('')
187
+
188
+ const existing = ref<Record<string, any> | null>(null)
189
+ const values = reactive(formValuesFrom(null))
190
+ const originalValues = reactive(formValuesFrom(null))
191
+
192
+ const clientName = computed(() => props.clientName || 'this client')
193
+ const siteCount = computed(() => Number(props.siteCount ?? 0))
194
+ const isEdit = computed(() => Boolean(existing.value?._id))
195
+ const isComplimentary = computed(() => values.billingMode === BILLING_COMPLIMENTARY)
196
+ const wasComplimentary = computed(
197
+ () => isEdit.value && originalValues.billingMode === BILLING_COMPLIMENTARY,
198
+ )
199
+
200
+ const selectedPlan = computed(
201
+ () => plans.value.find(p => String(p._id) === String(values.plan)) ?? null,
202
+ )
203
+ const monthly = computed(() =>
204
+ isComplimentary.value ? 0 : monthlyValue(selectedPlan.value, siteCount.value),
205
+ )
206
+ const originalMonthly = computed(() => {
207
+ if (originalValues.billingMode === BILLING_COMPLIMENTARY) return 0
208
+ const plan = plans.value.find(p => String(p._id) === String(originalValues.plan))
209
+ return monthlyValue(plan, siteCount.value)
210
+ })
211
+ const changedValue = computed(() => originalMonthly.value !== monthly.value)
212
+
213
+ /** The arithmetic, spelled out, so nobody has to work it out or trust it blind. */
214
+ const workingLine = computed(() => {
215
+ const sites = siteCount.value
216
+ const plural = sites === 1 ? 'site' : 'sites'
217
+ if (!selectedPlan.value) return `${sites} ${plural}. Choose a plan to see the monthly value.`
218
+
219
+ const price = formatMoney(Number(selectedPlan.value.price ?? 0))
220
+ const gross = formatMoney(monthlyValue(selectedPlan.value, sites))
221
+
222
+ return isComplimentary.value
223
+ ? `${price} per site, per month x ${sites} ${plural} = ${gross} - waived while this client is complimentary.`
224
+ : `${price} per site, per month x ${sites} ${plural} = ${gross}.`
225
+ })
226
+
227
+ async function load() {
228
+ // The plan list and the client's current record are independent reads, so
229
+ // they go together rather than one after the other.
230
+ const [planResult, subResult] = await Promise.allSettled([
231
+ getPlans({ limit: 100, status: 'active' }),
232
+ getConsoleSubscriptionByOrgId(props.orgId),
233
+ ])
234
+
235
+ if (planResult.status === 'fulfilled') {
236
+ const res: any = planResult.value
237
+ plans.value = res?.items ?? res?.data?.items ?? []
238
+ } else {
239
+ problem.value = readApiError(
240
+ planResult.reason,
241
+ 'The plan list could not be loaded, so there is nothing to choose from yet. Try again in a moment.',
242
+ )
243
+ }
244
+ plansLoading.value = false
245
+
246
+ if (subResult.status === 'fulfilled') {
247
+ // A client with no subscription is answered as 200 with null. That is the
248
+ // state most clients are in, not an error - it just means "set one up".
249
+ existing.value = (subResult.value as any) ?? null
250
+ } else {
251
+ problem.value = readApiError(
252
+ subResult.reason,
253
+ 'This client\'s subscription could not be read. Try again in a moment.',
254
+ )
255
+ }
256
+
257
+ Object.assign(values, formValuesFrom(existing.value))
258
+ Object.assign(originalValues, formValuesFrom(existing.value))
259
+ loading.value = false
260
+ }
261
+
262
+ async function save() {
263
+ problem.value = ''
264
+
265
+ const payload = buildConsolePayload(values, plans.value, siteCount.value)
266
+ const refusal = validateConsoleSubscription(payload, plans.value)
267
+ if (refusal) {
268
+ problem.value = refusal
269
+ return
270
+ }
271
+
272
+ saving.value = true
273
+ try {
274
+ if (isEdit.value) {
275
+ await updateConsoleSubscription(props.orgId, payload)
276
+ emit('saved', 'Subscription updated.')
277
+ } else {
278
+ await setUpConsoleSubscription(props.orgId, payload)
279
+ emit('saved', 'Subscription set up.')
280
+ }
281
+ } catch (error: any) {
282
+ problem.value = readApiError(
283
+ error,
284
+ 'That could not be saved. Check the details and try again.',
285
+ )
286
+ } finally {
287
+ saving.value = false
288
+ }
289
+ }
290
+
291
+ function close() {
292
+ if (saving.value) return
293
+ emit('cancel')
294
+ }
295
+
296
+ onMounted(load)
297
+ </script>
298
+
299
+ <style scoped>
300
+ .overlay {
301
+ position: fixed;
302
+ inset: 0;
303
+ background: var(--scrim);
304
+ display: flex;
305
+ align-items: center;
306
+ justify-content: center;
307
+ z-index: 1000;
308
+ padding: 16px;
309
+ }
310
+
311
+ .sub-dialog {
312
+ background: var(--card);
313
+ border-radius: 12px;
314
+ width: 100%;
315
+ max-width: 520px;
316
+ max-height: 90vh;
317
+ overflow-y: auto;
318
+ box-shadow: var(--shadow-modal);
319
+ }
320
+
321
+ .sub-head {
322
+ padding: 20px 24px 12px;
323
+ border-bottom: 1px solid var(--border);
324
+ }
325
+ .sub-head h3 {
326
+ font-size: 16px;
327
+ font-weight: 700;
328
+ color: var(--text);
329
+ margin: 0;
330
+ }
331
+ .sub-sub {
332
+ font-size: 13px;
333
+ color: var(--text2);
334
+ margin: 4px 0 0;
335
+ }
336
+
337
+ .sub-body {
338
+ padding: 18px 24px 4px;
339
+ }
340
+ .sub-loading {
341
+ font-size: 13px;
342
+ color: var(--text2);
343
+ margin: 8px 0 18px;
344
+ }
345
+
346
+ .sub-error {
347
+ font-size: 13px;
348
+ line-height: 1.45;
349
+ color: var(--err);
350
+ background: var(--err-bg);
351
+ border: 1px solid var(--err-border);
352
+ border-radius: 8px;
353
+ padding: 10px 12px;
354
+ margin: 0 0 16px;
355
+ }
356
+
357
+ .sub-field {
358
+ display: flex;
359
+ flex-direction: column;
360
+ gap: 6px;
361
+ margin-bottom: 16px;
362
+ border: none;
363
+ padding: 0;
364
+ min-width: 0;
365
+ }
366
+ .sub-field label,
367
+ .sub-field legend {
368
+ font-size: 12px;
369
+ font-weight: 600;
370
+ color: var(--text2);
371
+ padding: 0;
372
+ }
373
+ .sub-field select,
374
+ .sub-field input[type="date"] {
375
+ height: 38px;
376
+ width: 100%;
377
+ border: 1px solid var(--border);
378
+ border-radius: 8px;
379
+ background: var(--card);
380
+ color: var(--text);
381
+ font-size: 13px;
382
+ padding: 0 10px;
383
+ }
384
+ .sub-field select:disabled,
385
+ .sub-field input:disabled {
386
+ opacity: 0.6;
387
+ }
388
+
389
+ .sub-hint {
390
+ font-size: 12px;
391
+ line-height: 1.4;
392
+ color: var(--text2);
393
+ }
394
+ .sub-hint-strong {
395
+ color: var(--text);
396
+ font-weight: 600;
397
+ }
398
+
399
+ .sub-radio {
400
+ display: flex;
401
+ align-items: flex-start;
402
+ gap: 9px;
403
+ border: 1px solid var(--border);
404
+ border-radius: 8px;
405
+ padding: 10px 12px;
406
+ cursor: pointer;
407
+ }
408
+ .sub-radio input {
409
+ margin-top: 2px;
410
+ flex-shrink: 0;
411
+ }
412
+ .sub-radio strong {
413
+ display: block;
414
+ font-size: 13px;
415
+ font-weight: 600;
416
+ color: var(--text);
417
+ }
418
+ .sub-radio em {
419
+ display: block;
420
+ font-style: normal;
421
+ font-size: 12px;
422
+ line-height: 1.4;
423
+ color: var(--text2);
424
+ margin-top: 2px;
425
+ }
426
+
427
+ .sub-row {
428
+ display: flex;
429
+ gap: 14px;
430
+ }
431
+ .sub-row .sub-field {
432
+ flex: 1 1 0;
433
+ }
434
+
435
+ .sub-value {
436
+ border: 1px solid var(--border);
437
+ border-radius: 8px;
438
+ padding: 12px 14px;
439
+ margin-bottom: 18px;
440
+ display: flex;
441
+ flex-direction: column;
442
+ gap: 6px;
443
+ }
444
+ .sub-value-line {
445
+ display: flex;
446
+ align-items: baseline;
447
+ justify-content: space-between;
448
+ gap: 12px;
449
+ }
450
+ .sub-value-label {
451
+ font-size: 12px;
452
+ font-weight: 600;
453
+ color: var(--text2);
454
+ }
455
+ .sub-value-figure {
456
+ font-size: 18px;
457
+ font-weight: 800;
458
+ color: var(--text);
459
+ text-align: right;
460
+ }
461
+ .sub-value-figure.is-free {
462
+ color: var(--text2);
463
+ }
464
+ .sub-free-badge {
465
+ display: block;
466
+ font-size: 11px;
467
+ font-weight: 700;
468
+ color: var(--text2);
469
+ margin-top: 2px;
470
+ }
471
+ .sub-value-working {
472
+ font-size: 12px;
473
+ line-height: 1.4;
474
+ color: var(--text2);
475
+ margin: 0;
476
+ }
477
+
478
+ .sub-actions {
479
+ display: flex;
480
+ justify-content: flex-end;
481
+ gap: 10px;
482
+ padding: 14px 24px 20px;
483
+ }
484
+ .btn-cancel {
485
+ padding: 9px 18px;
486
+ font-size: 13px;
487
+ font-weight: 600;
488
+ border: 1px solid var(--border);
489
+ border-radius: 8px;
490
+ background: var(--card);
491
+ color: var(--text2);
492
+ cursor: pointer;
493
+ }
494
+ .btn-cancel:hover:not(:disabled) { background: var(--hover); }
495
+ .btn-save {
496
+ display: inline-flex;
497
+ align-items: center;
498
+ justify-content: center;
499
+ min-width: 150px;
500
+ padding: 9px 18px;
501
+ font-size: 13px;
502
+ font-weight: 600;
503
+ border: none;
504
+ border-radius: 8px;
505
+ background: var(--accent-strong);
506
+ color: var(--on-accent-strong);
507
+ cursor: pointer;
508
+ }
509
+ .btn-save:hover:not(:disabled) { opacity: 0.88; }
510
+ .btn-cancel:disabled,
511
+ .btn-save:disabled { opacity: 0.7; cursor: not-allowed; }
512
+
513
+ .spinner {
514
+ display: inline-block;
515
+ width: 12px;
516
+ height: 12px;
517
+ border: 1.5px solid rgba(255,255,255,0.5);
518
+ border-top-color: var(--card);
519
+ border-radius: 50%;
520
+ animation: spin 0.7s linear infinite;
521
+ }
522
+ @keyframes spin { to { transform: rotate(360deg); } }
523
+
524
+ @media (max-width: 520px) {
525
+ .sub-row { flex-direction: column; gap: 0; }
526
+ }
527
+ </style>
@@ -112,6 +112,43 @@ export default function useSubscription() {
112
112
  );
113
113
  }
114
114
 
115
+ /**
116
+ * THE SEVEN365 STAFF CONSOLE. `:id` is the CLIENT'S ORGANISATION id, never a
117
+ * subscription id - the API reads the organisation from the URL and never
118
+ * from the body, so these cannot be pointed at another client by accident.
119
+ *
120
+ * A separate path from `/api/subscriptions/org/:id` on purpose: that one is
121
+ * what a client's own subscription screen calls, and locking it to Seven365
122
+ * staff would have broken them. These three are staff-only on the server.
123
+ *
124
+ * No card field, no gateway, no money - Red Dot handles payment.
125
+ */
126
+ function getConsoleSubscriptionByOrgId(orgId: string) {
127
+ return useNuxtApp().$api<Record<string, any> | null>(
128
+ `/api/subscriptions/console/${orgId}`
129
+ );
130
+ }
131
+
132
+ function setUpConsoleSubscription(orgId: string, value: Record<string, any>) {
133
+ return useNuxtApp().$api<Record<string, any>>(
134
+ `/api/subscriptions/console/${orgId}`,
135
+ {
136
+ method: "POST",
137
+ body: value,
138
+ }
139
+ );
140
+ }
141
+
142
+ function updateConsoleSubscription(orgId: string, value: Record<string, any>) {
143
+ return useNuxtApp().$api<Record<string, any>>(
144
+ `/api/subscriptions/console/${orgId}`,
145
+ {
146
+ method: "PUT",
147
+ body: value,
148
+ }
149
+ );
150
+ }
151
+
115
152
  function updateSeatsById({
116
153
  subscriptionId = "",
117
154
  seats = 0,
@@ -146,5 +183,8 @@ export default function useSubscription() {
146
183
  initOrgSubscription,
147
184
  getByAffiliateId,
148
185
  updateSeatsById,
186
+ getConsoleSubscriptionByOrgId,
187
+ setUpConsoleSubscription,
188
+ updateConsoleSubscription,
149
189
  };
150
190
  }
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.202",
5
+ "version": "3.2.8-staging.204",
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.",