Skip to content
Merged
438 changes: 438 additions & 0 deletions app/Http/Controllers/API/GroupsController.php

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions app/Http/Controllers/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
45 changes: 45 additions & 0 deletions app/Http/Middleware/AdminMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Helpers\Fixometer;

class AdminMiddleware
{
/**
* Handle an incoming request to ensure user is an administrator.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
$user = Auth::user();

if (!$user) {
if ($request->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);
}
}
1 change: 1 addition & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions resources/js/api/groups.js
Original file line number Diff line number Diff line change
@@ -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;
},
};
2 changes: 2 additions & 0 deletions resources/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1309,6 +1310,7 @@ jQuery(document).ready(function () {
'eventsrequiringmoderation': EventsRequiringModeration,
'eventpage': EventPage,
'fixometerpage': FixometerPage,
'groupsmanagement': GroupsManagement,
'groupspage': GroupsPage,
'grouppage': GroupPage,
'groupaddeditpage': GroupAddEditPage,
Expand Down
248 changes: 248 additions & 0 deletions resources/js/components/admin/ConfirmationModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
<template>
<div v-if="show" class="modal fade show d-block" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ title }}
</h5>
<button type="button" class="btn-close" @click="$emit('cancel')" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>

<div class="modal-body">
<div v-if="groups.length <= 5" class="mt-3">
<h6>Affected Groups</h6>
<ul class="list-group">
<li v-for="group in groups" :key="group.id"
class="list-group-item d-flex align-items-center">
<div>
<strong>{{ group.name }}</strong>
<div class="small text-muted">{{ group.location }}</div>
</div>
</li>
</ul>
</div>
</div>

<div v-if="error" class="alert alert-danger">
{{ error }}
</div>

<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">
Cancel
</button>
<button type="button" :class="confirmButtonClass" class="btn" @click="$emit('confirm')">
{{ confirmButtonText }}
</button>
</div>
</div>
</div>


</div>
</template>

<script>
export default {
name: "ConfirmationModal",

props: {
show: {
type: Boolean,
default: false,
},
action: {
type: String,
default: null,
},
groups: {
type: Array,
default: () => [],
},
error: {
type: String,
default: null,
},
},

computed: {
title() {
const actions = {
delete: "Deletion",
approve: "Approval",
unapprove: "Unapproval",
archive: "Archiving",
unarchive: "Unarchiving",
};
return `Confirm ${actions[this.action]}` || "Confirm Action";
},

confirmButtonClass() {
const classes = {
delete: "btn btn-danger",
approve: "btn btn-success",
unapprove: "btn btn-warning",
archive: "btn btn-info",
unarchive: "btn-outline-info",
};
return classes[this.action] || "btn btn-primary";
},

confirmButtonText() {
const texts = {
delete: "Delete",
approve: "Approve",
unapprove: "Unapprove",
archive: "Archive",
unarchive: "Unarchive",
};
return texts[this.action]
},
},

watch: {
show(newValue) {
if (newValue) {
// Prevent body scroll when modal is open
document.body.style.overflow = "hidden";
} else {
// Restore body scroll when modal is closed
document.body.style.overflow = "";
}
},
},

beforeUnmount() {
// Ensure body scroll is restored when component is destroyed
document.body.style.overflow = "";
},
};
</script>

<style scoped>
.modal {
background-color: rgba(0, 0, 0, 0.5);
z-index: 1050;
}

.modal-dialog {
margin: 1.75rem auto;
max-width: 500px;
}

.modal-content {
border: none;
border-radius: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}

.modal-header {
border-bottom: 1px solid #dee2e6;
background-color: #f8f9fa;
border-radius: 0.5rem 0.5rem 0 0;
}

.modal-title {
font-weight: 600;
color: #495057;
}

.modal-body {
padding: 1.5rem;
}

.modal-body p {
margin-bottom: 1rem;
color: #6c757d;
line-height: 1.5;
}

.modal-footer {
border-top: 1px solid #dee2e6;
background-color: #f8f9fa;
border-radius: 0 0 0.5rem 0.5rem;
}

.btn {
font-weight: 500;
padding: 0.5rem 1.5rem;
border-radius: 0.25rem;
transition: all 0.2s ease-in-out;
/* Prevent layout shift by maintaining consistent border width */
border-width: 1px;
}

.btn:hover {
transform: translateY(-1px);
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
/* Ensure border width doesn't change on hover */
border-width: 1px;
}

.btn:disabled {
transform: none;
box-shadow: none;
border-width: 1px;
}

.btn-close {
font-size: 1.5rem;
line-height: 1;
color: #000;
text-shadow: 0 1px 0 #fff;
opacity: 0.8;
background: none;
border: none;
padding: 0.25rem 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
margin-left: auto;
}

.btn-close:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 0.25rem;
}

.alert {
margin-bottom: 0;
border-radius: 0.25rem;
}

.list-group-item {
text-decoration: none;

&:hover {
text-decoration: none;
}
}

/* Animation */
.modal.fade.show {
animation: modalFadeIn 0.15s ease-out;
}

@keyframes modalFadeIn {
from {
opacity: 0;
transform: scale(0.9);
}

to {
opacity: 1;
transform: scale(1);
}
}

.modal-backdrop {
z-index: 1040;
}
</style>
Loading