@7365admin1/layer-common 3.0.15 → 3.0.16

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.
@@ -1,212 +1,243 @@
1
1
  <template>
2
2
  <div class="page-wrap">
3
3
 
4
- <!-- ===== TOP BAR ===== -->
5
- <div class="topbar">
6
- <button class="btn-invite" @click="openInviteDialog">Invite Client</button>
7
-
8
- <div class="topbar-right">
9
- <div class="search-wrap">
10
- <input
11
- v-model="search"
12
- type="text"
13
- class="search-input"
14
- placeholder="Search Client"
15
- @input="handleSearch"
16
- />
17
- <button v-if="search" class="clear-btn" @click="search = ''; handleSearch()">×</button>
18
- <svg class="search-icon" viewBox="0 0 20 20" fill="none">
19
- <circle cx="9" cy="9" r="6" stroke="#9CA3AF" stroke-width="1.5"/>
20
- <path d="M13.5 13.5L17 17" stroke="#9CA3AF" stroke-width="1.5" stroke-linecap="round"/>
21
- </svg>
22
- </div>
23
-
24
- <!-- Filters: chỉ hiện ở active/suspended -->
25
- <div v-if="tab !== 'pending'" class="filter-wrap">
26
- <div class="filter-field">
27
- <label>Filter by status</label>
28
- <select v-model="filterStatus" @change="handleFilterChange">
29
- <option value="active">Active</option>
30
- <option value="suspended">Suspended</option>
31
- </select>
32
- <svg class="chevron" viewBox="0 0 10 6" fill="none">
33
- <path d="M1 1l4 4 4-4" stroke="#6B7280" stroke-width="1.4" stroke-linecap="round"/>
34
- </svg>
35
- </div>
36
-
37
- <div class="filter-field">
38
- <label>Filter by plan type</label>
39
- <select v-model="filterPlan" @change="handleFilterChange">
40
- <option value="">All</option>
41
- <option v-for="app in APP_LIST" :key="app.value" :value="app.value">
42
- {{ app.title }}
43
- </option>
44
- </select>
45
- <svg class="chevron" viewBox="0 0 10 6" fill="none">
46
- <path d="M1 1l4 4 4-4" stroke="#6B7280" stroke-width="1.4" stroke-linecap="round"/>
4
+ <!-- ===== TABLE VIEW: topbar + table đi chung 1 khối ===== -->
5
+ <template v-if="viewMode === 'table'">
6
+
7
+ <!-- ===== TOP BAR ===== -->
8
+ <div class="topbar">
9
+ <button class="btn-invite" @click="openInviteDialog">Invite Client</button>
10
+
11
+ <div class="topbar-right">
12
+ <div class="search-wrap">
13
+ <input
14
+ v-model="search"
15
+ type="text"
16
+ class="search-input"
17
+ placeholder="Search Client"
18
+ @input="handleSearch"
19
+ />
20
+ <button v-if="search" class="clear-btn" @click="search = ''; handleSearch()">×</button>
21
+ <svg class="search-icon" viewBox="0 0 20 20" fill="none">
22
+ <circle cx="9" cy="9" r="6" stroke="#9CA3AF" stroke-width="1.5"/>
23
+ <path d="M13.5 13.5L17 17" stroke="#9CA3AF" stroke-width="1.5" stroke-linecap="round"/>
47
24
  </svg>
48
25
  </div>
49
26
 
50
- <div class="filter-field">
51
- <label>Filter by billing cycle</label>
52
- <select v-model="filterBilling" @change="handleFilterChange">
53
- <option value="">All</option>
54
- <option value="monthly">Monthly</option>
55
- <option value="yearly">Yearly</option>
56
- </select>
57
- <svg class="chevron" viewBox="0 0 10 6" fill="none">
58
- <path d="M1 1l4 4 4-4" stroke="#6B7280" stroke-width="1.4" stroke-linecap="round"/>
59
- </svg>
27
+ <!-- Filters: chỉ hiện ở active/suspended -->
28
+ <div v-if="tab !== 'pending'" class="filter-wrap">
29
+ <div class="filter-field">
30
+ <label>Filter by status</label>
31
+ <select v-model="filterStatus" @change="handleFilterChange">
32
+ <option value="active">Active</option>
33
+ <option value="suspended">Suspended</option>
34
+ </select>
35
+ <svg class="chevron" viewBox="0 0 10 6" fill="none">
36
+ <path d="M1 1l4 4 4-4" stroke="#6B7280" stroke-width="1.4" stroke-linecap="round"/>
37
+ </svg>
38
+ </div>
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
+ <div class="filter-field">
54
+ <label>Filter by billing cycle</label>
55
+ <select v-model="filterBilling" @change="handleFilterChange">
56
+ <option value="">All</option>
57
+ <option value="monthly">Monthly</option>
58
+ <option value="yearly">Yearly</option>
59
+ </select>
60
+ <svg class="chevron" viewBox="0 0 10 6" fill="none">
61
+ <path d="M1 1l4 4 4-4" stroke="#6B7280" stroke-width="1.4" stroke-linecap="round"/>
62
+ </svg>
63
+ </div>
60
64
  </div>
61
65
  </div>
62
66
  </div>
63
- </div>
64
-
65
- <!-- ===== TABLE CARD ===== -->
66
- <div class="table-card">
67
67
 
68
- <!-- Toolbar row -->
69
- <div class="toolbar">
70
- <div class="toolbar-left">
71
- <button class="refresh-btn" :class="{ spinning: loading }" @click="handleRefresh">
72
- <svg viewBox="0 0 18 18" fill="none" width="15" height="15">
73
- <path d="M15.5 9A6.5 6.5 0 1 1 9 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
74
- <path d="M9 0.5v3.5l3-1.75L9 0.5z" fill="currentColor"/>
75
- </svg>
76
- </button>
77
- </div>
78
- <div class="toolbar-right">
79
- <span class="page-range-text">{{ pageRange }}</span>
80
- <button class="page-btn" :disabled="page <= 1" @click="handleUpdatePage(page - 1)">
81
- <svg viewBox="0 0 7 11" fill="none" width="7" height="11">
82
- <path d="M6 1L1.5 5.5 6 10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
83
- </svg>
84
- </button>
85
- <button class="page-btn" :disabled="page >= pages" @click="handleUpdatePage(page + 1)">
86
- <svg viewBox="0 0 7 11" fill="none" width="7" height="11">
87
- <path d="M1 1l4.5 4.5L1 10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
88
- </svg>
89
- </button>
68
+ <!-- ===== TABLE CARD ===== -->
69
+ <div class="table-card">
70
+
71
+ <!-- Toolbar row -->
72
+ <div class="toolbar">
73
+ <div class="toolbar-left">
74
+ <button class="refresh-btn" :class="{ spinning: loading }" @click="handleRefresh">
75
+ <svg viewBox="0 0 18 18" fill="none" width="15" height="15">
76
+ <path d="M15.5 9A6.5 6.5 0 1 1 9 2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
77
+ <path d="M9 0.5v3.5l3-1.75L9 0.5z" fill="currentColor"/>
78
+ </svg>
79
+ </button>
80
+ </div>
81
+ <div class="toolbar-right">
82
+ <span class="page-range-text">{{ pageRange }}</span>
83
+ <button class="page-btn" :disabled="page <= 1" @click="handleUpdatePage(page - 1)">
84
+ <svg viewBox="0 0 7 11" fill="none" width="7" height="11">
85
+ <path d="M6 1L1.5 5.5 6 10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
86
+ </svg>
87
+ </button>
88
+ <button class="page-btn" :disabled="page >= pages" @click="handleUpdatePage(page + 1)">
89
+ <svg viewBox="0 0 7 11" fill="none" width="7" height="11">
90
+ <path d="M1 1l4.5 4.5L1 10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
91
+ </svg>
92
+ </button>
93
+ </div>
90
94
  </div>
91
- </div>
92
95
 
93
- <!-- Tabs -->
94
- <div class="tabs-row">
95
- <button class="tab" :class="{ active: tab === 'active' }" @click="onTabChange('active')">Active</button>
96
- <button class="tab" :class="{ active: tab === 'suspended' }" @click="onTabChange('suspended')">Suspended</button>
97
- <button class="tab" :class="{ active: tab === 'pending' }" @click="onTabChange('pending')">Pending Invitation</button>
98
- </div>
96
+ <!-- Tabs -->
97
+ <div class="tabs-row">
98
+ <button class="tab" :class="{ active: tab === 'active' }" @click="onTabChange('active')">Active</button>
99
+ <button class="tab" :class="{ active: tab === 'suspended' }" @click="onTabChange('suspended')">Suspended</button>
100
+ <button class="tab" :class="{ active: tab === 'pending' }" @click="onTabChange('pending')">Pending Invitation</button>
101
+ </div>
99
102
 
100
- <!-- Loading bar -->
101
- <div v-if="loading" class="loading-bar"><div class="loading-bar-fill" /></div>
102
-
103
- <!-- TABLE: active / suspended -->
104
- <div v-if="tab !== 'pending'" class="table-scroll">
105
- <table>
106
- <thead>
107
- <tr>
108
- <th>Organization Name</th>
109
- <th>Email</th>
110
- <th>Sites</th>
111
- <th>Plan Type</th>
112
- <th>Billing Cycle</th>
113
- <th>Subscription Start</th>
114
- <th>Subscription End</th>
115
- <th>Status</th>
116
- <th>Actions</th>
117
- </tr>
118
- </thead>
119
- <tbody>
120
- <tr v-if="!loading && items.length === 0">
121
- <td colspan="9" class="empty">No records found.</td>
122
- </tr>
123
- <tr v-for="item in items" :key="item._id">
124
- <td class="td-name">{{ item.name }}</td>
125
- <td class="td-muted">{{ item.email }}</td>
126
- <td>
127
- <span class="sites-badge">{{ item.sites }}</span>
128
- </td>
129
- <td>{{ item.planType }}</td>
130
- <td class="td-muted">{{ item.billingCycle }}</td>
131
- <td>
132
- <span class="date-cell">
133
- <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
134
- <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
135
- <path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
136
- <path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
137
- </svg>
138
- {{ item.subscriptionStart }}
139
- </span>
140
- </td>
141
- <td>
142
- <span class="date-cell">
143
- <svg viewBox="0 0 16 16" fill="none" width="13" height="13">
144
- <rect x="1" y="3" width="14" height="12" rx="2" stroke="#9CA3AF" stroke-width="1.3"/>
145
- <path d="M1 7h14" stroke="#9CA3AF" stroke-width="1.3"/>
146
- <path d="M5 1v3M11 1v3" stroke="#9CA3AF" stroke-width="1.3" stroke-linecap="round"/>
147
- </svg>
148
- {{ item.subscriptionEnd }}
149
- </span>
150
- </td>
151
- <td>
152
- <span :class="['status-chip', item.status]">{{ item.status }}</span>
153
- </td>
154
- <td>
155
- <div class="action-wrap">
156
- <button class="dots-btn" @click.stop="toggleMenu(item._id)">
157
- <span /><span /><span />
158
- </button>
159
- <div v-if="openMenuId === item._id" class="dropdown">
160
- <button class="dd-item" @click="closeMenu">Edit</button>
161
- <button class="dd-item danger" @click="closeMenu">
162
- {{ item.status === 'suspended' ? 'Reactivate' : 'Suspend' }}
103
+ <!-- Loading bar -->
104
+ <div v-if="loading" class="loading-bar"><div class="loading-bar-fill" /></div>
105
+
106
+ <!-- TABLE: active / suspended -->
107
+ <div v-if="tab !== 'pending'" class="table-scroll">
108
+ <table>
109
+ <thead>
110
+ <tr>
111
+ <th>Organization Name</th>
112
+ <th>Email</th>
113
+ <th>Sites</th>
114
+ <th>Plan Type</th>
115
+ <th>Billing Cycle</th>
116
+ <th>Subscription Start</th>
117
+ <th>Subscription End</th>
118
+ <th>Status</th>
119
+ <th>Actions</th>
120
+ </tr>
121
+ </thead>
122
+ <tbody>
123
+ <tr v-if="!loading && items.length === 0">
124
+ <td colspan="9" class="empty">No records found.</td>
125
+ </tr>
126
+ <tr v-for="item in items" :key="item._id">
127
+ <td class="td-name">{{ item.name }}</td>
128
+ <td class="td-muted">{{ item.email }}</td>
129
+ <td>
130
+ <span class="sites-badge">{{ item.sites }}</span>
131
+ </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>
153
+ </td>
154
+ <td>
155
+ <span :class="['status-chip', item.status]">{{ item.status }}</span>
156
+ </td>
157
+ <td>
158
+ <div class="action-wrap">
159
+ <button class="dots-btn" @click.stop="toggleMenu(item._id)">
160
+ <span /><span /><span />
163
161
  </button>
162
+ <div v-if="openMenuId === item._id" class="dropdown">
163
+ <button
164
+ class="dd-item"
165
+ @click="viewClient(item)"
166
+ >
167
+ View Organization
168
+ </button>
169
+ <button
170
+ v-if="item.status === 'suspended'"
171
+ class="dd-item"
172
+ @click="activateClient(item)"
173
+ >
174
+ Activate
175
+ </button>
176
+
177
+ <button
178
+ v-else
179
+ class="dd-item danger"
180
+ @click="suspendClient(item)"
181
+ >
182
+ Suspend
183
+ </button>
184
+ </div>
164
185
  </div>
165
- </div>
166
- </td>
167
- </tr>
168
- </tbody>
169
- </table>
170
- </div>
186
+ </td>
187
+ </tr>
188
+ </tbody>
189
+ </table>
190
+ </div>
171
191
 
172
- <!-- TABLE: pending -->
173
- <div v-else class="table-scroll">
174
- <table>
175
- <thead>
176
- <tr>
177
- <th>Email</th>
178
- <th>App</th>
179
- <th>Role</th>
180
- <th>Status</th>
181
- <th>Action</th>
182
- </tr>
183
- </thead>
184
- <tbody>
185
- <tr v-if="!loading && items.length === 0">
186
- <td colspan="5" class="empty">No pending invitations.</td>
187
- </tr>
188
- <tr v-for="item in items" :key="item._id">
189
- <td class="td-muted">{{ item.email }}</td>
190
- <td>{{ item.app }}</td>
191
- <td class="td-muted">{{ item.role }}</td>
192
- <td><span class="status-chip pending">{{ item.status }}</span></td>
193
- <td>
194
- <button
195
- class="cancel-btn"
196
- :disabled="cancelLoadingId === item._id"
197
- @click="cancelInvite(item)"
198
- >
199
- <span v-if="cancelLoadingId === item._id" class="spinner" />
200
- <span v-else>Cancel Invite</span>
201
- </button>
202
- </td>
203
- </tr>
204
- </tbody>
205
- </table>
192
+ <!-- TABLE: pending -->
193
+ <div v-else class="table-scroll">
194
+ <table>
195
+ <thead>
196
+ <tr>
197
+ <th>Email</th>
198
+ <th>App</th>
199
+ <th>Role</th>
200
+ <th>Status</th>
201
+ <th>Action</th>
202
+ </tr>
203
+ </thead>
204
+ <tbody>
205
+ <tr v-if="!loading && items.length === 0">
206
+ <td colspan="5" class="empty">No pending invitations.</td>
207
+ </tr>
208
+ <tr v-for="item in items" :key="item._id">
209
+ <td class="td-muted">{{ item.email }}</td>
210
+ <td>{{ item.app }}</td>
211
+ <td class="td-muted">{{ item.role }}</td>
212
+ <td><span class="status-chip pending">{{ item.status }}</span></td>
213
+ <td>
214
+ <button
215
+ class="cancel-btn"
216
+ :disabled="cancelLoadingId === item._id"
217
+ @click="cancelInvite(item)"
218
+ >
219
+ <span v-if="cancelLoadingId === item._id" class="spinner" />
220
+ <span v-else>Cancel Invite</span>
221
+ </button>
222
+ </td>
223
+ </tr>
224
+ </tbody>
225
+ </table>
226
+ </div>
206
227
  </div>
228
+
229
+ </template>
230
+
231
+ <!-- ===== DETAIL VIEW: chiếm toàn bộ page-wrap, không còn topbar/table ===== -->
232
+ <div v-else-if="viewMode === 'detail'" class="client-detail">
233
+ <ClientDetailForm
234
+ :client="selectedClient"
235
+ @cancel="cancelView"
236
+ @submit="submitClient"
237
+ />
207
238
  </div>
208
239
 
209
- <!-- ===== DIALOG ===== -->
240
+ <!-- ===== DIALOG (luôn nằm ngoài, có thể mở ở bất kỳ view nào) ===== -->
210
241
  <div v-if="dialog" class="overlay" @click.self="dialog = false">
211
242
  <div class="dialog-box">
212
243
  <InvitationClientForm title="Invite Client" @success="handleSuccess" @cancel="dialog = false" />
@@ -221,14 +252,16 @@ import useOrg from '../composables/useOrg'
221
252
  import useVerification from '../composables/useVerification'
222
253
  import useRole from '../composables/useRole'
223
254
  import InvitationClientForm from './InvitationClientForm.vue'
255
+ import ClientDetailForm from './ClientDetailForm.vue'
224
256
  import useCustomerSite from '../composables/useCustomerSite'
225
257
  import useSubscription from '../composables/useSubscription'
226
258
 
259
+
227
260
  /* ── TYPES ── */
228
261
  type TabStatus = 'active' | 'suspended' | 'pending'
229
262
 
230
263
  /* ── COMPOSABLES ── */
231
- const { getOrganizationsWithSubscription } = useOrg()
264
+ const { getOrganizationsWithSubscription, updateStatus } = useOrg()
232
265
  const { getVerifications, cancelUserInvitation } = useVerification()
233
266
  const { getCustomerSites: getCustomerSitesByOrgId } = useCustomerSite()
234
267
  const { getByOrgId: getSubscriptionByOrgId } = useSubscription()
@@ -264,6 +297,8 @@ const totalItems = ref(0)
264
297
  const cancelLoadingId = ref<string | null>(null)
265
298
  const limit = ref(10)
266
299
  const roleCache = ref<Record<string, string>>({})
300
+ const viewMode = ref<'table' | 'detail'>('table')
301
+ const selectedClient = ref<any>(null)
267
302
 
268
303
  /* ── COMPUTED ── */
269
304
  const pageRange = computed(() => {
@@ -303,6 +338,25 @@ function parseTotalItems(pageRangeStr: string, fallback: number): number {
303
338
  return match ? Number(match[1]) : fallback
304
339
  }
305
340
 
341
+ async function suspendClient(item: any) {
342
+ try {
343
+ await updateStatus(item._id, "suspended")
344
+ closeMenu()
345
+ await fetchData()
346
+ } catch (err) {
347
+ console.error(err)
348
+ }
349
+ }
350
+
351
+ async function activateClient(item: any) {
352
+ try {
353
+ await updateStatus(item._id, "active")
354
+ closeMenu()
355
+ await fetchData()
356
+ } catch (err) {
357
+ console.error(err)
358
+ }
359
+ }
306
360
 
307
361
  async function fetchOrgs(status: 'active' | 'suspended') {
308
362
  loading.value = true
@@ -339,24 +393,18 @@ async function fetchOrgs(status: 'active' | 'suspended') {
339
393
  }
340
394
 
341
395
  return {
342
- _id: org._id,
343
-
344
- name: org.name,
345
- email: org.email,
346
- nature: org.nature,
347
-
348
- sites: `${siteCount} Sites`,
396
+ ...org,
349
397
 
350
- planType: planTypeLabel(sub),
398
+ planType: planTypeLabel(sub),
351
399
 
352
- billingCycle: sub?.billingCycle ?? "-",
400
+ billingCycle: sub?.billingCycle ?? "-",
353
401
 
354
- subscriptionStart: formatDate(sub?.createdAt),
402
+ subscriptionStart: formatDate(sub?.createdAt),
355
403
 
356
- subscriptionEnd: formatDate(sub?.nextBillingDate),
404
+ subscriptionEnd: formatDate(sub?.nextBillingDate),
357
405
 
358
- status: org?.status ?? status,
359
- }
406
+ sites: `${siteCount} Sites`,
407
+ }
360
408
  })
361
409
  )
362
410
 
@@ -404,6 +452,23 @@ async function fetchData() {
404
452
  }
405
453
  }
406
454
 
455
+ function viewClient(item: any) {
456
+ selectedClient.value = item
457
+ viewMode.value = 'detail'
458
+ closeMenu()
459
+ }
460
+
461
+ function cancelView() {
462
+ selectedClient.value = null
463
+ viewMode.value = 'table'
464
+ }
465
+
466
+ async function submitClient() {
467
+ // await update API
468
+
469
+ viewMode.value = 'table'
470
+ fetchData()
471
+ }
407
472
  /* ── WATCH: filterStatus ── */
408
473
  watch(filterStatus, (val) => {
409
474
  tab.value = val
@@ -785,7 +850,6 @@ tbody td { padding: 14px 16px; vertical-align: middle; }
785
850
  .dd-item.danger { color: #DC2626; }
786
851
  .dd-item.danger:hover { background: #FEF2F2; }
787
852
 
788
- /* ===== CANCEL INVITE ===== */
789
853
  .cancel-btn {
790
854
  display: inline-flex;
791
855
  align-items: center;
@@ -811,7 +875,10 @@ tbody td { padding: 14px 16px; vertical-align: middle; }
811
875
  animation: spin 0.7s linear infinite;
812
876
  }
813
877
 
814
- /* ===== DIALOG ===== */
878
+ .client-detail {
879
+ margin: 12px 0 0 0;
880
+ }
881
+
815
882
  .overlay {
816
883
  position: fixed;
817
884
  inset: 0;