diff --git a/app/Http/Controllers/API/GroupsController.php b/app/Http/Controllers/API/GroupsController.php new file mode 100644 index 0000000000..2f62ee5179 --- /dev/null +++ b/app/Http/Controllers/API/GroupsController.php @@ -0,0 +1,438 @@ +withCount(['allConfirmedHosts', 'allConfirmedRestarters']); + + // Apply search filter + if ($request->filled('search')) { + $search = $request->input('search'); + $query->where(function($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('location', 'like', "%{$search}%") + ->orWhere('postcode', 'like', "%{$search}%") + ->orWhere('area', 'like', "%{$search}%"); + }); + } + + // Handle sorting + $sortBy = $request->input('sort_by', 'name'); + $sortDirection = $request->input('sort_direction', 'asc'); + + // Validate sort direction + if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) { + $sortDirection = 'asc'; + } + + // Map frontend column names to database columns + $sortableColumns = [ + 'name' => 'name', + 'location' => 'location', + 'confirmed_hosts_count' => 'all_confirmed_hosts_count', + 'confirmed_restarters_count' => 'all_confirmed_restarters_count', + 'approved' => 'approved', + 'created_at' => 'created_at', + ]; + + // Default to name if invalid sort field + $sortColumn = $sortableColumns[$sortBy] ?? 'name'; + + $query->orderBy($sortColumn, $sortDirection); + + $perPage = min($request->input('per_page', 100), 500); + $groups = $query->paginate($perPage); + + // Transform data for frontend + $transformedGroups = $groups->getCollection()->map(function ($group) { + return $this->transformGroup($group); + }); + + return response()->json([ + 'success' => true, + 'data' => $transformedGroups, + 'current_page' => $groups->currentPage(), + 'last_page' => $groups->lastPage(), + 'per_page' => $groups->perPage(), + 'total' => $groups->total(), + 'from' => $groups->firstItem(), + 'to' => $groups->lastItem(), + ]); + + } catch (\Exception $e) { + Log::error('Error fetching groups: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to fetch groups', + 'error' => config('app.debug') ? $e->getMessage() : null + ], 500); + } + } + + public static function performSingleAction(int $group_id, string $action): JsonResponse + { + try { + $group = Group::findOrFail($group_id); + + $result = self::performAction($group, $action); + + return response()->json([ + 'success' => true, + 'message' => $result['message'], + 'group' => $result + ]); + } catch (\Exception $e) { + Log::error('Error performing action: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to perform action' + ], 500); + } + } + + public static function performBulkActions(Request $request, string $action): JsonResponse + { + try { + $group_ids = $request->input('group_ids'); + $groups = Group::whereIn('idgroups', $group_ids)->get(); + + $failedGroups = []; + + foreach ($groups as $group) { + try { + self::performAction($group, $action); + } catch (\Exception $e) { + $failedGroups[] = $e->getMessage(); + } + } + + if (count($failedGroups) > 0) { + throw new \Exception(implode(', ', $failedGroups)); + } + + return response()->json([ + 'success' => true, + 'message' => 'Bulk actions performed successfully' + ]); + + } catch (\Exception $e) { + Log::error('Error performing bulk actions: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to perform bulk actions. ' . $e->getMessage() + ], 500); + } + } + + private static function performAction(Group $group, string $action): array + { + switch ($action) { + case 'approve': + $group->approved = true; + $group->save(); + break; + + case 'unapprove': + $group->approved = false; + $group->save(); + break; + + case 'archive': + $group->archived_at = now(); + $group->save(); + break; + + case 'unarchive': + $group->archived_at = null; + $group->save(); + break; + + case 'delete': + $groupName = $group->name; + if (!$group->canDelete()) { + throw new \Exception("Group '{$groupName}' cannot be deleted because it has events with devices."); + } + $group->delete(); + break; + + default: + throw new \Exception("Invalid action: {$action}"); + } + + return [ + 'id' => $group->idgroups, + 'name' => $group->name, + 'message' => "Group '{$group->name}' has been {$action} successfully." + ]; + } + + public static function importGroups(Request $request): JsonResponse + { + try { + $file = $request->file('csv_file'); + $path = $file->getRealPath(); + $data = array_map('str_getcsv', file($path)); + $headers = array_shift($data); + + // Validate headers + $requiredHeaders = ['Name', 'Location']; + $missingHeaders = array_diff($requiredHeaders, $headers); + + if (!empty($missingHeaders)) { + return response()->json([ + 'success' => false, + 'message' => 'Missing required columns: ' . implode(', ', $missingHeaders) + ], 422); + } + + $created = 0; + $errors = []; + + DB::transaction(function() use ($data, $headers, &$created, &$errors) { + foreach ($data as $rowIndex => $row) { + if (empty(array_filter($row))) continue; // Skip empty rows + + try { + $groupData = array_combine($headers, $row); + + // Validate required fields + if (empty($groupData['Name']) || empty($groupData['Location'])) { + $errors[] = "Row " . ($rowIndex + 2) . ": Name and Location are required"; + continue; + } + + // Create group + $group = new Group(); + $group->name = $groupData['Name']; + $group->location = $groupData['Location']; + $group->postcode = $groupData['Postcode'] ?? null; + $group->area = $groupData['Area'] ?? null; + $group->country_code = $groupData['Country Code'] ?? null; + $group->latitude = $groupData['Latitude'] ?? null; + $group->longitude = $groupData['Longitude'] ?? null; + $group->website = $groupData['Website'] ?? null; + $group->phone = $groupData['Phone'] ?? null; + $group->email = $groupData['Email'] ?? null; + $group->free_text = $groupData['Description'] ?? null; + $group->approved = false; // New groups require approval + $group->save(); + + // Handle networks if provided + if (!empty($groupData['Networks'])) { + $networkNames = array_map('trim', explode(',', $groupData['Networks'])); + $networkIds = Network::whereIn('name', $networkNames)->pluck('id'); + if ($networkIds->isNotEmpty()) { + $group->networks()->attach($networkIds); + } + } + + $created++; + + } catch (\Exception $e) { + $errors[] = "Row " . ($rowIndex + 2) . ": " . $e->getMessage(); + } + } + }); + + if (count($errors) > 0) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to process CSV file. ' . implode(', ', $errors), + 'errors' => $errors + ], 422); + } + + return response()->json([ + 'success' => true, + 'message' => "Successfully created {$created} groups.", + 'data' => [ + 'created' => $created, + 'errors' => $errors + ] + ]); + + } catch (\Exception $e) { + Log::error('Error uploading CSV: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to process CSV file' + ], 500); + } + } + + public static function exportGroups(Request $request) + { + try { + // Build query with same logic as index method + $query = Group::with(['networks', 'group_tags']); + + // Apply search filter + if ($request->filled('search')) { + $search = $request->input('search'); + $query->where(function($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('location', 'like', "%{$search}%") + ->orWhere('postcode', 'like', "%{$search}%") + ->orWhere('area', 'like', "%{$search}%"); + }); + } + + // Apply status filter + if ($request->filled('status')) { + $status = $request->input('status'); + if ($status === 'approved') { + $query->where('approved', true); + } elseif ($status === 'pending') { + $query->where('approved', false); + } + } + + // Apply archived filter + if ($request->filled('archived')) { + $archived = $request->input('archived'); + if ($archived === 'yes') { + $query->whereNotNull('archived_at'); + } elseif ($archived === 'no') { + $query->whereNull('archived_at'); + } + } + + // Apply network filter + if ($request->filled('network')) { + $networkId = $request->input('network'); + $query->whereHas('networks', function($q) use ($networkId) { + $q->where('networks.id', $networkId); + }); + } + + // Apply country filter + if ($request->filled('country')) { + $countryCode = $request->input('country'); + $query->where('country_code', $countryCode); + } + + $groups = $query->orderBy('name', 'asc')->get(); + + // Create CSV content + $filename = 'groups_export_' . date('Y-m-d_H-i-s') . '.csv'; + $csvContent = self::generateCsvContent($groups); + + // Return CSV as download + return response($csvContent) + ->header('Content-Type', 'text/csv') + ->header('Content-Disposition', 'attachment; filename="' . $filename . '"') + ->header('Cache-Control', 'no-cache, no-store, must-revalidate') + ->header('Pragma', 'no-cache') + ->header('Expires', '0'); + + } catch (\Exception $e) { + Log::error('Error exporting groups CSV: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to export groups' + ], 500); + } + } + + + private static function generateCsvContent($groups) + { + $csvData = []; + + // Add headers + $csvData[] = [ + 'ID', 'Name', 'Location', 'Postcode', 'Area', 'Country Code', + 'Latitude', 'Longitude', 'Website', 'Phone', 'Email', + 'Approved', 'Archived', 'Networks', 'Tags', 'Description', + 'Hosts Count', 'Restarters Count', 'Created At' + ]; + + // Add data rows + foreach ($groups as $group) { + // Get country name from country code + $countryDisplay = null; + if ($group->country_code) { + $countryDisplay = \App\Helpers\Fixometer::getCountryFromCountryCode($group->country_code); + } + + // Manually calculate counts + $confirmedHostsCount = $group->allConfirmedHosts()->count(); + $confirmedRestartersCount = $group->allConfirmedRestarters()->count(); + + $csvData[] = [ + $group->idgroups, + $group->name, + $group->location, + $group->postcode, + $group->area, + $group->country_code, + $group->latitude, + $group->longitude, + $group->website, + $group->phone, + $group->email, + $group->approved ? 'Yes' : 'No', + $group->archived_at ? 'Yes' : 'No', + $group->networks->pluck('name')->join(', '), + $group->group_tags->pluck('tag_name')->join(', '), + $group->free_text, + $confirmedHostsCount, + $confirmedRestartersCount, + $group->created_at ? $group->created_at->format('Y-m-d H:i:s') : '' + ]; + } + + // Convert to CSV string + $output = fopen('php://temp', 'r+'); + foreach ($csvData as $row) { + fputcsv($output, $row); + } + rewind($output); + $csvContent = stream_get_contents($output); + fclose($output); + + return $csvContent; + } + + /** + * Transform group data for frontend + */ + private function transformGroup($group): array + { + // Get country name from country code + $countryDisplay = null; + if ($group->country_code) { + $countryDisplay = \App\Helpers\Fixometer::getCountryFromCountryCode($group->country_code); + } + + return [ + 'idgroups' => $group->idgroups, + 'name' => $group->name, + 'location' => $group->location, + 'postcode' => $group->postcode, + 'area' => $group->area, + 'country_code' => $group->country_code, + 'country_display' => $countryDisplay, + 'approved' => (bool) $group->approved, + 'archived_at' => $group->archived_at, + 'created_at' => $group->created_at, + 'networks' => $group->networks, + 'group_tags' => $group->group_tags, + 'confirmed_hosts_count' => $group->all_confirmed_hosts_count, + 'confirmed_restarters_count' => $group->all_confirmed_restarters_count, + ]; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 3c88e6fd53..92971eb5d5 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -105,4 +105,9 @@ public static function getStats2() 'wasteTotal' => $stats[0]->powered_waste, // + $stats[0]->unpowered_waste, ]; } + + public static function groups() + { + return view('admin.groups'); + } } diff --git a/app/Http/Middleware/AdminMiddleware.php b/app/Http/Middleware/AdminMiddleware.php new file mode 100644 index 0000000000..fd60bb58df --- /dev/null +++ b/app/Http/Middleware/AdminMiddleware.php @@ -0,0 +1,45 @@ +expectsJson()) { + return response()->json([ + 'success' => false, + 'message' => 'Authentication required' + ], 401); + } + return redirect()->guest(route('login')); + } + + if (!Fixometer::hasRole($user, 'Administrator')) { + if ($request->expectsJson()) { + return response()->json([ + 'success' => false, + 'message' => 'Administrator access required' + ], 403); + } + abort(403, 'Administrator access required'); + } + + return $next($request); + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index c1f11a764c..a4bef9cce8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -83,6 +83,7 @@ 'AcceptUserInvites' => \App\Http\Middleware\AcceptUserInvites::class, 'ensureAPIToken' => \App\Http\Middleware\EnsureAPIToken::class, 'customApiAuth' => \App\Http\Middleware\CustomApiTokenAuth::class, + 'admin' => \App\Http\Middleware\AdminMiddleware::class, 'localeSessionRedirect' => \Mcamara\LaravelLocalization\Middleware\LocaleSessionRedirect::class, 'localeViewPath' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationViewPath::class, 'localizationRedirect' => \Mcamara\LaravelLocalization\Middleware\LaravelLocalizationRedirectFilter::class, diff --git a/resources/js/api/groups.js b/resources/js/api/groups.js new file mode 100644 index 0000000000..91a89573f0 --- /dev/null +++ b/resources/js/api/groups.js @@ -0,0 +1,44 @@ +import axios from "axios"; + +const API_BASE = "/api/v2/admin/groups"; + +export default { + // Fetch groups with pagination and filtering + async fetchGroups(params = {}) { + const response = await axios.get(API_BASE, { params }); + return response.data; + }, + + // Perform single action on a group + async performAction(groupId, action) { + const response = await axios.post(`${API_BASE}/${groupId}/${action}`); + return response.data; + }, + + async performBulkActions(groupIds, action) { + const response = await axios.post(`${API_BASE}/bulk/${action}`, { + group_ids: groupIds, + }); + return response.data; + }, + + async importGroups(file, onUploadProgress) { + const formData = new FormData(); + formData.append("csv_file", file); + + const response = await axios.post(`${API_BASE}/import`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + onUploadProgress: onUploadProgress, + }); + return response.data; + }, + + async exportGroups() { + const response = await axios.get(`${API_BASE}/export`, { + responseType: "blob", + }); + return response; + }, +}; diff --git a/resources/js/app.js b/resources/js/app.js index 01e88c3914..addd49756c 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -40,6 +40,7 @@ import EventAddEdit from './components/EventAddEdit.vue' import EventsRequiringModeration from './components/EventsRequiringModeration' import EventPage from './components/EventPage.vue' import FixometerPage from './components/FixometerPage' +import GroupsManagement from './components/admin/GroupsManagement.vue' import GroupsPage from './components/GroupsPage.vue' import GroupPage from './components/GroupPage.vue' import GroupAddEditPage from './components/GroupAddEditPage.vue' @@ -1309,6 +1310,7 @@ jQuery(document).ready(function () { 'eventsrequiringmoderation': EventsRequiringModeration, 'eventpage': EventPage, 'fixometerpage': FixometerPage, + 'groupsmanagement': GroupsManagement, 'groupspage': GroupsPage, 'grouppage': GroupPage, 'groupaddeditpage': GroupAddEditPage, diff --git a/resources/js/components/admin/ConfirmationModal.vue b/resources/js/components/admin/ConfirmationModal.vue new file mode 100644 index 0000000000..a3775df14d --- /dev/null +++ b/resources/js/components/admin/ConfirmationModal.vue @@ -0,0 +1,248 @@ + + + + + \ No newline at end of file diff --git a/resources/js/components/admin/GroupsBulkAction.vue b/resources/js/components/admin/GroupsBulkAction.vue new file mode 100644 index 0000000000..7283d6d4d1 --- /dev/null +++ b/resources/js/components/admin/GroupsBulkAction.vue @@ -0,0 +1,92 @@ + + + + + \ No newline at end of file diff --git a/resources/js/components/admin/GroupsCsvUploadModal.vue b/resources/js/components/admin/GroupsCsvUploadModal.vue new file mode 100644 index 0000000000..69217603fd --- /dev/null +++ b/resources/js/components/admin/GroupsCsvUploadModal.vue @@ -0,0 +1,581 @@ + + + + + diff --git a/resources/js/components/admin/GroupsManagement.vue b/resources/js/components/admin/GroupsManagement.vue new file mode 100644 index 0000000000..2cacd69fce --- /dev/null +++ b/resources/js/components/admin/GroupsManagement.vue @@ -0,0 +1,508 @@ + + + + + diff --git a/resources/js/components/admin/GroupsTable.vue b/resources/js/components/admin/GroupsTable.vue new file mode 100644 index 0000000000..bee84d86d0 --- /dev/null +++ b/resources/js/components/admin/GroupsTable.vue @@ -0,0 +1,520 @@ + + + + + \ No newline at end of file diff --git a/resources/js/components/admin/ImportResultsModal.vue b/resources/js/components/admin/ImportResultsModal.vue new file mode 100644 index 0000000000..690a2b8663 --- /dev/null +++ b/resources/js/components/admin/ImportResultsModal.vue @@ -0,0 +1,322 @@ + + + + + \ No newline at end of file diff --git a/resources/views/admin/groups.blade.php b/resources/views/admin/groups.blade.php new file mode 100644 index 0000000000..a3aec884bf --- /dev/null +++ b/resources/views/admin/groups.blade.php @@ -0,0 +1,17 @@ +@extends('layouts.app') + +@section('title') +Groups Management +@endsection + +@section('content') +
+
+
+
+ +
+
+
+
+@endsection diff --git a/resources/views/layouts/navbar.blade.php b/resources/views/layouts/navbar.blade.php index 211a614e21..bdb0a342f8 100644 --- a/resources/views/layouts/navbar.blade.php +++ b/resources/views/layouts/navbar.blade.php @@ -133,6 +133,7 @@
  • Brands
  • Skills
  • Group tags
  • +
  • Groups Management
  • Categories
  • Users
  • Roles
  • diff --git a/routes/api.php b/routes/api.php index 75d5db32f8..1d7f5c64e4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -109,6 +109,15 @@ }); }); + // Admin Groups Management API + Route::prefix('/admin/groups')->middleware(['auth:api', App\Http\Middleware\AdminMiddleware::class])->group(function() { + Route::get('/', [App\Http\Controllers\API\GroupsController::class, 'index']); + Route::get('/export', [App\Http\Controllers\API\GroupsController::class, 'exportGroups']); + Route::post('import', [App\Http\Controllers\API\GroupsController::class, 'importGroups']); + Route::post('bulk/{action}', [App\Http\Controllers\API\GroupsController::class, 'performBulkActions']); + Route::post('{id}/{action}', [App\Http\Controllers\API\GroupsController::class, 'performSingleAction']); + }); + Route::get('/items', [API\ItemController::class, 'listItemsv2']); Route::prefix('/alerts')->group(function() { diff --git a/routes/web.php b/routes/web.php index 08106ca78d..c5b41dd8ae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -405,8 +405,9 @@ }); //Admin Controller - Route::prefix('admin')->group(function () { + Route::prefix('admin')->middleware(App\Http\Middleware\AdminMiddleware::class)->group(function () { Route::get('/stats', [AdminController::class, 'stats']); + Route::get('/groups', [AdminController::class, 'groups'])->name('admin.groups'); }); //Category Controller