|
|
@@ -8,16 +8,23 @@
|
|
|
</div>
|
|
|
|
|
|
<div class="content">
|
|
|
- <!-- Universal Search -->
|
|
|
- <div class="search-box">
|
|
|
- <input
|
|
|
- v-model="searchQuery"
|
|
|
- type="text"
|
|
|
- :placeholder="$t('devices.searchPlaceholder')"
|
|
|
- class="search-input"
|
|
|
- @input="onSearch"
|
|
|
- />
|
|
|
- <span v-if="searchQuery" class="search-clear" @click="clearSearch">×</span>
|
|
|
+ <!-- Search and Filters -->
|
|
|
+ <div class="filters-row">
|
|
|
+ <div class="search-box">
|
|
|
+ <input
|
|
|
+ v-model="searchQuery"
|
|
|
+ type="text"
|
|
|
+ :placeholder="$t('devices.searchPlaceholder')"
|
|
|
+ class="search-input"
|
|
|
+ @input="onSearch"
|
|
|
+ />
|
|
|
+ <span v-if="searchQuery" class="search-clear" @click="clearSearch">×</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <label class="filter-checkbox">
|
|
|
+ <input type="checkbox" v-model="onlineOnly" @change="loadDevices" />
|
|
|
+ <span>{{ $t('devices.onlineOnly') }}</span>
|
|
|
+ </label>
|
|
|
</div>
|
|
|
|
|
|
<div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
|
|
|
@@ -27,26 +34,33 @@
|
|
|
<table v-else-if="devices.length > 0" class="data-table">
|
|
|
<thead>
|
|
|
<tr>
|
|
|
- <th>{{ $t('devices.simpleId') }}</th>
|
|
|
- <th>{{ $t('devices.macAddress') }}</th>
|
|
|
- <th>{{ $t('devices.organization') }}</th>
|
|
|
- <th>{{ $t('common.status') }}</th>
|
|
|
- <th>{{ $t('devices.lastSeen') }}</th>
|
|
|
+ <th @click="sortBy('simple_id')">
|
|
|
+ {{ $t('devices.simpleId') }}
|
|
|
+ <span v-if="sortColumn === 'simple_id'">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
|
|
|
+ </th>
|
|
|
+ <th @click="sortBy('mac_address')">
|
|
|
+ {{ $t('devices.macAddress') }}
|
|
|
+ <span v-if="sortColumn === 'mac_address'">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
|
|
|
+ </th>
|
|
|
+ <th @click="sortBy('status')">
|
|
|
+ {{ $t('common.status') }}
|
|
|
+ <span v-if="sortColumn === 'status'">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
|
|
|
+ </th>
|
|
|
+ <th @click="sortBy('last_seen_at')">
|
|
|
+ {{ $t('devices.lastSeen') }}
|
|
|
+ <span v-if="sortColumn === 'last_seen_at'">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
|
|
|
+ </th>
|
|
|
<th>{{ $t('common.actions') }}</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
- <tr v-for="device in devices" :key="device.id">
|
|
|
+ <tr v-for="device in sortedDevices" :key="device.id">
|
|
|
<td><strong>#{{ device.simple_id }}</strong></td>
|
|
|
<td><code>{{ device.mac_address }}</code></td>
|
|
|
- <td>{{ getOrganizationName(device.organization_id) }}</td>
|
|
|
<td><span class="badge" :class="`status-${device.status}`">{{ $t(`devices.${device.status}`) }}</span></td>
|
|
|
<td>{{ formatDate(device.last_seen_at) }}</td>
|
|
|
<td>
|
|
|
- <div class="actions">
|
|
|
- <button @click="showEditModal(device)" class="btn-icon" title="Edit">✏️</button>
|
|
|
- <button @click="confirmDelete(device)" class="btn-icon" title="Delete">🗑️</button>
|
|
|
- </div>
|
|
|
+ <button @click="showEditModal(device)" class="btn-icon" title="Edit">✏️</button>
|
|
|
</td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
@@ -84,12 +98,9 @@
|
|
|
</div>
|
|
|
|
|
|
<div class="form-group">
|
|
|
- <label>{{ $t('common.status') }}</label>
|
|
|
- <select v-model="form.status">
|
|
|
- <option value="online">Online</option>
|
|
|
- <option value="offline">Offline</option>
|
|
|
- <option value="error">Error</option>
|
|
|
- </select>
|
|
|
+ <label>{{ $t('common.status') }} (auto)</label>
|
|
|
+ <input :value="$t(`devices.${editingDevice?.status}`)" type="text" disabled />
|
|
|
+ <p class="form-hint">{{ $t('devices.statusHint') }}</p>
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-footer">
|
|
|
@@ -102,28 +113,11 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- Delete Confirmation Modal -->
|
|
|
- <div v-if="deleteConfirmVisible" class="modal-overlay" @click="deleteConfirmVisible = false">
|
|
|
- <div class="modal modal-sm" @click.stop>
|
|
|
- <div class="modal-header">
|
|
|
- <h2>{{ $t('common.confirm') }}</h2>
|
|
|
- </div>
|
|
|
- <div class="modal-body">
|
|
|
- <p>Delete device <strong>#{{ deviceToDelete?.simple_id }}</strong> ({{ deviceToDelete?.mac_address }})?</p>
|
|
|
- </div>
|
|
|
- <div class="modal-footer">
|
|
|
- <button @click="deleteConfirmVisible = false" class="btn-secondary">{{ $t('common.cancel') }}</button>
|
|
|
- <button @click="deleteDevice" :disabled="deleting" class="btn-danger">
|
|
|
- {{ deleting ? $t('common.loading') : $t('common.delete') }}
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, onMounted } from 'vue'
|
|
|
+import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
|
import devicesApi from '@/api/devices'
|
|
|
import organizationsApi from '@/api/organizations'
|
|
|
|
|
|
@@ -132,19 +126,54 @@ const organizations = ref([])
|
|
|
const loading = ref(false)
|
|
|
const error = ref(null)
|
|
|
const modalVisible = ref(false)
|
|
|
-const deleteConfirmVisible = ref(false)
|
|
|
const editingDevice = ref(null)
|
|
|
-const deviceToDelete = ref(null)
|
|
|
const saving = ref(false)
|
|
|
-const deleting = ref(false)
|
|
|
const searchQuery = ref('')
|
|
|
+const onlineOnly = ref(false)
|
|
|
+const sortColumn = ref('simple_id')
|
|
|
+const sortDirection = ref('desc')
|
|
|
let searchDebounceTimer = null
|
|
|
+let pollingInterval = null
|
|
|
|
|
|
const form = ref({
|
|
|
- organization_id: null,
|
|
|
- status: 'offline'
|
|
|
+ organization_id: null
|
|
|
})
|
|
|
|
|
|
+const sortedDevices = computed(() => {
|
|
|
+ let result = [...devices.value]
|
|
|
+
|
|
|
+ // Sort
|
|
|
+ result.sort((a, b) => {
|
|
|
+ let aVal = a[sortColumn.value]
|
|
|
+ let bVal = b[sortColumn.value]
|
|
|
+
|
|
|
+ // Handle nulls
|
|
|
+ if (aVal === null || aVal === undefined) return 1
|
|
|
+ if (bVal === null || bVal === undefined) return -1
|
|
|
+
|
|
|
+ // String comparison
|
|
|
+ if (typeof aVal === 'string') {
|
|
|
+ aVal = aVal.toLowerCase()
|
|
|
+ bVal = bVal.toLowerCase()
|
|
|
+ }
|
|
|
+
|
|
|
+ if (aVal < bVal) return sortDirection.value === 'asc' ? -1 : 1
|
|
|
+ if (aVal > bVal) return sortDirection.value === 'asc' ? 1 : -1
|
|
|
+ return 0
|
|
|
+ })
|
|
|
+
|
|
|
+ return result
|
|
|
+})
|
|
|
+
|
|
|
+function sortBy(column) {
|
|
|
+ if (sortColumn.value === column) {
|
|
|
+ sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
|
|
+ } else {
|
|
|
+ sortColumn.value = column
|
|
|
+ sortDirection.value = 'asc'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
async function loadDevices() {
|
|
|
loading.value = true
|
|
|
error.value = null
|
|
|
@@ -153,6 +182,9 @@ async function loadDevices() {
|
|
|
if (searchQuery.value && searchQuery.value.length >= 2) {
|
|
|
params.search = searchQuery.value
|
|
|
}
|
|
|
+ if (onlineOnly.value) {
|
|
|
+ params.status = 'online'
|
|
|
+ }
|
|
|
devices.value = await devicesApi.getAllSuperadmin(params)
|
|
|
} catch (err) {
|
|
|
error.value = err.response?.data?.detail || 'Failed to load devices'
|
|
|
@@ -182,12 +214,6 @@ async function loadOrganizations() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function getOrganizationName(orgId) {
|
|
|
- if (!orgId) return 'Unassigned'
|
|
|
- const org = organizations.value.find(o => o.id === orgId)
|
|
|
- return org ? org.name : `Org #${orgId}`
|
|
|
-}
|
|
|
-
|
|
|
function formatDate(dateStr) {
|
|
|
if (!dateStr) return 'Never'
|
|
|
const date = new Date(dateStr)
|
|
|
@@ -197,8 +223,7 @@ function formatDate(dateStr) {
|
|
|
function showEditModal(device) {
|
|
|
editingDevice.value = device
|
|
|
form.value = {
|
|
|
- organization_id: device.organization_id,
|
|
|
- status: device.status
|
|
|
+ organization_id: device.organization_id
|
|
|
}
|
|
|
modalVisible.value = true
|
|
|
}
|
|
|
@@ -221,27 +246,25 @@ async function saveDevice() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function confirmDelete(device) {
|
|
|
- deviceToDelete.value = device
|
|
|
- deleteConfirmVisible.value = true
|
|
|
-}
|
|
|
-
|
|
|
-async function deleteDevice() {
|
|
|
- deleting.value = true
|
|
|
- try {
|
|
|
- await devicesApi.deleteSuperadmin(deviceToDelete.value.id)
|
|
|
- await loadDevices()
|
|
|
- deleteConfirmVisible.value = false
|
|
|
- } catch (err) {
|
|
|
- alert(err.response?.data?.detail || 'Failed to delete device')
|
|
|
- } finally {
|
|
|
- deleting.value = false
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
onMounted(() => {
|
|
|
loadDevices()
|
|
|
loadOrganizations()
|
|
|
+
|
|
|
+ // Real-time polling every 10 seconds
|
|
|
+ pollingInterval = setInterval(() => {
|
|
|
+ if (!modalVisible.value) {
|
|
|
+ loadDevices()
|
|
|
+ }
|
|
|
+ }, 10000)
|
|
|
+})
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ if (pollingInterval) {
|
|
|
+ clearInterval(pollingInterval)
|
|
|
+ }
|
|
|
+ if (searchDebounceTimer) {
|
|
|
+ clearTimeout(searchDebounceTimer)
|
|
|
+ }
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
@@ -252,27 +275,31 @@ onMounted(() => {
|
|
|
.page-header p { color: #718096; font-size: 16px; }
|
|
|
.content { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
|
|
|
|
|
|
-/* Universal Search */
|
|
|
-.search-box { position: relative; margin-bottom: 20px; }
|
|
|
-.search-input { width: 100%; padding: 12px 40px 12px 16px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; }
|
|
|
+/* Filters Row */
|
|
|
+.filters-row { display: flex; gap: 16px; align-items: center; margin-bottom: 20px; }
|
|
|
+.search-box { position: relative; flex: 1; max-width: 400px; }
|
|
|
+.search-input { width: 100%; padding: 10px 40px 10px 14px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; }
|
|
|
.search-input:focus { outline: none; border-color: #667eea; }
|
|
|
.search-input::placeholder { color: #a0aec0; }
|
|
|
.search-clear { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); font-size: 24px; color: #a0aec0; cursor: pointer; line-height: 1; padding: 0 4px; }
|
|
|
.search-clear:hover { color: #718096; }
|
|
|
+.filter-checkbox { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; white-space: nowrap; }
|
|
|
+.filter-checkbox input[type="checkbox"] { cursor: pointer; width: 16px; height: 16px; }
|
|
|
+.filter-checkbox span { color: #4a5568; font-size: 14px; }
|
|
|
.loading, .error, .empty { text-align: center; padding: 40px; color: #718096; }
|
|
|
.error { color: #e53e3e; }
|
|
|
.data-table { width: 100%; border-collapse: collapse; }
|
|
|
-.data-table th { text-align: left; padding: 12px; border-bottom: 2px solid #e2e8f0; font-weight: 600; color: #4a5568; font-size: 14px; }
|
|
|
-.data-table td { padding: 12px; border-bottom: 1px solid #e2e8f0; color: #1a202c; }
|
|
|
+.data-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid #e2e8f0; font-weight: 600; color: #4a5568; font-size: 13px; cursor: pointer; user-select: none; }
|
|
|
+.data-table th:hover { background: #f7fafc; }
|
|
|
+.data-table td { padding: 6px 12px; border-bottom: 1px solid #e2e8f0; color: #1a202c; font-size: 14px; }
|
|
|
.data-table tbody tr:hover { background: #f7fafc; }
|
|
|
code { background: #f7fafc; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 13px; }
|
|
|
.badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; background: #e2e8f0; color: #718096; }
|
|
|
.badge.status-online { background: #c6f6d5; color: #22543d; }
|
|
|
.badge.status-offline { background: #e2e8f0; color: #718096; }
|
|
|
.badge.status-error { background: #fed7d7; color: #742a2a; }
|
|
|
-.actions { display: flex; gap: 8px; }
|
|
|
-.btn-icon { padding: 4px 8px; background: none; border: none; cursor: pointer; font-size: 16px; opacity: 0.7; transition: opacity 0.2s; }
|
|
|
-.btn-icon:hover { opacity: 1; }
|
|
|
+.btn-icon { padding: 6px 10px; background: none; border: none; cursor: pointer; font-size: 16px; opacity: 0.7; transition: opacity 0.2s; }
|
|
|
+.btn-icon:hover { opacity: 1; background: #f7fafc; border-radius: 4px; }
|
|
|
.btn-primary { padding: 12px 24px; background: #667eea; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
|
|
.btn-primary:hover { background: #5568d3; }
|
|
|
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
|
@@ -294,4 +321,5 @@ code { background: #f7fafc; padding: 4px 8px; border-radius: 4px; font-family: m
|
|
|
.form-group input, .form-group select { width: 100%; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; }
|
|
|
.form-group input:focus, .form-group select:focus { outline: none; border-color: #667eea; }
|
|
|
.form-group input:disabled { background: #f7fafc; color: #718096; }
|
|
|
+.form-hint { margin-top: 6px; font-size: 12px; color: #718096; }
|
|
|
</style>
|