Browse Source

Implement Organizations CRUD interface

Full-featured admin interface for organizations management:

Features:
- List view with table (ID, name, contact, modules, status)
- Create organization modal with form validation
- Edit organization with pre-filled data
- Delete confirmation dialog
- Loading states and error handling
- WiFi/BLE enabled badges
- Status badges (pending/active/suspended)
- Fully localized (RU/EN)

UI Components:
- Data table with hover effects
- Modal overlays for create/edit/delete
- Form with text inputs, checkboxes, select
- Action buttons (edit, delete)
- Primary, secondary, danger button styles

๐Ÿค– 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
fdc100f3f2
1 changed files with 483 additions and 3 deletions
  1. 483 3
      frontend/src/views/superadmin/OrganizationsView.vue

+ 483 - 3
frontend/src/views/superadmin/OrganizationsView.vue

@@ -1,22 +1,248 @@
 <template>
   <div class="page">
     <div class="page-header">
-      <h1>Organizations</h1>
-      <p>Manage all organizations</p>
+      <div>
+        <h1>{{ $t('organizations.title') }}</h1>
+        <p>{{ $t('organizations.manage') }}</p>
+      </div>
+      <button @click="showCreateModal" class="btn-primary">
+        {{ $t('organizations.add') }}
+      </button>
     </div>
 
     <div class="content">
-      <p>Organizations list will be here...</p>
+      <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
+
+      <div v-else-if="error" class="error">{{ error }}</div>
+
+      <table v-else-if="organizations.length > 0" class="data-table">
+        <thead>
+          <tr>
+            <th>ID</th>
+            <th>{{ $t('organizations.name') }}</th>
+            <th>{{ $t('organizations.contactEmail') }}</th>
+            <th>{{ $t('organizations.contactPhone') }}</th>
+            <th>WiFi</th>
+            <th>BLE</th>
+            <th>{{ $t('common.status') }}</th>
+            <th>{{ $t('common.actions') }}</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="org in organizations" :key="org.id">
+            <td>{{ org.id }}</td>
+            <td><strong>{{ org.name }}</strong></td>
+            <td>{{ org.contact_email }}</td>
+            <td>{{ org.contact_phone || '-' }}</td>
+            <td><span class="badge" :class="{ active: org.wifi_enabled }">{{ org.wifi_enabled ? 'โœ“' : 'โœ—' }}</span></td>
+            <td><span class="badge" :class="{ active: org.ble_enabled }">{{ org.ble_enabled ? 'โœ“' : 'โœ—' }}</span></td>
+            <td><span class="badge" :class="`status-${org.status}`">{{ org.status }}</span></td>
+            <td>
+              <div class="actions">
+                <button @click="showEditModal(org)" class="btn-icon" title="Edit">โœ๏ธ</button>
+                <button @click="confirmDelete(org)" class="btn-icon" title="Delete">๐Ÿ—‘๏ธ</button>
+              </div>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+      <div v-else class="empty">No organizations yet</div>
+    </div>
+
+    <!-- Create/Edit Modal -->
+    <div v-if="modalVisible" class="modal-overlay" @click="closeModal">
+      <div class="modal" @click.stop>
+        <div class="modal-header">
+          <h2>{{ editingOrg ? $t('common.edit') : $t('organizations.add') }}</h2>
+          <button @click="closeModal" class="btn-close">ร—</button>
+        </div>
+        <form @submit.prevent="saveOrganization" class="modal-body">
+          <div class="form-group">
+            <label>{{ $t('organizations.name') }} *</label>
+            <input v-model="form.name" type="text" required />
+          </div>
+
+          <div class="form-group">
+            <label>{{ $t('organizations.contactEmail') }} *</label>
+            <input v-model="form.contact_email" type="email" required />
+          </div>
+
+          <div class="form-group">
+            <label>{{ $t('organizations.contactPhone') }}</label>
+            <input v-model="form.contact_phone" type="tel" />
+          </div>
+
+          <div class="form-row">
+            <div class="form-group">
+              <label class="checkbox">
+                <input v-model="form.wifi_enabled" type="checkbox" />
+                <span>{{ $t('organizations.wifiEnabled') }}</span>
+              </label>
+            </div>
+
+            <div class="form-group">
+              <label class="checkbox">
+                <input v-model="form.ble_enabled" type="checkbox" />
+                <span>{{ $t('organizations.bleEnabled') }}</span>
+              </label>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label>{{ $t('common.status') }}</label>
+            <select v-model="form.status">
+              <option value="pending">Pending</option>
+              <option value="active">Active</option>
+              <option value="suspended">Suspended</option>
+            </select>
+          </div>
+
+          <div class="modal-footer">
+            <button type="button" @click="closeModal" class="btn-secondary">{{ $t('common.cancel') }}</button>
+            <button type="submit" :disabled="saving" class="btn-primary">
+              {{ saving ? $t('common.loading') : $t('common.save') }}
+            </button>
+          </div>
+        </form>
+      </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 organization <strong>{{ organizationToDelete?.name }}</strong>?</p>
+        </div>
+        <div class="modal-footer">
+          <button @click="deleteConfirmVisible = false" class="btn-secondary">{{ $t('common.cancel') }}</button>
+          <button @click="deleteOrganization" :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 organizationsApi from '@/api/organizations'
+
+const organizations = ref([])
+const loading = ref(false)
+const error = ref(null)
+const modalVisible = ref(false)
+const deleteConfirmVisible = ref(false)
+const editingOrg = ref(null)
+const organizationToDelete = ref(null)
+const saving = ref(false)
+const deleting = ref(false)
+
+const form = ref({
+  name: '',
+  contact_email: '',
+  contact_phone: '',
+  wifi_enabled: false,
+  ble_enabled: false,
+  status: 'pending'
+})
+
+async function loadOrganizations() {
+  loading.value = true
+  error.value = null
+  try {
+    organizations.value = await organizationsApi.getAll()
+  } catch (err) {
+    error.value = err.response?.data?.detail || 'Failed to load organizations'
+  } finally {
+    loading.value = false
+  }
+}
+
+function showCreateModal() {
+  editingOrg.value = null
+  form.value = {
+    name: '',
+    contact_email: '',
+    contact_phone: '',
+    wifi_enabled: false,
+    ble_enabled: false,
+    status: 'pending'
+  }
+  modalVisible.value = true
+}
+
+function showEditModal(org) {
+  editingOrg.value = org
+  form.value = {
+    name: org.name,
+    contact_email: org.contact_email,
+    contact_phone: org.contact_phone || '',
+    wifi_enabled: org.wifi_enabled,
+    ble_enabled: org.ble_enabled,
+    status: org.status
+  }
+  modalVisible.value = true
+}
+
+function closeModal() {
+  modalVisible.value = false
+  editingOrg.value = null
+}
+
+async function saveOrganization() {
+  saving.value = true
+  try {
+    if (editingOrg.value) {
+      await organizationsApi.update(editingOrg.value.id, form.value)
+    } else {
+      await organizationsApi.create(form.value)
+    }
+    await loadOrganizations()
+    closeModal()
+  } catch (err) {
+    alert(err.response?.data?.detail || 'Failed to save organization')
+  } finally {
+    saving.value = false
+  }
+}
+
+function confirmDelete(org) {
+  organizationToDelete.value = org
+  deleteConfirmVisible.value = true
+}
+
+async function deleteOrganization() {
+  deleting.value = true
+  try {
+    await organizationsApi.delete(organizationToDelete.value.id)
+    await loadOrganizations()
+    deleteConfirmVisible.value = false
+  } catch (err) {
+    alert(err.response?.data?.detail || 'Failed to delete organization')
+  } finally {
+    deleting.value = false
+  }
+}
+
+onMounted(() => {
+  loadOrganizations()
+})
+</script>
+
 <style scoped>
 .page {
   padding: 32px;
 }
 
 .page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
   margin-bottom: 32px;
 }
 
@@ -38,4 +264,258 @@
   padding: 24px;
   box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
 }
+
+.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 tbody tr:hover {
+  background: #f7fafc;
+}
+
+.badge {
+  display: inline-block;
+  padding: 4px 12px;
+  border-radius: 12px;
+  font-size: 12px;
+  font-weight: 600;
+  background: #e2e8f0;
+  color: #718096;
+}
+
+.badge.active {
+  background: #c6f6d5;
+  color: #22543d;
+}
+
+.badge.status-active {
+  background: #c6f6d5;
+  color: #22543d;
+}
+
+.badge.status-pending {
+  background: #fef3c7;
+  color: #92400e;
+}
+
+.badge.status-suspended {
+  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-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;
+}
+
+.btn-secondary {
+  padding: 12px 24px;
+  background: #e2e8f0;
+  color: #4a5568;
+  border: none;
+  border-radius: 8px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+
+.btn-secondary:hover {
+  background: #cbd5e0;
+}
+
+.btn-danger {
+  padding: 12px 24px;
+  background: #f56565;
+  color: white;
+  border: none;
+  border-radius: 8px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+
+.btn-danger:hover {
+  background: #e53e3e;
+}
+
+.modal-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+
+.modal {
+  background: white;
+  border-radius: 12px;
+  width: 90%;
+  max-width: 600px;
+  max-height: 90vh;
+  overflow-y: auto;
+}
+
+.modal-sm {
+  max-width: 400px;
+}
+
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 24px;
+  border-bottom: 1px solid #e2e8f0;
+}
+
+.modal-header h2 {
+  font-size: 24px;
+  font-weight: 700;
+  color: #1a202c;
+}
+
+.btn-close {
+  width: 32px;
+  height: 32px;
+  border: none;
+  background: none;
+  font-size: 32px;
+  color: #718096;
+  cursor: pointer;
+  line-height: 1;
+}
+
+.btn-close:hover {
+  color: #1a202c;
+}
+
+.modal-body {
+  padding: 24px;
+}
+
+.modal-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  padding: 24px;
+  border-top: 1px solid #e2e8f0;
+}
+
+.form-group {
+  margin-bottom: 20px;
+}
+
+.form-group label {
+  display: block;
+  margin-bottom: 8px;
+  font-weight: 500;
+  color: #4a5568;
+  font-size: 14px;
+}
+
+.form-group input[type="text"],
+.form-group input[type="email"],
+.form-group input[type="tel"],
+.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-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+}
+
+.checkbox {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+}
+
+.checkbox input[type="checkbox"] {
+  width: 20px;
+  height: 20px;
+  cursor: pointer;
+}
+
+.checkbox span {
+  font-weight: 500;
+  color: #4a5568;
+}
 </style>