@7365admin1/layer-common 3.2.8-staging.201 → 3.2.8-staging.203
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.
|
@@ -70,177 +70,54 @@
|
|
|
70
70
|
</section>
|
|
71
71
|
|
|
72
72
|
<!-- ===== SUBSCRIPTION INFORMATION ===== -->
|
|
73
|
+
<!--
|
|
74
|
+
READ-ONLY on purpose. This section used to offer six dropdowns and two
|
|
75
|
+
date boxes that looked editable and saved nothing: the plan list was two
|
|
76
|
+
invented names, and Start / End were always blank because nothing ever
|
|
77
|
+
loaded them. It now shows what the client's record actually says, using
|
|
78
|
+
the same wording as the Client List. Setting or changing a subscription is
|
|
79
|
+
a separate piece of work with a real endpoint behind it.
|
|
80
|
+
-->
|
|
73
81
|
<section class="section">
|
|
74
82
|
<h3>Subscription Information</h3>
|
|
75
83
|
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<div class="select-wrap">
|
|
80
|
-
<select v-model="form.planType">
|
|
81
|
-
<option v-for="opt in PLAN_TYPE_OPTIONS" :key="opt" :value="opt">
|
|
82
|
-
{{ opt }}
|
|
83
|
-
</option>
|
|
84
|
-
</select>
|
|
85
|
-
<svg class="chevron" viewBox="0 0 10 6" fill="none">
|
|
86
|
-
<path
|
|
87
|
-
d="M1 1l4 4 4-4"
|
|
88
|
-
stroke="currentColor"
|
|
89
|
-
stroke-width="1.4"
|
|
90
|
-
stroke-linecap="round"
|
|
91
|
-
/>
|
|
92
|
-
</svg>
|
|
93
|
-
</div>
|
|
94
|
-
</div>
|
|
95
|
-
<div class="field">
|
|
96
|
-
<label>Auto Renewal</label>
|
|
97
|
-
<div class="select-wrap">
|
|
98
|
-
<select v-model="form.autoRenewal">
|
|
99
|
-
<option
|
|
100
|
-
v-for="opt in AUTO_RENEWAL_OPTIONS"
|
|
101
|
-
:key="opt"
|
|
102
|
-
:value="opt"
|
|
103
|
-
>
|
|
104
|
-
{{ opt }}
|
|
105
|
-
</option>
|
|
106
|
-
</select>
|
|
107
|
-
<svg class="chevron" viewBox="0 0 10 6" fill="none">
|
|
108
|
-
<path
|
|
109
|
-
d="M1 1l4 4 4-4"
|
|
110
|
-
stroke="currentColor"
|
|
111
|
-
stroke-width="1.4"
|
|
112
|
-
stroke-linecap="round"
|
|
113
|
-
/>
|
|
114
|
-
</svg>
|
|
115
|
-
</div>
|
|
116
|
-
</div>
|
|
117
|
-
</div>
|
|
84
|
+
<p v-if="subscription.state === 'none'" class="sub-none">
|
|
85
|
+
No subscription set up for this client yet.
|
|
86
|
+
</p>
|
|
118
87
|
|
|
119
|
-
<
|
|
120
|
-
<div class="
|
|
121
|
-
<
|
|
122
|
-
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
{{ opt }}
|
|
130
|
-
</option>
|
|
131
|
-
</select>
|
|
132
|
-
<svg class="chevron" viewBox="0 0 10 6" fill="none">
|
|
133
|
-
<path
|
|
134
|
-
d="M1 1l4 4 4-4"
|
|
135
|
-
stroke="currentColor"
|
|
136
|
-
stroke-width="1.4"
|
|
137
|
-
stroke-linecap="round"
|
|
138
|
-
/>
|
|
139
|
-
</svg>
|
|
88
|
+
<template v-else>
|
|
89
|
+
<div class="row">
|
|
90
|
+
<div class="field">
|
|
91
|
+
<label>Subscription</label>
|
|
92
|
+
<p
|
|
93
|
+
class="read-value"
|
|
94
|
+
:class="{ 'needs-attention': subscription.needsAttention }"
|
|
95
|
+
>
|
|
96
|
+
{{ subscription.label }}
|
|
97
|
+
</p>
|
|
140
98
|
</div>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
<div class="select-wrap">
|
|
145
|
-
<select v-model="form.status">
|
|
146
|
-
<option v-for="opt in STATUS_OPTIONS" :key="opt" :value="opt">
|
|
147
|
-
{{ opt }}
|
|
148
|
-
</option>
|
|
149
|
-
</select>
|
|
150
|
-
<svg class="chevron" viewBox="0 0 10 6" fill="none">
|
|
151
|
-
<path
|
|
152
|
-
d="M1 1l4 4 4-4"
|
|
153
|
-
stroke="currentColor"
|
|
154
|
-
stroke-width="1.4"
|
|
155
|
-
stroke-linecap="round"
|
|
156
|
-
/>
|
|
157
|
-
</svg>
|
|
99
|
+
<div class="field">
|
|
100
|
+
<label>Billing Cycle</label>
|
|
101
|
+
<p class="read-value">{{ subscription.billingCycle }}</p>
|
|
158
102
|
</div>
|
|
159
103
|
</div>
|
|
160
|
-
</div>
|
|
161
104
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
<select v-model="form.subscriptionPlan">
|
|
167
|
-
<option
|
|
168
|
-
v-for="opt in subscriptionPlanOptions"
|
|
169
|
-
:key="opt"
|
|
170
|
-
:value="opt"
|
|
171
|
-
>
|
|
172
|
-
{{ opt }}
|
|
173
|
-
</option>
|
|
174
|
-
</select>
|
|
175
|
-
<svg class="chevron" viewBox="0 0 10 6" fill="none">
|
|
176
|
-
<path
|
|
177
|
-
d="M1 1l4 4 4-4"
|
|
178
|
-
stroke="currentColor"
|
|
179
|
-
stroke-width="1.4"
|
|
180
|
-
stroke-linecap="round"
|
|
181
|
-
/>
|
|
182
|
-
</svg>
|
|
105
|
+
<div class="row">
|
|
106
|
+
<div class="field">
|
|
107
|
+
<label>Start Date</label>
|
|
108
|
+
<p class="read-value">{{ subscription.start }}</p>
|
|
183
109
|
</div>
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
<div class="field">
|
|
193
|
-
<label>Start Date</label>
|
|
194
|
-
<div class="date-input">
|
|
195
|
-
<input type="text" :value="form.startDate" disabled />
|
|
196
|
-
<svg viewBox="0 0 16 16" fill="none" width="14" height="14">
|
|
197
|
-
<rect
|
|
198
|
-
x="1"
|
|
199
|
-
y="3"
|
|
200
|
-
width="14"
|
|
201
|
-
height="12"
|
|
202
|
-
rx="2"
|
|
203
|
-
stroke="currentColor"
|
|
204
|
-
stroke-width="1.3"
|
|
205
|
-
/>
|
|
206
|
-
<path d="M1 7h14" stroke="currentColor" stroke-width="1.3" />
|
|
207
|
-
<path
|
|
208
|
-
d="M5 1v3M11 1v3"
|
|
209
|
-
stroke="currentColor"
|
|
210
|
-
stroke-width="1.3"
|
|
211
|
-
stroke-linecap="round"
|
|
212
|
-
/>
|
|
213
|
-
</svg>
|
|
214
|
-
</div>
|
|
215
|
-
</div>
|
|
216
|
-
<div class="field">
|
|
217
|
-
<label>End Date</label>
|
|
218
|
-
<div class="date-input">
|
|
219
|
-
<input type="text" :value="form.endDate" disabled />
|
|
220
|
-
<svg viewBox="0 0 16 16" fill="none" width="14" height="14">
|
|
221
|
-
<rect
|
|
222
|
-
x="1"
|
|
223
|
-
y="3"
|
|
224
|
-
width="14"
|
|
225
|
-
height="12"
|
|
226
|
-
rx="2"
|
|
227
|
-
stroke="currentColor"
|
|
228
|
-
stroke-width="1.3"
|
|
229
|
-
/>
|
|
230
|
-
<path d="M1 7h14" stroke="currentColor" stroke-width="1.3" />
|
|
231
|
-
<path
|
|
232
|
-
d="M5 1v3M11 1v3"
|
|
233
|
-
stroke="currentColor"
|
|
234
|
-
stroke-width="1.3"
|
|
235
|
-
stroke-linecap="round"
|
|
236
|
-
/>
|
|
237
|
-
</svg>
|
|
110
|
+
<div class="field">
|
|
111
|
+
<label>End Date</label>
|
|
112
|
+
<p
|
|
113
|
+
class="read-value"
|
|
114
|
+
:class="{ 'needs-attention': subscription.needsAttention }"
|
|
115
|
+
>
|
|
116
|
+
{{ subscription.end }}
|
|
117
|
+
</p>
|
|
238
118
|
</div>
|
|
239
119
|
</div>
|
|
240
|
-
</
|
|
241
|
-
<p class="hint hint-below">
|
|
242
|
-
Start and end date depend on the billing cycle, and cannot be edited.
|
|
243
|
-
</p>
|
|
120
|
+
</template>
|
|
244
121
|
</section>
|
|
245
122
|
|
|
246
123
|
<!-- ===== INCLUDED APPLICATION ===== -->
|
|
@@ -281,6 +158,7 @@
|
|
|
281
158
|
import { reactive, computed, watch } from 'vue'
|
|
282
159
|
import useOrg from '../composables/useOrg'
|
|
283
160
|
import useCustomerSite from '../composables/useCustomerSite'
|
|
161
|
+
import { describeClientSubscription } from '../utils/client-subscription'
|
|
284
162
|
import useUser from '../composables/useUser'
|
|
285
163
|
|
|
286
164
|
const {
|
|
@@ -359,21 +237,6 @@ const emit = defineEmits<{
|
|
|
359
237
|
}>()
|
|
360
238
|
|
|
361
239
|
/* ── CONSTANTS ── */
|
|
362
|
-
const PLAN_TYPE_OPTIONS = [
|
|
363
|
-
'Free (Bundle)',
|
|
364
|
-
'Paid (Bundle)',
|
|
365
|
-
'Paid (Custom App)'
|
|
366
|
-
]
|
|
367
|
-
const AUTO_RENEWAL_OPTIONS = ['No', 'Yes']
|
|
368
|
-
const BILLING_CYCLE_OPTIONS = ['Monthly', 'Annually']
|
|
369
|
-
const STATUS_OPTIONS = ['Active', 'Suspend']
|
|
370
|
-
|
|
371
|
-
// Subscription plan options keyed by billing cycle (annotation from mockup)
|
|
372
|
-
const SUBSCRIPTION_PLAN_BY_CYCLE: Record<string, string[]> = {
|
|
373
|
-
Monthly: ['Standard Plan', 'Premium Plan'],
|
|
374
|
-
Annually: ['Standard Plan', 'Premium Plan']
|
|
375
|
-
}
|
|
376
|
-
|
|
377
240
|
const PHONE_RULES: Record<string, number> = {
|
|
378
241
|
'+60': 9,
|
|
379
242
|
'+61': 9,
|
|
@@ -399,31 +262,15 @@ const form = reactive({
|
|
|
399
262
|
organizationName: '',
|
|
400
263
|
phone: '',
|
|
401
264
|
|
|
402
|
-
planType: 'Paid (Bundle)',
|
|
403
|
-
autoRenewal: 'Yes',
|
|
404
|
-
billingCycle: 'Monthly',
|
|
405
|
-
status: 'Active',
|
|
406
|
-
subscriptionPlan: 'Standard Plan',
|
|
407
|
-
|
|
408
|
-
startDate: '',
|
|
409
|
-
endDate: '',
|
|
410
|
-
|
|
411
265
|
includedApps: [] as string[],
|
|
412
266
|
|
|
413
267
|
sites: [] as SiteRow[]
|
|
414
268
|
})
|
|
415
269
|
|
|
416
270
|
/* ── COMPUTED ── */
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
/* ── WATCH: keep subscription plan valid when billing cycle changes ── */
|
|
422
|
-
watch(subscriptionPlanOptions, options => {
|
|
423
|
-
if (!options.includes(form.subscriptionPlan)) {
|
|
424
|
-
form.subscriptionPlan = options[0] ?? ''
|
|
425
|
-
}
|
|
426
|
-
})
|
|
271
|
+
// The row the Client List already fetched carries the subscription sub-document,
|
|
272
|
+
// so the drawer says exactly what the list says - no second, disagreeing source.
|
|
273
|
+
const subscription = computed(() => describeClientSubscription(props.client))
|
|
427
274
|
|
|
428
275
|
watch(
|
|
429
276
|
() => form.name,
|
|
@@ -454,10 +301,6 @@ watch([phonePrefix, phoneNumber], ([prefix, value]) => {
|
|
|
454
301
|
})
|
|
455
302
|
|
|
456
303
|
/* ── EVENTS ── */
|
|
457
|
-
function onBillingCycleChange() {
|
|
458
|
-
// Start / End date are derived from billing cycle and are not editable.
|
|
459
|
-
// Recompute or refetch here as needed once wired to a real API.
|
|
460
|
-
}
|
|
461
304
|
function validateForm() {
|
|
462
305
|
errors.name = ''
|
|
463
306
|
errors.organizationName = ''
|
|
@@ -660,10 +503,6 @@ watch(
|
|
|
660
503
|
gap: 20px;
|
|
661
504
|
margin-bottom: 14px;
|
|
662
505
|
}
|
|
663
|
-
.row-single {
|
|
664
|
-
grid-template-columns: 1fr;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
506
|
.field {
|
|
668
507
|
display: flex;
|
|
669
508
|
flex-direction: column;
|
|
@@ -697,68 +536,17 @@ watch(
|
|
|
697
536
|
color: var(--text2);
|
|
698
537
|
}
|
|
699
538
|
|
|
700
|
-
/* =====
|
|
701
|
-
.
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
.select-wrap select {
|
|
705
|
-
width: 100%;
|
|
706
|
-
height: 38px;
|
|
707
|
-
border: 1px solid var(--border);
|
|
708
|
-
border-radius: var(--r-inner);
|
|
709
|
-
padding: 0 30px 0 12px;
|
|
710
|
-
font-size: 13px;
|
|
539
|
+
/* ===== READ-ONLY VALUES ===== */
|
|
540
|
+
/* A value the staff member reads rather than edits. */
|
|
541
|
+
.read-value {
|
|
542
|
+
font-size: 14px;
|
|
711
543
|
color: var(--text);
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
cursor: pointer;
|
|
715
|
-
outline: none;
|
|
716
|
-
}
|
|
717
|
-
.select-wrap select:focus {
|
|
718
|
-
border-color: var(--muted);
|
|
719
|
-
}
|
|
720
|
-
.select-wrap .chevron {
|
|
721
|
-
position: absolute;
|
|
722
|
-
right: 11px;
|
|
723
|
-
top: 50%;
|
|
724
|
-
transform: translateY(-50%);
|
|
725
|
-
width: 10px;
|
|
726
|
-
height: 6px;
|
|
727
|
-
pointer-events: none;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/* ===== DATE ===== */
|
|
731
|
-
.date-input {
|
|
732
|
-
position: relative;
|
|
733
|
-
display: flex;
|
|
734
|
-
align-items: center;
|
|
735
|
-
}
|
|
736
|
-
.date-input input {
|
|
737
|
-
width: 100%;
|
|
738
|
-
height: 38px;
|
|
739
|
-
border: 1px solid var(--border);
|
|
740
|
-
border-radius: var(--r-inner);
|
|
741
|
-
padding: 0 34px 0 12px;
|
|
742
|
-
font-size: 13px;
|
|
743
|
-
color: var(--text2);
|
|
744
|
-
background: var(--bg);
|
|
745
|
-
outline: none;
|
|
746
|
-
}
|
|
747
|
-
.date-input svg {
|
|
748
|
-
position: absolute;
|
|
749
|
-
right: 12px;
|
|
750
|
-
pointer-events: none;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
/* ===== HINT ===== */
|
|
754
|
-
.hint {
|
|
755
|
-
font-size: 11px;
|
|
756
|
-
color: var(--text2);
|
|
757
|
-
line-height: 1.4;
|
|
758
|
-
}
|
|
759
|
-
.hint-below {
|
|
760
|
-
margin-top: 4px;
|
|
544
|
+
padding: 10px 0;
|
|
545
|
+
min-height: 20px;
|
|
761
546
|
}
|
|
547
|
+
.sub-none { font-size: 14px; color: var(--text2); }
|
|
548
|
+
/* Owner decision 8: an end date has passed and somebody has to look at it. */
|
|
549
|
+
.needs-attention { color: var(--warn); font-weight: 500; }
|
|
762
550
|
|
|
763
551
|
/* ===== INCLUDED APPS ===== */
|
|
764
552
|
.app-list {
|
|
@@ -37,19 +37,6 @@
|
|
|
37
37
|
</svg>
|
|
38
38
|
</div>
|
|
39
39
|
|
|
40
|
-
<div class="filter-field">
|
|
41
|
-
<label>Filter by plan type</label>
|
|
42
|
-
<select v-model="filterPlan" @change="handleFilterChange">
|
|
43
|
-
<option value="">All</option>
|
|
44
|
-
<option v-for="app in APP_LIST" :key="app.value" :value="app.value">
|
|
45
|
-
{{ app.title }}
|
|
46
|
-
</option>
|
|
47
|
-
</select>
|
|
48
|
-
<svg class="chevron" viewBox="0 0 10 6" fill="none">
|
|
49
|
-
<path d="M1 1l4 4 4-4" stroke="#6B7280" stroke-width="1.4" stroke-linecap="round"/>
|
|
50
|
-
</svg>
|
|
51
|
-
</div>
|
|
52
|
-
|
|
53
40
|
<div class="filter-field">
|
|
54
41
|
<label>Filter by billing cycle</label>
|
|
55
42
|
<select v-model="filterBilling" @change="handleFilterChange">
|
|
@@ -111,10 +98,10 @@
|
|
|
111
98
|
<th>Organization Name</th>
|
|
112
99
|
<th>Email</th>
|
|
113
100
|
<th>Sites</th>
|
|
114
|
-
<th>
|
|
101
|
+
<th>Subscription</th>
|
|
115
102
|
<th>Billing Cycle</th>
|
|
116
|
-
<th>
|
|
117
|
-
<th>
|
|
103
|
+
<th>Start Date</th>
|
|
104
|
+
<th>End Date</th>
|
|
118
105
|
<th>Status</th>
|
|
119
106
|
<th>Actions</th>
|
|
120
107
|
</tr>
|
|
@@ -129,28 +116,43 @@
|
|
|
129
116
|
<td>
|
|
130
117
|
<span class="sites-badge">{{ item.sites }}</span>
|
|
131
118
|
</td>
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
<path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
|
|
140
|
-
</svg>
|
|
141
|
-
{{ item.subscriptionStart }}
|
|
142
|
-
</span>
|
|
143
|
-
</td>
|
|
144
|
-
<td>
|
|
145
|
-
<span class="date-cell">
|
|
146
|
-
<svg viewBox="0 0 16 16" fill="none" width="13" height="13">
|
|
147
|
-
<rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
|
|
148
|
-
<path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
|
|
149
|
-
<path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
|
|
150
|
-
</svg>
|
|
151
|
-
{{ item.subscriptionEnd }}
|
|
152
|
-
</span>
|
|
119
|
+
<!--
|
|
120
|
+
A client nobody has set up yet used to print four dashes here,
|
|
121
|
+
which reads as a broken screen. It is one ordinary state, so it
|
|
122
|
+
is said once, in words.
|
|
123
|
+
-->
|
|
124
|
+
<td v-if="item.sub.state === 'none'" colspan="4" class="sub-none">
|
|
125
|
+
No subscription set up
|
|
153
126
|
</td>
|
|
127
|
+
|
|
128
|
+
<template v-else>
|
|
129
|
+
<td>
|
|
130
|
+
<span class="sub-label" :class="{ 'needs-attention': item.sub.needsAttention }">
|
|
131
|
+
{{ item.sub.label }}
|
|
132
|
+
</span>
|
|
133
|
+
</td>
|
|
134
|
+
<td class="td-muted">{{ item.sub.billingCycle }}</td>
|
|
135
|
+
<td>
|
|
136
|
+
<span class="date-cell">
|
|
137
|
+
<svg viewBox="0 0 16 16" fill="none" width="13" height="13">
|
|
138
|
+
<rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
|
|
139
|
+
<path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
|
|
140
|
+
<path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
|
|
141
|
+
</svg>
|
|
142
|
+
{{ item.sub.start }}
|
|
143
|
+
</span>
|
|
144
|
+
</td>
|
|
145
|
+
<td>
|
|
146
|
+
<span class="date-cell" :class="{ 'needs-attention': item.sub.needsAttention }">
|
|
147
|
+
<svg viewBox="0 0 16 16" fill="none" width="13" height="13">
|
|
148
|
+
<rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
|
|
149
|
+
<path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
|
|
150
|
+
<path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
|
|
151
|
+
</svg>
|
|
152
|
+
{{ item.sub.end }}
|
|
153
|
+
</span>
|
|
154
|
+
</td>
|
|
155
|
+
</template>
|
|
154
156
|
<td>
|
|
155
157
|
<span :class="['status-chip', item.status]">{{ item.status }}</span>
|
|
156
158
|
</td>
|
|
@@ -272,7 +274,7 @@ import useRole from '../composables/useRole'
|
|
|
272
274
|
import InvitationClientForm from './InvitationClientForm.vue'
|
|
273
275
|
import ClientDetailForm from './ClientDetailForm.vue'
|
|
274
276
|
import useCustomerSite from '../composables/useCustomerSite'
|
|
275
|
-
import
|
|
277
|
+
import { describeClientSubscription } from '../utils/client-subscription'
|
|
276
278
|
|
|
277
279
|
|
|
278
280
|
/* ── TYPES ── */
|
|
@@ -282,7 +284,6 @@ type TabStatus = 'active' | 'suspended' | 'pending'
|
|
|
282
284
|
const { getOrganizationsWithSubscription, updateStatus } = useOrg()
|
|
283
285
|
const { getVerifications, cancelUserInvitation } = useVerification()
|
|
284
286
|
const { getCustomerSites: getCustomerSitesByOrgId } = useCustomerSite()
|
|
285
|
-
const { getByOrgId: getSubscriptionByOrgId } = useSubscription()
|
|
286
287
|
const { getRoleById } = useRole()
|
|
287
288
|
|
|
288
289
|
/* ── CONSTANTS ── */
|
|
@@ -306,7 +307,6 @@ const tableScrollEl = ref<HTMLElement | null>(null)
|
|
|
306
307
|
|
|
307
308
|
// filterStatus
|
|
308
309
|
const filterStatus = ref<'active' | 'suspended'>('active')
|
|
309
|
-
const filterPlan = ref('') // '' = All | any APP_LIST value
|
|
310
310
|
const filterBilling = ref('') // '' = All | 'monthly' | 'yearly'
|
|
311
311
|
|
|
312
312
|
const items = ref<any[]>([])
|
|
@@ -338,21 +338,6 @@ const activeMenuItem = computed(() =>
|
|
|
338
338
|
)
|
|
339
339
|
|
|
340
340
|
/* ── HELPERS ── */
|
|
341
|
-
function formatDate(date?: string | Date) {
|
|
342
|
-
if (date == null || date === '') return '-'
|
|
343
|
-
const d = date instanceof Date ? date : new Date(date as string)
|
|
344
|
-
return isNaN(d.getTime()) ? '-' : d.toLocaleDateString('en-US')
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function planTypeLabel(sub: Record<string, any>) {
|
|
348
|
-
if (!sub) return '-'
|
|
349
|
-
if (sub.paidType) return String(sub.paidType)
|
|
350
|
-
if (sub.type === 'organization') return 'Organization'
|
|
351
|
-
if (sub.type === 'affiliate') return 'Affiliate'
|
|
352
|
-
const desc = String(sub.description || '').trim()
|
|
353
|
-
return desc ? (desc.length > 48 ? `${desc.slice(0, 45)}…` : desc) : '-'
|
|
354
|
-
}
|
|
355
|
-
|
|
356
341
|
async function resolveRoleName(id: string) {
|
|
357
342
|
if (!id) return '-'
|
|
358
343
|
if (roleCache.value[id]) return roleCache.value[id]
|
|
@@ -418,7 +403,6 @@ async function fetchOrgs(status: 'active' | 'suspended') {
|
|
|
418
403
|
search: search.value,
|
|
419
404
|
limit: limit.value,
|
|
420
405
|
status,
|
|
421
|
-
type: filterPlan.value,
|
|
422
406
|
billingCycle: filterBilling.value,
|
|
423
407
|
})
|
|
424
408
|
|
|
@@ -428,8 +412,6 @@ async function fetchOrgs(status: 'active' | 'suspended') {
|
|
|
428
412
|
|
|
429
413
|
items.value = await Promise.all(
|
|
430
414
|
orgs.map(async (org: any) => {
|
|
431
|
-
const sub = org?.subscription ?? {}
|
|
432
|
-
|
|
433
415
|
let siteCount = siteCache[org._id]
|
|
434
416
|
|
|
435
417
|
if (siteCount === undefined) {
|
|
@@ -444,18 +426,14 @@ async function fetchOrgs(status: 'active' | 'suspended') {
|
|
|
444
426
|
}
|
|
445
427
|
|
|
446
428
|
return {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
planType: planTypeLabel(sub),
|
|
429
|
+
...org,
|
|
450
430
|
|
|
451
|
-
|
|
431
|
+
sub: describeClientSubscription(org),
|
|
452
432
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
sites: `${siteCount} Sites`,
|
|
458
|
-
}
|
|
433
|
+
// The column header already says Sites, so the badge is the number.
|
|
434
|
+
// (It also used to read "1 Sites".)
|
|
435
|
+
sites: siteCount,
|
|
436
|
+
}
|
|
459
437
|
})
|
|
460
438
|
)
|
|
461
439
|
|
|
@@ -869,6 +847,12 @@ tbody tr:hover { background: var(--thead); }
|
|
|
869
847
|
tbody td { vertical-align: middle; }
|
|
870
848
|
|
|
871
849
|
.empty { text-align: center; color: var(--muted); padding: 48px 0 !important; }
|
|
850
|
+
|
|
851
|
+
/* A client nobody has set up yet: plainly stated, not shouted, not a dash. */
|
|
852
|
+
.sub-none { color: var(--text2); font-size: 13px; }
|
|
853
|
+
.sub-label { font-size: 13px; }
|
|
854
|
+
/* Owner decision 8: an end date has passed and somebody has to look at it. */
|
|
855
|
+
.needs-attention { color: var(--warn); font-weight: 500; }
|
|
872
856
|
.td-name { font-weight: 500; color: var(--text); }
|
|
873
857
|
.td-muted { color: var(--muted); font-size: 13px; }
|
|
874
858
|
|
package/components/TableMain.vue
CHANGED
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.203",
|
|
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,145 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
NOT_SET,
|
|
6
|
+
describeClientSubscription,
|
|
7
|
+
formatDate,
|
|
8
|
+
} from "./client-subscription.ts";
|
|
9
|
+
|
|
10
|
+
/** What the list endpoint really returns for an org that HAS a subscription. */
|
|
11
|
+
function orgWithSub(sub: Record<string, any>) {
|
|
12
|
+
return {
|
|
13
|
+
_id: "org1",
|
|
14
|
+
name: "A Client",
|
|
15
|
+
status: "active",
|
|
16
|
+
subscription: {
|
|
17
|
+
_id: "sub1",
|
|
18
|
+
org: "org1",
|
|
19
|
+
type: "organization",
|
|
20
|
+
billingCycle: "monthly",
|
|
21
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
22
|
+
nextBillingDate: "2027-01-01T00:00:00.000Z",
|
|
23
|
+
status: "active",
|
|
24
|
+
...sub,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NOW = new Date("2026-08-20T00:00:00.000Z");
|
|
30
|
+
|
|
31
|
+
test("no subscription document is a state, not a broken row", () => {
|
|
32
|
+
// The left join collapses a missing subscription to `{}` - this is the shape
|
|
33
|
+
// 11 production clients are in, and it used to render as four dashes.
|
|
34
|
+
for (const org of [{ subscription: {} }, { subscription: null }, {}, null, undefined]) {
|
|
35
|
+
const v = describeClientSubscription(org as any, NOW);
|
|
36
|
+
assert.equal(v.state, "none", JSON.stringify(org));
|
|
37
|
+
assert.equal(v.label, "No subscription set up");
|
|
38
|
+
assert.equal(v.billingCycle, NOT_SET);
|
|
39
|
+
assert.equal(v.start, NOT_SET);
|
|
40
|
+
assert.equal(v.end, NOT_SET);
|
|
41
|
+
assert.equal(v.needsAttention, false);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a running record with nothing else proven is plain Active, never 'paying'", () => {
|
|
46
|
+
// Nothing the endpoint sends proves anybody is paying, so it must not say so.
|
|
47
|
+
const v = describeClientSubscription(orgWithSub({}), NOW);
|
|
48
|
+
assert.equal(v.state, "active");
|
|
49
|
+
assert.equal(v.label, "Active");
|
|
50
|
+
assert.equal(v.billingCycle, "Monthly");
|
|
51
|
+
assert.equal(v.needsAttention, false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("complimentary is only claimed when the record says so", () => {
|
|
55
|
+
const v = describeClientSubscription(
|
|
56
|
+
orgWithSub({ billingMode: "complimentary" }),
|
|
57
|
+
NOW,
|
|
58
|
+
);
|
|
59
|
+
assert.equal(v.state, "complimentary");
|
|
60
|
+
assert.equal(v.label, "Complimentary (not charged)");
|
|
61
|
+
|
|
62
|
+
// and the opposite claim needs the same proof
|
|
63
|
+
assert.equal(
|
|
64
|
+
describeClientSubscription(orgWithSub({ billingMode: "paid" }), NOW).label,
|
|
65
|
+
"Active (paying)",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("suspended and ended come from the record's own status", () => {
|
|
70
|
+
assert.equal(
|
|
71
|
+
describeClientSubscription(orgWithSub({ status: "suspended" }), NOW).state,
|
|
72
|
+
"suspended",
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
for (const s of ["ended", "canceled", "cancelled", "expired", "TERMINATED", " Ended "]) {
|
|
76
|
+
const v = describeClientSubscription(orgWithSub({ status: s }), NOW);
|
|
77
|
+
assert.equal(v.state, "ended", s);
|
|
78
|
+
assert.equal(v.label, "Ended");
|
|
79
|
+
assert.equal(v.needsAttention, false);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("owner decision 8: a passed end date is flagged, not silently left as Active", () => {
|
|
84
|
+
const v = describeClientSubscription(
|
|
85
|
+
orgWithSub({ nextBillingDate: "2026-08-01T00:00:00.000Z" }),
|
|
86
|
+
NOW,
|
|
87
|
+
);
|
|
88
|
+
assert.equal(v.state, "ended");
|
|
89
|
+
assert.equal(v.label, "Ended (needs attention)");
|
|
90
|
+
assert.equal(v.needsAttention, true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a suspended record is not re-labelled by its dates", () => {
|
|
94
|
+
// Status wins: a client already suspended does not need flagging again.
|
|
95
|
+
const v = describeClientSubscription(
|
|
96
|
+
orgWithSub({ status: "suspended", nextBillingDate: "2026-08-01T00:00:00.000Z" }),
|
|
97
|
+
NOW,
|
|
98
|
+
);
|
|
99
|
+
assert.equal(v.state, "suspended");
|
|
100
|
+
assert.equal(v.needsAttention, false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("Phase 2's startDate / endDate win over the fallbacks when they arrive", () => {
|
|
104
|
+
const v = describeClientSubscription(
|
|
105
|
+
orgWithSub({
|
|
106
|
+
startDate: "2026-03-05T00:00:00.000Z",
|
|
107
|
+
endDate: "2027-03-05T00:00:00.000Z",
|
|
108
|
+
createdAt: "2020-01-01T00:00:00.000Z",
|
|
109
|
+
nextBillingDate: "2020-02-01T00:00:00.000Z",
|
|
110
|
+
}),
|
|
111
|
+
NOW,
|
|
112
|
+
);
|
|
113
|
+
// The stale fallbacks are both in the past; if they had won, this row would
|
|
114
|
+
// have been flagged. It is not, so the real dates were used.
|
|
115
|
+
assert.equal(v.state, "active");
|
|
116
|
+
assert.equal(v.start, formatDate("2026-03-05T00:00:00.000Z"));
|
|
117
|
+
assert.equal(v.end, formatDate("2027-03-05T00:00:00.000Z"));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a missing or unreadable value says 'Not set', not a dash", () => {
|
|
121
|
+
assert.equal(formatDate(null), NOT_SET);
|
|
122
|
+
assert.equal(formatDate(""), NOT_SET);
|
|
123
|
+
assert.equal(formatDate("not a date"), NOT_SET);
|
|
124
|
+
assert.notEqual(formatDate("2026-08-20T00:00:00.000Z"), NOT_SET);
|
|
125
|
+
|
|
126
|
+
const v = describeClientSubscription(
|
|
127
|
+
orgWithSub({ billingCycle: "", createdAt: null, nextBillingDate: null }),
|
|
128
|
+
NOW,
|
|
129
|
+
);
|
|
130
|
+
assert.equal(v.billingCycle, NOT_SET);
|
|
131
|
+
assert.equal(v.start, NOT_SET);
|
|
132
|
+
assert.equal(v.end, NOT_SET);
|
|
133
|
+
assert.equal(v.state, "active");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a billing cycle the screen does not know is shown, not swallowed", () => {
|
|
137
|
+
assert.equal(
|
|
138
|
+
describeClientSubscription(orgWithSub({ billingCycle: "quarterly" }), NOW).billingCycle,
|
|
139
|
+
"quarterly",
|
|
140
|
+
);
|
|
141
|
+
assert.equal(
|
|
142
|
+
describeClientSubscription(orgWithSub({ billingCycle: "YEARLY" }), NOW).billingCycle,
|
|
143
|
+
"Yearly",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT A CLIENT'S SUBSCRIPTION ROW HONESTLY SAYS, IN ONE PLACE.
|
|
3
|
+
*
|
|
4
|
+
* The super-admin Client List used to print "-" in Plan Type, Billing Cycle,
|
|
5
|
+
* Subscription Start and Subscription End for every client that has no
|
|
6
|
+
* subscription document. Four dashes read as a broken screen. They are not:
|
|
7
|
+
* they are a real, ordinary state - nobody has set that client up yet.
|
|
8
|
+
*
|
|
9
|
+
* This file turns the row the API actually returns into words a Seven365 staff
|
|
10
|
+
* member can act on without being told how the data is stored underneath.
|
|
11
|
+
*
|
|
12
|
+
* THE RULE THIS FILE OBEYS: never claim more than the record proves.
|
|
13
|
+
* The projection behind `GET /api/organizations/v2/orgs/subscriptions`
|
|
14
|
+
* (core `organization.repo.ts`, `$project`) sends exactly:
|
|
15
|
+
*
|
|
16
|
+
* _id, org, type, paidSeats, currentSeats, maxSeats,
|
|
17
|
+
* billingCycle, nextBillingDate, createdAt, status
|
|
18
|
+
*
|
|
19
|
+
* It does NOT send a plan, a price, or - today - a charging arrangement. So a
|
|
20
|
+
* client whose record exists and is running is called "Active", never
|
|
21
|
+
* "Active - paying", because nothing in the data proves anybody is paying.
|
|
22
|
+
* Where two states cannot yet be told apart, the lesser claim wins.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The five states a client's subscription can be in, as a person would say them. */
|
|
26
|
+
export type TClientSubscriptionState =
|
|
27
|
+
| "none"
|
|
28
|
+
| "complimentary"
|
|
29
|
+
| "active"
|
|
30
|
+
| "suspended"
|
|
31
|
+
| "ended";
|
|
32
|
+
|
|
33
|
+
export interface TClientSubscriptionView {
|
|
34
|
+
state: TClientSubscriptionState;
|
|
35
|
+
/** The one line the "Subscription" column shows. */
|
|
36
|
+
label: string;
|
|
37
|
+
/** True when somebody has to do something about this client. */
|
|
38
|
+
needsAttention: boolean;
|
|
39
|
+
/** "Monthly" / "Yearly" / "Not set". */
|
|
40
|
+
billingCycle: string;
|
|
41
|
+
/** A date, or "Not set". */
|
|
42
|
+
start: string;
|
|
43
|
+
/** A date, or "Not set". */
|
|
44
|
+
end: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const NOT_SET = "Not set";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A status the record can carry that means the arrangement is over. `canceled`
|
|
51
|
+
* is the spelling the hourly billing job writes, so it has to be here; the
|
|
52
|
+
* other spellings cost nothing and stop a one-letter difference showing a
|
|
53
|
+
* finished client as running.
|
|
54
|
+
*/
|
|
55
|
+
const ENDED_STATUSES = new Set([
|
|
56
|
+
"ended",
|
|
57
|
+
"canceled",
|
|
58
|
+
"cancelled",
|
|
59
|
+
"expired",
|
|
60
|
+
"terminated",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const BILLING_CYCLE_LABELS: Record<string, string> = {
|
|
64
|
+
monthly: "Monthly",
|
|
65
|
+
yearly: "Yearly",
|
|
66
|
+
annually: "Yearly",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Same behaviour the screen already had, with wording instead of a dash. */
|
|
70
|
+
export function formatDate(value?: string | Date | null): string {
|
|
71
|
+
if (value == null || value === "") return NOT_SET;
|
|
72
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
73
|
+
return isNaN(d.getTime()) ? NOT_SET : d.toLocaleDateString("en-US");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPast(value: unknown, now: Date): boolean {
|
|
77
|
+
if (value == null || value === "") return false;
|
|
78
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
79
|
+
return !isNaN(d.getTime()) && d.getTime() < now.getTime();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read one organisation row from the list endpoint and say what it means.
|
|
84
|
+
*
|
|
85
|
+
* `org.subscription` is a LEFT join: an organisation with no subscription
|
|
86
|
+
* document still comes back as a row, with the sub-document collapsed to `{}`.
|
|
87
|
+
* That empty object - specifically, the absence of `_id` - is what "nobody has
|
|
88
|
+
* set this client up" looks like on the wire, and it is the state most clients
|
|
89
|
+
* are in right now.
|
|
90
|
+
*/
|
|
91
|
+
export function describeClientSubscription(
|
|
92
|
+
org: Record<string, any> | null | undefined,
|
|
93
|
+
now: Date = new Date(),
|
|
94
|
+
): TClientSubscriptionView {
|
|
95
|
+
const sub = org?.subscription ?? {};
|
|
96
|
+
|
|
97
|
+
if (!sub._id) {
|
|
98
|
+
return {
|
|
99
|
+
state: "none",
|
|
100
|
+
label: "No subscription set up",
|
|
101
|
+
needsAttention: false,
|
|
102
|
+
billingCycle: NOT_SET,
|
|
103
|
+
start: NOT_SET,
|
|
104
|
+
end: NOT_SET,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const cycleKey = String(sub.billingCycle ?? "").trim().toLowerCase();
|
|
109
|
+
|
|
110
|
+
// `startDate` / `endDate` are the fields the build spec adds in Phase 2. Until
|
|
111
|
+
// then the endpoint sends neither, so both fall through to what it does send -
|
|
112
|
+
// which is what this screen has always displayed.
|
|
113
|
+
const view = {
|
|
114
|
+
billingCycle: BILLING_CYCLE_LABELS[cycleKey] ?? (cycleKey ? String(sub.billingCycle) : NOT_SET),
|
|
115
|
+
start: formatDate(sub.startDate ?? sub.createdAt),
|
|
116
|
+
end: formatDate(sub.endDate ?? sub.nextBillingDate),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const status = String(sub.status ?? "").trim().toLowerCase();
|
|
120
|
+
|
|
121
|
+
if (status === "suspended") {
|
|
122
|
+
return { ...view, state: "suspended", label: "Suspended", needsAttention: false };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (ENDED_STATUSES.has(status)) {
|
|
126
|
+
return { ...view, state: "ended", label: "Ended", needsAttention: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Owner decision 8: when an end date passes, the client is flagged here FIRST.
|
|
130
|
+
// The record still says it is running, so the honest line is that the dates
|
|
131
|
+
// and the status disagree and a person has to settle it. Nothing on this
|
|
132
|
+
// screen suspends anybody - that is the job Phase 3 builds.
|
|
133
|
+
if (isPast(sub.endDate ?? sub.nextBillingDate, now)) {
|
|
134
|
+
return { ...view, state: "ended", label: "Ended (needs attention)", needsAttention: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ponytail: dormant until Phase 2 writes `billingMode`. Nothing sends it
|
|
138
|
+
// today, so today every live record falls through to plain "Active" - which
|
|
139
|
+
// is the honest answer, because nothing in the data proves anybody is paying.
|
|
140
|
+
const billingMode = String(sub.billingMode ?? "").trim().toLowerCase();
|
|
141
|
+
|
|
142
|
+
if (billingMode === "complimentary") {
|
|
143
|
+
return {
|
|
144
|
+
...view,
|
|
145
|
+
state: "complimentary",
|
|
146
|
+
label: "Complimentary (not charged)",
|
|
147
|
+
needsAttention: false,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
...view,
|
|
153
|
+
state: "active",
|
|
154
|
+
label: billingMode === "paid" ? "Active (paying)" : "Active",
|
|
155
|
+
needsAttention: false,
|
|
156
|
+
};
|
|
157
|
+
}
|