Browse Source

feat: Major UI improvements for Devices table

UI Changes:
- Компактная таблица (padding 8px/6px вместо 12px)
- Сортировка по всем колонкам (клик на заголовок)
- Убрана колонка Organization (не нужна)
- Добавлен фильтр "Только онлайн"
- Убрано редактируемое поле Status (теперь read-only с hint)
- Убрана кнопка Delete из Actions
- Удалено модальное окно подтверждения удаления
- Real-time polling каждые 10 секунд

Features:
- Sortable columns with ↑/↓ indicators
- Online-only filter checkbox
- Status auto-calculated (online if config < 60 sec ago)
- Live data updates without page refresh
- Cleaner single-button Actions column

Removed:
- Organization column (not needed in devices list)
- Delete button (странный функционал)
- Status editing (должен быть автоматическим)

Updated i18n:
- devices.onlineOnly
- devices.statusHint
- devices.searchPlaceholder (без organization)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
root 1 month ago
parent
commit
4c383f8b88
3 changed files with 118 additions and 86 deletions
  1. BIN
      2025-12-28_17-42.png
  2. 6 2
      frontend/src/i18n/index.js
  3. 112 84
      frontend/src/views/superadmin/DevicesView.vue

BIN
2025-12-28_17-42.png


+ 6 - 2
frontend/src/i18n/index.js

@@ -61,7 +61,9 @@ const messages = {
       macAddress: 'MAC Address',
       organization: 'Organization',
       lastSeen: 'Last Seen',
-      searchPlaceholder: 'Search by MAC, ID, organization...',
+      searchPlaceholder: 'Search by MAC, ID...',
+      onlineOnly: 'Online only',
+      statusHint: 'Auto: online if fetched config < 60 sec ago',
       manageAction: 'Manage Devices',
       manageDesc: 'View and assign devices',
       online: 'Online',
@@ -154,7 +156,9 @@ const messages = {
       macAddress: 'MAC адрес',
       organization: 'Организация',
       lastSeen: 'Последняя активность',
-      searchPlaceholder: 'Поиск по MAC, ID, организации...',
+      searchPlaceholder: 'Поиск по MAC, ID...',
+      onlineOnly: 'Только онлайн',
+      statusHint: 'Авто: онлайн если забирал конфиг < 60 сек назад',
       manageAction: 'Управление устройствами',
       manageDesc: 'Просмотр и назначение устройств',
       online: 'Онлайн',

+ 112 - 84
frontend/src/views/superadmin/DevicesView.vue

@@ -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>