@7365admin1/layer-common 1.11.43 → 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.
- package/CHANGELOG.md +12 -0
- package/components/ClientMain.vue +719 -299
- package/components/DashboardMain.vue +2041 -117
- package/components/DocumentManagement.vue +351 -53
- package/components/InvitationClientForm.vue +23 -10
- package/components/ServiceProviderMain.vue +856 -113
- package/components/SlideCardGroup.vue +1 -1
- package/components/TableV3.vue +179 -0
- package/composables/useOrg.ts +9 -1
- package/composables/useOrganizationSwitcher.ts +11 -0
- package/composables/useServiceProvider.ts +42 -18
- package/composables/useUser.ts +36 -30
- package/composables/useVerification.ts +5 -2
- package/package.json +1 -1
|
@@ -1,76 +1,222 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
</
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
</
|
|
61
|
-
</
|
|
62
|
-
</
|
|
63
|
-
</
|
|
64
|
-
|
|
65
|
-
<!--
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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:
|
|
103
|
-
{ title:
|
|
104
|
-
{ title:
|
|
105
|
-
{ title:
|
|
106
|
-
{ title:
|
|
107
|
-
{ title:
|
|
108
|
-
{ title:
|
|
109
|
-
{ title:
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
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
|
|
140
|
-
const
|
|
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
|
-
/*
|
|
276
|
+
/* ── HELPERS ── */
|
|
146
277
|
function formatDate(date?: string | Date) {
|
|
147
278
|
if (date == null || date === '') return '-'
|
|
148
|
-
const d =
|
|
149
|
-
|
|
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')
|
|
287
|
+
if (sub.type === 'affiliate') return 'Affiliate'
|
|
159
288
|
const desc = String(sub.description || '').trim()
|
|
160
|
-
|
|
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
|
|
168
|
-
const
|
|
169
|
-
roleCache.value[id] =
|
|
170
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
243
|
-
const res
|
|
244
|
-
page:
|
|
341
|
+
async function fetchOrgs(status: 'active' | 'suspended') {
|
|
342
|
+
const res = await getAllV2({
|
|
343
|
+
page: page.value,
|
|
245
344
|
search: search.value,
|
|
246
|
-
limit:
|
|
247
|
-
status
|
|
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
|
-
|
|
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
|
|
297
|
-
pages.value
|
|
298
|
-
|
|
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:
|
|
306
|
-
page:
|
|
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
|
-
|
|
311
|
-
|
|
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 === '
|
|
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
|
-
/*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
373
|
-
},
|
|
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
|
|
381
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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>
|