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 @@
+
+ Groups Management
+ {{ totalCount }} groups
+
+
+
+
+
+
+
+
+
+
+
+ Name
+
+
+
+ Location
+
+
+
+ Hosts
+
+
+
+ Volunteers
+
+
+
+ Status
+
+
+
+ Created At
+
+
+ Actions
+
+
+
+
+
+
+
+ No groups found
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ group.confirmed_hosts_count || 0 }}
+
+
+ {{ group.confirmed_restarters_count || 0 }}
+
+
+ Approved
+ Pending
+
+
+
+
+
+