@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.
@@ -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">
@@ -69,6 +56,11 @@
69
56
  <div class="table-card">
70
57
 
71
58
  <!-- Toolbar row -->
59
+ <div v-if="savedNote" class="saved-note" role="status">
60
+ {{ savedNote }}
61
+ <button class="saved-note-close" @click="savedNote = ''">&times;</button>
62
+ </div>
63
+
72
64
  <div class="toolbar">
73
65
  <div class="toolbar-left">
74
66
  <button class="refresh-btn" :class="{ spinning: loading }" @click="handleRefresh">
@@ -111,10 +103,10 @@
111
103
  <th>Organization Name</th>
112
104
  <th>Email</th>
113
105
  <th>Sites</th>
114
- <th>Plan Type</th>
106
+ <th>Subscription</th>
115
107
  <th>Billing Cycle</th>
116
- <th>Subscription Start</th>
117
- <th>Subscription End</th>
108
+ <th>Start Date</th>
109
+ <th>End Date</th>
118
110
  <th>Status</th>
119
111
  <th>Actions</th>
120
112
  </tr>
@@ -129,28 +121,46 @@
129
121
  <td>
130
122
  <span class="sites-badge">{{ item.sites }}</span>
131
123
  </td>
132
- <td>{{ item.planType }}</td>
133
- <td class="td-muted">{{ item.billingCycle }}</td>
134
- <td>
135
- <span class="date-cell">
136
- <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
137
- <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
138
- <path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
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>
124
+ <!--
125
+ A client nobody has set up yet used to print four dashes here,
126
+ which reads as a broken screen. It is one ordinary state, so it
127
+ is said once, in words.
128
+ -->
129
+ <td v-if="item.sub.state === 'none'" colspan="4" class="sub-none">
130
+ No subscription set up
131
+ <button class="sub-setup-btn" @click="openSubscription(item)">
132
+ Set up subscription
133
+ </button>
153
134
  </td>
135
+
136
+ <template v-else>
137
+ <td>
138
+ <span class="sub-label" :class="{ 'needs-attention': item.sub.needsAttention }">
139
+ {{ item.sub.label }}
140
+ </span>
141
+ </td>
142
+ <td class="td-muted">{{ item.sub.billingCycle }}</td>
143
+ <td>
144
+ <span class="date-cell">
145
+ <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
146
+ <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
147
+ <path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
148
+ <path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
149
+ </svg>
150
+ {{ item.sub.start }}
151
+ </span>
152
+ </td>
153
+ <td>
154
+ <span class="date-cell" :class="{ 'needs-attention': item.sub.needsAttention }">
155
+ <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
156
+ <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
157
+ <path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
158
+ <path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
159
+ </svg>
160
+ {{ item.sub.end }}
161
+ </span>
162
+ </td>
163
+ </template>
154
164
  <td>
155
165
  <span :class="['status-chip', item.status]">{{ item.status }}</span>
156
166
  </td>
@@ -210,9 +220,24 @@
210
220
  :client="selectedClient"
211
221
  @cancel="cancelView"
212
222
  @submit="submitClient"
223
+ @manage-subscription="openSubscription(selectedClient)"
213
224
  />
214
225
  </div>
215
226
 
227
+ <!--
228
+ SET UP OR CHANGE A CLIENT'S SUBSCRIPTION. Opened from the row that has
229
+ none, from the row menu, or from the client's own drawer - all three land
230
+ here, so there is one form and one set of rules behind them.
231
+ -->
232
+ <ClientSubscriptionForm
233
+ v-if="subscriptionClient"
234
+ :org-id="subscriptionClient._id"
235
+ :client-name="subscriptionClient.name"
236
+ :site-count="subscriptionClient.sites"
237
+ @cancel="subscriptionClient = null"
238
+ @saved="onSubscriptionSaved"
239
+ />
240
+
216
241
  <div v-if="dialog" class="overlay" @click.self="dialog = false">
217
242
  <div class="dialog-box">
218
243
  <InvitationClientForm title="Invite Client" @success="handleSuccess" @cancel="dialog = false" />
@@ -245,6 +270,9 @@
245
270
  <button class="dd-item" @click="viewClient(activeMenuItem)">
246
271
  View Organization
247
272
  </button>
273
+ <button class="dd-item" @click="openSubscription(activeMenuItem)">
274
+ {{ activeMenuItem?.sub?.state === 'none' ? 'Set up subscription' : 'Edit subscription' }}
275
+ </button>
248
276
  <button
249
277
  v-if="activeMenuItem?.status === 'suspended'"
250
278
  class="dd-item"
@@ -271,8 +299,9 @@ import useVerification from '../composables/useVerification'
271
299
  import useRole from '../composables/useRole'
272
300
  import InvitationClientForm from './InvitationClientForm.vue'
273
301
  import ClientDetailForm from './ClientDetailForm.vue'
302
+ import ClientSubscriptionForm from './ClientSubscriptionForm.vue'
274
303
  import useCustomerSite from '../composables/useCustomerSite'
275
- import useSubscription from '../composables/useSubscription'
304
+ import { describeClientSubscription } from '../utils/client-subscription'
276
305
 
277
306
 
278
307
  /* ── TYPES ── */
@@ -282,7 +311,6 @@ type TabStatus = 'active' | 'suspended' | 'pending'
282
311
  const { getOrganizationsWithSubscription, updateStatus } = useOrg()
283
312
  const { getVerifications, cancelUserInvitation } = useVerification()
284
313
  const { getCustomerSites: getCustomerSitesByOrgId } = useCustomerSite()
285
- const { getByOrgId: getSubscriptionByOrgId } = useSubscription()
286
314
  const { getRoleById } = useRole()
287
315
 
288
316
  /* ── CONSTANTS ── */
@@ -306,7 +334,6 @@ const tableScrollEl = ref<HTMLElement | null>(null)
306
334
 
307
335
  // filterStatus
308
336
  const filterStatus = ref<'active' | 'suspended'>('active')
309
- const filterPlan = ref('') // '' = All | any APP_LIST value
310
337
  const filterBilling = ref('') // '' = All | 'monthly' | 'yearly'
311
338
 
312
339
  const items = ref<any[]>([])
@@ -321,6 +348,10 @@ const roleCache = ref<Record<string, string>>({})
321
348
  const viewMode = ref<'table' | 'detail'>('table')
322
349
  const selectedClient = ref<any>(null)
323
350
 
351
+ /* subscription set-up / edit */
352
+ const subscriptionClient = ref<any>(null)
353
+ const savedNote = ref('')
354
+
324
355
  /* suspend confirmation */
325
356
  const confirmSuspendItem = ref<any>(null)
326
357
  const confirmSuspendLoading = ref(false)
@@ -338,21 +369,6 @@ const activeMenuItem = computed(() =>
338
369
  )
339
370
 
340
371
  /* ── 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
372
  async function resolveRoleName(id: string) {
357
373
  if (!id) return '-'
358
374
  if (roleCache.value[id]) return roleCache.value[id]
@@ -387,6 +403,30 @@ async function activateClient(item: any) {
387
403
  }
388
404
  }
389
405
 
406
+ /* ── SUBSCRIPTION ── */
407
+ function openSubscription(item: any) {
408
+ if (!item?._id) return
409
+ savedNote.value = ''
410
+ subscriptionClient.value = item
411
+ closeMenu()
412
+ }
413
+
414
+ async function onSubscriptionSaved(message: string) {
415
+ subscriptionClient.value = null
416
+ savedNote.value = message
417
+ // The list derives every subscription state from the record itself, so
418
+ // re-reading it is all that is needed for the row to tell the truth again.
419
+ await fetchData()
420
+
421
+ // The drawer reads the same row, so if it is open it has to be handed the
422
+ // refreshed one - otherwise it would still be showing what was true before
423
+ // the save.
424
+ if (selectedClient.value?._id) {
425
+ selectedClient.value =
426
+ items.value.find(i => i._id === selectedClient.value._id) ?? selectedClient.value
427
+ }
428
+ }
429
+
390
430
  /* ── SUSPEND CONFIRMATION ── */
391
431
  function askSuspend(item: any) {
392
432
  confirmSuspendItem.value = item
@@ -418,7 +458,6 @@ async function fetchOrgs(status: 'active' | 'suspended') {
418
458
  search: search.value,
419
459
  limit: limit.value,
420
460
  status,
421
- type: filterPlan.value,
422
461
  billingCycle: filterBilling.value,
423
462
  })
424
463
 
@@ -428,8 +467,6 @@ async function fetchOrgs(status: 'active' | 'suspended') {
428
467
 
429
468
  items.value = await Promise.all(
430
469
  orgs.map(async (org: any) => {
431
- const sub = org?.subscription ?? {}
432
-
433
470
  let siteCount = siteCache[org._id]
434
471
 
435
472
  if (siteCount === undefined) {
@@ -444,18 +481,14 @@ async function fetchOrgs(status: 'active' | 'suspended') {
444
481
  }
445
482
 
446
483
  return {
447
- ...org,
448
-
449
- planType: planTypeLabel(sub),
484
+ ...org,
450
485
 
451
- billingCycle: sub?.billingCycle ?? "-",
486
+ sub: describeClientSubscription(org),
452
487
 
453
- subscriptionStart: formatDate(sub?.createdAt),
454
-
455
- subscriptionEnd: formatDate(sub?.nextBillingDate),
456
-
457
- sites: `${siteCount} Sites`,
458
- }
488
+ // The column header already says Sites, so the badge is the number.
489
+ // (It also used to read "1 Sites".)
490
+ sites: siteCount,
491
+ }
459
492
  })
460
493
  )
461
494
 
@@ -869,6 +902,50 @@ tbody tr:hover { background: var(--thead); }
869
902
  tbody td { vertical-align: middle; }
870
903
 
871
904
  .empty { text-align: center; color: var(--muted); padding: 48px 0 !important; }
905
+
906
+ /* A client nobody has set up yet: plainly stated, not shouted, not a dash. */
907
+ .sub-none { color: var(--text2); font-size: 13px; }
908
+ /* The way in, said next to the state it fixes. It sits in a cell that already
909
+ spans four columns, so it adds no width to the table. */
910
+ .sub-setup-btn {
911
+ margin-left: 10px;
912
+ padding: 3px 10px;
913
+ font-size: 12px;
914
+ font-weight: 600;
915
+ border: 1px solid var(--border);
916
+ border-radius: 12px;
917
+ background: var(--card);
918
+ color: var(--accent-text);
919
+ cursor: pointer;
920
+ white-space: nowrap;
921
+ }
922
+ .sub-setup-btn:hover { background: var(--hover); }
923
+
924
+ .saved-note {
925
+ display: flex;
926
+ align-items: center;
927
+ justify-content: space-between;
928
+ gap: 10px;
929
+ margin: 12px 16px 0;
930
+ padding: 9px 12px;
931
+ border: 1px solid var(--border);
932
+ border-radius: 8px;
933
+ background: var(--ok-bg);
934
+ color: var(--text);
935
+ font-size: 13px;
936
+ font-weight: 600;
937
+ }
938
+ .saved-note-close {
939
+ border: none;
940
+ background: none;
941
+ color: var(--text2);
942
+ font-size: 16px;
943
+ line-height: 1;
944
+ cursor: pointer;
945
+ }
946
+ .sub-label { font-size: 13px; }
947
+ /* Owner decision 8: an end date has passed and somebody has to look at it. */
948
+ .needs-attention { color: var(--warn); font-weight: 500; }
872
949
  .td-name { font-weight: 500; color: var(--text); }
873
950
  .td-muted { color: var(--muted); font-size: 13px; }
874
951