@7365admin1/layer-common 3.2.8-staging.203 → 3.2.8-staging.205
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/components/ClientDetailForm.vue +35 -1
- package/components/ClientMain.vue +138 -8
- package/components/ClientSubscriptionForm.vue +527 -0
- package/composables/useSubscription.ts +40 -0
- package/package.json +1 -1
- package/utils/client-nature.test.ts +77 -0
- package/utils/subscription-form.test.ts +284 -0
- package/utils/subscription-form.ts +265 -0
|
@@ -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.
|
|
5
|
+
"version": "3.2.8-staging.205",
|
|
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.",
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* THE CLIENT LIST'S "NATURE" COLUMN.
|
|
6
|
+
*
|
|
7
|
+
* The column prints `formatNature(org.nature)`, the same helper the admin app's
|
|
8
|
+
* Organizations list uses - so the two screens say a nature the same way while
|
|
9
|
+
* both exist, and the console keeps saying it after that screen is retired.
|
|
10
|
+
*
|
|
11
|
+
* `nature` is a REQUIRED enum on the organisation model
|
|
12
|
+
* (`iservice365-core/src/models/organization.model.ts` `allowedNatures`), so
|
|
13
|
+
* what this asserts is that every legal value comes out as something a person
|
|
14
|
+
* reads - not a raw `security_agency`.
|
|
15
|
+
*
|
|
16
|
+
* `useUtils()` is a Nuxt composable: it opens with `useState("search", ...)`,
|
|
17
|
+
* which only exists inside a Nuxt app. One stub makes the factory constructible
|
|
18
|
+
* here; `formatNature` itself touches nothing but its argument.
|
|
19
|
+
*/
|
|
20
|
+
(globalThis as any).useState = (_key: string, init: () => any) => ({
|
|
21
|
+
value: init(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const { formatNature } = (await import("../composables/useUtils.ts")).default();
|
|
25
|
+
|
|
26
|
+
/** `allowedNatures`, copied from the core model, in the model's own order. */
|
|
27
|
+
const ALLOWED_NATURES = [
|
|
28
|
+
"real_estate_developer",
|
|
29
|
+
"property_management_agency",
|
|
30
|
+
"security_agency",
|
|
31
|
+
"cleaning_services",
|
|
32
|
+
"mechanical_electrical_services",
|
|
33
|
+
"landscaping_services",
|
|
34
|
+
"pest_control_services",
|
|
35
|
+
"pool_maintenance_services",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
test("every nature the organisation model allows prints as words", () => {
|
|
39
|
+
assert.deepEqual(
|
|
40
|
+
ALLOWED_NATURES.map((v) => formatNature(v)),
|
|
41
|
+
[
|
|
42
|
+
"Real Estate Developer",
|
|
43
|
+
"Property Management Agency",
|
|
44
|
+
"Security Agency",
|
|
45
|
+
"Cleaning Services",
|
|
46
|
+
"Mechanical Electrical Services",
|
|
47
|
+
"Landscaping Services",
|
|
48
|
+
"Pest Control Services",
|
|
49
|
+
"Pool Maintenance Services",
|
|
50
|
+
],
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("no allowed nature is left as its stored value", () => {
|
|
55
|
+
for (const value of ALLOWED_NATURES) {
|
|
56
|
+
const label = formatNature(value);
|
|
57
|
+
assert.notEqual(label, value, `${value} was printed raw`);
|
|
58
|
+
assert.ok(!label.includes("_"), `${value} kept an underscore`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("an organisation with no nature yields nothing, so the column says so itself", () => {
|
|
63
|
+
// The cell renders `nature ? formatNature(nature) : 'Not recorded'`. This is
|
|
64
|
+
// the half that decides the fallback is needed: the helper answers "" rather
|
|
65
|
+
// than a label, and a blank cell reads as a broken screen.
|
|
66
|
+
assert.equal(formatNature(""), "");
|
|
67
|
+
assert.equal(formatNature(undefined as unknown as string), "");
|
|
68
|
+
assert.equal(formatNature(null as unknown as string), "");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a value the model does not list is still printed, not swallowed", () => {
|
|
72
|
+
// `customer.service.ts` creates property-owner orgs with `property_owner`,
|
|
73
|
+
// which is NOT in `allowedNatures`. Those rows appear in the Client List, and
|
|
74
|
+
// the column showing what they are is the point - so an unknown value is
|
|
75
|
+
// formatted like any other rather than blanked.
|
|
76
|
+
assert.equal(formatNature("property_owner"), "Property Owner");
|
|
77
|
+
});
|