@7365admin1/layer-common 1.11.44 → 1.11.45

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,76 +1,222 @@
1
1
  <template>
2
- <v-row no-gutters>
3
- <v-col cols="12">
4
- <TableMain
5
-
6
- :headers="currentHeaders"
7
- :items="items"
8
- :loading="loading"
9
- :page="page"
10
- :pages="pages"
11
- :page-range="pageRange"
12
- :items-per-page="limit"
13
- :extension-height="120"
14
- :offset="300"
15
- show-header
16
- @refresh="handleRefresh"
17
- @update:page="handleUpdatePage"
18
- >
19
- <!-- EXTENSION -->
20
- <template #extension>
21
- <v-row no-gutters class="w-100 d-flex flex-column ga-2 pt-2">
22
- <v-tabs v-model="tab" class="w-100" height="32" @update:model-value="onTabChange">
23
- <v-tab value="active">Active</v-tab>
24
- <v-tab value="suspended">Suspended</v-tab>
25
- <v-tab value="pending">Pending Invitation</v-tab>
26
- </v-tabs>
27
-
28
- <div class="px-3 py-2 w-100">
29
- <v-text-field v-model="search" density="compact" placeholder="Search" clearable max-width="300"
30
- append-inner-icon="mdi-magnify" hide-details @update:model-value="handleSearch" />
31
- </div>
32
- </v-row>
33
- </template>
34
-
35
- <!-- ACTIONS -->
36
- <template #actions>
37
- <v-btn class="text-none" rounded="pill" variant="tonal" size="large" @click="openInviteDialog">
38
- Invite Client
39
- </v-btn>
40
- </template>
41
-
42
- <!-- INDEX CELL -->
43
- <!-- <template #item.index="{ index }">
44
- {{ (currentPage - 1) * 10 + index + 1 }}
45
- </template> -->
46
-
47
- <!-- STATUS CELL -->
48
- <template #item.status="{ value }">
49
- <v-chip size="small" :color="value === 'suspended' ? 'error' : 'success'" variant="flat">
50
- {{ value }}
51
- </v-chip>
52
- </template>
53
-
54
- <!-- ACTION CELL (pending only) -->
55
- <template #item.action="{ item }">
56
- <v-btn v-if="tab === 'pending'" size="small" color="error" variant="tonal"
57
- :loading="cancelLoadingId === item._id" :disabled="cancelLoadingId === item._id"
58
- @click="cancelInvite(item)">
59
- Cancel Invite
60
- </v-btn>
61
- </template>
62
- </TableMain>
63
- </v-col>
64
-
65
- <!-- DIALOG -->
66
- <v-dialog v-model="dialog" max-width="500">
67
- <InvitationClientForm title="Invite Client" @success="handleSuccess" @cancel="dialog = false" />
68
- </v-dialog>
69
- </v-row>
2
+ <div class="page-wrap">
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"/>
47
+ </svg>
48
+ </div>
49
+
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>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+
65
+ <!-- ===== TABLE CARD ===== -->
66
+ <div class="table-card">
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>
90
+ </div>
91
+ </div>
92
+
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>
99
+
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' }}
163
+ </button>
164
+ </div>
165
+ </div>
166
+ </td>
167
+ </tr>
168
+ </tbody>
169
+ </table>
170
+ </div>
171
+
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>
206
+ </div>
207
+ </div>
208
+
209
+ <!-- ===== DIALOG ===== -->
210
+ <div v-if="dialog" class="overlay" @click.self="dialog = false">
211
+ <div class="dialog-box">
212
+ <InvitationClientForm title="Invite Client" @success="handleSuccess" @cancel="dialog = false" />
213
+ </div>
214
+ </div>
215
+ </div>
70
216
  </template>
71
217
 
72
218
  <script setup lang="ts">
73
- import { computed, nextTick, onMounted, ref } from 'vue'
219
+ import { computed, nextTick, onMounted, ref, watch } from 'vue'
74
220
  import useOrg from '../composables/useOrg'
75
221
  import useVerification from '../composables/useVerification'
76
222
  import useRole from '../composables/useRole'
@@ -78,282 +224,190 @@ import InvitationClientForm from './InvitationClientForm.vue'
78
224
  import useCustomerSite from '../composables/useCustomerSite'
79
225
  import useSubscription from '../composables/useSubscription'
80
226
 
81
- const tab = ref<'active' | 'suspended' | 'pending'>('active')
82
- const dialog = ref(false)
227
+ /* ── TYPES ── */
228
+ type TabStatus = 'active' | 'suspended' | 'pending'
83
229
 
230
+ /* ── COMPOSABLES ── */
84
231
  const { getAllV2 } = useOrg()
85
232
  const { getVerifications, cancelUserInvitation } = useVerification()
86
233
  const { getCustomerSites: getCustomerSitesByOrgId } = useCustomerSite()
87
234
  const { getByOrgId: getSubscriptionByOrgId } = useSubscription()
88
235
  const { getRoleById } = useRole()
89
- const roleCache = ref<Record<string, string>>({})
90
-
91
- /* ================= STATE ================= */
92
- const items = ref<any[]>([])
93
- const loading = ref(false)
94
- const page = ref(1)
95
- const pages = ref(1)
96
- const search = ref('')
97
- const totalItems = ref(0)
98
- const cancelLoadingId = ref<string | null>(null)
99
-
100
236
 
237
+ /* ── CONSTANTS ── */
101
238
  const APP_LIST = [
102
- { title: "Organization", value: "organization" },
103
- { title: "Security Agency", value: "security_agency" },
104
- { title: "Cleaning Services", value: "cleaning_services" },
105
- { title: "Property Management Agency", value: "property_management_agency" },
106
- { title: "Mechanical & Electrical Services", value: "mechanical_electrical_services" },
107
- { title: "Pest Control Services", value: "pest_control_services" },
108
- { title: "Landscaping Services", value: "landscaping_services" },
109
- { title: "Pool Maintenance Services", value: "pool_maintenance_services" },
110
- ]
111
- /* ================= HEADERS ================= */
112
- const orgHeaders = [
113
- { title: 'Organization Name', key: 'name' },
114
- { title: 'Email', key: 'email' },
115
- { title: 'Sites', key: 'sites' },
116
- { title: 'Plan Type', key: 'planType' },
117
- { title: 'Billing Cycle', key: 'billingCycle' },
118
- { title: 'Subscription Start', key: 'subscriptionStart' },
119
- { title: 'Subscription End', key: 'subscriptionEnd' },
120
- { title: 'Status', key: 'status' },
121
- { title: 'Actions', key: 'actions' },
239
+ { title: 'Organization', value: 'organization' },
240
+ { title: 'Security Agency', value: 'security_agency' },
241
+ { title: 'Cleaning Services', value: 'cleaning_services' },
242
+ { title: 'Property Management Agency', value: 'property_management_agency' },
243
+ { title: 'Mechanical & Electrical Services', value: 'mechanical_electrical_services' },
244
+ { title: 'Pest Control Services', value: 'pest_control_services' },
245
+ { title: 'Landscaping Services', value: 'landscaping_services' },
246
+ { title: 'Pool Maintenance Services', value: 'pool_maintenance_services' },
122
247
  ]
123
248
 
124
- const inviteHeaders = [
125
- { title: 'Email', key: 'email' },
126
- { title: 'App', key: 'app' },
127
- { title: 'Role', key: 'role' },
128
- { title: 'Status', key: 'status' },
129
- { title: 'Action', key: 'action' },
130
- ]
131
-
132
- /* ================= COMPUTED ================= */
133
- const currentHeaders = computed(() =>
134
- tab.value === 'pending' ? inviteHeaders : orgHeaders
135
- )
249
+ /* ── STATE ── */
250
+ const tab = ref<TabStatus>('active')
251
+ const dialog = ref(false)
252
+ const openMenuId = ref<string | null>(null)
253
+ // filterStatus sync với tab, dùng để filter + đổi tab qua select
254
+ const filterStatus = ref<'active' | 'suspended'>('active')
255
+ const filterPlan = ref('') // '' = All | any APP_LIST value
256
+ const filterBilling = ref('') // '' = All | 'monthly' | 'yearly'
257
+
258
+ const items = ref<any[]>([])
259
+ const loading = ref(false)
260
+ const page = ref(1)
261
+ const pages = ref(1)
262
+ const search = ref('')
263
+ const totalItems = ref(0)
264
+ const cancelLoadingId = ref<string | null>(null)
265
+ const limit = ref(10)
266
+ const roleCache = ref<Record<string, string>>({})
136
267
 
268
+ /* ── COMPUTED ── */
137
269
  const pageRange = computed(() => {
138
270
  if (!totalItems.value) return '0-0 of 0'
139
- const pageSize = 10
140
- const start = (page.value - 1) * pageSize + 1
141
- const end = Math.min(page.value * pageSize, totalItems.value)
271
+ const start = (page.value - 1) * limit.value + 1
272
+ const end = Math.min(page.value * limit.value, totalItems.value)
142
273
  return `${start}-${end} of ${totalItems.value}`
143
274
  })
144
275
 
145
- /* ================= HELPERS ================= */
276
+ /* ── HELPERS ── */
146
277
  function formatDate(date?: string | Date) {
147
278
  if (date == null || date === '') return '-'
148
- const d = typeof date === 'object' && date instanceof Date ? date : new Date(date as string)
149
- if (Number.isNaN(d.getTime())) return '-'
150
- return d.toLocaleDateString('en-US')
279
+ const d = date instanceof Date ? date : new Date(date as string)
280
+ return isNaN(d.getTime()) ? '-' : d.toLocaleDateString('en-US')
151
281
  }
152
282
 
153
- /** Subscription document không luôn có `paidType`; fallback theo type/description. */
154
283
  function planTypeLabel(sub: Record<string, any>) {
155
284
  if (!sub) return '-'
156
285
  if (sub.paidType) return String(sub.paidType)
157
286
  if (sub.type === 'organization') return 'Organization'
158
- if (sub.type === 'affiliate') return 'Affiliate'
287
+ if (sub.type === 'affiliate') return 'Affiliate'
159
288
  const desc = String(sub.description || '').trim()
160
- if (desc) return desc.length > 48 ? `${desc.slice(0, 45)}…` : desc
161
- return '-'
289
+ return desc ? (desc.length > 48 ? `${desc.slice(0, 45)}…` : desc) : '-'
162
290
  }
163
291
 
164
292
  async function resolveRoleName(id: string) {
165
293
  if (!id) return '-'
166
294
  if (roleCache.value[id]) return roleCache.value[id]
167
- const res = await getRoleById(id)
168
- const roleName = res?.name ?? '-'
169
- roleCache.value[id] = roleName
170
- return roleName
171
- }
172
- const limit = ref(10)
173
- /* ================= FETCH ================= */
174
- async function fetchActive() {
175
- const res = await getAllV2({
176
- page: page.value,
177
- search: search.value,
178
- limit: limit.value,
179
- status: 'active',
180
- })
181
-
182
- const orgs = res?.data?.items || res?.items || []
183
-
184
- const mapped = await Promise.all(
185
- orgs.map(async (org: any) => {
186
- let sitesCount = 0
187
- let planType = '-'
188
- let billingCycle = '-'
189
- let subscriptionStart = '-'
190
- let subscriptionEnd = '-'
191
-
192
- try {
193
- const siteRes = await getCustomerSitesByOrgId(org._id)
194
-
195
- sitesCount =
196
- siteRes?.data?.totalItems ||
197
- siteRes?.totalItems ||
198
- siteRes?.data?.items?.length ||
199
- siteRes?.items?.length ||
200
- 0
201
-
202
- const subRes = await getSubscriptionByOrgId(org._id)
203
- const subscription = subRes?.data || subRes
204
-
205
- if (subscription) {
206
- planType = planTypeLabel(subscription)
207
- billingCycle = subscription?.billingCycle || '-'
208
- subscriptionStart = formatDate(subscription?.createdAt)
209
- subscriptionEnd = formatDate(subscription?.nextBillingDate)
210
- }
211
- } catch (e) {
212
- console.error(e)
213
- }
214
-
215
- return {
216
- _id: `${org._id}-${page.value}`,
217
- ...org,
218
- sites: `${sitesCount} Sites`,
219
- planType,
220
- billingCycle,
221
- subscriptionStart,
222
- subscriptionEnd,
223
- status: org?.status || 'active',
224
- actions: '—',
225
- }
226
- })
227
- )
228
-
229
- items.value = []
230
-
231
- await nextTick()
232
-
233
- items.value = [...mapped]
234
-
235
- pages.value = res?.pages || 1
295
+ const res = await getRoleById(id)
296
+ const name = res?.name ?? '-'
297
+ roleCache.value[id] = name
298
+ return name
299
+ }
236
300
 
237
- const match = res?.pageRange?.match(/of\s+(\d+)/i)
301
+ function parseTotalItems(pageRangeStr: string, fallback: number): number {
302
+ const match = pageRangeStr?.match(/of\s+(\d+)/i)
303
+ return match ? Number(match[1]) : fallback
304
+ }
238
305
 
239
- totalItems.value = match ? Number(match[1]) : mapped.length
306
+ /* ── FETCH ── */
307
+ async function mapOrg(org: any, status: string) {
308
+ let sitesCount = 0, planType = '-', billingCycle = '-',
309
+ subscriptionStart = '-', subscriptionEnd = '-'
310
+ try {
311
+ const siteRes = await getCustomerSitesByOrgId(org._id)
312
+ sitesCount = siteRes?.data?.totalItems
313
+ ?? siteRes?.totalItems
314
+ ?? siteRes?.data?.items?.length
315
+ ?? siteRes?.items?.length
316
+ ?? 0
317
+
318
+ const subRes = await getSubscriptionByOrgId(org._id)
319
+ const sub = subRes?.data ?? subRes
320
+ if (sub) {
321
+ planType = planTypeLabel(sub)
322
+ billingCycle = sub?.billingCycle ?? '-'
323
+ subscriptionStart = formatDate(sub?.createdAt)
324
+ subscriptionEnd = formatDate(sub?.nextBillingDate)
325
+ }
326
+ } catch (e) {
327
+ console.error('mapOrg error:', org._id, e)
328
+ }
329
+ return {
330
+ _id: `${org._id}-${page.value}`,
331
+ ...org,
332
+ sites: `${sitesCount} Sites`,
333
+ planType,
334
+ billingCycle,
335
+ subscriptionStart,
336
+ subscriptionEnd,
337
+ status: org?.status ?? status,
338
+ }
240
339
  }
241
340
 
242
- async function fetchSuspended() {
243
- const res = await getAllV2({
244
- page: page.value,
341
+ async function fetchOrgs(status: 'active' | 'suspended') {
342
+ const res = await getAllV2({
343
+ page: page.value,
245
344
  search: search.value,
246
- limit: limit.value,
247
- status: 'suspended',
345
+ limit: limit.value,
346
+ status,
347
+ // truyền filter xuống API nếu backend hỗ trợ
348
+ ...(filterPlan.value ? { app: filterPlan.value } : {}),
349
+ ...(filterBilling.value ? { billingCycle: filterBilling.value } : {}),
248
350
  })
249
- const orgs = res?.data?.items || res?.items || []
250
-
251
- const mapped = await Promise.all(
252
- orgs.map(async (org: any) => {
253
- let sitesCount = 0
254
- let planType = '-'
255
- let billingCycle = '-'
256
- let subscriptionStart = '-'
257
- let subscriptionEnd = '-'
258
-
259
- try {
260
- const siteRes = await getCustomerSitesByOrgId(org._id)
261
- sitesCount =
262
- siteRes?.data?.totalItems ||
263
- siteRes?.totalItems ||
264
- siteRes?.data?.items?.length ||
265
- siteRes?.items?.length ||
266
- 0
267
-
268
- const subRes = await getSubscriptionByOrgId(org._id)
269
- const subscription = subRes?.data || subRes
270
- if (subscription) {
271
- planType = planTypeLabel(subscription)
272
- billingCycle = subscription?.billingCycle || '-'
273
- subscriptionStart = formatDate(subscription?.createdAt)
274
- subscriptionEnd = formatDate(subscription?.nextBillingDate)
275
- }
276
- } catch (e) {
277
- console.error('Error mapping suspended org:', org._id, e)
278
- }
279
-
280
- return {
281
- _id: `${org._id}-${page.value}`,
282
- ...org,
283
- sites: `${sitesCount} Sites`,
284
- planType,
285
- billingCycle,
286
- subscriptionStart,
287
- subscriptionEnd,
288
- status: org?.status || 'suspended',
289
- actions: '—',
290
- }
291
- })
292
- )
293
351
 
294
- items.value = []
352
+ const orgs = res?.data?.items ?? res?.items ?? []
353
+ const mapped = await Promise.all(orgs.map((org: any) => mapOrg(org, status)))
354
+
355
+ items.value = []
295
356
  await nextTick()
296
- items.value = [...mapped]
297
- pages.value = res?.pages || 1
298
- const match = res?.pageRange?.match(/of\s+(\d+)/i)
299
- totalItems.value = match ? Number(match[1]) : mapped.length
357
+ items.value = [...mapped]
358
+ pages.value = res?.pages ?? 1
359
+ totalItems.value = parseTotalItems(res?.pageRange, mapped.length)
300
360
  }
301
361
 
302
362
  async function fetchPending() {
303
363
  const res = await getVerifications({
304
364
  status: 'pending',
305
- type: 'user-invite',
306
- page: page.value,
307
- search: search.value,
365
+ type: 'user-invite',
366
+ page: page.value,
367
+ search: search.value, // search hoạt động đúng ở pending
308
368
  })
309
369
 
310
- items.value = [
311
- ...(await Promise.all(
312
- (res?.items || []).map(async (i: any) => ({
370
+ items.value = await Promise.all(
371
+ (res?.items ?? []).map(async (i: any) => ({
313
372
  ...i,
314
- app:
315
- APP_LIST.find(a => a.value === i.metadata?.app)?.title ||
316
- i.metadata?.app ||
317
- '-',
318
-
373
+ app: APP_LIST.find(a => a.value === i.metadata?.app)?.title ?? i.metadata?.app ?? '-',
319
374
  role: await resolveRoleName(i.metadata?.role),
320
375
  }))
321
- ))
322
- ]
323
-
324
- pages.value = res?.pages || 1
325
-
326
- const match = res?.pageRange?.match(/of\s+(\d+)/i)
327
-
328
- totalItems.value = match
329
- ? Number(match[1])
330
- : items.value.length
376
+ )
377
+ pages.value = res?.pages ?? 1
378
+ totalItems.value = parseTotalItems(res?.pageRange, items.value.length)
331
379
  }
332
380
 
333
381
  async function fetchData() {
334
382
  loading.value = true
335
-
336
383
  try {
337
- if (tab.value === 'active') {
338
- await fetchActive()
339
- }
340
- else if (tab.value === 'suspended') {
341
- await fetchSuspended()
342
- }
343
- else {
384
+ if (tab.value === 'pending') {
344
385
  await fetchPending()
386
+ } else {
387
+ await fetchOrgs(tab.value)
345
388
  }
346
389
  } finally {
347
390
  loading.value = false
348
391
  }
349
392
  }
350
393
 
351
- /* ================= EVENTS ================= */
394
+ /* ── WATCH: filterStatus select đổi → sync tab ── */
395
+ watch(filterStatus, (val) => {
396
+ tab.value = val
397
+ page.value = 1
398
+ fetchData()
399
+ })
400
+
401
+ /* ── EVENTS ── */
352
402
  async function handleUpdatePage(p: number) {
353
403
  page.value = p
354
404
  await fetchData()
355
405
  }
356
- async function onTabChange() {
406
+
407
+ async function onTabChange(value: TabStatus) {
408
+ tab.value = value
409
+ // sync filterStatus khi click tab (chỉ 2 giá trị active/suspended)
410
+ if (value !== 'pending') filterStatus.value = value
357
411
  page.value = 1
358
412
  await fetchData()
359
413
  }
@@ -362,27 +416,35 @@ function handleRefresh() {
362
416
  fetchData()
363
417
  }
364
418
 
365
- let searchTimeout: any = null
366
-
419
+ let searchTimeout: ReturnType<typeof setTimeout> | null = null
367
420
  function handleSearch() {
368
- clearTimeout(searchTimeout)
369
-
370
- searchTimeout = setTimeout(async () => {
421
+ if (searchTimeout) clearTimeout(searchTimeout)
422
+ searchTimeout = setTimeout(() => {
371
423
  page.value = 1
372
- await fetchData()
373
- }, 500)
374
- }
375
- /* ================= DIALOG ================= */
376
- function openInviteDialog() {
377
- dialog.value = true
424
+ fetchData() // dùng search.value trực tiếp, không truyền param
425
+ }, 300)
378
426
  }
379
427
 
380
- function handleSuccess() {
381
- dialog.value = false
428
+ function handleFilterChange() {
429
+ // filterStatus watch sẽ tự trigger fetchData nếu status đổi
430
+ // nếu chỉ plan/billing thay đổi thì gọi thủ công
431
+ page.value = 1
382
432
  fetchData()
383
433
  }
384
434
 
385
- /* ================= CANCEL INVITE ================= */
435
+ /* ── MENU ── */
436
+ function toggleMenu(id: string) {
437
+ openMenuId.value = openMenuId.value === id ? null : id
438
+ }
439
+ function closeMenu() {
440
+ openMenuId.value = null
441
+ }
442
+
443
+ /* ── DIALOG ── */
444
+ function openInviteDialog() { dialog.value = true }
445
+ function handleSuccess() { dialog.value = false; fetchData() }
446
+
447
+ /* ── CANCEL INVITE ── */
386
448
  async function cancelInvite(item: any) {
387
449
  try {
388
450
  cancelLoadingId.value = item._id
@@ -393,8 +455,366 @@ async function cancelInvite(item: any) {
393
455
  }
394
456
  }
395
457
 
396
- /* ================= INIT ================= */
397
- onMounted(() => {
398
- fetchData()
399
- })
458
+ onMounted(() => fetchData())
400
459
  </script>
460
+
461
+ <style scoped>
462
+ * { box-sizing: border-box; margin: 0; padding: 0; }
463
+
464
+ .page-wrap {
465
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
466
+ font-size: 14px;
467
+ color: #111827;
468
+ background: transparent;
469
+ }
470
+
471
+ /* ===== TOPBAR ===== */
472
+ .topbar {
473
+ display: flex;
474
+ align-items: center;
475
+ gap: 12px;
476
+ padding: 12px 0;
477
+ background: transparent;
478
+ }
479
+ .topbar-right {
480
+ margin-left: auto;
481
+ display: flex;
482
+ align-items: center;
483
+ gap: 12px;
484
+ }
485
+ .btn-invite {
486
+ background: #fff;
487
+ color: #111827;
488
+ border: 1px solid #D1D5DB;
489
+ border-radius: 24px;
490
+ padding: 10px 22px;
491
+ font-size: 14px;
492
+ font-weight: 600;
493
+ cursor: pointer;
494
+ white-space: nowrap;
495
+ transition: background 0.15s;
496
+ }
497
+ .btn-invite:hover { background: #F9FAFB; }
498
+
499
+ .search-wrap {
500
+ position: relative;
501
+ width: 260px;
502
+ flex-shrink: 0;
503
+ }
504
+ .search-input {
505
+ width: 100%;
506
+ height: 36px;
507
+ border: 1px solid #E5E7EB;
508
+ border-radius: 8px;
509
+ padding: 0 34px 0 12px;
510
+ font-size: 13px;
511
+ color: #111827;
512
+ background: #fff;
513
+ outline: none;
514
+ }
515
+ .search-input:focus { border-color: #9CA3AF; }
516
+ .search-input::placeholder { color: #9CA3AF; }
517
+ .search-icon {
518
+ position: absolute;
519
+ right: 12px;
520
+ top: 50%;
521
+ transform: translateY(-50%);
522
+ width: 15px;
523
+ height: 15px;
524
+ pointer-events: none;
525
+ }
526
+ .clear-btn {
527
+ position: absolute;
528
+ right: 32px;
529
+ top: 50%;
530
+ transform: translateY(-50%);
531
+ border: none;
532
+ background: transparent;
533
+ color: #9CA3AF;
534
+ font-size: 16px;
535
+ cursor: pointer;
536
+ line-height: 1;
537
+ padding: 0;
538
+ }
539
+ .clear-btn:hover { color: #374151; }
540
+
541
+ .filter-wrap {
542
+ display: flex;
543
+ gap: 10px;
544
+ flex-shrink: 0;
545
+ }
546
+ .filter-field {
547
+ position: relative;
548
+ display: flex;
549
+ flex-direction: column;
550
+ }
551
+ .filter-field label {
552
+ position: absolute;
553
+ top: -8px;
554
+ left: 9px;
555
+ font-size: 10px;
556
+ color: #6B7280;
557
+ background: #fff;
558
+ padding: 0 3px;
559
+ pointer-events: none;
560
+ z-index: 1;
561
+ white-space: nowrap;
562
+ }
563
+ .filter-field select {
564
+ height: 36px;
565
+ border: 1px solid #E5E7EB;
566
+ border-radius: 8px;
567
+ padding: 0 28px 0 10px;
568
+ font-size: 13px;
569
+ color: #111827;
570
+ background: #fff;
571
+ appearance: none;
572
+ cursor: pointer;
573
+ outline: none;
574
+ min-width: 110px;
575
+ }
576
+ .filter-field select:focus { border-color: #9CA3AF; }
577
+ .chevron {
578
+ position: absolute;
579
+ right: 9px;
580
+ top: 50%;
581
+ transform: translateY(-50%);
582
+ width: 10px;
583
+ height: 6px;
584
+ pointer-events: none;
585
+ }
586
+
587
+ /* ===== TABLE CARD ===== */
588
+ .table-card {
589
+ margin: 12px 0 0 0;
590
+ background: #fff;
591
+ border: 1px solid #E5E7EB;
592
+ border-radius: 10px;
593
+ overflow: hidden;
594
+ }
595
+
596
+ /* ===== TOOLBAR ===== */
597
+ .toolbar {
598
+ display: flex;
599
+ align-items: center;
600
+ justify-content: space-between;
601
+ padding: 6px 12px;
602
+ background: #fff;
603
+ border-bottom: 1px solid #F3F4F6;
604
+ }
605
+ .toolbar-left { display: flex; align-items: center; }
606
+ .toolbar-right { display: flex; align-items: center; gap: 6px; }
607
+
608
+ .refresh-btn {
609
+ background: none;
610
+ border: none;
611
+ cursor: pointer;
612
+ color: #6B7280;
613
+ width: 28px;
614
+ height: 28px;
615
+ border-radius: 6px;
616
+ display: flex;
617
+ align-items: center;
618
+ justify-content: center;
619
+ transition: background 0.15s;
620
+ }
621
+ .refresh-btn:hover { background: #F3F4F6; }
622
+ .refresh-btn.spinning svg { animation: spin 0.8s linear infinite; }
623
+ @keyframes spin { to { transform: rotate(360deg); } }
624
+
625
+ .page-range-text { font-size: 12px; color: #6B7280; }
626
+ .page-btn {
627
+ background: none;
628
+ border: 1px solid #E5E7EB;
629
+ border-radius: 5px;
630
+ width: 24px;
631
+ height: 24px;
632
+ display: flex;
633
+ align-items: center;
634
+ justify-content: center;
635
+ cursor: pointer;
636
+ color: #374151;
637
+ transition: background 0.1s;
638
+ }
639
+ .page-btn:hover:not(:disabled) { background: #F3F4F6; }
640
+ .page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
641
+
642
+ /* ===== TABS ===== */
643
+ .tabs-row {
644
+ display: flex;
645
+ border-bottom: 1px solid #E5E7EB;
646
+ padding: 0 16px;
647
+ }
648
+ .tab {
649
+ padding: 10px 14px;
650
+ font-size: 13px;
651
+ color: #6B7280;
652
+ background: none;
653
+ border: none;
654
+ border-bottom: 2px solid transparent;
655
+ cursor: pointer;
656
+ margin-bottom: -1px;
657
+ transition: color 0.15s, border-color 0.15s;
658
+ }
659
+ .tab:hover { color: #374151; }
660
+ .tab.active { color: #111827; border-bottom-color: #111827; font-weight: 500; }
661
+
662
+ /* ===== LOADING BAR ===== */
663
+ .loading-bar { height: 2px; background: #F3F4F6; overflow: hidden; }
664
+ .loading-bar-fill {
665
+ height: 100%;
666
+ width: 35%;
667
+ background: #6B7280;
668
+ animation: slide 1.1s ease-in-out infinite;
669
+ }
670
+ @keyframes slide {
671
+ 0% { transform: translateX(-150%); }
672
+ 100% { transform: translateX(400%); }
673
+ }
674
+
675
+ /* ===== TABLE ===== */
676
+ .table-scroll { overflow-x: auto; }
677
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
678
+ thead th {
679
+ padding: 10px 16px;
680
+ text-align: left;
681
+ font-weight: 600;
682
+ font-size: 13px;
683
+ color: #374151;
684
+ border-bottom: 1px solid #E5E7EB;
685
+ white-space: nowrap;
686
+ background: #fff;
687
+ }
688
+ tbody tr { border-bottom: 1px solid #F3F4F6; transition: background 0.1s; }
689
+ tbody tr:last-child { border-bottom: none; }
690
+ tbody tr:hover { background: #FAFAFA; }
691
+ tbody td { padding: 14px 16px; vertical-align: middle; }
692
+
693
+ .empty { text-align: center; color: #9CA3AF; padding: 48px 0 !important; }
694
+ .td-name { font-weight: 500; color: #111827; }
695
+ .td-muted { color: #6B7280; font-size: 13px; }
696
+
697
+ .sites-badge {
698
+ display: inline-block;
699
+ background: #F3F4F6;
700
+ border-radius: 20px;
701
+ padding: 3px 12px;
702
+ font-size: 12px;
703
+ color: #374151;
704
+ }
705
+ .date-cell {
706
+ display: inline-flex;
707
+ align-items: center;
708
+ gap: 5px;
709
+ font-size: 13px;
710
+ color: #374151;
711
+ }
712
+
713
+ /* ===== STATUS CHIPS ===== */
714
+ .status-chip {
715
+ display: inline-flex;
716
+ align-items: center;
717
+ gap: 5px;
718
+ border-radius: 20px;
719
+ padding: 4px 12px;
720
+ font-size: 12px;
721
+ font-weight: 500;
722
+ }
723
+ .status-chip::before { content: ''; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
724
+ .status-chip.active { background: #D1FAE5; color: #065F46; }
725
+ .status-chip.active::before { background: #10B981; }
726
+ .status-chip.suspended { background: #FEE2E2; color: #991B1B; }
727
+ .status-chip.suspended::before { background: #EF4444; }
728
+ .status-chip.pending { background: #FEF9C3; color: #854D0E; }
729
+ .status-chip.pending::before { background: #EAB308; }
730
+
731
+ /* ===== ACTION MENU ===== */
732
+ .action-wrap { position: relative; }
733
+ .dots-btn {
734
+ display: inline-flex;
735
+ align-items: center;
736
+ gap: 3px;
737
+ background: none;
738
+ border: none;
739
+ border-radius: 6px;
740
+ padding: 6px 8px;
741
+ cursor: pointer;
742
+ color: #9CA3AF;
743
+ transition: background 0.15s;
744
+ }
745
+ .dots-btn:hover { background: #F3F4F6; color: #374151; }
746
+ .dots-btn span { display: block; width: 4px; height: 4px; border-radius: 50%; background: currentColor; }
747
+
748
+ .dropdown {
749
+ position: absolute;
750
+ right: 0;
751
+ top: calc(100% + 4px);
752
+ background: #fff;
753
+ border: 1px solid #E5E7EB;
754
+ border-radius: 8px;
755
+ box-shadow: 0 4px 12px rgba(0,0,0,0.08);
756
+ min-width: 130px;
757
+ z-index: 50;
758
+ overflow: hidden;
759
+ }
760
+ .dd-item {
761
+ display: block;
762
+ width: 100%;
763
+ padding: 9px 14px;
764
+ font-size: 13px;
765
+ text-align: left;
766
+ background: none;
767
+ border: none;
768
+ cursor: pointer;
769
+ color: #374151;
770
+ transition: background 0.1s;
771
+ }
772
+ .dd-item:hover { background: #F9FAFB; }
773
+ .dd-item.danger { color: #DC2626; }
774
+ .dd-item.danger:hover { background: #FEF2F2; }
775
+
776
+ /* ===== CANCEL INVITE ===== */
777
+ .cancel-btn {
778
+ display: inline-flex;
779
+ align-items: center;
780
+ gap: 6px;
781
+ padding: 5px 12px;
782
+ font-size: 12px;
783
+ color: #DC2626;
784
+ background: #FEF2F2;
785
+ border: 1px solid #FECACA;
786
+ border-radius: 6px;
787
+ cursor: pointer;
788
+ transition: background 0.15s;
789
+ }
790
+ .cancel-btn:hover { background: #FEE2E2; }
791
+ .cancel-btn:disabled { opacity: 0.5; cursor: not-allowed; }
792
+ .spinner {
793
+ display: inline-block;
794
+ width: 11px;
795
+ height: 11px;
796
+ border: 1.5px solid #FECACA;
797
+ border-top-color: #DC2626;
798
+ border-radius: 50%;
799
+ animation: spin 0.7s linear infinite;
800
+ }
801
+
802
+ /* ===== DIALOG ===== */
803
+ .overlay {
804
+ position: fixed;
805
+ inset: 0;
806
+ background: rgba(0,0,0,0.35);
807
+ display: flex;
808
+ align-items: center;
809
+ justify-content: center;
810
+ z-index: 1000;
811
+ }
812
+ .dialog-box {
813
+ background: #fff;
814
+ border-radius: 12px;
815
+ max-width: 500px;
816
+ width: 90%;
817
+ box-shadow: 0 16px 48px rgba(0,0,0,0.12);
818
+ overflow: hidden;
819
+ }
820
+ </style>